mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
refactor(board-view): reorganize into modular folder structure
- Extract board-view into organized subfolders following new pattern: - components/: kanban-card, kanban-column - dialogs/: all dialog and modal components (8 files) - hooks/: all board-specific hooks (10 files) - shared/: reusable components between dialogs (model-selector, etc.) - Rename all files to kebab-case convention - Add barrel exports (index.ts) for clean imports - Add docs/folder-pattern.md documenting the folder structure - Reduce board-view.tsx from ~3600 lines to ~490 lines 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,578 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
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";
|
|
||||||
import {
|
|
||||||
DescriptionImageDropZone,
|
|
||||||
FeatureImagePath as DescriptionImagePath,
|
|
||||||
ImagePreviewMap,
|
|
||||||
} from "@/components/ui/description-image-dropzone";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import {
|
|
||||||
MessageSquare,
|
|
||||||
Settings2,
|
|
||||||
FlaskConical,
|
|
||||||
Plus,
|
|
||||||
Brain,
|
|
||||||
UserCircle,
|
|
||||||
Zap,
|
|
||||||
Scale,
|
|
||||||
Cpu,
|
|
||||||
Rocket,
|
|
||||||
Sparkles,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { cn, modelSupportsThinking } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
useAppStore,
|
|
||||||
AgentModel,
|
|
||||||
ThinkingLevel,
|
|
||||||
FeatureImage,
|
|
||||||
AIProfile,
|
|
||||||
} from "@/store/app-store";
|
|
||||||
|
|
||||||
type ModelOption = {
|
|
||||||
id: AgentModel;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
badge?: string;
|
|
||||||
provider: "claude";
|
|
||||||
};
|
|
||||||
|
|
||||||
const CLAUDE_MODELS: ModelOption[] = [
|
|
||||||
{
|
|
||||||
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: "opus",
|
|
||||||
label: "Claude Opus",
|
|
||||||
description: "Most capable model for complex work.",
|
|
||||||
badge: "Premium",
|
|
||||||
provider: "claude",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Profile icon mapping
|
|
||||||
const PROFILE_ICONS: Record<
|
|
||||||
string,
|
|
||||||
React.ComponentType<{ className?: string }>
|
|
||||||
> = {
|
|
||||||
Brain,
|
|
||||||
Zap,
|
|
||||||
Scale,
|
|
||||||
Cpu,
|
|
||||||
Rocket,
|
|
||||||
Sparkles,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface AddFeatureDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onAdd: (feature: {
|
|
||||||
category: string;
|
|
||||||
description: string;
|
|
||||||
steps: string[];
|
|
||||||
images: FeatureImage[];
|
|
||||||
imagePaths: DescriptionImagePath[];
|
|
||||||
skipTests: boolean;
|
|
||||||
model: AgentModel;
|
|
||||||
thinkingLevel: ThinkingLevel;
|
|
||||||
}) => void;
|
|
||||||
categorySuggestions: string[];
|
|
||||||
defaultSkipTests: boolean;
|
|
||||||
isMaximized: boolean;
|
|
||||||
showProfilesOnly: boolean;
|
|
||||||
aiProfiles: AIProfile[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AddFeatureDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onAdd,
|
|
||||||
categorySuggestions,
|
|
||||||
defaultSkipTests,
|
|
||||||
isMaximized,
|
|
||||||
showProfilesOnly,
|
|
||||||
aiProfiles,
|
|
||||||
}: AddFeatureDialogProps) {
|
|
||||||
const [newFeature, setNewFeature] = useState({
|
|
||||||
category: "",
|
|
||||||
description: "",
|
|
||||||
steps: [""],
|
|
||||||
images: [] as FeatureImage[],
|
|
||||||
imagePaths: [] as DescriptionImagePath[],
|
|
||||||
skipTests: false,
|
|
||||||
model: "opus" as AgentModel,
|
|
||||||
thinkingLevel: "none" as ThinkingLevel,
|
|
||||||
});
|
|
||||||
const [newFeaturePreviewMap, setNewFeaturePreviewMap] =
|
|
||||||
useState<ImagePreviewMap>(() => new Map());
|
|
||||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
|
||||||
const [descriptionError, setDescriptionError] = useState(false);
|
|
||||||
|
|
||||||
// Sync skipTests default when dialog opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setNewFeature((prev) => ({
|
|
||||||
...prev,
|
|
||||||
skipTests: defaultSkipTests,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [open, defaultSkipTests]);
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
|
||||||
// Validate description is required
|
|
||||||
if (!newFeature.description.trim()) {
|
|
||||||
setDescriptionError(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const category = newFeature.category || "Uncategorized";
|
|
||||||
const selectedModel = newFeature.model;
|
|
||||||
const normalizedThinking = modelSupportsThinking(selectedModel)
|
|
||||||
? newFeature.thinkingLevel
|
|
||||||
: "none";
|
|
||||||
|
|
||||||
onAdd({
|
|
||||||
category,
|
|
||||||
description: newFeature.description,
|
|
||||||
steps: newFeature.steps.filter((s) => s.trim()),
|
|
||||||
images: newFeature.images,
|
|
||||||
imagePaths: newFeature.imagePaths,
|
|
||||||
skipTests: newFeature.skipTests,
|
|
||||||
model: selectedModel,
|
|
||||||
thinkingLevel: normalizedThinking,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset form
|
|
||||||
setNewFeature({
|
|
||||||
category: "",
|
|
||||||
description: "",
|
|
||||||
steps: [""],
|
|
||||||
images: [],
|
|
||||||
imagePaths: [],
|
|
||||||
skipTests: defaultSkipTests,
|
|
||||||
model: "opus",
|
|
||||||
thinkingLevel: "none",
|
|
||||||
});
|
|
||||||
setNewFeaturePreviewMap(new Map());
|
|
||||||
setShowAdvancedOptions(false);
|
|
||||||
setDescriptionError(false);
|
|
||||||
onOpenChange(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDialogClose = (open: boolean) => {
|
|
||||||
onOpenChange(open);
|
|
||||||
// Clear preview map, validation error, and reset advanced options when dialog closes
|
|
||||||
if (!open) {
|
|
||||||
setNewFeaturePreviewMap(new Map());
|
|
||||||
setShowAdvancedOptions(false);
|
|
||||||
setDescriptionError(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderModelOptions = (
|
|
||||||
options: ModelOption[],
|
|
||||||
selectedModel: AgentModel,
|
|
||||||
onSelect: (model: AgentModel) => void,
|
|
||||||
testIdPrefix = "model-select"
|
|
||||||
) => (
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{options.map((option) => {
|
|
||||||
const isSelected = selectedModel === option.id;
|
|
||||||
// Shorter display names for compact view
|
|
||||||
const shortName = option.label.replace("Claude ", "");
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={option.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSelect(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",
|
|
||||||
isSelected
|
|
||||||
? "bg-primary text-primary-foreground border-primary"
|
|
||||||
: "bg-background hover:bg-accent border-input"
|
|
||||||
)}
|
|
||||||
data-testid={`${testIdPrefix}-${option.id}`}
|
|
||||||
>
|
|
||||||
{shortName}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const newModelAllowsThinking = modelSupportsThinking(newFeature.model);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={handleDialogClose}>
|
|
||||||
<DialogContent
|
|
||||||
compact={!isMaximized}
|
|
||||||
data-testid="add-feature-dialog"
|
|
||||||
onPointerDownOutside={(e) => {
|
|
||||||
// Prevent dialog from closing when clicking on category autocomplete dropdown
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onInteractOutside={(e) => {
|
|
||||||
// Prevent dialog from closing when clicking on category autocomplete dropdown
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Add New Feature</DialogTitle>
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<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" />
|
|
||||||
Prompt
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="model" data-testid="tab-model">
|
|
||||||
<Settings2 className="w-4 h-4 mr-2" />
|
|
||||||
Model
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="testing" data-testid="tab-testing">
|
|
||||||
<FlaskConical className="w-4 h-4 mr-2" />
|
|
||||||
Testing
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* Prompt Tab */}
|
|
||||||
<TabsContent value="prompt" className="space-y-4 overflow-y-auto">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="description">Description</Label>
|
|
||||||
<DescriptionImageDropZone
|
|
||||||
value={newFeature.description}
|
|
||||||
onChange={(value) => {
|
|
||||||
setNewFeature({ ...newFeature, description: value });
|
|
||||||
if (value.trim()) {
|
|
||||||
setDescriptionError(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
images={newFeature.imagePaths}
|
|
||||||
onImagesChange={(images) =>
|
|
||||||
setNewFeature({ ...newFeature, imagePaths: images })
|
|
||||||
}
|
|
||||||
placeholder="Describe the feature..."
|
|
||||||
previewMap={newFeaturePreviewMap}
|
|
||||||
onPreviewMapChange={setNewFeaturePreviewMap}
|
|
||||||
autoFocus
|
|
||||||
error={descriptionError}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="category">Category (optional)</Label>
|
|
||||||
<CategoryAutocomplete
|
|
||||||
value={newFeature.category}
|
|
||||||
onChange={(value) =>
|
|
||||||
setNewFeature({ ...newFeature, category: value })
|
|
||||||
}
|
|
||||||
suggestions={categorySuggestions}
|
|
||||||
placeholder="e.g., Core, UI, API"
|
|
||||||
data-testid="feature-category-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Model Tab */}
|
|
||||||
<TabsContent value="model" className="space-y-4 overflow-y-auto">
|
|
||||||
{/* Show Advanced Options Toggle - only when profiles-only mode is enabled */}
|
|
||||||
{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-xs text-muted-foreground">
|
|
||||||
Only showing AI profiles. Advanced model tweaking is hidden.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
|
|
||||||
data-testid="show-advanced-options-toggle"
|
|
||||||
>
|
|
||||||
<Settings2 className="w-4 h-4 mr-2" />
|
|
||||||
{showAdvancedOptions ? "Hide" : "Show"} Advanced
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quick Select Profile Section */}
|
|
||||||
{aiProfiles.length > 0 && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="flex items-center gap-2">
|
|
||||||
<UserCircle className="w-4 h-4 text-brand-500" />
|
|
||||||
Quick Select Profile
|
|
||||||
</Label>
|
|
||||||
<span className="text-[11px] px-2 py-0.5 rounded-full border border-brand-500/40 text-brand-500">
|
|
||||||
Presets
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{aiProfiles.slice(0, 6).map((profile) => {
|
|
||||||
const IconComponent = profile.icon
|
|
||||||
? PROFILE_ICONS[profile.icon]
|
|
||||||
: Brain;
|
|
||||||
const isSelected =
|
|
||||||
newFeature.model === profile.model &&
|
|
||||||
newFeature.thinkingLevel === profile.thinkingLevel;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={profile.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setNewFeature({
|
|
||||||
...newFeature,
|
|
||||||
model: profile.model,
|
|
||||||
thinkingLevel: profile.thinkingLevel,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"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"
|
|
||||||
)}
|
|
||||||
data-testid={`profile-quick-select-${profile.id}`}
|
|
||||||
>
|
|
||||||
<div className="w-7 h-7 rounded flex items-center justify-center flex-shrink-0 bg-primary/10">
|
|
||||||
{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}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Or customize below. Manage profiles in{" "}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onOpenChange(false);
|
|
||||||
useAppStore.getState().setCurrentView("profiles");
|
|
||||||
}}
|
|
||||||
className="text-brand-500 hover:underline"
|
|
||||||
>
|
|
||||||
AI Profiles
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Separator */}
|
|
||||||
{aiProfiles.length > 0 &&
|
|
||||||
(!showProfilesOnly || showAdvancedOptions) && (
|
|
||||||
<div className="border-t border-border" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Claude Models Section - Hidden when showProfilesOnly is true and showAdvancedOptions is false */}
|
|
||||||
{(!showProfilesOnly || showAdvancedOptions) && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="flex items-center gap-2">
|
|
||||||
<Brain className="w-4 h-4 text-primary" />
|
|
||||||
Claude (SDK)
|
|
||||||
</Label>
|
|
||||||
<span className="text-[11px] px-2 py-0.5 rounded-full border border-primary/40 text-primary">
|
|
||||||
Native
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{renderModelOptions(
|
|
||||||
CLAUDE_MODELS,
|
|
||||||
newFeature.model,
|
|
||||||
(model) =>
|
|
||||||
setNewFeature({
|
|
||||||
...newFeature,
|
|
||||||
model,
|
|
||||||
thinkingLevel: modelSupportsThinking(model)
|
|
||||||
? newFeature.thinkingLevel
|
|
||||||
: "none",
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Thinking Level - Only shown when Claude model is selected */}
|
|
||||||
{newModelAllowsThinking && (
|
|
||||||
<div className="space-y-2 pt-2 border-t border-border">
|
|
||||||
<Label className="flex items-center gap-2 text-sm">
|
|
||||||
<Brain className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
Thinking Level
|
|
||||||
</Label>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
"none",
|
|
||||||
"low",
|
|
||||||
"medium",
|
|
||||||
"high",
|
|
||||||
"ultrathink",
|
|
||||||
] as ThinkingLevel[]
|
|
||||||
).map((level) => (
|
|
||||||
<button
|
|
||||||
key={level}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setNewFeature({
|
|
||||||
...newFeature,
|
|
||||||
thinkingLevel: level,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors min-w-[60px]",
|
|
||||||
newFeature.thinkingLevel === level
|
|
||||||
? "bg-primary text-primary-foreground border-primary"
|
|
||||||
: "bg-background hover:bg-accent border-input"
|
|
||||||
)}
|
|
||||||
data-testid={`thinking-level-${level}`}
|
|
||||||
>
|
|
||||||
{level === "none" && "None"}
|
|
||||||
{level === "low" && "Low"}
|
|
||||||
{level === "medium" && "Med"}
|
|
||||||
{level === "high" && "High"}
|
|
||||||
{level === "ultrathink" && "Ultra"}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Higher levels give more time to reason through complex
|
|
||||||
problems.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Testing Tab */}
|
|
||||||
<TabsContent value="testing" className="space-y-4 overflow-y-auto">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id="skip-tests"
|
|
||||||
checked={!newFeature.skipTests}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setNewFeature({
|
|
||||||
...newFeature,
|
|
||||||
skipTests: checked !== true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
data-testid="skip-tests-checkbox"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="skip-tests"
|
|
||||||
className="text-sm cursor-pointer"
|
|
||||||
>
|
|
||||||
Enable automated testing
|
|
||||||
</Label>
|
|
||||||
<FlaskConical className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
When enabled, this feature will use automated TDD. When disabled,
|
|
||||||
it will require manual verification.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Verification Steps - Only shown when skipTests is enabled */}
|
|
||||||
{newFeature.skipTests && (
|
|
||||||
<div className="space-y-2 pt-2 border-t border-border">
|
|
||||||
<Label>Verification Steps</Label>
|
|
||||||
<p className="text-xs text-muted-foreground mb-2">
|
|
||||||
Add manual steps to verify this feature works correctly.
|
|
||||||
</p>
|
|
||||||
{newFeature.steps.map((step, index) => (
|
|
||||||
<Input
|
|
||||||
key={index}
|
|
||||||
placeholder={`Verification step ${index + 1}`}
|
|
||||||
value={step}
|
|
||||||
onChange={(e) => {
|
|
||||||
const steps = [...newFeature.steps];
|
|
||||||
steps[index] = e.target.value;
|
|
||||||
setNewFeature({ ...newFeature, steps });
|
|
||||||
}}
|
|
||||||
data-testid={`feature-step-${index}-input`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
setNewFeature({
|
|
||||||
...newFeature,
|
|
||||||
steps: [...newFeature.steps, ""],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
data-testid="add-step-button"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Add Verification Step
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<HotkeyButton
|
|
||||||
onClick={handleAdd}
|
|
||||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
|
||||||
hotkeyActive={open}
|
|
||||||
data-testid="confirm-add-feature"
|
|
||||||
>
|
|
||||||
Add Feature
|
|
||||||
</HotkeyButton>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,541 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
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";
|
|
||||||
import {
|
|
||||||
DescriptionImageDropZone,
|
|
||||||
FeatureImagePath as DescriptionImagePath,
|
|
||||||
ImagePreviewMap,
|
|
||||||
} from "@/components/ui/description-image-dropzone";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import {
|
|
||||||
MessageSquare,
|
|
||||||
Settings2,
|
|
||||||
FlaskConical,
|
|
||||||
Plus,
|
|
||||||
Brain,
|
|
||||||
UserCircle,
|
|
||||||
Zap,
|
|
||||||
Scale,
|
|
||||||
Cpu,
|
|
||||||
Rocket,
|
|
||||||
Sparkles,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { cn, modelSupportsThinking } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
Feature,
|
|
||||||
AgentModel,
|
|
||||||
ThinkingLevel,
|
|
||||||
AIProfile,
|
|
||||||
} from "@/store/app-store";
|
|
||||||
|
|
||||||
type ModelOption = {
|
|
||||||
id: AgentModel;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
badge?: string;
|
|
||||||
provider: "claude";
|
|
||||||
};
|
|
||||||
|
|
||||||
const CLAUDE_MODELS: ModelOption[] = [
|
|
||||||
{
|
|
||||||
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: "opus",
|
|
||||||
label: "Claude Opus",
|
|
||||||
description: "Most capable model for complex work.",
|
|
||||||
badge: "Premium",
|
|
||||||
provider: "claude",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Profile icon mapping
|
|
||||||
const PROFILE_ICONS: Record<
|
|
||||||
string,
|
|
||||||
React.ComponentType<{ className?: string }>
|
|
||||||
> = {
|
|
||||||
Brain,
|
|
||||||
Zap,
|
|
||||||
Scale,
|
|
||||||
Cpu,
|
|
||||||
Rocket,
|
|
||||||
Sparkles,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface EditFeatureDialogProps {
|
|
||||||
feature: Feature | null;
|
|
||||||
onClose: () => void;
|
|
||||||
onUpdate: (featureId: string, updates: {
|
|
||||||
category: string;
|
|
||||||
description: string;
|
|
||||||
steps: string[];
|
|
||||||
skipTests: boolean;
|
|
||||||
model: AgentModel;
|
|
||||||
thinkingLevel: ThinkingLevel;
|
|
||||||
imagePaths: DescriptionImagePath[];
|
|
||||||
}) => void;
|
|
||||||
categorySuggestions: string[];
|
|
||||||
isMaximized: boolean;
|
|
||||||
showProfilesOnly: boolean;
|
|
||||||
aiProfiles: AIProfile[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EditFeatureDialog({
|
|
||||||
feature,
|
|
||||||
onClose,
|
|
||||||
onUpdate,
|
|
||||||
categorySuggestions,
|
|
||||||
isMaximized,
|
|
||||||
showProfilesOnly,
|
|
||||||
aiProfiles,
|
|
||||||
}: EditFeatureDialogProps) {
|
|
||||||
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
|
|
||||||
const [editFeaturePreviewMap, setEditFeaturePreviewMap] =
|
|
||||||
useState<ImagePreviewMap>(() => new Map());
|
|
||||||
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
|
|
||||||
|
|
||||||
// Update local state when feature prop changes
|
|
||||||
useEffect(() => {
|
|
||||||
setEditingFeature(feature);
|
|
||||||
if (!feature) {
|
|
||||||
setEditFeaturePreviewMap(new Map());
|
|
||||||
setShowEditAdvancedOptions(false);
|
|
||||||
}
|
|
||||||
}, [feature]);
|
|
||||||
|
|
||||||
const handleUpdate = () => {
|
|
||||||
if (!editingFeature) return;
|
|
||||||
|
|
||||||
const selectedModel = (editingFeature.model ?? "opus") as AgentModel;
|
|
||||||
const normalizedThinking = modelSupportsThinking(selectedModel)
|
|
||||||
? editingFeature.thinkingLevel
|
|
||||||
: "none";
|
|
||||||
|
|
||||||
const updates = {
|
|
||||||
category: editingFeature.category,
|
|
||||||
description: editingFeature.description,
|
|
||||||
steps: editingFeature.steps,
|
|
||||||
skipTests: editingFeature.skipTests,
|
|
||||||
model: selectedModel,
|
|
||||||
thinkingLevel: normalizedThinking,
|
|
||||||
imagePaths: editingFeature.imagePaths ?? [],
|
|
||||||
};
|
|
||||||
|
|
||||||
onUpdate(editingFeature.id, updates);
|
|
||||||
setEditFeaturePreviewMap(new Map());
|
|
||||||
setShowEditAdvancedOptions(false);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDialogClose = (open: boolean) => {
|
|
||||||
if (!open) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderModelOptions = (
|
|
||||||
options: ModelOption[],
|
|
||||||
selectedModel: AgentModel,
|
|
||||||
onSelect: (model: AgentModel) => void,
|
|
||||||
testIdPrefix = "model-select"
|
|
||||||
) => (
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{options.map((option) => {
|
|
||||||
const isSelected = selectedModel === option.id;
|
|
||||||
// Shorter display names for compact view
|
|
||||||
const shortName = option.label.replace("Claude ", "");
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={option.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSelect(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",
|
|
||||||
isSelected
|
|
||||||
? "bg-primary text-primary-foreground border-primary"
|
|
||||||
: "bg-background hover:bg-accent border-input"
|
|
||||||
)}
|
|
||||||
data-testid={`${testIdPrefix}-${option.id}`}
|
|
||||||
>
|
|
||||||
{shortName}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const editModelAllowsThinking = modelSupportsThinking(editingFeature?.model);
|
|
||||||
|
|
||||||
if (!editingFeature) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={!!editingFeature} onOpenChange={handleDialogClose}>
|
|
||||||
<DialogContent
|
|
||||||
compact={!isMaximized}
|
|
||||||
data-testid="edit-feature-dialog"
|
|
||||||
onPointerDownOutside={(e) => {
|
|
||||||
// Prevent dialog from closing when clicking on category autocomplete dropdown
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onInteractOutside={(e) => {
|
|
||||||
// Prevent dialog from closing when clicking on category autocomplete dropdown
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<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" />
|
|
||||||
Prompt
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="model" data-testid="edit-tab-model">
|
|
||||||
<Settings2 className="w-4 h-4 mr-2" />
|
|
||||||
Model
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="testing" data-testid="edit-tab-testing">
|
|
||||||
<FlaskConical className="w-4 h-4 mr-2" />
|
|
||||||
Testing
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* Prompt Tab */}
|
|
||||||
<TabsContent value="prompt" className="space-y-4 overflow-y-auto">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="edit-description">Description</Label>
|
|
||||||
<DescriptionImageDropZone
|
|
||||||
value={editingFeature.description}
|
|
||||||
onChange={(value) =>
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
description: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
images={editingFeature.imagePaths ?? []}
|
|
||||||
onImagesChange={(images) =>
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
imagePaths: images,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="Describe the feature..."
|
|
||||||
previewMap={editFeaturePreviewMap}
|
|
||||||
onPreviewMapChange={setEditFeaturePreviewMap}
|
|
||||||
data-testid="edit-feature-description"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="edit-category">Category (optional)</Label>
|
|
||||||
<CategoryAutocomplete
|
|
||||||
value={editingFeature.category}
|
|
||||||
onChange={(value) =>
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
category: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
suggestions={categorySuggestions}
|
|
||||||
placeholder="e.g., Core, UI, API"
|
|
||||||
data-testid="edit-feature-category"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Model Tab */}
|
|
||||||
<TabsContent value="model" className="space-y-4 overflow-y-auto">
|
|
||||||
{/* Show Advanced Options Toggle - only when profiles-only mode is enabled */}
|
|
||||||
{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-xs text-muted-foreground">
|
|
||||||
Only showing AI profiles. Advanced model tweaking is hidden.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
setShowEditAdvancedOptions(!showEditAdvancedOptions)
|
|
||||||
}
|
|
||||||
data-testid="edit-show-advanced-options-toggle"
|
|
||||||
>
|
|
||||||
<Settings2 className="w-4 h-4 mr-2" />
|
|
||||||
{showEditAdvancedOptions ? "Hide" : "Show"} Advanced
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quick Select Profile Section */}
|
|
||||||
{aiProfiles.length > 0 && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="flex items-center gap-2">
|
|
||||||
<UserCircle className="w-4 h-4 text-brand-500" />
|
|
||||||
Quick Select Profile
|
|
||||||
</Label>
|
|
||||||
<span className="text-[11px] px-2 py-0.5 rounded-full border border-brand-500/40 text-brand-500">
|
|
||||||
Presets
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{aiProfiles.slice(0, 6).map((profile) => {
|
|
||||||
const IconComponent = profile.icon
|
|
||||||
? PROFILE_ICONS[profile.icon]
|
|
||||||
: Brain;
|
|
||||||
const isSelected =
|
|
||||||
editingFeature.model === profile.model &&
|
|
||||||
editingFeature.thinkingLevel === profile.thinkingLevel;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={profile.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
model: profile.model,
|
|
||||||
thinkingLevel: profile.thinkingLevel,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"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"
|
|
||||||
)}
|
|
||||||
data-testid={`edit-profile-quick-select-${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" />
|
|
||||||
)}
|
|
||||||
</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}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Or customize below.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Separator */}
|
|
||||||
{aiProfiles.length > 0 &&
|
|
||||||
(!showProfilesOnly || showEditAdvancedOptions) && (
|
|
||||||
<div className="border-t border-border" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Claude Models Section - Hidden when showProfilesOnly is true and showEditAdvancedOptions is false */}
|
|
||||||
{(!showProfilesOnly || showEditAdvancedOptions) && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="flex items-center gap-2">
|
|
||||||
<Brain className="w-4 h-4 text-primary" />
|
|
||||||
Claude (SDK)
|
|
||||||
</Label>
|
|
||||||
<span className="text-[11px] px-2 py-0.5 rounded-full border border-primary/40 text-primary">
|
|
||||||
Native
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{renderModelOptions(
|
|
||||||
CLAUDE_MODELS,
|
|
||||||
(editingFeature.model ?? "opus") as AgentModel,
|
|
||||||
(model) =>
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
model,
|
|
||||||
thinkingLevel: modelSupportsThinking(model)
|
|
||||||
? editingFeature.thinkingLevel
|
|
||||||
: "none",
|
|
||||||
}),
|
|
||||||
"edit-model-select"
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Thinking Level - Only shown when Claude model is selected */}
|
|
||||||
{editModelAllowsThinking && (
|
|
||||||
<div className="space-y-2 pt-2 border-t border-border">
|
|
||||||
<Label className="flex items-center gap-2 text-sm">
|
|
||||||
<Brain className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
Thinking Level
|
|
||||||
</Label>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
"none",
|
|
||||||
"low",
|
|
||||||
"medium",
|
|
||||||
"high",
|
|
||||||
"ultrathink",
|
|
||||||
] as ThinkingLevel[]
|
|
||||||
).map((level) => (
|
|
||||||
<button
|
|
||||||
key={level}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
thinkingLevel: level,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors min-w-[60px]",
|
|
||||||
(editingFeature.thinkingLevel ?? "none") === level
|
|
||||||
? "bg-primary text-primary-foreground border-primary"
|
|
||||||
: "bg-background hover:bg-accent border-input"
|
|
||||||
)}
|
|
||||||
data-testid={`edit-thinking-level-${level}`}
|
|
||||||
>
|
|
||||||
{level === "none" && "None"}
|
|
||||||
{level === "low" && "Low"}
|
|
||||||
{level === "medium" && "Med"}
|
|
||||||
{level === "high" && "High"}
|
|
||||||
{level === "ultrathink" && "Ultra"}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Higher levels give more time to reason through complex
|
|
||||||
problems.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Testing Tab */}
|
|
||||||
<TabsContent value="testing" className="space-y-4 overflow-y-auto">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id="edit-skip-tests"
|
|
||||||
checked={!(editingFeature.skipTests ?? false)}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
skipTests: checked !== true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
data-testid="edit-skip-tests-checkbox"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="edit-skip-tests"
|
|
||||||
className="text-sm cursor-pointer"
|
|
||||||
>
|
|
||||||
Enable automated testing
|
|
||||||
</Label>
|
|
||||||
<FlaskConical className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
When enabled, this feature will use automated TDD. When disabled,
|
|
||||||
it will require manual verification.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Verification Steps - Only shown when skipTests is enabled */}
|
|
||||||
{editingFeature.skipTests && (
|
|
||||||
<div className="space-y-2 pt-2 border-t border-border">
|
|
||||||
<Label>Verification Steps</Label>
|
|
||||||
<p className="text-xs text-muted-foreground mb-2">
|
|
||||||
Add manual steps to verify this feature works correctly.
|
|
||||||
</p>
|
|
||||||
{editingFeature.steps.map((step, index) => (
|
|
||||||
<Input
|
|
||||||
key={index}
|
|
||||||
value={step}
|
|
||||||
placeholder={`Verification step ${index + 1}`}
|
|
||||||
onChange={(e) => {
|
|
||||||
const steps = [...editingFeature.steps];
|
|
||||||
steps[index] = e.target.value;
|
|
||||||
setEditingFeature({ ...editingFeature, steps });
|
|
||||||
}}
|
|
||||||
data-testid={`edit-feature-step-${index}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
steps: [...editingFeature.steps, ""],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Add Verification Step
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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"
|
|
||||||
>
|
|
||||||
Save Changes
|
|
||||||
</HotkeyButton>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
137
apps/app/src/components/views/board-view/board-controls.tsx
Normal file
137
apps/app/src/components/views/board-view/board-controls.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoardControls({
|
||||||
|
isMounted,
|
||||||
|
onShowBoardBackground,
|
||||||
|
onShowCompletedModal,
|
||||||
|
completedCount,
|
||||||
|
kanbanCardDetailLevel,
|
||||||
|
onDetailLevelChange,
|
||||||
|
}: BoardControlsProps) {
|
||||||
|
if (!isMounted) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
{/* Board Background Button */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onShowBoardBackground}
|
||||||
|
className="h-8 px-2"
|
||||||
|
data-testid="board-background-button"
|
||||||
|
>
|
||||||
|
<ImageIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Board Background Settings</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Completed/Archived Features Button */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onShowCompletedModal}
|
||||||
|
className="h-8 px-2 relative"
|
||||||
|
data-testid="completed-features-button"
|
||||||
|
>
|
||||||
|
<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}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Completed Features ({completedCount})</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Kanban Card Detail Level Toggle */}
|
||||||
|
<div
|
||||||
|
className="flex items-center rounded-lg bg-secondary border border-border"
|
||||||
|
data-testid="kanban-detail-toggle"
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
)}
|
||||||
|
data-testid="kanban-toggle-minimal"
|
||||||
|
>
|
||||||
|
<Minimize2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Minimal - Title & category only</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
)}
|
||||||
|
data-testid="kanban-toggle-standard"
|
||||||
|
>
|
||||||
|
<Square className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Standard - Steps & progress</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
)}
|
||||||
|
data-testid="kanban-toggle-detailed"
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Detailed - Model, tools & tasks</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { KanbanCard } from "./kanban-card";
|
||||||
|
export { KanbanColumn } from "./kanban-column";
|
||||||
22
apps/app/src/components/views/board-view/constants.ts
Normal file
22
apps/app/src/components/views/board-view/constants.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Feature } from "@/store/app-store";
|
||||||
|
|
||||||
|
export type ColumnId = Feature["status"];
|
||||||
|
|
||||||
|
export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
|
||||||
|
{ id: "backlog", title: "Backlog", colorClass: "bg-[var(--status-backlog)]" },
|
||||||
|
{
|
||||||
|
id: "in_progress",
|
||||||
|
title: "In Progress",
|
||||||
|
colorClass: "bg-[var(--status-in-progress)]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "waiting_approval",
|
||||||
|
title: "Waiting Approval",
|
||||||
|
colorClass: "bg-[var(--status-waiting)]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "verified",
|
||||||
|
title: "Verified",
|
||||||
|
colorClass: "bg-[var(--status-success)]",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
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 { Label } from "@/components/ui/label";
|
||||||
|
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
|
||||||
|
import {
|
||||||
|
DescriptionImageDropZone,
|
||||||
|
FeatureImagePath as DescriptionImagePath,
|
||||||
|
ImagePreviewMap,
|
||||||
|
} from "@/components/ui/description-image-dropzone";
|
||||||
|
import { MessageSquare, Settings2, FlaskConical } from "lucide-react";
|
||||||
|
import { modelSupportsThinking } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
useAppStore,
|
||||||
|
AgentModel,
|
||||||
|
ThinkingLevel,
|
||||||
|
FeatureImage,
|
||||||
|
AIProfile,
|
||||||
|
} from "@/store/app-store";
|
||||||
|
import {
|
||||||
|
ModelSelector,
|
||||||
|
ThinkingLevelSelector,
|
||||||
|
ProfileQuickSelect,
|
||||||
|
TestingTabContent,
|
||||||
|
} from "../shared";
|
||||||
|
|
||||||
|
interface AddFeatureDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onAdd: (feature: {
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
steps: string[];
|
||||||
|
images: FeatureImage[];
|
||||||
|
imagePaths: DescriptionImagePath[];
|
||||||
|
skipTests: boolean;
|
||||||
|
model: AgentModel;
|
||||||
|
thinkingLevel: ThinkingLevel;
|
||||||
|
}) => void;
|
||||||
|
categorySuggestions: string[];
|
||||||
|
defaultSkipTests: boolean;
|
||||||
|
isMaximized: boolean;
|
||||||
|
showProfilesOnly: boolean;
|
||||||
|
aiProfiles: AIProfile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddFeatureDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onAdd,
|
||||||
|
categorySuggestions,
|
||||||
|
defaultSkipTests,
|
||||||
|
isMaximized,
|
||||||
|
showProfilesOnly,
|
||||||
|
aiProfiles,
|
||||||
|
}: AddFeatureDialogProps) {
|
||||||
|
const [newFeature, setNewFeature] = useState({
|
||||||
|
category: "",
|
||||||
|
description: "",
|
||||||
|
steps: [""],
|
||||||
|
images: [] as FeatureImage[],
|
||||||
|
imagePaths: [] as DescriptionImagePath[],
|
||||||
|
skipTests: false,
|
||||||
|
model: "opus" as AgentModel,
|
||||||
|
thinkingLevel: "none" as ThinkingLevel,
|
||||||
|
});
|
||||||
|
const [newFeaturePreviewMap, setNewFeaturePreviewMap] =
|
||||||
|
useState<ImagePreviewMap>(() => new Map());
|
||||||
|
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
||||||
|
const [descriptionError, setDescriptionError] = useState(false);
|
||||||
|
|
||||||
|
// Sync skipTests default when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setNewFeature((prev) => ({
|
||||||
|
...prev,
|
||||||
|
skipTests: defaultSkipTests,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [open, defaultSkipTests]);
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!newFeature.description.trim()) {
|
||||||
|
setDescriptionError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = newFeature.category || "Uncategorized";
|
||||||
|
const selectedModel = newFeature.model;
|
||||||
|
const normalizedThinking = modelSupportsThinking(selectedModel)
|
||||||
|
? newFeature.thinkingLevel
|
||||||
|
: "none";
|
||||||
|
|
||||||
|
onAdd({
|
||||||
|
category,
|
||||||
|
description: newFeature.description,
|
||||||
|
steps: newFeature.steps.filter((s) => s.trim()),
|
||||||
|
images: newFeature.images,
|
||||||
|
imagePaths: newFeature.imagePaths,
|
||||||
|
skipTests: newFeature.skipTests,
|
||||||
|
model: selectedModel,
|
||||||
|
thinkingLevel: normalizedThinking,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setNewFeature({
|
||||||
|
category: "",
|
||||||
|
description: "",
|
||||||
|
steps: [""],
|
||||||
|
images: [],
|
||||||
|
imagePaths: [],
|
||||||
|
skipTests: defaultSkipTests,
|
||||||
|
model: "opus",
|
||||||
|
thinkingLevel: "none",
|
||||||
|
});
|
||||||
|
setNewFeaturePreviewMap(new Map());
|
||||||
|
setShowAdvancedOptions(false);
|
||||||
|
setDescriptionError(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogClose = (open: boolean) => {
|
||||||
|
onOpenChange(open);
|
||||||
|
if (!open) {
|
||||||
|
setNewFeaturePreviewMap(new Map());
|
||||||
|
setShowAdvancedOptions(false);
|
||||||
|
setDescriptionError(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModelSelect = (model: AgentModel) => {
|
||||||
|
setNewFeature({
|
||||||
|
...newFeature,
|
||||||
|
model,
|
||||||
|
thinkingLevel: modelSupportsThinking(model)
|
||||||
|
? newFeature.thinkingLevel
|
||||||
|
: "none",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
|
||||||
|
setNewFeature({
|
||||||
|
...newFeature,
|
||||||
|
model,
|
||||||
|
thinkingLevel,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const newModelAllowsThinking = modelSupportsThinking(newFeature.model);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleDialogClose}>
|
||||||
|
<DialogContent
|
||||||
|
compact={!isMaximized}
|
||||||
|
data-testid="add-feature-dialog"
|
||||||
|
onPointerDownOutside={(e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onInteractOutside={(e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add New Feature</DialogTitle>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
Prompt
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="model" data-testid="tab-model">
|
||||||
|
<Settings2 className="w-4 h-4 mr-2" />
|
||||||
|
Model
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="testing" data-testid="tab-testing">
|
||||||
|
<FlaskConical className="w-4 h-4 mr-2" />
|
||||||
|
Testing
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Prompt Tab */}
|
||||||
|
<TabsContent value="prompt" className="space-y-4 overflow-y-auto">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<DescriptionImageDropZone
|
||||||
|
value={newFeature.description}
|
||||||
|
onChange={(value) => {
|
||||||
|
setNewFeature({ ...newFeature, description: value });
|
||||||
|
if (value.trim()) {
|
||||||
|
setDescriptionError(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
images={newFeature.imagePaths}
|
||||||
|
onImagesChange={(images) =>
|
||||||
|
setNewFeature({ ...newFeature, imagePaths: images })
|
||||||
|
}
|
||||||
|
placeholder="Describe the feature..."
|
||||||
|
previewMap={newFeaturePreviewMap}
|
||||||
|
onPreviewMapChange={setNewFeaturePreviewMap}
|
||||||
|
autoFocus
|
||||||
|
error={descriptionError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="category">Category (optional)</Label>
|
||||||
|
<CategoryAutocomplete
|
||||||
|
value={newFeature.category}
|
||||||
|
onChange={(value) =>
|
||||||
|
setNewFeature({ ...newFeature, category: value })
|
||||||
|
}
|
||||||
|
suggestions={categorySuggestions}
|
||||||
|
placeholder="e.g., Core, UI, API"
|
||||||
|
data-testid="feature-category-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Model Tab */}
|
||||||
|
<TabsContent value="model" className="space-y-4 overflow-y-auto">
|
||||||
|
{/* 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-xs text-muted-foreground">
|
||||||
|
Only showing AI profiles. Advanced model tweaking is hidden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
|
||||||
|
data-testid="show-advanced-options-toggle"
|
||||||
|
>
|
||||||
|
<Settings2 className="w-4 h-4 mr-2" />
|
||||||
|
{showAdvancedOptions ? "Hide" : "Show"} Advanced
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Select Profile Section */}
|
||||||
|
<ProfileQuickSelect
|
||||||
|
profiles={aiProfiles}
|
||||||
|
selectedModel={newFeature.model}
|
||||||
|
selectedThinkingLevel={newFeature.thinkingLevel}
|
||||||
|
onSelect={handleProfileSelect}
|
||||||
|
showManageLink
|
||||||
|
onManageLinkClick={() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
useAppStore.getState().setCurrentView("profiles");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
{aiProfiles.length > 0 &&
|
||||||
|
(!showProfilesOnly || showAdvancedOptions) && (
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Claude Models Section */}
|
||||||
|
{(!showProfilesOnly || showAdvancedOptions) && (
|
||||||
|
<>
|
||||||
|
<ModelSelector
|
||||||
|
selectedModel={newFeature.model}
|
||||||
|
onModelSelect={handleModelSelect}
|
||||||
|
/>
|
||||||
|
{newModelAllowsThinking && (
|
||||||
|
<ThinkingLevelSelector
|
||||||
|
selectedLevel={newFeature.thinkingLevel}
|
||||||
|
onLevelSelect={(level) =>
|
||||||
|
setNewFeature({ ...newFeature, thinkingLevel: level })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Testing Tab */}
|
||||||
|
<TabsContent value="testing" className="space-y-4 overflow-y-auto">
|
||||||
|
<TestingTabContent
|
||||||
|
skipTests={newFeature.skipTests}
|
||||||
|
onSkipTestsChange={(skipTests) =>
|
||||||
|
setNewFeature({ ...newFeature, skipTests })
|
||||||
|
}
|
||||||
|
steps={newFeature.steps}
|
||||||
|
onStepsChange={(steps) =>
|
||||||
|
setNewFeature({ ...newFeature, steps })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<HotkeyButton
|
||||||
|
onClick={handleAdd}
|
||||||
|
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||||
|
hotkeyActive={open}
|
||||||
|
data-testid="confirm-add-feature"
|
||||||
|
>
|
||||||
|
Add Feature
|
||||||
|
</HotkeyButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
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";
|
||||||
|
|
||||||
|
interface CompletedFeaturesModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
completedFeatures: Feature[];
|
||||||
|
onUnarchive: (feature: Feature) => void;
|
||||||
|
onDelete: (feature: Feature) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompletedFeaturesModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
completedFeatures,
|
||||||
|
onUnarchive,
|
||||||
|
onDelete,
|
||||||
|
}: CompletedFeaturesModalProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className="max-w-6xl max-h-[90vh] flex flex-col"
|
||||||
|
data-testid="completed-features-modal"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Completed Features</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{completedFeatures.length === 0
|
||||||
|
? "No completed features yet."
|
||||||
|
: `${completedFeatures.length} completed feature${
|
||||||
|
completedFeatures.length > 1 ? "s" : ""
|
||||||
|
}`}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex-1 overflow-y-auto py-4">
|
||||||
|
{completedFeatures.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8">
|
||||||
|
<ArchiveRestore className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||||
|
<p>No completed features</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{completedFeatures.map((feature) => (
|
||||||
|
<Card
|
||||||
|
key={feature.id}
|
||||||
|
className="flex flex-col"
|
||||||
|
data-testid={`completed-card-${feature.id}`}
|
||||||
|
>
|
||||||
|
<CardHeader className="p-3 pb-2 flex-1">
|
||||||
|
<CardTitle className="text-sm leading-tight line-clamp-3">
|
||||||
|
{feature.description || feature.summary || feature.id}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs mt-1 truncate">
|
||||||
|
{feature.category || "Uncategorized"}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<div className="p-3 pt-0 flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-7 text-xs"
|
||||||
|
onClick={() => onUnarchive(feature)}
|
||||||
|
data-testid={`unarchive-${feature.id}`}
|
||||||
|
>
|
||||||
|
<ArchiveRestore className="w-3 h-3 mr-1" />
|
||||||
|
Restore
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => onDelete(feature)}
|
||||||
|
data-testid={`delete-completed-${feature.id}`}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"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 DeleteAllVerifiedDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
verifiedCount: number;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteAllVerifiedDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
verifiedCount,
|
||||||
|
onConfirm,
|
||||||
|
}: DeleteAllVerifiedDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent data-testid="delete-all-verified-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete All Verified Features</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
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.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={onConfirm} data-testid="confirm-delete-all-verified">
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete All
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} 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;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteCompletedFeatureDialog({
|
||||||
|
feature,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: DeleteCompletedFeatureDialogProps) {
|
||||||
|
if (!feature) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!feature} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent data-testid="delete-completed-confirmation-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||||
|
<Trash2 className="w-5 h-5" />
|
||||||
|
Delete Feature
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to permanently delete this feature?
|
||||||
|
<span className="block mt-2 font-medium text-foreground">
|
||||||
|
"{feature.description?.slice(0, 100)}
|
||||||
|
{(feature.description?.length ?? 0) > 100 ? "..." : ""}"
|
||||||
|
</span>
|
||||||
|
<span className="block mt-2 text-destructive font-medium">
|
||||||
|
This action cannot be undone.
|
||||||
|
</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
data-testid="cancel-delete-completed-button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={onConfirm}
|
||||||
|
data-testid="confirm-delete-completed-button"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
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 { Label } from "@/components/ui/label";
|
||||||
|
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
|
||||||
|
import {
|
||||||
|
DescriptionImageDropZone,
|
||||||
|
FeatureImagePath as DescriptionImagePath,
|
||||||
|
ImagePreviewMap,
|
||||||
|
} from "@/components/ui/description-image-dropzone";
|
||||||
|
import { MessageSquare, Settings2, FlaskConical } from "lucide-react";
|
||||||
|
import { modelSupportsThinking } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Feature,
|
||||||
|
AgentModel,
|
||||||
|
ThinkingLevel,
|
||||||
|
AIProfile,
|
||||||
|
} from "@/store/app-store";
|
||||||
|
import {
|
||||||
|
ModelSelector,
|
||||||
|
ThinkingLevelSelector,
|
||||||
|
ProfileQuickSelect,
|
||||||
|
TestingTabContent,
|
||||||
|
} from "../shared";
|
||||||
|
|
||||||
|
interface EditFeatureDialogProps {
|
||||||
|
feature: Feature | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onUpdate: (
|
||||||
|
featureId: string,
|
||||||
|
updates: {
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
steps: string[];
|
||||||
|
skipTests: boolean;
|
||||||
|
model: AgentModel;
|
||||||
|
thinkingLevel: ThinkingLevel;
|
||||||
|
imagePaths: DescriptionImagePath[];
|
||||||
|
}
|
||||||
|
) => void;
|
||||||
|
categorySuggestions: string[];
|
||||||
|
isMaximized: boolean;
|
||||||
|
showProfilesOnly: boolean;
|
||||||
|
aiProfiles: AIProfile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditFeatureDialog({
|
||||||
|
feature,
|
||||||
|
onClose,
|
||||||
|
onUpdate,
|
||||||
|
categorySuggestions,
|
||||||
|
isMaximized,
|
||||||
|
showProfilesOnly,
|
||||||
|
aiProfiles,
|
||||||
|
}: EditFeatureDialogProps) {
|
||||||
|
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
|
||||||
|
const [editFeaturePreviewMap, setEditFeaturePreviewMap] =
|
||||||
|
useState<ImagePreviewMap>(() => new Map());
|
||||||
|
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditingFeature(feature);
|
||||||
|
if (!feature) {
|
||||||
|
setEditFeaturePreviewMap(new Map());
|
||||||
|
setShowEditAdvancedOptions(false);
|
||||||
|
}
|
||||||
|
}, [feature]);
|
||||||
|
|
||||||
|
const handleUpdate = () => {
|
||||||
|
if (!editingFeature) return;
|
||||||
|
|
||||||
|
const selectedModel = (editingFeature.model ?? "opus") as AgentModel;
|
||||||
|
const normalizedThinking: ThinkingLevel = modelSupportsThinking(selectedModel)
|
||||||
|
? (editingFeature.thinkingLevel ?? "none")
|
||||||
|
: "none";
|
||||||
|
|
||||||
|
const updates = {
|
||||||
|
category: editingFeature.category,
|
||||||
|
description: editingFeature.description,
|
||||||
|
steps: editingFeature.steps,
|
||||||
|
skipTests: editingFeature.skipTests ?? false,
|
||||||
|
model: selectedModel,
|
||||||
|
thinkingLevel: normalizedThinking,
|
||||||
|
imagePaths: editingFeature.imagePaths ?? [],
|
||||||
|
};
|
||||||
|
|
||||||
|
onUpdate(editingFeature.id, updates);
|
||||||
|
setEditFeaturePreviewMap(new Map());
|
||||||
|
setShowEditAdvancedOptions(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogClose = (open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModelSelect = (model: AgentModel) => {
|
||||||
|
if (!editingFeature) return;
|
||||||
|
setEditingFeature({
|
||||||
|
...editingFeature,
|
||||||
|
model,
|
||||||
|
thinkingLevel: modelSupportsThinking(model)
|
||||||
|
? editingFeature.thinkingLevel
|
||||||
|
: "none",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
|
||||||
|
if (!editingFeature) return;
|
||||||
|
setEditingFeature({
|
||||||
|
...editingFeature,
|
||||||
|
model,
|
||||||
|
thinkingLevel,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const editModelAllowsThinking = modelSupportsThinking(editingFeature?.model);
|
||||||
|
|
||||||
|
if (!editingFeature) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!editingFeature} onOpenChange={handleDialogClose}>
|
||||||
|
<DialogContent
|
||||||
|
compact={!isMaximized}
|
||||||
|
data-testid="edit-feature-dialog"
|
||||||
|
onPointerDownOutside={(e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onInteractOutside={(e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
Prompt
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="model" data-testid="edit-tab-model">
|
||||||
|
<Settings2 className="w-4 h-4 mr-2" />
|
||||||
|
Model
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="testing" data-testid="edit-tab-testing">
|
||||||
|
<FlaskConical className="w-4 h-4 mr-2" />
|
||||||
|
Testing
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Prompt Tab */}
|
||||||
|
<TabsContent value="prompt" className="space-y-4 overflow-y-auto">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-description">Description</Label>
|
||||||
|
<DescriptionImageDropZone
|
||||||
|
value={editingFeature.description}
|
||||||
|
onChange={(value) =>
|
||||||
|
setEditingFeature({
|
||||||
|
...editingFeature,
|
||||||
|
description: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
images={editingFeature.imagePaths ?? []}
|
||||||
|
onImagesChange={(images) =>
|
||||||
|
setEditingFeature({
|
||||||
|
...editingFeature,
|
||||||
|
imagePaths: images,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Describe the feature..."
|
||||||
|
previewMap={editFeaturePreviewMap}
|
||||||
|
onPreviewMapChange={setEditFeaturePreviewMap}
|
||||||
|
data-testid="edit-feature-description"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-category">Category (optional)</Label>
|
||||||
|
<CategoryAutocomplete
|
||||||
|
value={editingFeature.category}
|
||||||
|
onChange={(value) =>
|
||||||
|
setEditingFeature({
|
||||||
|
...editingFeature,
|
||||||
|
category: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
suggestions={categorySuggestions}
|
||||||
|
placeholder="e.g., Core, UI, API"
|
||||||
|
data-testid="edit-feature-category"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Model Tab */}
|
||||||
|
<TabsContent value="model" className="space-y-4 overflow-y-auto">
|
||||||
|
{/* 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-xs text-muted-foreground">
|
||||||
|
Only showing AI profiles. Advanced model tweaking is hidden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setShowEditAdvancedOptions(!showEditAdvancedOptions)
|
||||||
|
}
|
||||||
|
data-testid="edit-show-advanced-options-toggle"
|
||||||
|
>
|
||||||
|
<Settings2 className="w-4 h-4 mr-2" />
|
||||||
|
{showEditAdvancedOptions ? "Hide" : "Show"} Advanced
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Select Profile Section */}
|
||||||
|
<ProfileQuickSelect
|
||||||
|
profiles={aiProfiles}
|
||||||
|
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" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Claude Models Section */}
|
||||||
|
{(!showProfilesOnly || showEditAdvancedOptions) && (
|
||||||
|
<>
|
||||||
|
<ModelSelector
|
||||||
|
selectedModel={(editingFeature.model ?? "opus") as AgentModel}
|
||||||
|
onModelSelect={handleModelSelect}
|
||||||
|
testIdPrefix="edit-model-select"
|
||||||
|
/>
|
||||||
|
{editModelAllowsThinking && (
|
||||||
|
<ThinkingLevelSelector
|
||||||
|
selectedLevel={editingFeature.thinkingLevel ?? "none"}
|
||||||
|
onLevelSelect={(level) =>
|
||||||
|
setEditingFeature({
|
||||||
|
...editingFeature,
|
||||||
|
thinkingLevel: level,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
testIdPrefix="edit-thinking-level"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Testing Tab */}
|
||||||
|
<TabsContent value="testing" className="space-y-4 overflow-y-auto">
|
||||||
|
<TestingTabContent
|
||||||
|
skipTests={editingFeature.skipTests ?? false}
|
||||||
|
onSkipTestsChange={(skipTests) =>
|
||||||
|
setEditingFeature({ ...editingFeature, skipTests })
|
||||||
|
}
|
||||||
|
steps={editingFeature.steps}
|
||||||
|
onStepsChange={(steps) =>
|
||||||
|
setEditingFeature({ ...editingFeature, steps })
|
||||||
|
}
|
||||||
|
testIdPrefix="edit"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</HotkeyButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
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";
|
||||||
|
import {
|
||||||
|
DescriptionImageDropZone,
|
||||||
|
FeatureImagePath as DescriptionImagePath,
|
||||||
|
ImagePreviewMap,
|
||||||
|
} from "@/components/ui/description-image-dropzone";
|
||||||
|
import { MessageSquare } from "lucide-react";
|
||||||
|
import { Feature } from "@/store/app-store";
|
||||||
|
|
||||||
|
interface FollowUpDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
feature: Feature | null;
|
||||||
|
prompt: string;
|
||||||
|
imagePaths: DescriptionImagePath[];
|
||||||
|
previewMap: ImagePreviewMap;
|
||||||
|
onPromptChange: (prompt: string) => void;
|
||||||
|
onImagePathsChange: (paths: DescriptionImagePath[]) => void;
|
||||||
|
onPreviewMapChange: (map: ImagePreviewMap) => void;
|
||||||
|
onSend: () => void;
|
||||||
|
isMaximized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FollowUpDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
feature,
|
||||||
|
prompt,
|
||||||
|
imagePaths,
|
||||||
|
previewMap,
|
||||||
|
onPromptChange,
|
||||||
|
onImagePathsChange,
|
||||||
|
onPreviewMapChange,
|
||||||
|
onSend,
|
||||||
|
isMaximized,
|
||||||
|
}: FollowUpDialogProps) {
|
||||||
|
const handleClose = (open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent
|
||||||
|
compact={!isMaximized}
|
||||||
|
data-testid="follow-up-dialog"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && prompt.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Follow-Up Prompt</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Send additional instructions to continue working on this feature.
|
||||||
|
{feature && (
|
||||||
|
<span className="block mt-2 text-primary">
|
||||||
|
Feature: {feature.description.slice(0, 100)}
|
||||||
|
{feature.description.length > 100 ? "..." : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4 overflow-y-auto flex-1 min-h-0">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="follow-up-prompt">Instructions</Label>
|
||||||
|
<DescriptionImageDropZone
|
||||||
|
value={prompt}
|
||||||
|
onChange={onPromptChange}
|
||||||
|
images={imagePaths}
|
||||||
|
onImagesChange={onImagePathsChange}
|
||||||
|
placeholder="Describe what needs to be fixed or changed..."
|
||||||
|
previewMap={previewMap}
|
||||||
|
onPreviewMapChange={onPreviewMapChange}
|
||||||
|
/>
|
||||||
|
</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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<HotkeyButton
|
||||||
|
onClick={onSend}
|
||||||
|
disabled={!prompt.trim()}
|
||||||
|
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||||
|
hotkeyActive={open}
|
||||||
|
data-testid="confirm-follow-up"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-4 h-4 mr-2" />
|
||||||
|
Send Follow-Up
|
||||||
|
</HotkeyButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export { AddFeatureDialog } from "./add-feature-dialog";
|
||||||
|
export { AgentOutputModal } from "./agent-output-modal";
|
||||||
|
export { CompletedFeaturesModal } from "./completed-features-modal";
|
||||||
|
export { DeleteAllVerifiedDialog } from "./delete-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";
|
||||||
10
apps/app/src/components/views/board-view/hooks/index.ts
Normal file
10
apps/app/src/components/views/board-view/hooks/index.ts
Normal file
@@ -0,0 +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";
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
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";
|
||||||
|
import { useAutoMode } from "@/hooks/use-auto-mode";
|
||||||
|
import { truncateDescription } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface UseBoardActionsProps {
|
||||||
|
currentProject: { path: string; id: string } | null;
|
||||||
|
features: Feature[];
|
||||||
|
runningAutoTasks: string[];
|
||||||
|
loadFeatures: () => Promise<void>;
|
||||||
|
persistFeatureCreate: (feature: 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;
|
||||||
|
setShowOutputModal: (show: boolean) => void;
|
||||||
|
setOutputFeature: (feature: Feature | null) => void;
|
||||||
|
followUpFeature: Feature | null;
|
||||||
|
followUpPrompt: string;
|
||||||
|
followUpImagePaths: DescriptionImagePath[];
|
||||||
|
setFollowUpFeature: (feature: Feature | null) => void;
|
||||||
|
setFollowUpPrompt: (prompt: string) => void;
|
||||||
|
setFollowUpImagePaths: (paths: DescriptionImagePath[]) => void;
|
||||||
|
setFollowUpPreviewMap: (map: Map<string, string>) => void;
|
||||||
|
setShowFollowUpDialog: (show: boolean) => void;
|
||||||
|
inProgressFeaturesForShortcuts: Feature[];
|
||||||
|
outputFeature: Feature | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBoardActions({
|
||||||
|
currentProject,
|
||||||
|
features,
|
||||||
|
runningAutoTasks,
|
||||||
|
loadFeatures,
|
||||||
|
persistFeatureCreate,
|
||||||
|
persistFeatureUpdate,
|
||||||
|
persistFeatureDelete,
|
||||||
|
saveCategory,
|
||||||
|
setEditingFeature,
|
||||||
|
setShowOutputModal,
|
||||||
|
setOutputFeature,
|
||||||
|
followUpFeature,
|
||||||
|
followUpPrompt,
|
||||||
|
followUpImagePaths,
|
||||||
|
setFollowUpFeature,
|
||||||
|
setFollowUpPrompt,
|
||||||
|
setFollowUpImagePaths,
|
||||||
|
setFollowUpPreviewMap,
|
||||||
|
setShowFollowUpDialog,
|
||||||
|
inProgressFeaturesForShortcuts,
|
||||||
|
outputFeature,
|
||||||
|
}: UseBoardActionsProps) {
|
||||||
|
const { addFeature, updateFeature, removeFeature, moveFeature, useWorktrees } = useAppStore();
|
||||||
|
const autoMode = useAutoMode();
|
||||||
|
|
||||||
|
const handleAddFeature = useCallback(
|
||||||
|
(featureData: {
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
steps: string[];
|
||||||
|
images: FeatureImage[];
|
||||||
|
imagePaths: DescriptionImagePath[];
|
||||||
|
skipTests: boolean;
|
||||||
|
model: AgentModel;
|
||||||
|
thinkingLevel: ThinkingLevel;
|
||||||
|
}) => {
|
||||||
|
const newFeatureData = {
|
||||||
|
...featureData,
|
||||||
|
status: "backlog" as const,
|
||||||
|
};
|
||||||
|
const createdFeature = addFeature(newFeatureData);
|
||||||
|
persistFeatureCreate(createdFeature);
|
||||||
|
saveCategory(featureData.category);
|
||||||
|
},
|
||||||
|
[addFeature, persistFeatureCreate, saveCategory]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUpdateFeature = useCallback(
|
||||||
|
(
|
||||||
|
featureId: string,
|
||||||
|
updates: {
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
steps: string[];
|
||||||
|
skipTests: boolean;
|
||||||
|
model: AgentModel;
|
||||||
|
thinkingLevel: ThinkingLevel;
|
||||||
|
imagePaths: DescriptionImagePath[];
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
updateFeature(featureId, updates);
|
||||||
|
persistFeatureUpdate(featureId, updates);
|
||||||
|
if (updates.category) {
|
||||||
|
saveCategory(updates.category);
|
||||||
|
}
|
||||||
|
setEditingFeature(null);
|
||||||
|
},
|
||||||
|
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteFeature = useCallback(
|
||||||
|
async (featureId: string) => {
|
||||||
|
const feature = features.find((f) => f.id === featureId);
|
||||||
|
if (!feature) return;
|
||||||
|
|
||||||
|
const isRunning = runningAutoTasks.includes(featureId);
|
||||||
|
|
||||||
|
if (isRunning) {
|
||||||
|
try {
|
||||||
|
await autoMode.stopFeature(featureId);
|
||||||
|
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.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feature.imagePaths && feature.imagePaths.length > 0) {
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
for (const imagePathObj of feature.imagePaths) {
|
||||||
|
try {
|
||||||
|
await api.deleteFile(imagePathObj.path);
|
||||||
|
console.log(`[Board] Deleted image: ${imagePathObj.path}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Board] Failed to delete image ${imagePathObj.path}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Board] Error deleting images for feature ${featureId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFeature(featureId);
|
||||||
|
persistFeatureDelete(featureId);
|
||||||
|
},
|
||||||
|
[features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRunFeature = useCallback(
|
||||||
|
async (feature: Feature) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.autoMode) {
|
||||||
|
console.error("Auto mode API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.autoMode.runFeature(
|
||||||
|
currentProject.path,
|
||||||
|
feature.id,
|
||||||
|
useWorktrees
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log("[Board] Feature run started successfully");
|
||||||
|
} else {
|
||||||
|
console.error("[Board] Failed to run feature:", result.error);
|
||||||
|
await loadFeatures();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Board] Error running feature:", error);
|
||||||
|
await loadFeatures();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject, useWorktrees, loadFeatures]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStartImplementation = useCallback(
|
||||||
|
async (feature: Feature) => {
|
||||||
|
if (!autoMode.canStartNewTask) {
|
||||||
|
toast.error("Concurrency limit reached", {
|
||||||
|
description: `You can only have ${autoMode.maxConcurrency} task${
|
||||||
|
autoMode.maxConcurrency > 1 ? "s" : ""
|
||||||
|
} running at a time. Wait for a task to complete or increase the limit.`,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = {
|
||||||
|
status: "in_progress" as const,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
updateFeature(feature.id, updates);
|
||||||
|
persistFeatureUpdate(feature.id, updates);
|
||||||
|
console.log("[Board] Feature moved to in_progress, starting agent...");
|
||||||
|
await handleRunFeature(feature);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[autoMode, updateFeature, persistFeatureUpdate, handleRunFeature]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleVerifyFeature = useCallback(
|
||||||
|
async (feature: Feature) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.autoMode) {
|
||||||
|
console.error("Auto mode API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.autoMode.verifyFeature(currentProject.path, feature.id);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log("[Board] Feature verification started successfully");
|
||||||
|
} else {
|
||||||
|
console.error("[Board] Failed to verify feature:", result.error);
|
||||||
|
await loadFeatures();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Board] Error verifying feature:", error);
|
||||||
|
await loadFeatures();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject, loadFeatures]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResumeFeature = useCallback(
|
||||||
|
async (feature: Feature) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.autoMode) {
|
||||||
|
console.error("Auto mode API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.autoMode.resumeFeature(currentProject.path, feature.id);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log("[Board] Feature resume started successfully");
|
||||||
|
} else {
|
||||||
|
console.error("[Board] Failed to resume feature:", result.error);
|
||||||
|
await loadFeatures();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Board] Error resuming feature:", error);
|
||||||
|
await loadFeatures();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject, loadFeatures]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleManualVerify = useCallback(
|
||||||
|
(feature: Feature) => {
|
||||||
|
moveFeature(feature.id, "verified");
|
||||||
|
persistFeatureUpdate(feature.id, {
|
||||||
|
status: "verified",
|
||||||
|
justFinishedAt: undefined,
|
||||||
|
});
|
||||||
|
toast.success("Feature verified", {
|
||||||
|
description: `Marked as verified: ${truncateDescription(feature.description)}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[moveFeature, persistFeatureUpdate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMoveBackToInProgress = useCallback(
|
||||||
|
(feature: Feature) => {
|
||||||
|
const updates = {
|
||||||
|
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)}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateFeature, persistFeatureUpdate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenFollowUp = useCallback(
|
||||||
|
(feature: Feature) => {
|
||||||
|
setFollowUpFeature(feature);
|
||||||
|
setFollowUpPrompt("");
|
||||||
|
setFollowUpImagePaths([]);
|
||||||
|
setShowFollowUpDialog(true);
|
||||||
|
},
|
||||||
|
[setFollowUpFeature, setFollowUpPrompt, setFollowUpImagePaths, setShowFollowUpDialog]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSendFollowUp = useCallback(async () => {
|
||||||
|
if (!currentProject || !followUpFeature || !followUpPrompt.trim()) return;
|
||||||
|
|
||||||
|
const featureId = followUpFeature.id;
|
||||||
|
const featureDescription = followUpFeature.description;
|
||||||
|
const prompt = followUpPrompt;
|
||||||
|
|
||||||
|
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.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = {
|
||||||
|
status: "in_progress" as const,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
justFinishedAt: undefined,
|
||||||
|
};
|
||||||
|
updateFeature(featureId, updates);
|
||||||
|
persistFeatureUpdate(featureId, updates);
|
||||||
|
|
||||||
|
setShowFollowUpDialog(false);
|
||||||
|
setFollowUpFeature(null);
|
||||||
|
setFollowUpPrompt("");
|
||||||
|
setFollowUpImagePaths([]);
|
||||||
|
setFollowUpPreviewMap(new Map());
|
||||||
|
|
||||||
|
toast.success("Follow-up started", {
|
||||||
|
description: `Continuing work on: ${truncateDescription(featureDescription)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const imagePaths = followUpImagePaths.map((img) => img.path);
|
||||||
|
api.autoMode
|
||||||
|
.followUpFeature(currentProject.path, followUpFeature.id, followUpPrompt, imagePaths)
|
||||||
|
.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",
|
||||||
|
});
|
||||||
|
loadFeatures();
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
currentProject,
|
||||||
|
followUpFeature,
|
||||||
|
followUpPrompt,
|
||||||
|
followUpImagePaths,
|
||||||
|
updateFeature,
|
||||||
|
persistFeatureUpdate,
|
||||||
|
setShowFollowUpDialog,
|
||||||
|
setFollowUpFeature,
|
||||||
|
setFollowUpPrompt,
|
||||||
|
setFollowUpImagePaths,
|
||||||
|
setFollowUpPreviewMap,
|
||||||
|
loadFeatures,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleCommitFeature = useCallback(
|
||||||
|
async (feature: Feature) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
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.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.autoMode.commitFeature(currentProject.path, feature.id);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
moveFeature(feature.id, "verified");
|
||||||
|
persistFeatureUpdate(feature.id, { status: "verified" });
|
||||||
|
toast.success("Feature committed", {
|
||||||
|
description: `Committed and verified: ${truncateDescription(feature.description)}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
await loadFeatures();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject, moveFeature, persistFeatureUpdate, loadFeatures]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRevertFeature = useCallback(
|
||||||
|
async (feature: Feature) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
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.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error("[Board] Failed to revert feature:", result.error);
|
||||||
|
toast.error("Failed to revert feature", {
|
||||||
|
description: result.error || "An error occurred",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Board] Error reverting feature:", error);
|
||||||
|
toast.error("Failed to revert feature", {
|
||||||
|
description: error instanceof Error ? error.message : "An error occurred",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject, loadFeatures]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMergeFeature = useCallback(
|
||||||
|
async (feature: Feature) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
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.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject, loadFeatures]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCompleteFeature = useCallback(
|
||||||
|
(feature: Feature) => {
|
||||||
|
const updates = {
|
||||||
|
status: "completed" as const,
|
||||||
|
};
|
||||||
|
updateFeature(feature.id, updates);
|
||||||
|
persistFeatureUpdate(feature.id, updates);
|
||||||
|
|
||||||
|
toast.success("Feature completed", {
|
||||||
|
description: `Archived: ${truncateDescription(feature.description)}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateFeature, persistFeatureUpdate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUnarchiveFeature = useCallback(
|
||||||
|
(feature: Feature) => {
|
||||||
|
const updates = {
|
||||||
|
status: "verified" as const,
|
||||||
|
};
|
||||||
|
updateFeature(feature.id, updates);
|
||||||
|
persistFeatureUpdate(feature.id, updates);
|
||||||
|
|
||||||
|
toast.success("Feature restored", {
|
||||||
|
description: `Moved back to verified: ${truncateDescription(feature.description)}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateFeature, persistFeatureUpdate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleViewOutput = useCallback(
|
||||||
|
(feature: Feature) => {
|
||||||
|
setOutputFeature(feature);
|
||||||
|
setShowOutputModal(true);
|
||||||
|
},
|
||||||
|
[setOutputFeature, setShowOutputModal]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOutputModalNumberKeyPress = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
const index = key === "0" ? 9 : parseInt(key, 10) - 1;
|
||||||
|
const targetFeature = inProgressFeaturesForShortcuts[index];
|
||||||
|
|
||||||
|
if (!targetFeature) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetFeature.id === outputFeature?.id) {
|
||||||
|
setShowOutputModal(false);
|
||||||
|
} else {
|
||||||
|
setOutputFeature(targetFeature);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[inProgressFeaturesForShortcuts, outputFeature?.id, setShowOutputModal, setOutputFeature]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleForceStopFeature = useCallback(
|
||||||
|
async (feature: Feature) => {
|
||||||
|
try {
|
||||||
|
await autoMode.stopFeature(feature.id);
|
||||||
|
|
||||||
|
const targetStatus =
|
||||||
|
feature.skipTests && feature.status === "waiting_approval"
|
||||||
|
? "waiting_approval"
|
||||||
|
: "backlog";
|
||||||
|
|
||||||
|
if (targetStatus !== feature.status) {
|
||||||
|
moveFeature(feature.id, targetStatus);
|
||||||
|
persistFeatureUpdate(feature.id, { status: targetStatus });
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Agent stopped", {
|
||||||
|
description:
|
||||||
|
targetStatus === "waiting_approval"
|
||||||
|
? `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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[autoMode, moveFeature, persistFeatureUpdate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStartNextFeatures = useCallback(async () => {
|
||||||
|
const backlogFeatures = features.filter((f) => f.status === "backlog");
|
||||||
|
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.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const featuresToStart = backlogFeatures.slice(0, availableSlots);
|
||||||
|
|
||||||
|
for (const feature of featuresToStart) {
|
||||||
|
await handleStartImplementation(feature);
|
||||||
|
}
|
||||||
|
}, [features, runningAutoTasks, handleStartImplementation]);
|
||||||
|
|
||||||
|
const handleDeleteAllVerified = useCallback(async () => {
|
||||||
|
const verifiedFeatures = features.filter((f) => f.status === "verified");
|
||||||
|
|
||||||
|
for (const feature of verifiedFeatures) {
|
||||||
|
const isRunning = runningAutoTasks.includes(feature.id);
|
||||||
|
if (isRunning) {
|
||||||
|
try {
|
||||||
|
await autoMode.stopFeature(feature.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[Board] Error stopping feature before delete:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
removeFeature(feature.id);
|
||||||
|
persistFeatureDelete(feature.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("All verified features deleted", {
|
||||||
|
description: `Deleted ${verifiedFeatures.length} feature(s).`,
|
||||||
|
});
|
||||||
|
}, [features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleAddFeature,
|
||||||
|
handleUpdateFeature,
|
||||||
|
handleDeleteFeature,
|
||||||
|
handleStartImplementation,
|
||||||
|
handleVerifyFeature,
|
||||||
|
handleResumeFeature,
|
||||||
|
handleManualVerify,
|
||||||
|
handleMoveBackToInProgress,
|
||||||
|
handleOpenFollowUp,
|
||||||
|
handleSendFollowUp,
|
||||||
|
handleCommitFeature,
|
||||||
|
handleRevertFeature,
|
||||||
|
handleMergeFeature,
|
||||||
|
handleCompleteFeature,
|
||||||
|
handleUnarchiveFeature,
|
||||||
|
handleViewOutput,
|
||||||
|
handleOutputModalNumberKeyPress,
|
||||||
|
handleForceStopFeature,
|
||||||
|
handleStartNextFeatures,
|
||||||
|
handleDeleteAllVerified,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get background settings for current project
|
||||||
|
const backgroundSettings = useMemo(() => {
|
||||||
|
return (
|
||||||
|
(currentProject && boardBackgroundByProject[currentProject.path]) ||
|
||||||
|
defaultBackgroundSettings
|
||||||
|
);
|
||||||
|
}, [currentProject, boardBackgroundByProject]);
|
||||||
|
|
||||||
|
// Build background image style if image exists
|
||||||
|
const backgroundImageStyle = useMemo(() => {
|
||||||
|
if (!backgroundSettings.imagePath || !currentProject) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundImage: `url(${
|
||||||
|
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"
|
||||||
|
}/api/fs/image?path=${encodeURIComponent(
|
||||||
|
backgroundSettings.imagePath
|
||||||
|
)}&projectPath=${encodeURIComponent(currentProject.path)}${
|
||||||
|
backgroundSettings.imageVersion
|
||||||
|
? `&v=${backgroundSettings.imageVersion}`
|
||||||
|
: ""
|
||||||
|
})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
} as React.CSSProperties;
|
||||||
|
}, [backgroundSettings, currentProject]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundSettings,
|
||||||
|
backgroundImageStyle,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useMemo, useCallback } from "react";
|
||||||
|
import { Feature } from "@/store/app-store";
|
||||||
|
|
||||||
|
type ColumnId = Feature["status"];
|
||||||
|
|
||||||
|
interface UseBoardColumnFeaturesProps {
|
||||||
|
features: Feature[];
|
||||||
|
runningAutoTasks: string[];
|
||||||
|
searchQuery: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBoardColumnFeatures({
|
||||||
|
features,
|
||||||
|
runningAutoTasks,
|
||||||
|
searchQuery,
|
||||||
|
}: UseBoardColumnFeaturesProps) {
|
||||||
|
// Memoize column features to prevent unnecessary re-renders
|
||||||
|
const columnFeaturesMap = useMemo(() => {
|
||||||
|
const map: Record<ColumnId, Feature[]> = {
|
||||||
|
backlog: [],
|
||||||
|
in_progress: [],
|
||||||
|
waiting_approval: [],
|
||||||
|
verified: [],
|
||||||
|
completed: [], // Completed features are shown in the archive modal, not as a column
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter features by search query (case-insensitive)
|
||||||
|
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||||
|
const filteredFeatures = normalizedQuery
|
||||||
|
? features.filter(
|
||||||
|
(f) =>
|
||||||
|
f.description.toLowerCase().includes(normalizedQuery) ||
|
||||||
|
f.category?.toLowerCase().includes(normalizedQuery)
|
||||||
|
)
|
||||||
|
: features;
|
||||||
|
|
||||||
|
filteredFeatures.forEach((f) => {
|
||||||
|
// If feature has a running agent, always show it in "in_progress"
|
||||||
|
const isRunning = runningAutoTasks.includes(f.id);
|
||||||
|
if (isRunning) {
|
||||||
|
map.in_progress.push(f);
|
||||||
|
} else {
|
||||||
|
// Otherwise, use the feature's status (fallback to backlog for unknown statuses)
|
||||||
|
const status = f.status as ColumnId;
|
||||||
|
if (map[status]) {
|
||||||
|
map[status].push(f);
|
||||||
|
} else {
|
||||||
|
// Unknown status, default to backlog
|
||||||
|
map.backlog.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort backlog by priority: 1 (high) -> 2 (medium) -> 3 (low) -> no priority
|
||||||
|
map.backlog.sort((a, b) => {
|
||||||
|
const aPriority = a.priority ?? 999; // Features without priority go last
|
||||||
|
const bPriority = b.priority ?? 999;
|
||||||
|
return aPriority - bPriority;
|
||||||
|
});
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, [features, runningAutoTasks, searchQuery]);
|
||||||
|
|
||||||
|
const getColumnFeatures = useCallback(
|
||||||
|
(columnId: ColumnId) => {
|
||||||
|
return columnFeaturesMap[columnId];
|
||||||
|
},
|
||||||
|
[columnFeaturesMap]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize completed features for the archive modal
|
||||||
|
const completedFeatures = useMemo(() => {
|
||||||
|
return features.filter((f) => f.status === "completed");
|
||||||
|
}, [features]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
columnFeaturesMap,
|
||||||
|
getColumnFeatures,
|
||||||
|
completedFeatures,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
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>;
|
||||||
|
handleStartImplementation: (feature: Feature) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBoardDragDrop({
|
||||||
|
features,
|
||||||
|
currentProject,
|
||||||
|
runningAutoTasks,
|
||||||
|
persistFeatureUpdate,
|
||||||
|
handleStartImplementation,
|
||||||
|
}: UseBoardDragDropProps) {
|
||||||
|
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
||||||
|
const { moveFeature } = useAppStore();
|
||||||
|
|
||||||
|
const handleDragStart = useCallback(
|
||||||
|
(event: DragStartEvent) => {
|
||||||
|
const { active } = event;
|
||||||
|
const feature = features.find((f) => f.id === active.id);
|
||||||
|
if (feature) {
|
||||||
|
setActiveFeature(feature);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[features]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
async (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
setActiveFeature(null);
|
||||||
|
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
const featureId = active.id as string;
|
||||||
|
const overId = over.id as string;
|
||||||
|
|
||||||
|
// Find the feature being dragged
|
||||||
|
const draggedFeature = features.find((f) => f.id === featureId);
|
||||||
|
if (!draggedFeature) return;
|
||||||
|
|
||||||
|
// Check if this is a running task (non-skipTests, TDD)
|
||||||
|
const isRunningTask = runningAutoTasks.includes(featureId);
|
||||||
|
|
||||||
|
// Determine if dragging is allowed based on status and skipTests
|
||||||
|
// - Backlog items can always be dragged
|
||||||
|
// - waiting_approval items can always be dragged (to allow manual verification via drag)
|
||||||
|
// - verified items can always be dragged (to allow moving back to waiting_approval)
|
||||||
|
// - skipTests (non-TDD) items can be dragged between in_progress and verified
|
||||||
|
// - Non-skipTests (TDD) items that are in progress cannot be dragged (they are running)
|
||||||
|
if (
|
||||||
|
draggedFeature.status !== "backlog" &&
|
||||||
|
draggedFeature.status !== "waiting_approval" &&
|
||||||
|
draggedFeature.status !== "verified"
|
||||||
|
) {
|
||||||
|
// Only allow dragging in_progress if it's a skipTests feature and not currently running
|
||||||
|
if (!draggedFeature.skipTests || isRunningTask) {
|
||||||
|
console.log(
|
||||||
|
"[Board] Cannot drag feature - TDD feature or currently running"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetStatus: ColumnId | null = null;
|
||||||
|
|
||||||
|
// Check if we dropped on a column
|
||||||
|
const column = COLUMNS.find((c) => c.id === overId);
|
||||||
|
if (column) {
|
||||||
|
targetStatus = column.id;
|
||||||
|
} else {
|
||||||
|
// Dropped on another feature - find its column
|
||||||
|
const overFeature = features.find((f) => f.id === overId);
|
||||||
|
if (overFeature) {
|
||||||
|
targetStatus = overFeature.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetStatus) return;
|
||||||
|
|
||||||
|
// Same column, nothing to do
|
||||||
|
if (targetStatus === draggedFeature.status) return;
|
||||||
|
|
||||||
|
// Handle different drag scenarios
|
||||||
|
if (draggedFeature.status === "backlog") {
|
||||||
|
// From backlog
|
||||||
|
if (targetStatus === "in_progress") {
|
||||||
|
// Use helper function to handle concurrency check and start implementation
|
||||||
|
await handleStartImplementation(draggedFeature);
|
||||||
|
} else {
|
||||||
|
moveFeature(featureId, targetStatus);
|
||||||
|
persistFeatureUpdate(featureId, { status: targetStatus });
|
||||||
|
}
|
||||||
|
} 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");
|
||||||
|
// Clear justFinishedAt timestamp when manually verifying via drag
|
||||||
|
persistFeatureUpdate(featureId, {
|
||||||
|
status: "verified",
|
||||||
|
justFinishedAt: undefined,
|
||||||
|
});
|
||||||
|
toast.success("Feature verified", {
|
||||||
|
description: `Manually verified: ${draggedFeature.description.slice(
|
||||||
|
0,
|
||||||
|
50
|
||||||
|
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||||
|
});
|
||||||
|
} else if (targetStatus === "backlog") {
|
||||||
|
// Allow moving waiting_approval cards back to backlog
|
||||||
|
moveFeature(featureId, "backlog");
|
||||||
|
// Clear justFinishedAt timestamp when moving back to backlog
|
||||||
|
persistFeatureUpdate(featureId, {
|
||||||
|
status: "backlog",
|
||||||
|
justFinishedAt: undefined,
|
||||||
|
});
|
||||||
|
toast.info("Feature moved to backlog", {
|
||||||
|
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||||
|
0,
|
||||||
|
50
|
||||||
|
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (draggedFeature.skipTests) {
|
||||||
|
// skipTests feature being moved between in_progress and verified
|
||||||
|
if (
|
||||||
|
targetStatus === "verified" &&
|
||||||
|
draggedFeature.status === "in_progress"
|
||||||
|
) {
|
||||||
|
// Manual verify via drag
|
||||||
|
moveFeature(featureId, "verified");
|
||||||
|
persistFeatureUpdate(featureId, { status: "verified" });
|
||||||
|
toast.success("Feature verified", {
|
||||||
|
description: `Marked as verified: ${draggedFeature.description.slice(
|
||||||
|
0,
|
||||||
|
50
|
||||||
|
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||||
|
});
|
||||||
|
} else 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", {
|
||||||
|
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
|
||||||
|
0,
|
||||||
|
50
|
||||||
|
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||||
|
});
|
||||||
|
} else if (targetStatus === "backlog") {
|
||||||
|
// Allow moving skipTests cards back 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 ? "..." : ""}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (draggedFeature.status === "verified") {
|
||||||
|
// Handle verified TDD (non-skipTests) features being moved back
|
||||||
|
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", {
|
||||||
|
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
|
||||||
|
0,
|
||||||
|
50
|
||||||
|
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||||
|
});
|
||||||
|
} else if (targetStatus === "backlog") {
|
||||||
|
// Allow moving verified cards back 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 ? "..." : ""}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
features,
|
||||||
|
runningAutoTasks,
|
||||||
|
moveFeature,
|
||||||
|
persistFeatureUpdate,
|
||||||
|
handleStartImplementation,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeFeature,
|
||||||
|
handleDragStart,
|
||||||
|
handleDragEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { useAutoMode } from "@/hooks/use-auto-mode";
|
||||||
|
|
||||||
|
interface UseBoardEffectsProps {
|
||||||
|
currentProject: { path: string; id: string } | null;
|
||||||
|
specCreatingForProject: string | null;
|
||||||
|
setSpecCreatingForProject: (path: string | null) => void;
|
||||||
|
setSuggestionsCount: (count: number) => void;
|
||||||
|
setFeatureSuggestions: (suggestions: any[]) => void;
|
||||||
|
setIsGeneratingSuggestions: (generating: boolean) => void;
|
||||||
|
checkContextExists: (featureId: string) => Promise<boolean>;
|
||||||
|
features: any[];
|
||||||
|
isLoading: boolean;
|
||||||
|
setFeaturesWithContext: (set: Set<string>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBoardEffects({
|
||||||
|
currentProject,
|
||||||
|
specCreatingForProject,
|
||||||
|
setSpecCreatingForProject,
|
||||||
|
setSuggestionsCount,
|
||||||
|
setFeatureSuggestions,
|
||||||
|
setIsGeneratingSuggestions,
|
||||||
|
checkContextExists,
|
||||||
|
features,
|
||||||
|
isLoading,
|
||||||
|
setFeaturesWithContext,
|
||||||
|
}: UseBoardEffectsProps) {
|
||||||
|
const autoMode = useAutoMode();
|
||||||
|
|
||||||
|
// Make current project available globally for modal
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentProject) {
|
||||||
|
(window as any).__currentProject = currentProject;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
(window as any).__currentProject = null;
|
||||||
|
};
|
||||||
|
}, [currentProject]);
|
||||||
|
|
||||||
|
// Listen for suggestions events to update count (persists even when dialog is closed)
|
||||||
|
useEffect(() => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.suggestions) return;
|
||||||
|
|
||||||
|
const unsubscribe = api.suggestions.onEvent((event) => {
|
||||||
|
if (event.type === "suggestions_complete" && event.suggestions) {
|
||||||
|
setSuggestionsCount(event.suggestions.length);
|
||||||
|
setFeatureSuggestions(event.suggestions);
|
||||||
|
setIsGeneratingSuggestions(false);
|
||||||
|
} else if (event.type === "suggestions_error") {
|
||||||
|
setIsGeneratingSuggestions(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [setSuggestionsCount, setFeatureSuggestions, setIsGeneratingSuggestions]);
|
||||||
|
|
||||||
|
// Subscribe to spec regeneration events to clear creating state on completion
|
||||||
|
useEffect(() => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.specRegeneration) return;
|
||||||
|
|
||||||
|
const unsubscribe = api.specRegeneration.onEvent((event) => {
|
||||||
|
console.log(
|
||||||
|
"[BoardView] Spec regeneration event:",
|
||||||
|
event.type,
|
||||||
|
"for project:",
|
||||||
|
event.projectPath
|
||||||
|
);
|
||||||
|
|
||||||
|
if (event.projectPath !== specCreatingForProject) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "spec_regeneration_complete") {
|
||||||
|
setSpecCreatingForProject(null);
|
||||||
|
} else if (event.type === "spec_regeneration_error") {
|
||||||
|
setSpecCreatingForProject(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [specCreatingForProject, setSpecCreatingForProject]);
|
||||||
|
|
||||||
|
// Sync running tasks from electron backend on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
const syncRunningTasks = async () => {
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.autoMode?.status) return;
|
||||||
|
|
||||||
|
const status = await api.autoMode.status(currentProject.path);
|
||||||
|
if (status.success) {
|
||||||
|
const projectId = currentProject.id;
|
||||||
|
const { clearRunningTasks, addRunningTask, setAutoModeRunning } =
|
||||||
|
useAppStore.getState();
|
||||||
|
|
||||||
|
if (status.runningFeatures) {
|
||||||
|
console.log(
|
||||||
|
"[Board] Syncing running tasks from backend:",
|
||||||
|
status.runningFeatures
|
||||||
|
);
|
||||||
|
|
||||||
|
clearRunningTasks(projectId);
|
||||||
|
|
||||||
|
status.runningFeatures.forEach((featureId: string) => {
|
||||||
|
addRunningTask(projectId, featureId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAutoModeRunning =
|
||||||
|
status.autoLoopRunning ?? status.isRunning ?? false;
|
||||||
|
console.log(
|
||||||
|
"[Board] Syncing auto mode running state:",
|
||||||
|
isAutoModeRunning
|
||||||
|
);
|
||||||
|
setAutoModeRunning(projectId, isAutoModeRunning);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Board] Failed to sync running tasks:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
syncRunningTasks();
|
||||||
|
}, [currentProject]);
|
||||||
|
|
||||||
|
// Check which features have context files
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAllContexts = async () => {
|
||||||
|
const featuresWithPotentialContext = features.filter(
|
||||||
|
(f) =>
|
||||||
|
f.status === "in_progress" ||
|
||||||
|
f.status === "waiting_approval" ||
|
||||||
|
f.status === "verified"
|
||||||
|
);
|
||||||
|
const contextChecks = await Promise.all(
|
||||||
|
featuresWithPotentialContext.map(async (f) => ({
|
||||||
|
id: f.id,
|
||||||
|
hasContext: await checkContextExists(f.id),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const newSet = new Set<string>();
|
||||||
|
contextChecks.forEach(({ id, hasContext }) => {
|
||||||
|
if (hasContext) {
|
||||||
|
newSet.add(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setFeaturesWithContext(newSet);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (features.length > 0 && !isLoading) {
|
||||||
|
checkAllContexts();
|
||||||
|
}
|
||||||
|
}, [features, isLoading, checkContextExists, setFeaturesWithContext]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
||||||
|
const { features, setFeatures } = useAppStore();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Track previous project path to detect project switches
|
||||||
|
const prevProjectPathRef = useRef<string | null>(null);
|
||||||
|
const isInitialLoadRef = useRef(true);
|
||||||
|
const isSwitchingProjectRef = useRef(false);
|
||||||
|
|
||||||
|
// Load features using features API
|
||||||
|
// IMPORTANT: Do NOT add 'features' to dependency array - it would cause infinite reload loop
|
||||||
|
const loadFeatures = useCallback(async () => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
const currentPath = currentProject.path;
|
||||||
|
const previousPath = prevProjectPathRef.current;
|
||||||
|
const isProjectSwitch =
|
||||||
|
previousPath !== null && currentPath !== previousPath;
|
||||||
|
|
||||||
|
// Get cached features from store (without adding to dependencies)
|
||||||
|
const cachedFeatures = useAppStore.getState().features;
|
||||||
|
|
||||||
|
// 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}`
|
||||||
|
);
|
||||||
|
isSwitchingProjectRef.current = true;
|
||||||
|
isInitialLoadRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the ref to track current project
|
||||||
|
prevProjectPathRef.current = currentPath;
|
||||||
|
|
||||||
|
// Only show loading spinner on initial load to prevent board flash during reloads
|
||||||
|
if (isInitialLoadRef.current) {
|
||||||
|
setIsLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.features) {
|
||||||
|
console.error("[BoardView] Features API not available");
|
||||||
|
// Keep cached features if API is unavailable
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// Successfully loaded features - now safe to set them
|
||||||
|
setFeatures(featuresWithIds);
|
||||||
|
|
||||||
|
// Only clear categories on project switch AFTER successful load
|
||||||
|
if (isProjectSwitch) {
|
||||||
|
setPersistedCategories([]);
|
||||||
|
}
|
||||||
|
} else if (!result.success && 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) {
|
||||||
|
setFeatures([]);
|
||||||
|
setPersistedCategories([]);
|
||||||
|
}
|
||||||
|
// Otherwise keep cached features
|
||||||
|
}
|
||||||
|
} catch (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) {
|
||||||
|
setFeatures([]);
|
||||||
|
setPersistedCategories([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
isInitialLoadRef.current = false;
|
||||||
|
isSwitchingProjectRef.current = false;
|
||||||
|
}
|
||||||
|
}, [currentProject, setFeatures]);
|
||||||
|
|
||||||
|
// Load persisted categories from file
|
||||||
|
const loadCategories = useCallback(async () => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.readFile(
|
||||||
|
`${currentProject.path}/.automaker/categories.json`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success && result.content) {
|
||||||
|
const parsed = JSON.parse(result.content);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
setPersistedCategories(parsed);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// File doesn't exist, ensure categories are cleared
|
||||||
|
setPersistedCategories([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load categories:", error);
|
||||||
|
// If file doesn't exist, ensure categories are cleared
|
||||||
|
setPersistedCategories([]);
|
||||||
|
}
|
||||||
|
}, [currentProject]);
|
||||||
|
|
||||||
|
// Save a new category to the persisted categories file
|
||||||
|
const saveCategory = useCallback(
|
||||||
|
async (category: string) => {
|
||||||
|
if (!currentProject || !category.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
|
||||||
|
// Read existing categories
|
||||||
|
let categories: string[] = [...persistedCategories];
|
||||||
|
|
||||||
|
// Add new category if it doesn't exist
|
||||||
|
if (!categories.includes(category)) {
|
||||||
|
categories.push(category);
|
||||||
|
categories.sort(); // Keep sorted
|
||||||
|
|
||||||
|
// Write back to file
|
||||||
|
await api.writeFile(
|
||||||
|
`${currentProject.path}/.automaker/categories.json`,
|
||||||
|
JSON.stringify(categories, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
setPersistedCategories(categories);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save category:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject, persistedCategories]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subscribe to spec regeneration complete events to refresh kanban board
|
||||||
|
useEffect(() => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.specRegeneration) return;
|
||||||
|
|
||||||
|
const unsubscribe = api.specRegeneration.onEvent((event) => {
|
||||||
|
// Refresh the kanban board when spec regeneration completes for the current project
|
||||||
|
if (
|
||||||
|
event.type === "spec_regeneration_complete" &&
|
||||||
|
currentProject &&
|
||||||
|
event.projectPath === currentProject.path
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
"[BoardView] Spec regeneration complete, refreshing features"
|
||||||
|
);
|
||||||
|
loadFeatures();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [currentProject, loadFeatures]);
|
||||||
|
|
||||||
|
// Listen for auto mode feature completion and errors to reload features
|
||||||
|
useEffect(() => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.autoMode || !currentProject) return;
|
||||||
|
|
||||||
|
const { removeRunningTask } = useAppStore.getState();
|
||||||
|
const projectId = currentProject.id;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (event.type === "auto_mode_feature_complete") {
|
||||||
|
// Reload features when a feature is completed
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
} 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
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove from running tasks so it moves to the correct column
|
||||||
|
if (event.featureId) {
|
||||||
|
removeRunningTask(eventProjectId, event.featureId);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadFeatures();
|
||||||
|
|
||||||
|
// Check for authentication errors and show a more helpful message
|
||||||
|
const isAuthError =
|
||||||
|
event.errorType === "authentication" ||
|
||||||
|
(event.error &&
|
||||||
|
(event.error.includes("Authentication failed") ||
|
||||||
|
event.error.includes("Invalid API key")));
|
||||||
|
|
||||||
|
if (isAuthError) {
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, [loadFeatures, currentProject]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFeatures();
|
||||||
|
}, [loadFeatures]);
|
||||||
|
|
||||||
|
// Load persisted categories on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadCategories();
|
||||||
|
}, [loadCategories]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
features,
|
||||||
|
isLoading,
|
||||||
|
persistedCategories,
|
||||||
|
loadFeatures,
|
||||||
|
loadCategories,
|
||||||
|
saveCategory,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useMemo, useRef, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
useKeyboardShortcuts,
|
||||||
|
useKeyboardShortcutsConfig,
|
||||||
|
KeyboardShortcut,
|
||||||
|
} from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
import { Feature } from "@/store/app-store";
|
||||||
|
|
||||||
|
interface UseBoardKeyboardShortcutsProps {
|
||||||
|
features: Feature[];
|
||||||
|
runningAutoTasks: string[];
|
||||||
|
onAddFeature: () => void;
|
||||||
|
onStartNextFeatures: () => void;
|
||||||
|
onViewOutput: (feature: Feature) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBoardKeyboardShortcuts({
|
||||||
|
features,
|
||||||
|
runningAutoTasks,
|
||||||
|
onAddFeature,
|
||||||
|
onStartNextFeatures,
|
||||||
|
onViewOutput,
|
||||||
|
}: UseBoardKeyboardShortcutsProps) {
|
||||||
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
|
|
||||||
|
// Get in-progress features for keyboard shortcuts (memoized for shortcuts)
|
||||||
|
const inProgressFeaturesForShortcuts = useMemo(() => {
|
||||||
|
return features.filter((f) => {
|
||||||
|
const isRunning = runningAutoTasks.includes(f.id);
|
||||||
|
return isRunning || f.status === "in_progress";
|
||||||
|
});
|
||||||
|
}, [features, runningAutoTasks]);
|
||||||
|
|
||||||
|
// Ref to hold the start next callback (to avoid dependency issues)
|
||||||
|
const startNextFeaturesRef = useRef<() => void>(() => {});
|
||||||
|
|
||||||
|
// Update ref when callback changes
|
||||||
|
useEffect(() => {
|
||||||
|
startNextFeaturesRef.current = onStartNextFeatures;
|
||||||
|
}, [onStartNextFeatures]);
|
||||||
|
|
||||||
|
// Keyboard shortcuts for this view
|
||||||
|
const boardShortcuts: KeyboardShortcut[] = useMemo(() => {
|
||||||
|
const shortcutsList: KeyboardShortcut[] = [
|
||||||
|
{
|
||||||
|
key: shortcuts.addFeature,
|
||||||
|
action: onAddFeature,
|
||||||
|
description: "Add new feature",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: shortcuts.startNext,
|
||||||
|
action: () => startNextFeaturesRef.current(),
|
||||||
|
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);
|
||||||
|
shortcutsList.push({
|
||||||
|
key,
|
||||||
|
action: () => {
|
||||||
|
onViewOutput(feature);
|
||||||
|
},
|
||||||
|
description: `View output for in-progress card ${index + 1}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return shortcutsList;
|
||||||
|
}, [inProgressFeaturesForShortcuts, shortcuts, onAddFeature, onViewOutput]);
|
||||||
|
|
||||||
|
useKeyboardShortcuts(boardShortcuts);
|
||||||
|
|
||||||
|
return {
|
||||||
|
inProgressFeaturesForShortcuts,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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) {
|
||||||
|
const { updateFeature } = useAppStore();
|
||||||
|
|
||||||
|
// Persist feature update to API (replaces saveFeatures)
|
||||||
|
const persistFeatureUpdate = useCallback(
|
||||||
|
async (featureId: string, updates: Partial<Feature>) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.features) {
|
||||||
|
console.error("[BoardView] Features API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject, updateFeature]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persist feature creation to API
|
||||||
|
const persistFeatureCreate = useCallback(
|
||||||
|
async (feature: Feature) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.features) {
|
||||||
|
console.error("[BoardView] Features API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.features.create(currentProject.path, feature);
|
||||||
|
if (result.success && result.feature) {
|
||||||
|
updateFeature(result.feature.id, result.feature);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to persist feature creation:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject, updateFeature]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persist feature deletion to API
|
||||||
|
const persistFeatureDelete = useCallback(
|
||||||
|
async (featureId: string) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.features) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
persistFeatureCreate,
|
||||||
|
persistFeatureUpdate,
|
||||||
|
persistFeatureDelete,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { Feature } from "@/store/app-store";
|
||||||
|
import {
|
||||||
|
FeatureImagePath as DescriptionImagePath,
|
||||||
|
ImagePreviewMap,
|
||||||
|
} 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 [followUpImagePaths, setFollowUpImagePaths] = useState<DescriptionImagePath[]>([]);
|
||||||
|
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<ImagePreviewMap>(() => new Map());
|
||||||
|
|
||||||
|
const resetFollowUpState = useCallback(() => {
|
||||||
|
setShowFollowUpDialog(false);
|
||||||
|
setFollowUpFeature(null);
|
||||||
|
setFollowUpPrompt("");
|
||||||
|
setFollowUpImagePaths([]);
|
||||||
|
setFollowUpPreviewMap(new Map());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFollowUpDialogChange = useCallback((open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
resetFollowUpState();
|
||||||
|
} else {
|
||||||
|
setShowFollowUpDialog(open);
|
||||||
|
}
|
||||||
|
}, [resetFollowUpState]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
showFollowUpDialog,
|
||||||
|
followUpFeature,
|
||||||
|
followUpPrompt,
|
||||||
|
followUpImagePaths,
|
||||||
|
followUpPreviewMap,
|
||||||
|
// Setters
|
||||||
|
setShowFollowUpDialog,
|
||||||
|
setFollowUpFeature,
|
||||||
|
setFollowUpPrompt,
|
||||||
|
setFollowUpImagePaths,
|
||||||
|
setFollowUpPreviewMap,
|
||||||
|
// Helpers
|
||||||
|
resetFollowUpState,
|
||||||
|
handleFollowUpDialogChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import type { FeatureSuggestion } from "@/lib/electron";
|
||||||
|
|
||||||
|
export function useSuggestionsState() {
|
||||||
|
const [showSuggestionsDialog, setShowSuggestionsDialog] = useState(false);
|
||||||
|
const [suggestionsCount, setSuggestionsCount] = useState(0);
|
||||||
|
const [featureSuggestions, setFeatureSuggestions] = useState<FeatureSuggestion[]>([]);
|
||||||
|
const [isGeneratingSuggestions, setIsGeneratingSuggestions] = useState(false);
|
||||||
|
|
||||||
|
const updateSuggestions = useCallback((suggestions: FeatureSuggestion[]) => {
|
||||||
|
setFeatureSuggestions(suggestions);
|
||||||
|
setSuggestionsCount(suggestions.length);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeSuggestionsDialog = useCallback(() => {
|
||||||
|
setShowSuggestionsDialog(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
showSuggestionsDialog,
|
||||||
|
suggestionsCount,
|
||||||
|
featureSuggestions,
|
||||||
|
isGeneratingSuggestions,
|
||||||
|
// Setters
|
||||||
|
setShowSuggestionsDialog,
|
||||||
|
setSuggestionsCount,
|
||||||
|
setFeatureSuggestions,
|
||||||
|
setIsGeneratingSuggestions,
|
||||||
|
// Helpers
|
||||||
|
updateSuggestions,
|
||||||
|
closeSuggestionsDialog,
|
||||||
|
};
|
||||||
|
}
|
||||||
244
apps/app/src/components/views/board-view/kanban-board.tsx
Normal file
244
apps/app/src/components/views/board-view/kanban-board.tsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragOverlay,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { KanbanColumn, KanbanCard } from "./components";
|
||||||
|
import { Feature } from "@/store/app-store";
|
||||||
|
import { FastForward, Lightbulb, Trash2 } from "lucide-react";
|
||||||
|
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
import { COLUMNS, ColumnId } from "./constants";
|
||||||
|
|
||||||
|
interface KanbanBoardProps {
|
||||||
|
sensors: any;
|
||||||
|
collisionDetectionStrategy: (args: any) => any;
|
||||||
|
onDragStart: (event: any) => void;
|
||||||
|
onDragEnd: (event: any) => void;
|
||||||
|
activeFeature: Feature | null;
|
||||||
|
getColumnFeatures: (columnId: ColumnId) => Feature[];
|
||||||
|
backgroundImageStyle: React.CSSProperties;
|
||||||
|
backgroundSettings: {
|
||||||
|
columnOpacity: number;
|
||||||
|
columnBorderEnabled: boolean;
|
||||||
|
hideScrollbar: boolean;
|
||||||
|
cardOpacity: number;
|
||||||
|
cardGlassmorphism: boolean;
|
||||||
|
cardBorderEnabled: boolean;
|
||||||
|
cardBorderOpacity: number;
|
||||||
|
};
|
||||||
|
onEdit: (feature: Feature) => void;
|
||||||
|
onDelete: (featureId: string) => void;
|
||||||
|
onViewOutput: (feature: Feature) => void;
|
||||||
|
onVerify: (feature: Feature) => void;
|
||||||
|
onResume: (feature: Feature) => void;
|
||||||
|
onForceStop: (feature: Feature) => void;
|
||||||
|
onManualVerify: (feature: Feature) => void;
|
||||||
|
onMoveBackToInProgress: (feature: Feature) => void;
|
||||||
|
onFollowUp: (feature: Feature) => void;
|
||||||
|
onCommit: (feature: Feature) => void;
|
||||||
|
onRevert: (feature: Feature) => void;
|
||||||
|
onMerge: (feature: Feature) => void;
|
||||||
|
onComplete: (feature: Feature) => void;
|
||||||
|
onImplement: (feature: Feature) => void;
|
||||||
|
featuresWithContext: Set<string>;
|
||||||
|
runningAutoTasks: string[];
|
||||||
|
shortcuts: ReturnType<typeof useKeyboardShortcutsConfig>;
|
||||||
|
onStartNextFeatures: () => void;
|
||||||
|
onShowSuggestions: () => void;
|
||||||
|
suggestionsCount: number;
|
||||||
|
onDeleteAllVerified: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KanbanBoard({
|
||||||
|
sensors,
|
||||||
|
collisionDetectionStrategy,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
activeFeature,
|
||||||
|
getColumnFeatures,
|
||||||
|
backgroundImageStyle,
|
||||||
|
backgroundSettings,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onViewOutput,
|
||||||
|
onVerify,
|
||||||
|
onResume,
|
||||||
|
onForceStop,
|
||||||
|
onManualVerify,
|
||||||
|
onMoveBackToInProgress,
|
||||||
|
onFollowUp,
|
||||||
|
onCommit,
|
||||||
|
onRevert,
|
||||||
|
onMerge,
|
||||||
|
onComplete,
|
||||||
|
onImplement,
|
||||||
|
featuresWithContext,
|
||||||
|
runningAutoTasks,
|
||||||
|
shortcuts,
|
||||||
|
onStartNextFeatures,
|
||||||
|
onShowSuggestions,
|
||||||
|
suggestionsCount,
|
||||||
|
onDeleteAllVerified,
|
||||||
|
}: KanbanBoardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex-1 overflow-x-auto px-4 pb-4 relative"
|
||||||
|
style={backgroundImageStyle}
|
||||||
|
>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={collisionDetectionStrategy}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
>
|
||||||
|
<div className="flex gap-5 h-full min-w-max py-1">
|
||||||
|
{COLUMNS.map((column) => {
|
||||||
|
const columnFeatures = getColumnFeatures(column.id);
|
||||||
|
return (
|
||||||
|
<KanbanColumn
|
||||||
|
key={column.id}
|
||||||
|
id={column.id}
|
||||||
|
title={column.title}
|
||||||
|
colorClass={column.colorClass}
|
||||||
|
count={columnFeatures.length}
|
||||||
|
opacity={backgroundSettings.columnOpacity}
|
||||||
|
showBorder={backgroundSettings.columnBorderEnabled}
|
||||||
|
hideScrollbar={backgroundSettings.hideScrollbar}
|
||||||
|
headerAction={
|
||||||
|
column.id === "verified" &&
|
||||||
|
columnFeatures.length > 0 ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={onDeleteAllVerified}
|
||||||
|
data-testid="delete-all-verified-button"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3 mr-1" />
|
||||||
|
Delete All
|
||||||
|
</Button>
|
||||||
|
) : column.id === "backlog" ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 relative"
|
||||||
|
onClick={onShowSuggestions}
|
||||||
|
title="Feature Suggestions"
|
||||||
|
data-testid="feature-suggestions-button"
|
||||||
|
>
|
||||||
|
<Lightbulb className="w-3.5 h-3.5" />
|
||||||
|
{suggestionsCount > 0 && (
|
||||||
|
<span
|
||||||
|
className="absolute -top-1 -right-1 w-4 h-4 text-[9px] font-mono rounded-full bg-yellow-500 text-black flex items-center justify-center"
|
||||||
|
data-testid="suggestions-count"
|
||||||
|
>
|
||||||
|
{suggestionsCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{columnFeatures.length > 0 && (
|
||||||
|
<HotkeyButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
|
||||||
|
onClick={onStartNextFeatures}
|
||||||
|
hotkey={shortcuts.startNext}
|
||||||
|
hotkeyActive={false}
|
||||||
|
data-testid="start-next-button"
|
||||||
|
>
|
||||||
|
<FastForward className="w-3 h-3 mr-1" />
|
||||||
|
Make
|
||||||
|
</HotkeyButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={columnFeatures.map((f) => f.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{columnFeatures.map((feature, index) => {
|
||||||
|
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
|
||||||
|
let shortcutKey: string | undefined;
|
||||||
|
if (column.id === "in_progress" && index < 10) {
|
||||||
|
shortcutKey =
|
||||||
|
index === 9 ? "0" : String(index + 1);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<KanbanCard
|
||||||
|
key={feature.id}
|
||||||
|
feature={feature}
|
||||||
|
onEdit={() => onEdit(feature)}
|
||||||
|
onDelete={() => onDelete(feature.id)}
|
||||||
|
onViewOutput={() => onViewOutput(feature)}
|
||||||
|
onVerify={() => onVerify(feature)}
|
||||||
|
onResume={() => onResume(feature)}
|
||||||
|
onForceStop={() => onForceStop(feature)}
|
||||||
|
onManualVerify={() => onManualVerify(feature)}
|
||||||
|
onMoveBackToInProgress={() =>
|
||||||
|
onMoveBackToInProgress(feature)
|
||||||
|
}
|
||||||
|
onFollowUp={() => onFollowUp(feature)}
|
||||||
|
onCommit={() => onCommit(feature)}
|
||||||
|
onRevert={() => onRevert(feature)}
|
||||||
|
onMerge={() => onMerge(feature)}
|
||||||
|
onComplete={() => onComplete(feature)}
|
||||||
|
onImplement={() => onImplement(feature)}
|
||||||
|
hasContext={featuresWithContext.has(feature.id)}
|
||||||
|
isCurrentAutoTask={runningAutoTasks.includes(
|
||||||
|
feature.id
|
||||||
|
)}
|
||||||
|
shortcutKey={shortcutKey}
|
||||||
|
opacity={backgroundSettings.cardOpacity}
|
||||||
|
glassmorphism={
|
||||||
|
backgroundSettings.cardGlassmorphism
|
||||||
|
}
|
||||||
|
cardBorderEnabled={
|
||||||
|
backgroundSettings.cardBorderEnabled
|
||||||
|
}
|
||||||
|
cardBorderOpacity={
|
||||||
|
backgroundSettings.cardBorderOpacity
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SortableContext>
|
||||||
|
</KanbanColumn>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DragOverlay
|
||||||
|
dropAnimation={{
|
||||||
|
duration: 200,
|
||||||
|
easing: "cubic-bezier(0.18, 0.67, 0.6, 1.22)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeFeature && (
|
||||||
|
<Card className="w-72 rotate-2 shadow-2xl shadow-black/25 border-primary/50 bg-card/95 backdrop-blur-sm transition-transform">
|
||||||
|
<CardHeader className="p-3">
|
||||||
|
<CardTitle className="text-sm font-medium line-clamp-2">
|
||||||
|
{activeFeature.description}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs text-muted-foreground">
|
||||||
|
{activeFeature.category}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
apps/app/src/components/views/board-view/shared/index.ts
Normal file
5
apps/app/src/components/views/board-view/shared/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from "./model-constants";
|
||||||
|
export * from "./model-selector";
|
||||||
|
export * from "./thinking-level-selector";
|
||||||
|
export * from "./profile-quick-select";
|
||||||
|
export * from "./testing-tab-content";
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { 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";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CLAUDE_MODELS: ModelOption[] = [
|
||||||
|
{
|
||||||
|
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: "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_LEVEL_LABELS: Record<ThinkingLevel, string> = {
|
||||||
|
none: "None",
|
||||||
|
low: "Low",
|
||||||
|
medium: "Med",
|
||||||
|
high: "High",
|
||||||
|
ultrathink: "Ultra",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Profile icon mapping
|
||||||
|
export const PROFILE_ICONS: Record<
|
||||||
|
string,
|
||||||
|
React.ComponentType<{ className?: string }>
|
||||||
|
> = {
|
||||||
|
Brain,
|
||||||
|
Zap,
|
||||||
|
Scale,
|
||||||
|
Cpu,
|
||||||
|
Rocket,
|
||||||
|
Sparkles,
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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;
|
||||||
|
onModelSelect: (model: AgentModel) => void;
|
||||||
|
testIdPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelSelector({
|
||||||
|
selectedModel,
|
||||||
|
onModelSelect,
|
||||||
|
testIdPrefix = "model-select",
|
||||||
|
}: ModelSelectorProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Brain className="w-4 h-4 text-primary" />
|
||||||
|
Claude (SDK)
|
||||||
|
</Label>
|
||||||
|
<span className="text-[11px] px-2 py-0.5 rounded-full border border-primary/40 text-primary">
|
||||||
|
Native
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{CLAUDE_MODELS.map((option) => {
|
||||||
|
const isSelected = selectedModel === option.id;
|
||||||
|
const shortName = option.label.replace("Claude ", "");
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
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",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "bg-background hover:bg-accent border-input"
|
||||||
|
)}
|
||||||
|
data-testid={`${testIdPrefix}-${option.id}`}
|
||||||
|
>
|
||||||
|
{shortName}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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[];
|
||||||
|
selectedModel: AgentModel;
|
||||||
|
selectedThinkingLevel: ThinkingLevel;
|
||||||
|
onSelect: (model: AgentModel, thinkingLevel: ThinkingLevel) => void;
|
||||||
|
testIdPrefix?: string;
|
||||||
|
showManageLink?: boolean;
|
||||||
|
onManageLinkClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileQuickSelect({
|
||||||
|
profiles,
|
||||||
|
selectedModel,
|
||||||
|
selectedThinkingLevel,
|
||||||
|
onSelect,
|
||||||
|
testIdPrefix = "profile-quick-select",
|
||||||
|
showManageLink = false,
|
||||||
|
onManageLinkClick,
|
||||||
|
}: ProfileQuickSelectProps) {
|
||||||
|
if (profiles.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<UserCircle className="w-4 h-4 text-brand-500" />
|
||||||
|
Quick Select Profile
|
||||||
|
</Label>
|
||||||
|
<span className="text-[11px] px-2 py-0.5 rounded-full border border-brand-500/40 text-brand-500">
|
||||||
|
Presets
|
||||||
|
</span>
|
||||||
|
</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 isSelected =
|
||||||
|
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",
|
||||||
|
isSelected
|
||||||
|
? "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" />
|
||||||
|
)}
|
||||||
|
</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}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Or customize below.
|
||||||
|
{showManageLink && onManageLinkClick && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
Manage profiles in{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onManageLinkClick}
|
||||||
|
className="text-brand-500 hover:underline"
|
||||||
|
>
|
||||||
|
AI Profiles
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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;
|
||||||
|
onSkipTestsChange: (skipTests: boolean) => void;
|
||||||
|
steps: string[];
|
||||||
|
onStepsChange: (steps: string[]) => void;
|
||||||
|
testIdPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TestingTabContent({
|
||||||
|
skipTests,
|
||||||
|
onSkipTestsChange,
|
||||||
|
steps,
|
||||||
|
onStepsChange,
|
||||||
|
testIdPrefix = "",
|
||||||
|
}: TestingTabContentProps) {
|
||||||
|
const checkboxId = testIdPrefix ? `${testIdPrefix}-skip-tests` : "skip-tests";
|
||||||
|
|
||||||
|
const handleStepChange = (index: number, value: string) => {
|
||||||
|
const newSteps = [...steps];
|
||||||
|
newSteps[index] = value;
|
||||||
|
onStepsChange(newSteps);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddStep = () => {
|
||||||
|
onStepsChange([...steps, ""]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={checkboxId}
|
||||||
|
checked={!skipTests}
|
||||||
|
onCheckedChange={(checked) => onSkipTestsChange(checked !== true)}
|
||||||
|
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}skip-tests-checkbox`}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor={checkboxId} className="text-sm cursor-pointer">
|
||||||
|
Enable automated testing
|
||||||
|
</Label>
|
||||||
|
<FlaskConical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
When enabled, this feature will use automated TDD. When disabled, it
|
||||||
|
will require manual verification.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Verification Steps - Only shown when skipTests is enabled */}
|
||||||
|
{skipTests && (
|
||||||
|
<div className="space-y-2 pt-2 border-t border-border">
|
||||||
|
<Label>Verification Steps</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
Add manual steps to verify this feature works correctly.
|
||||||
|
</p>
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<Input
|
||||||
|
key={index}
|
||||||
|
value={step}
|
||||||
|
placeholder={`Verification step ${index + 1}`}
|
||||||
|
onChange={(e) => handleStepChange(index, e.target.value)}
|
||||||
|
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}feature-step-${index}${testIdPrefix ? "" : "-input"}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddStep}
|
||||||
|
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}add-step-button`}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Verification Step
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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;
|
||||||
|
onLevelSelect: (level: ThinkingLevel) => void;
|
||||||
|
testIdPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThinkingLevelSelector({
|
||||||
|
selectedLevel,
|
||||||
|
onLevelSelect,
|
||||||
|
testIdPrefix = "thinking-level",
|
||||||
|
}: ThinkingLevelSelectorProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 pt-2 border-t border-border">
|
||||||
|
<Label className="flex items-center gap-2 text-sm">
|
||||||
|
<Brain className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
Thinking Level
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{THINKING_LEVELS.map((level) => (
|
||||||
|
<button
|
||||||
|
key={level}
|
||||||
|
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]",
|
||||||
|
selectedLevel === level
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "bg-background hover:bg-accent border-input"
|
||||||
|
)}
|
||||||
|
data-testid={`${testIdPrefix}-${level}`}
|
||||||
|
>
|
||||||
|
{THINKING_LEVEL_LABELS[level]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Higher levels give more time to reason through complex problems.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -46,3 +46,4 @@ export const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [
|
|||||||
{ id: "ultrathink", label: "Ultrathink" },
|
{ id: "ultrathink", label: "Ultrathink" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ export function getProviderFromModel(model: AgentModel): ModelProvider {
|
|||||||
return "claude";
|
return "claude";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,3 +25,13 @@ export function getModelDisplayName(model: AgentModel | string): string {
|
|||||||
};
|
};
|
||||||
return displayNames[model] || model;
|
return displayNames[model] || model;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate a description string with ellipsis
|
||||||
|
*/
|
||||||
|
export function truncateDescription(description: string, maxLength = 50): string {
|
||||||
|
if (description.length <= maxLength) {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
return `${description.slice(0, maxLength)}...`;
|
||||||
|
}
|
||||||
|
|||||||
163
docs/folder-pattern.md
Normal file
163
docs/folder-pattern.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# Folder & Naming Pattern Guide
|
||||||
|
|
||||||
|
This document defines the folder structure and naming conventions used in this codebase.
|
||||||
|
|
||||||
|
## File Naming Convention
|
||||||
|
|
||||||
|
**All files use kebab-case** (lowercase with hyphens):
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ add-feature-dialog.tsx
|
||||||
|
✅ use-board-actions.ts
|
||||||
|
✅ board-view.tsx
|
||||||
|
|
||||||
|
❌ AddFeatureDialog.tsx
|
||||||
|
❌ useBoardActions.ts
|
||||||
|
❌ BoardView.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Export Naming Convention
|
||||||
|
|
||||||
|
While files use kebab-case, **exports use PascalCase for components and camelCase for hooks/functions**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// File: add-feature-dialog.tsx
|
||||||
|
export function AddFeatureDialog() { ... }
|
||||||
|
|
||||||
|
// File: use-board-actions.ts
|
||||||
|
export function useBoardActions() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
## View Folder Structure
|
||||||
|
|
||||||
|
Each complex view should have its own folder with the following structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
components/views/
|
||||||
|
├── [view-name].tsx # Entry point (exports the main view)
|
||||||
|
└── [view-name]/ # Subfolder for complex views
|
||||||
|
├── components/ # View-specific reusable components
|
||||||
|
│ ├── index.ts # Barrel export
|
||||||
|
│ └── [component].tsx # Individual components
|
||||||
|
├── dialogs/ # View-specific dialogs and modals
|
||||||
|
│ ├── index.ts # Barrel export
|
||||||
|
│ └── [dialog-name].tsx # Individual dialogs/modals
|
||||||
|
├── hooks/ # View-specific hooks
|
||||||
|
│ ├── index.ts # Barrel export
|
||||||
|
│ └── use-[name].ts # Individual hooks
|
||||||
|
├── shared/ # Shared utilities between components
|
||||||
|
│ ├── index.ts # Barrel export
|
||||||
|
│ └── [name].ts # Shared code
|
||||||
|
├── constants.ts # View constants
|
||||||
|
├── types.ts # View-specific types (optional)
|
||||||
|
├── utils.ts # View utilities (optional)
|
||||||
|
└── [main-component].tsx # Main view components (e.g., kanban-board.tsx)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: board-view
|
||||||
|
|
||||||
|
```
|
||||||
|
components/views/
|
||||||
|
├── board-view.tsx # Entry point
|
||||||
|
└── board-view/
|
||||||
|
├── components/
|
||||||
|
│ ├── index.ts
|
||||||
|
│ ├── kanban-card.tsx
|
||||||
|
│ └── kanban-column.tsx
|
||||||
|
├── dialogs/
|
||||||
|
│ ├── index.ts
|
||||||
|
│ ├── add-feature-dialog.tsx
|
||||||
|
│ ├── edit-feature-dialog.tsx
|
||||||
|
│ ├── follow-up-dialog.tsx
|
||||||
|
│ ├── delete-all-verified-dialog.tsx
|
||||||
|
│ ├── delete-completed-feature-dialog.tsx
|
||||||
|
│ ├── completed-features-modal.tsx
|
||||||
|
│ ├── agent-output-modal.tsx
|
||||||
|
│ └── feature-suggestions-dialog.tsx
|
||||||
|
├── hooks/
|
||||||
|
│ ├── index.ts
|
||||||
|
│ ├── use-board-actions.ts
|
||||||
|
│ ├── use-board-features.ts
|
||||||
|
│ └── use-board-drag-drop.ts
|
||||||
|
├── shared/
|
||||||
|
│ ├── index.ts
|
||||||
|
│ ├── model-constants.ts
|
||||||
|
│ └── model-selector.tsx
|
||||||
|
├── constants.ts
|
||||||
|
└── kanban-board.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Global vs View-Specific Code
|
||||||
|
|
||||||
|
### Global (`src/hooks/`, `src/lib/`, etc.)
|
||||||
|
Code that is used across **multiple views**:
|
||||||
|
- `src/hooks/use-auto-mode.ts` - Used by board-view, agent-view, etc.
|
||||||
|
- `src/hooks/use-keyboard-shortcuts.ts` - Used across the app
|
||||||
|
- `src/lib/utils.ts` - Global utilities
|
||||||
|
|
||||||
|
### View-Specific (`[view-name]/hooks/`, `[view-name]/components/`)
|
||||||
|
Code that is **only used within a single view**:
|
||||||
|
- `board-view/hooks/use-board-actions.ts` - Only used by board-view
|
||||||
|
- `board-view/components/kanban-card.tsx` - Only used by board-view
|
||||||
|
|
||||||
|
## Barrel Exports
|
||||||
|
|
||||||
|
Use `index.ts` files to create clean import paths:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// board-view/hooks/index.ts
|
||||||
|
export { useBoardActions } from "./use-board-actions";
|
||||||
|
export { useBoardFeatures } from "./use-board-features";
|
||||||
|
|
||||||
|
// Usage in board-view.tsx
|
||||||
|
import { useBoardActions, useBoardFeatures } from "./board-view/hooks";
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Create a Subfolder
|
||||||
|
|
||||||
|
Create a subfolder for a view when:
|
||||||
|
1. The view file exceeds ~500 lines
|
||||||
|
2. The view has 3+ related components
|
||||||
|
3. The view has 2+ custom hooks
|
||||||
|
4. Multiple dialogs/modals are specific to the view
|
||||||
|
|
||||||
|
## Dialogs Folder
|
||||||
|
|
||||||
|
The `dialogs/` folder contains all dialog and modal components specific to a view:
|
||||||
|
|
||||||
|
### What goes in `dialogs/`:
|
||||||
|
- Confirmation dialogs (e.g., `delete-all-verified-dialog.tsx`)
|
||||||
|
- Form dialogs (e.g., `add-feature-dialog.tsx`, `edit-feature-dialog.tsx`)
|
||||||
|
- Modal overlays (e.g., `agent-output-modal.tsx`, `completed-features-modal.tsx`)
|
||||||
|
- Any component that renders as an overlay/popup
|
||||||
|
|
||||||
|
### Naming convention:
|
||||||
|
- Use `-dialog.tsx` suffix for confirmation/form dialogs
|
||||||
|
- Use `-modal.tsx` suffix for content-heavy modals
|
||||||
|
|
||||||
|
### Barrel export pattern:
|
||||||
|
```tsx
|
||||||
|
// dialogs/index.ts
|
||||||
|
export { AddFeatureDialog } from "./add-feature-dialog";
|
||||||
|
export { EditFeatureDialog } from "./edit-feature-dialog";
|
||||||
|
export { AgentOutputModal } from "./agent-output-modal";
|
||||||
|
// ... etc
|
||||||
|
|
||||||
|
// Usage in view entry point
|
||||||
|
import {
|
||||||
|
AddFeatureDialog,
|
||||||
|
EditFeatureDialog,
|
||||||
|
AgentOutputModal,
|
||||||
|
} from "./board-view/dialogs";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Location | File Naming | Export Naming |
|
||||||
|
|----------|-------------|---------------|
|
||||||
|
| Components | `kebab-case.tsx` | `PascalCase` |
|
||||||
|
| Dialogs | `*-dialog.tsx` or `*-modal.tsx` | `PascalCase` |
|
||||||
|
| Hooks | `use-kebab-case.ts` | `camelCase` |
|
||||||
|
| Utils/Lib | `kebab-case.ts` | `camelCase` |
|
||||||
|
| Types | `kebab-case.ts` | `PascalCase` |
|
||||||
|
| Constants | `constants.ts` | `SCREAMING_SNAKE_CASE` |
|
||||||
Reference in New Issue
Block a user