mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
184
app/src/components/ui/command.tsx
Normal file
184
app/src/components/ui/command.tsx
Normal 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,
|
||||
}
|
||||
48
app/src/components/ui/popover.tsx
Normal file
48
app/src/components/ui/popover.tsx
Normal 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 }
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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'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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user