feat: restructure feature management and update project files

- Introduced a new `package-lock.json` to manage dependencies.
- Removed obsolete `.automaker/feature_list.json` and replaced it with a new structure under `.automaker/features/{id}/feature.json` for better organization.
- Updated various components to utilize the new features API for managing features, including creation, updates, and deletions.
- Enhanced the UI to reflect changes in feature management, including updates to the sidebar and board view.
- Improved documentation and comments throughout the codebase to clarify the new feature management process.
This commit is contained in:
Cody Seibert
2025-12-10 19:11:36 -05:00
parent 38a609593b
commit 15981c8e1b
44 changed files with 2486 additions and 1644 deletions

View File

@@ -1271,7 +1271,7 @@ export function Sidebar() {
Generate feature list
</label>
<p className="text-xs text-muted-foreground">
Automatically populate feature_list.json with all features
Automatically create features in the features folder
from the implementation roadmap after the spec is generated.
</p>
</div>

View File

@@ -1,11 +1,23 @@
"use client";
import * as React from "react";
import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Input } from "./input";
import { Check, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
interface CategoryAutocompleteProps {
value: string;
@@ -26,225 +38,54 @@ export function CategoryAutocomplete({
disabled = false,
"data-testid": testId,
}: CategoryAutocompleteProps) {
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState(value);
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
// Update internal state when value prop changes
useEffect(() => {
setInputValue(value);
}, [value]);
// Filter suggestions based on input
useEffect(() => {
const searchTerm = inputValue.toLowerCase().trim();
if (searchTerm === "") {
setFilteredSuggestions(suggestions);
} else {
const filtered = suggestions.filter((s) =>
s.toLowerCase().includes(searchTerm)
);
setFilteredSuggestions(filtered);
}
setHighlightedIndex(-1);
}, [inputValue, suggestions]);
// Update dropdown position when open and handle scroll/resize
useEffect(() => {
const updatePosition = () => {
if (isOpen && containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY,
left: rect.left + window.scrollX,
width: rect.width,
});
}
};
updatePosition();
if (isOpen) {
window.addEventListener("scroll", updatePosition, true);
window.addEventListener("resize", updatePosition);
return () => {
window.removeEventListener("scroll", updatePosition, true);
window.removeEventListener("resize", updatePosition);
};
}
}, [isOpen]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node) &&
listRef.current &&
!listRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Scroll highlighted item into view
useEffect(() => {
if (highlightedIndex >= 0 && listRef.current) {
const items = listRef.current.querySelectorAll("li");
const highlightedItem = items[highlightedIndex];
if (highlightedItem) {
highlightedItem.scrollIntoView({ block: "nearest" });
}
}
}, [highlightedIndex]);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setInputValue(newValue);
onChange(newValue);
setIsOpen(true);
},
[onChange]
);
const handleSelect = useCallback(
(suggestion: string) => {
setInputValue(suggestion);
onChange(suggestion);
setIsOpen(false);
inputRef.current?.focus();
},
[onChange]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!isOpen) {
if (e.key === "ArrowDown" || e.key === "Enter") {
e.preventDefault();
setIsOpen(true);
}
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setHighlightedIndex((prev) =>
prev < filteredSuggestions.length - 1 ? prev + 1 : prev
);
break;
case "ArrowUp":
e.preventDefault();
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : -1));
break;
case "Enter":
e.preventDefault();
if (highlightedIndex >= 0 && filteredSuggestions[highlightedIndex]) {
handleSelect(filteredSuggestions[highlightedIndex]);
} else {
setIsOpen(false);
}
break;
case "Escape":
e.preventDefault();
setIsOpen(false);
break;
case "Tab":
setIsOpen(false);
break;
}
},
[isOpen, highlightedIndex, filteredSuggestions, handleSelect]
);
const handleFocus = useCallback(() => {
setIsOpen(true);
}, []);
const [open, setOpen] = React.useState(false);
return (
<div ref={containerRef} className={cn("relative", className)}>
<div className="relative">
<Input
ref={inputRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
placeholder={placeholder}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn("w-full justify-between", className)}
data-testid={testId}
className="pr-8"
/>
<button
type="button"
className={cn(
"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors",
disabled && "pointer-events-none opacity-50"
)}
onClick={() => setIsOpen(!isOpen)}
tabIndex={-1}
>
<ChevronDown
className={cn(
"h-4 w-4 transition-transform duration-200",
isOpen && "rotate-180"
)}
/>
</button>
</div>
{isOpen && filteredSuggestions.length > 0 && typeof document !== "undefined" &&
createPortal(
<ul
ref={listRef}
className="fixed z-[9999] max-h-60 overflow-auto rounded-md border bg-background p-1 shadow-md animate-in fade-in-0 zoom-in-95"
role="listbox"
data-testid="category-autocomplete-list"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
}}
>
{filteredSuggestions.map((suggestion, index) => (
<li
key={suggestion}
role="option"
aria-selected={highlightedIndex === index}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors",
highlightedIndex === index && "bg-accent text-accent-foreground",
inputValue === suggestion && "font-medium"
)}
onMouseDown={(e) => {
e.preventDefault();
handleSelect(suggestion);
}}
onMouseEnter={() => setHighlightedIndex(index)}
data-testid={`category-option-${suggestion.toLowerCase().replace(/\s+/g, "-")}`}
>
{inputValue === suggestion && (
<Check className="mr-2 h-4 w-4 text-primary" />
)}
<span className={cn(inputValue !== suggestion && "ml-6")}>
{value
? suggestions.find((s) => s === value) ?? value
: placeholder}
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search category..." className="h-9" />
<CommandList>
<CommandEmpty>No category found.</CommandEmpty>
<CommandGroup>
{suggestions.map((suggestion) => (
<CommandItem
key={suggestion}
value={suggestion}
onSelect={(currentValue) => {
onChange(currentValue === value ? "" : currentValue);
setOpen(false);
}}
data-testid={`category-option-${suggestion.toLowerCase().replace(/\s+/g, "-")}`}
>
{suggestion}
</span>
</li>
))}
</ul>,
document.body
)}
</div>
<Check
className={cn(
"ml-auto",
value === suggestion ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -20,6 +20,8 @@ interface AgentOutputModalProps {
onClose: () => void;
featureDescription: string;
featureId: string;
/** The status of the feature - used to determine if spinner should be shown */
featureStatus?: string;
/** Called when a number key (0-9) is pressed while the modal is open */
onNumberKeyPress?: (key: string) => void;
}
@@ -31,6 +33,7 @@ export function AgentOutputModal({
onClose,
featureDescription,
featureId,
featureStatus,
onNumberKeyPress,
}: AgentOutputModalProps) {
const [output, setOutput] = useState<string>("");
@@ -70,16 +73,18 @@ export function AgentOutputModal({
projectPathRef.current = currentProject.path;
setProjectPath(currentProject.path);
// Ensure context directory exists
const contextDir = `${currentProject.path}/.automaker/agents-context`;
await api.mkdir(contextDir);
// Use features API to get agent output
if (api.features) {
const result = await api.features.getAgentOutput(
currentProject.path,
featureId
);
// Try to read existing output file
const outputPath = `${contextDir}/${featureId}.md`;
const result = await api.readFile(outputPath);
if (result.success && result.content) {
setOutput(result.content);
if (result.success) {
setOutput(result.content || "");
} else {
setOutput("");
}
} else {
setOutput("");
}
@@ -102,9 +107,10 @@ export function AgentOutputModal({
if (!api) return;
try {
const contextDir = `${projectPathRef.current}/.automaker/agents-context`;
const outputPath = `${contextDir}/${featureId}.md`;
// Use features API - agent output is stored in features/{id}/agent-output.md
// We need to write it directly since there's no updateAgentOutput method
// The context-manager handles this on the backend, but for frontend edits we write directly
const outputPath = `${projectPathRef.current}/.automaker/features/${featureId}/agent-output.md`;
await api.writeFile(outputPath, newContent);
} catch (error) {
console.error("Failed to save output:", error);
@@ -250,7 +256,10 @@ export function AgentOutputModal({
<DialogHeader className="flex-shrink-0">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2">
<Loader2 className="w-5 h-5 text-primary animate-spin" />
{featureStatus !== "verified" &&
featureStatus !== "waiting_approval" && (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
)}
Agent Output
</DialogTitle>
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">

View File

@@ -155,7 +155,7 @@ export function AgentToolsView() {
// In mock mode, simulate terminal output
// In real Electron mode, this would use child_process
const mockOutputs: Record<string, string> = {
ls: "app_spec.txt\nfeature_list.json\nnode_modules\npackage.json\nsrc\ntests\ntsconfig.json",
ls: "app_spec.txt\nfeatures\nnode_modules\npackage.json\nsrc\ntests\ntsconfig.json",
pwd: currentProject?.path || "/Users/demo/project",
"echo hello": "hello",
whoami: "automaker-agent",

View File

@@ -594,11 +594,11 @@ export function AgentView() {
className={cn(
"max-w-[80%]",
message.role === "user"
? "bg-primary text-primary-foreground"
? "bg-transparent border border-primary text-foreground"
: "border-l-4 border-primary bg-card"
)}
>
<CardContent className="p-3">
<CardContent className="px-3 py-2">
{message.role === "assistant" ? (
<Markdown className="text-sm text-primary prose-headings:text-primary prose-strong:text-primary prose-code:text-primary">
{message.content}
@@ -610,9 +610,9 @@ export function AgentView() {
)}
<p
className={cn(
"text-xs mt-2",
"text-xs mt-1",
message.role === "user"
? "text-primary-foreground/70"
? "text-muted-foreground"
: "text-primary/70"
)}
>

View File

@@ -409,7 +409,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
}
}, [currentProject, projectAnalysis]);
// Generate .automaker/feature_list.json from analysis
// Generate features from analysis and save to .automaker/features folder
const generateFeatureList = useCallback(async () => {
if (!currentProject || !projectAnalysis) return;
@@ -755,23 +755,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
});
}
// Generate the feature list content
const featureListContent = JSON.stringify(detectedFeatures, null, 2);
// Write the feature list file
const featureListPath = `${currentProject.path}/feature_list.json`;
const writeResult = await api.writeFile(
featureListPath,
featureListContent
);
if (writeResult.success) {
setFeatureListGenerated(true);
} else {
setFeatureListError(
writeResult.error || "Failed to write feature list file"
);
// Create each feature using the features API
for (const feature of detectedFeatures) {
await api.features.create(currentProject.path, feature);
}
setFeatureListGenerated(true);
} catch (error) {
console.error("Failed to generate feature list:", error);
setFeatureListError(
@@ -1041,7 +1030,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
Generate Feature List
</CardTitle>
<CardDescription>
Create .automaker/feature_list.json from analysis
Create features from analysis
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
@@ -1074,7 +1063,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
data-testid="feature-list-generated-success"
>
<CheckCircle className="w-4 h-4" />
<span>feature_list.json created successfully!</span>
<span>Features created successfully!</span>
</div>
)}
{featureListError && (

View File

@@ -85,6 +85,7 @@ import {
Minimize2,
Square,
Maximize2,
Shuffle,
} from "lucide-react";
import { toast } from "sonner";
import { Slider } from "@/components/ui/slider";
@@ -242,6 +243,8 @@ export function BoardView() {
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<ImagePreviewMap>(
() => new Map()
);
const [editFeaturePreviewMap, setEditFeaturePreviewMap] =
useState<ImagePreviewMap>(() => new Map());
// Local state to temporarily show advanced options when profiles-only mode is enabled
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
@@ -390,7 +393,7 @@ export function BoardView() {
return rectIntersection(args);
}, []);
// Load features from file
// Load features using features API
const loadFeatures = useCallback(async () => {
if (!currentProject) return;
@@ -419,21 +422,25 @@ export function BoardView() {
try {
const api = getElectronAPI();
const result = await api.readFile(
`${currentProject.path}/.automaker/feature_list.json`
);
if (!api.features) {
console.error("[BoardView] Features API not available");
return;
}
if (result.success && result.content) {
const parsed = JSON.parse(result.content);
const featuresWithIds = parsed.map((f: any, index: number) => ({
...f,
id: f.id || `feature-${index}-${Date.now()}`,
status: f.status || "backlog",
startedAt: f.startedAt, // Preserve startedAt timestamp
// Ensure model and thinkingLevel are set for backward compatibility
model: f.model || "opus",
thinkingLevel: f.thinkingLevel || "none",
}));
const 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",
})
);
setFeatures(featuresWithIds);
}
} catch (error) {
@@ -529,6 +536,9 @@ export function BoardView() {
// Reload features when a feature is completed
console.log("[Board] Feature completed, reloading features...");
loadFeatures();
// Play ding sound when feature is done
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(
@@ -627,41 +637,75 @@ export function BoardView() {
}
}, [features, isLoading]);
// Save features to file
const saveFeatures = useCallback(async () => {
if (!currentProject) return;
// Persist feature update to API (replaces saveFeatures)
const persistFeatureUpdate = useCallback(
async (featureId: string, updates: Partial<Feature>) => {
if (!currentProject) return;
try {
const api = getElectronAPI();
const toSave = features.map((f) => ({
id: f.id,
category: f.category,
description: f.description,
steps: f.steps,
status: f.status,
startedAt: f.startedAt,
imagePaths: f.imagePaths,
skipTests: f.skipTests,
summary: f.summary,
model: f.model,
thinkingLevel: f.thinkingLevel,
error: f.error,
}));
await api.writeFile(
`${currentProject.path}/.automaker/feature_list.json`,
JSON.stringify(toSave, null, 2)
);
} catch (error) {
console.error("Failed to save features:", error);
}
}, [currentProject, features]);
try {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
return;
}
// Save when features change (after initial load is complete)
useEffect(() => {
if (!isLoading && !isSwitchingProjectRef.current) {
saveFeatures();
}
}, [features, saveFeatures, isLoading]);
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]
);
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
@@ -690,13 +734,15 @@ export function BoardView() {
// 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 or verified cannot be dragged
// - Non-skipTests (TDD) items that are in progress cannot be dragged (they are running)
if (
draggedFeature.status !== "backlog" &&
draggedFeature.status !== "waiting_approval"
draggedFeature.status !== "waiting_approval" &&
draggedFeature.status !== "verified"
) {
// Only allow dragging in_progress/verified if it's a skipTests feature and not currently running
// 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"
@@ -744,14 +790,17 @@ export function BoardView() {
// From backlog
if (targetStatus === "in_progress") {
// Update with startedAt timestamp
updateFeature(featureId, {
const updates = {
status: targetStatus,
startedAt: new Date().toISOString(),
});
};
updateFeature(featureId, updates);
persistFeatureUpdate(featureId, updates);
console.log("[Board] Feature moved to in_progress, starting agent...");
await handleRunFeature(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
@@ -759,6 +808,7 @@ export function BoardView() {
// features often have skipTests=true, and we want status-based handling first
if (targetStatus === "verified") {
moveFeature(featureId, "verified");
persistFeatureUpdate(featureId, { status: "verified" });
toast.success("Feature verified", {
description: `Manually verified: ${draggedFeature.description.slice(
0,
@@ -768,6 +818,7 @@ export function BoardView() {
} else if (targetStatus === "backlog") {
// Allow moving waiting_approval 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,
@@ -783,6 +834,7 @@ export function BoardView() {
) {
// Manual verify via drag
moveFeature(featureId, "verified");
persistFeatureUpdate(featureId, { status: "verified" });
toast.success("Feature verified", {
description: `Marked as verified: ${draggedFeature.description.slice(
0,
@@ -790,16 +842,14 @@ export function BoardView() {
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
} else if (
targetStatus === "in_progress" &&
targetStatus === "waiting_approval" &&
draggedFeature.status === "verified"
) {
// Move back to in_progress
updateFeature(featureId, {
status: "in_progress",
startedAt: new Date().toISOString(),
});
// 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 In Progress: ${draggedFeature.description.slice(
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
@@ -807,6 +857,30 @@ export function BoardView() {
} 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,
@@ -828,17 +902,19 @@ export function BoardView() {
const normalizedThinking = modelSupportsThinking(selectedModel)
? newFeature.thinkingLevel
: "none";
addFeature({
const newFeatureData = {
category,
description: newFeature.description,
steps: newFeature.steps.filter((s) => s.trim()),
status: "backlog",
status: "backlog" as const,
images: newFeature.images,
imagePaths: newFeature.imagePaths,
skipTests: newFeature.skipTests,
model: selectedModel,
thinkingLevel: normalizedThinking,
});
};
const createdFeature = addFeature(newFeatureData);
persistFeatureCreate(createdFeature);
// Persist the category
saveCategory(category);
setNewFeature({
@@ -864,14 +940,19 @@ export function BoardView() {
? editingFeature.thinkingLevel
: "none";
updateFeature(editingFeature.id, {
const updates = {
category: editingFeature.category,
description: editingFeature.description,
steps: editingFeature.steps,
skipTests: editingFeature.skipTests,
model: selectedModel,
thinkingLevel: normalizedThinking,
});
imagePaths: editingFeature.imagePaths,
};
updateFeature(editingFeature.id, updates);
persistFeatureUpdate(editingFeature.id, updates);
// Clear the preview map after saving
setEditFeaturePreviewMap(new Map());
// Persist the category if it's new
if (editingFeature.category) {
saveCategory(editingFeature.category);
@@ -904,13 +985,14 @@ export function BoardView() {
}
}
// Delete agent context file if it exists
// Note: Agent context file will be deleted automatically when feature folder is deleted
// via persistFeatureDelete, so no manual deletion needed
if (currentProject) {
try {
const api = getElectronAPI();
const contextPath = `${currentProject.path}/.automaker/agents-context/${featureId}.md`;
await api.deleteFile(contextPath);
console.log(`[Board] Deleted agent context for feature ${featureId}`);
// Feature folder deletion handles agent-output.md automatically
console.log(
`[Board] Feature ${featureId} will be deleted (including agent context)`
);
} catch (error) {
// Context file might not exist, which is fine
console.log(
@@ -944,6 +1026,7 @@ export function BoardView() {
// Remove the feature immediately without confirmation
removeFeature(featureId);
persistFeatureDelete(featureId);
};
const handleRunFeature = async (feature: Feature) => {
@@ -1056,6 +1139,7 @@ export function BoardView() {
description: feature.description,
});
moveFeature(feature.id, "verified");
persistFeatureUpdate(feature.id, { status: "verified" });
toast.success("Feature verified", {
description: `Marked as verified: ${feature.description.slice(0, 50)}${
feature.description.length > 50 ? "..." : ""
@@ -1069,10 +1153,12 @@ export function BoardView() {
id: feature.id,
description: feature.description,
});
updateFeature(feature.id, {
status: "in_progress",
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: ${feature.description.slice(
0,
@@ -1119,10 +1205,12 @@ export function BoardView() {
}
// Move feature back to in_progress before sending follow-up
updateFeature(featureId, {
status: "in_progress",
const updates = {
status: "in_progress" as const,
startedAt: new Date().toISOString(),
});
};
updateFeature(featureId, updates);
persistFeatureUpdate(featureId, updates);
// Reset follow-up state immediately (close dialog, clear form)
setShowFollowUpDialog(false);
@@ -1181,6 +1269,7 @@ export function BoardView() {
console.log("[Board] Feature committed successfully");
// Move to verified status
moveFeature(feature.id, "verified");
persistFeatureUpdate(feature.id, { status: "verified" });
toast.success("Feature committed", {
description: `Committed and verified: ${feature.description.slice(
0,
@@ -1210,7 +1299,9 @@ export function BoardView() {
id: feature.id,
description: feature.description,
});
updateFeature(feature.id, { status: "waiting_approval" });
const updates = { status: "waiting_approval" as const };
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.info("Feature ready for review", {
description: `Ready for approval: ${feature.description.slice(0, 50)}${
feature.description.length > 50 ? "..." : ""
@@ -1426,6 +1517,7 @@ export function BoardView() {
if (targetStatus !== feature.status) {
moveFeature(feature.id, targetStatus);
persistFeatureUpdate(feature.id, { status: targetStatus });
}
toast.success("Agent stopped", {
@@ -1473,10 +1565,12 @@ export function BoardView() {
for (const feature of featuresToStart) {
// Update the feature status with startedAt timestamp
updateFeature(feature.id, {
status: "in_progress",
const updates = {
status: "in_progress" as const,
startedAt: new Date().toISOString(),
});
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
// Start the agent for this feature
await handleRunFeature(feature);
}
@@ -1885,7 +1979,24 @@ export function BoardView() {
}
}}
>
<DialogContent compact={!isMaximized} data-testid="add-feature-dialog">
<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>
@@ -2276,10 +2387,28 @@ export function BoardView() {
if (!open) {
setEditingFeature(null);
setShowEditAdvancedOptions(false);
setEditFeaturePreviewMap(new Map());
}
}}
>
<DialogContent compact={!isMaximized} data-testid="edit-feature-dialog">
<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>
@@ -2308,16 +2437,24 @@ export function BoardView() {
<TabsContent value="prompt" className="space-y-4 overflow-y-auto">
<div className="space-y-2">
<Label htmlFor="edit-description">Description</Label>
<Textarea
id="edit-description"
placeholder="Describe the feature..."
<DescriptionImageDropZone
value={editingFeature.description}
onChange={(e) =>
onChange={(value) =>
setEditingFeature({
...editingFeature,
description: e.target.value,
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>
@@ -2669,6 +2806,7 @@ export function BoardView() {
onClose={() => setShowOutputModal(false)}
featureDescription={outputFeature?.description || ""}
featureId={outputFeature?.id || ""}
featureStatus={outputFeature?.status}
onNumberKeyPress={handleOutputModalNumberKeyPress}
/>
@@ -2720,12 +2858,12 @@ export function BoardView() {
}
}
// Delete agent context file if it exists
// Note: Agent context file will be deleted automatically when feature folder is deleted
// via persistFeatureDelete, so no manual deletion needed
try {
const contextPath = `${currentProject.path}/.automaker/agents-context/${feature.id}.md`;
await api.deleteFile(contextPath);
// Feature folder deletion handles agent-output.md automatically
console.log(
`[Board] Deleted agent context for feature ${feature.id}`
`[Board] Feature ${feature.id} will be deleted (including agent context)`
);
} catch (error) {
// Context file might not exist, which is fine
@@ -2737,6 +2875,7 @@ export function BoardView() {
// Remove the feature
removeFeature(feature.id);
persistFeatureDelete(feature.id);
}
setShowDeleteAllVerifiedDialog(false);

View File

@@ -202,12 +202,13 @@ export function FeatureSuggestionsDialog({
skipTests: true, // As specified, testing mode true
}));
// Merge with existing features
const updatedFeatures = [...features, ...newFeatures];
// Create each new feature using the features API
for (const feature of newFeatures) {
await api.features.create(projectPath, feature);
}
// Save to file
const featureListPath = `${projectPath}/.automaker/feature_list.json`;
await api.writeFile(featureListPath, JSON.stringify(updatedFeatures, null, 2));
// Merge with existing features for store update
const updatedFeatures = [...features, ...newFeatures];
// Update store
setFeatures(updatedFeatures);

View File

@@ -300,26 +300,20 @@ export function InterviewView() {
generatedSpec
);
// Create initial .automaker/feature_list.json
await api.writeFile(
`${fullProjectPath}/.automaker/feature_list.json`,
JSON.stringify(
[
{
category: "Core",
description: "Initial project setup",
steps: [
"Step 1: Review app_spec.txt",
"Step 2: Set up development environment",
"Step 3: Start implementing features",
],
passes: false,
},
],
null,
2
)
);
// Create initial feature in the features folder
const initialFeature = {
id: `feature-${Date.now()}-0`,
category: "Core",
description: "Initial project setup",
status: "backlog",
steps: [
"Step 1: Review app_spec.txt",
"Step 2: Set up development environment",
"Step 3: Start implementing features",
],
skipTests: true,
};
await api.features.create(fullProjectPath, initialFeature);
const project = {
id: `project-${Date.now()}`,
@@ -432,20 +426,20 @@ export function InterviewView() {
className={cn(
"max-w-[80%]",
message.role === "user"
? "bg-primary text-primary-foreground"
? "bg-transparent border border-primary text-foreground"
: "border-l-4 border-primary bg-card"
)}
>
<CardContent className="p-3">
<CardContent className="px-3 py-2">
<p className={cn(
"text-sm whitespace-pre-wrap",
message.role === "assistant" && "text-primary"
)}>{message.content}</p>
<p
className={cn(
"text-xs mt-2",
"text-xs mt-1",
message.role === "user"
? "text-primary-foreground/70"
? "text-muted-foreground"
: "text-primary/70"
)}
>

View File

@@ -12,6 +12,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import {
Dialog,
DialogContent,
@@ -52,6 +53,8 @@ import {
GitBranch,
Undo2,
GitMerge,
ChevronDown,
ChevronUp,
} from "lucide-react";
import { CountUpTimer } from "@/components/ui/count-up-timer";
import { getElectronAPI } from "@/lib/electron";
@@ -116,6 +119,7 @@ export const KanbanCard = memo(function KanbanCard({
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false);
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const { kanbanCardDetailLevel } = useAppStore();
// Check if feature has worktree
@@ -149,12 +153,26 @@ export const KanbanCard = memo(function KanbanCard({
const currentProject = (window as any).__currentProject;
if (!currentProject?.path) return;
const contextPath = `${currentProject.path}/.automaker/agents-context/${feature.id}.md`;
// Use features API to get agent output
if (api.features) {
const result = await api.features.getAgentOutput(
currentProject.path,
feature.id
);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
setAgentInfo(info);
}
} else {
// Fallback to direct file read for backward compatibility
const contextPath = `${currentProject.path}/.automaker/features/${feature.id}/agent-output.md`;
const result = await api.readFile(contextPath);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
setAgentInfo(info);
}
}
} catch {
// Context file might not exist
@@ -216,16 +234,19 @@ export const KanbanCard = memo(function KanbanCard({
ref={setNodeRef}
style={style}
className={cn(
"cursor-grab active:cursor-grabbing transition-all backdrop-blur-sm border-border relative kanban-card-content",
"cursor-grab active:cursor-grabbing transition-all backdrop-blur-sm border-border relative kanban-card-content select-none",
isDragging && "opacity-50 scale-105 shadow-lg",
isCurrentAutoTask &&
"border-running-indicator border-2 shadow-running-indicator/50 shadow-lg animate-pulse",
feature.error &&
!isCurrentAutoTask &&
"border-red-500 border-2 shadow-red-500/30 shadow-lg"
"border-red-500 border-2 shadow-red-500/30 shadow-lg",
!isDraggable && "cursor-default"
)}
data-testid={`kanban-card-${feature.id}`}
onDoubleClick={onEdit}
{...attributes}
{...(isDraggable ? listeners : {})}
>
{/* Shortcut key badge for in-progress cards */}
{shortcutKey && (
@@ -323,6 +344,7 @@ export const KanbanCard = memo(function KanbanCard({
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`menu-${feature.id}`}
>
<MoreVertical className="w-4 h-4" />
@@ -369,17 +391,45 @@ export const KanbanCard = memo(function KanbanCard({
<div className="flex items-start gap-2">
{isDraggable && (
<div
{...listeners}
className="mt-0.5 touch-none cursor-grab"
className="-ml-2 -mt-1 p-2 touch-none"
data-testid={`drag-handle-${feature.id}`}
>
<GripVertical className="w-4 h-4 text-muted-foreground" />
</div>
)}
<div className="flex-1 min-w-0 overflow-hidden">
<CardTitle className="text-sm leading-tight break-words hyphens-auto line-clamp-3 overflow-hidden">
<CardTitle
className={cn(
"text-sm leading-tight break-words hyphens-auto overflow-hidden",
!isDescriptionExpanded && "line-clamp-3"
)}
>
{feature.description}
</CardTitle>
{/* Show More/Less toggle - only show when description is likely truncated */}
{feature.description.length > 100 && (
<button
onClick={(e) => {
e.stopPropagation();
setIsDescriptionExpanded(!isDescriptionExpanded);
}}
onPointerDown={(e) => e.stopPropagation()}
className="flex items-center gap-0.5 text-[10px] text-muted-foreground hover:text-foreground mt-1 transition-colors"
data-testid={`toggle-description-${feature.id}`}
>
{isDescriptionExpanded ? (
<>
<ChevronUp className="w-3 h-3" />
<span>Show Less</span>
</>
) : (
<>
<ChevronDown className="w-3 h-3" />
<span>Show More</span>
</>
)}
</button>
)}
<CardDescription className="text-xs mt-1 truncate">
{feature.category}
</CardDescription>
@@ -504,6 +554,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
setIsSummaryDialogOpen(true);
}}
onPointerDown={(e) => e.stopPropagation()}
className="p-0.5 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground shrink-0"
title="View full summary"
data-testid={`expand-summary-${feature.id}`}
@@ -557,6 +608,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1" />
@@ -572,6 +624,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onForceStop();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`force-stop-${feature.id}`}
>
<StopCircle className="w-3 h-3 mr-1" />
@@ -592,6 +645,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onManualVerify();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`manual-verify-${feature.id}`}
>
<CheckCircle2 className="w-3 h-3 mr-1" />
@@ -606,6 +660,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onResume();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`resume-feature-${feature.id}`}
>
<RotateCcw className="w-3 h-3 mr-1" />
@@ -620,6 +675,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onVerify();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`verify-feature-${feature.id}`}
>
<PlayCircle className="w-3 h-3 mr-1" />
@@ -635,6 +691,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-inprogress-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1" />
@@ -655,6 +712,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-verified-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1" />
@@ -678,6 +736,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
setIsRevertDialogOpen(true);
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`revert-${feature.id}`}
>
<Undo2 className="w-3.5 h-3.5" />
@@ -699,6 +758,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onFollowUp();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`follow-up-${feature.id}`}
>
<MessageSquare className="w-3 h-3 mr-1 shrink-0" />
@@ -715,6 +775,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onMerge();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`merge-${feature.id}`}
title="Merge changes into main branch"
>
@@ -732,6 +793,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onCommit();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`commit-${feature.id}`}
>
<GitCommit className="w-3 h-3 mr-1" />
@@ -753,7 +815,7 @@ export const KanbanCard = memo(function KanbanCard({
be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogFooter className="mt-6">
<Button
variant="ghost"
onClick={handleCancelDelete}
@@ -761,13 +823,15 @@ export const KanbanCard = memo(function KanbanCard({
>
Cancel
</Button>
<Button
<HotkeyButton
variant="destructive"
onClick={handleConfirmDelete}
data-testid="confirm-delete-button"
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={isDeleteDialogOpen}
>
Delete
</Button>
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -41,6 +41,7 @@ import {
Settings2,
RefreshCw,
Info,
RotateCcw,
} from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { Checkbox } from "@/components/ui/checkbox";
@@ -162,8 +163,13 @@ export function SettingsView() {
hasOpenAIKey: boolean;
hasGoogleKey: boolean;
} | null>(null);
const [editingShortcut, setEditingShortcut] = useState<
keyof KeyboardShortcuts | null
>(null);
const [shortcutValue, setShortcutValue] = useState("");
const [shortcutError, setShortcutError] = useState<string | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Get authentication status from setup store
const { claudeAuthStatus, codexAuthStatus } = useSetupStore();
@@ -804,7 +810,7 @@ export function SettingsView() {
Current Authentication Configuration
</Label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Claude Authentication Status */}
<div className="p-3 rounded-lg bg-card border border-border">
@@ -858,7 +864,9 @@ export function SettingsView() {
) : apiKeyStatus?.hasAnthropicKey ? (
<div className="flex items-center gap-2 text-blue-400">
<Info className="w-3 h-3 shrink-0" />
<span>Using environment variable (ANTHROPIC_API_KEY)</span>
<span>
Using environment variable (ANTHROPIC_API_KEY)
</span>
</div>
) : apiKeys.anthropic ? (
<div className="flex items-center gap-2 text-blue-400">
@@ -920,7 +928,9 @@ export function SettingsView() {
) : apiKeyStatus?.hasOpenAIKey ? (
<div className="flex items-center gap-2 text-blue-400">
<Info className="w-3 h-3 shrink-0" />
<span>Using environment variable (OPENAI_API_KEY)</span>
<span>
Using environment variable (OPENAI_API_KEY)
</span>
</div>
) : apiKeys.openai ? (
<div className="flex items-center gap-2 text-blue-400">
@@ -948,7 +958,9 @@ export function SettingsView() {
{apiKeyStatus?.hasGoogleKey ? (
<div className="flex items-center gap-2 text-blue-400">
<Info className="w-3 h-3 shrink-0" />
<span>Using environment variable (GOOGLE_API_KEY)</span>
<span>
Using environment variable (GOOGLE_API_KEY)
</span>
</div>
) : apiKeys.google ? (
<div className="flex items-center gap-2 text-blue-400">
@@ -1632,13 +1644,34 @@ export function SettingsView() {
</div>
<div className="space-y-2">
{[
{ key: "board" as keyof KeyboardShortcuts, label: "Kanban Board" },
{ key: "agent" as keyof KeyboardShortcuts, label: "Agent Runner" },
{ key: "spec" as keyof KeyboardShortcuts, label: "Spec Editor" },
{ key: "context" as keyof KeyboardShortcuts, label: "Context" },
{ key: "tools" as keyof KeyboardShortcuts, label: "Agent Tools" },
{ key: "profiles" as keyof KeyboardShortcuts, label: "AI Profiles" },
{ key: "settings" as keyof KeyboardShortcuts, label: "Settings" },
{
key: "board" as keyof KeyboardShortcuts,
label: "Kanban Board",
},
{
key: "agent" as keyof KeyboardShortcuts,
label: "Agent Runner",
},
{
key: "spec" as keyof KeyboardShortcuts,
label: "Spec Editor",
},
{
key: "context" as keyof KeyboardShortcuts,
label: "Context",
},
{
key: "tools" as keyof KeyboardShortcuts,
label: "Agent Tools",
},
{
key: "profiles" as keyof KeyboardShortcuts,
label: "AI Profiles",
},
{
key: "settings" as keyof KeyboardShortcuts,
label: "Settings",
},
].map(({ key, label }) => (
<div
key={key}
@@ -1654,17 +1687,26 @@ export function SettingsView() {
const value = e.target.value.toUpperCase();
setShortcutValue(value);
// Check for conflicts
const conflict = Object.entries(keyboardShortcuts).find(
([k, v]) => k !== key && v.toUpperCase() === value
const conflict = Object.entries(
keyboardShortcuts
).find(
([k, v]) =>
k !== key && v.toUpperCase() === value
);
if (conflict) {
setShortcutError(`Already used by ${conflict[0]}`);
setShortcutError(
`Already used by ${conflict[0]}`
);
} else {
setShortcutError(null);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !shortcutError && shortcutValue) {
if (
e.key === "Enter" &&
!shortcutError &&
shortcutValue
) {
setKeyboardShortcut(key, shortcutValue);
setEditingShortcut(null);
setShortcutValue("");
@@ -1722,15 +1764,19 @@ export function SettingsView() {
}}
className={cn(
"px-3 py-1.5 text-sm font-mono rounded bg-sidebar-accent/20 border border-sidebar-border hover:bg-sidebar-accent/30 transition-colors",
keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] &&
keyboardShortcuts[key] !==
DEFAULT_KEYBOARD_SHORTCUTS[key] &&
"border-brand-500/50 bg-brand-500/10 text-brand-400"
)}
data-testid={`shortcut-${key}`}
>
{keyboardShortcuts[key]}
</button>
{keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] && (
<span className="text-xs text-brand-400">(modified)</span>
{keyboardShortcuts[key] !==
DEFAULT_KEYBOARD_SHORTCUTS[key] && (
<span className="text-xs text-brand-400">
(modified)
</span>
)}
</>
)}
@@ -1750,7 +1796,10 @@ export function SettingsView() {
</h3>
<div className="space-y-2">
{[
{ key: "toggleSidebar" as keyof KeyboardShortcuts, label: "Toggle Sidebar" },
{
key: "toggleSidebar" as keyof KeyboardShortcuts,
label: "Toggle Sidebar",
},
].map(({ key, label }) => (
<div
key={key}
@@ -1766,17 +1815,23 @@ export function SettingsView() {
const value = e.target.value;
setShortcutValue(value);
// Check for conflicts
const conflict = Object.entries(keyboardShortcuts).find(
([k, v]) => k !== key && v === value
);
const conflict = Object.entries(
keyboardShortcuts
).find(([k, v]) => k !== key && v === value);
if (conflict) {
setShortcutError(`Already used by ${conflict[0]}`);
setShortcutError(
`Already used by ${conflict[0]}`
);
} else {
setShortcutError(null);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !shortcutError && shortcutValue) {
if (
e.key === "Enter" &&
!shortcutError &&
shortcutValue
) {
setKeyboardShortcut(key, shortcutValue);
setEditingShortcut(null);
setShortcutValue("");
@@ -1832,15 +1887,19 @@ export function SettingsView() {
}}
className={cn(
"px-3 py-1.5 text-sm font-mono rounded bg-sidebar-accent/20 border border-sidebar-border hover:bg-sidebar-accent/30 transition-colors",
keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] &&
keyboardShortcuts[key] !==
DEFAULT_KEYBOARD_SHORTCUTS[key] &&
"border-brand-500/50 bg-brand-500/10 text-brand-400"
)}
data-testid={`shortcut-${key}`}
>
{keyboardShortcuts[key]}
</button>
{keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] && (
<span className="text-xs text-brand-400">(modified)</span>
{keyboardShortcuts[key] !==
DEFAULT_KEYBOARD_SHORTCUTS[key] && (
<span className="text-xs text-brand-400">
(modified)
</span>
)}
</>
)}
@@ -1857,15 +1916,42 @@ export function SettingsView() {
</h3>
<div className="space-y-2">
{[
{ key: "addFeature" as keyof KeyboardShortcuts, label: "Add Feature" },
{ key: "addContextFile" as keyof KeyboardShortcuts, label: "Add Context File" },
{ key: "startNext" as keyof KeyboardShortcuts, label: "Start Next Features" },
{ key: "newSession" as keyof KeyboardShortcuts, label: "New Session" },
{ key: "openProject" as keyof KeyboardShortcuts, label: "Open Project" },
{ key: "projectPicker" as keyof KeyboardShortcuts, label: "Project Picker" },
{ key: "cyclePrevProject" as keyof KeyboardShortcuts, label: "Previous Project" },
{ key: "cycleNextProject" as keyof KeyboardShortcuts, label: "Next Project" },
{ key: "addProfile" as keyof KeyboardShortcuts, label: "Add Profile" },
{
key: "addFeature" as keyof KeyboardShortcuts,
label: "Add Feature",
},
{
key: "addContextFile" as keyof KeyboardShortcuts,
label: "Add Context File",
},
{
key: "startNext" as keyof KeyboardShortcuts,
label: "Start Next Features",
},
{
key: "newSession" as keyof KeyboardShortcuts,
label: "New Session",
},
{
key: "openProject" as keyof KeyboardShortcuts,
label: "Open Project",
},
{
key: "projectPicker" as keyof KeyboardShortcuts,
label: "Project Picker",
},
{
key: "cyclePrevProject" as keyof KeyboardShortcuts,
label: "Previous Project",
},
{
key: "cycleNextProject" as keyof KeyboardShortcuts,
label: "Next Project",
},
{
key: "addProfile" as keyof KeyboardShortcuts,
label: "Add Profile",
},
].map(({ key, label }) => (
<div
key={key}
@@ -1881,17 +1967,26 @@ export function SettingsView() {
const value = e.target.value.toUpperCase();
setShortcutValue(value);
// Check for conflicts
const conflict = Object.entries(keyboardShortcuts).find(
([k, v]) => k !== key && v.toUpperCase() === value
const conflict = Object.entries(
keyboardShortcuts
).find(
([k, v]) =>
k !== key && v.toUpperCase() === value
);
if (conflict) {
setShortcutError(`Already used by ${conflict[0]}`);
setShortcutError(
`Already used by ${conflict[0]}`
);
} else {
setShortcutError(null);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !shortcutError && shortcutValue) {
if (
e.key === "Enter" &&
!shortcutError &&
shortcutValue
) {
setKeyboardShortcut(key, shortcutValue);
setEditingShortcut(null);
setShortcutValue("");
@@ -1947,15 +2042,19 @@ export function SettingsView() {
}}
className={cn(
"px-3 py-1.5 text-sm font-mono rounded bg-sidebar-accent/20 border border-sidebar-border hover:bg-sidebar-accent/30 transition-colors",
keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] &&
keyboardShortcuts[key] !==
DEFAULT_KEYBOARD_SHORTCUTS[key] &&
"border-brand-500/50 bg-brand-500/10 text-brand-400"
)}
data-testid={`shortcut-${key}`}
>
{keyboardShortcuts[key]}
</button>
{keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] && (
<span className="text-xs text-brand-400">(modified)</span>
{keyboardShortcuts[key] !==
DEFAULT_KEYBOARD_SHORTCUTS[key] && (
<span className="text-xs text-brand-400">
(modified)
</span>
)}
</>
)}
@@ -1973,9 +2072,9 @@ export function SettingsView() {
About Keyboard Shortcuts
</p>
<p className="text-blue-400/80 text-xs mt-1">
Shortcuts won&apos;t trigger when typing in input fields. Use
single keys (A-Z, 0-9) or special keys like ` (backtick).
Changes take effect immediately.
Shortcuts won&apos;t trigger when typing in input fields.
Use single keys (A-Z, 0-9) or special keys like `
(backtick). Changes take effect immediately.
</p>
</div>
</div>

View File

@@ -287,7 +287,7 @@ export function SpecView() {
Generate feature list
</label>
<p className="text-xs text-muted-foreground">
Automatically populate feature_list.json with all features from the
Automatically create features in the features folder from the
implementation roadmap after the spec is generated.
</p>
</div>