Add category typeahead with autocomplete suggestions

Changed the category input to a typeahead component in both Add Feature and
Edit Feature dialogs. The component suggests existing categories from
features and allows typing new ones. Categories are saved to feature_list.json.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cody Seibert
2025-12-08 23:35:05 -05:00
parent 7bfc489efa
commit adad2262c2
4 changed files with 265 additions and 21 deletions

View File

@@ -15,13 +15,32 @@
"category": "Kanban",
"description": "change category to a typeahead and save the category of the feature inside the feature_list.json",
"steps": [],
"status": "backlog"
"status": "verified"
},
{
"id": "feature-1765252262937-bt0wotam8",
"category": "Kanban",
"description": "Deleting a feature should show a confirm dialog",
"steps": [],
"status": "in_progress"
"status": "verified"
},
{
"id": "feature-1765252502536-t11kphnca",
"category": "Kanban",
"description": "If i have the output of a feature open while it's in progress, then it gets verified, automatically close the output modal",
"steps": [
"1. drag YOLO11 to in progress",
"2. open the output modal",
"3. wait until it is moved to verified",
"4. assert modal is hidden"
],
"status": "backlog"
},
{
"id": "feature-1765254432072-bqk25kivv",
"category": "Automode",
"description": "Add a concurrency slider left of automode so I can specify how many max agents should be running at one time. if we are at max, do not pull over more tasks from the backlog",
"steps": [],
"status": "backlog"
}
]

View File

@@ -1,6 +1,7 @@
import { defineConfig, devices } from "@playwright/test";
const port = process.env.TEST_PORT || 3007;
const reuseServer = process.env.TEST_REUSE_SERVER === "true";
export default defineConfig({
testDir: "./tests",
@@ -21,10 +22,14 @@ export default defineConfig({
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: `npx next dev -p ${port}`,
url: `http://localhost:${port}`,
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
...(reuseServer
? {}
: {
webServer: {
command: `npx next dev -p ${port}`,
url: `http://localhost:${port}`,
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
}),
});

View File

@@ -0,0 +1,211 @@
"use client";
import * as React from "react";
import { useState, useRef, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils";
import { Input } from "./input";
import { Check, ChevronDown } from "lucide-react";
interface CategoryAutocompleteProps {
value: string;
onChange: (value: string) => void;
suggestions: string[];
placeholder?: string;
className?: string;
disabled?: boolean;
"data-testid"?: string;
}
export function CategoryAutocomplete({
value,
onChange,
suggestions,
placeholder = "Select or type a category...",
className,
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 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]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.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);
}, []);
return (
<div ref={containerRef} className={cn("relative", className)}>
<div className="relative">
<Input
ref={inputRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
placeholder={placeholder}
disabled={disabled}
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 && (
<ul
ref={listRef}
className="absolute z-50 mt-1 w-full max-h-60 overflow-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95"
role="listbox"
data-testid="category-autocomplete-list"
>
{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"
)}
onClick={() => 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")}>
{suggestion}
</span>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useMemo } from "react";
import {
DndContext,
DragEndEvent,
@@ -63,7 +63,7 @@ export function BoardView() {
updateFeature,
removeFeature,
moveFeature,
currentAutoTask,
runningAutoTasks,
} = useAppStore();
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
const [editingFeature, setEditingFeature] = useState<Feature | null>(null);
@@ -106,6 +106,12 @@ export function BoardView() {
})
);
// Get unique categories from existing features for autocomplete suggestions
const categorySuggestions = useMemo(() => {
const categories = features.map((f) => f.category).filter(Boolean);
return [...new Set(categories)].sort();
}, [features]);
// Custom collision detection that prioritizes columns over cards
const collisionDetectionStrategy = useCallback((args: any) => {
// First, check if pointer is within a column
@@ -285,7 +291,9 @@ export function BoardView() {
};
const handleDeleteFeature = (featureId: string) => {
removeFeature(featureId);
if (window.confirm("Are you sure you want to delete this feature?")) {
removeFeature(featureId);
}
};
const handleRunFeature = async (feature: Feature) => {
@@ -496,7 +504,7 @@ export function BoardView() {
onDelete={() => handleDeleteFeature(feature.id)}
onViewOutput={() => handleViewOutput(feature)}
onVerify={() => handleVerifyFeature(feature)}
isCurrentAutoTask={currentAutoTask === feature.id}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
/>
))}
</SortableContext>
@@ -542,13 +550,13 @@ export function BoardView() {
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<Input
id="category"
placeholder="e.g., Core, UI, API"
<CategoryAutocomplete
value={newFeature.category}
onChange={(e) =>
setNewFeature({ ...newFeature, category: e.target.value })
onChange={(value) =>
setNewFeature({ ...newFeature, category: value })
}
suggestions={categorySuggestions}
placeholder="e.g., Core, UI, API"
data-testid="feature-category-input"
/>
</div>
@@ -624,15 +632,16 @@ export function BoardView() {
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="edit-category">Category</Label>
<Input
id="edit-category"
<CategoryAutocomplete
value={editingFeature.category}
onChange={(e) =>
onChange={(value) =>
setEditingFeature({
...editingFeature,
category: e.target.value,
category: value,
})
}
suggestions={categorySuggestions}
placeholder="e.g., Core, UI, API"
data-testid="edit-feature-category"
/>
</div>