diff --git a/.automaker/feature_list.json b/.automaker/feature_list.json index 676488e1..e8cb6f8f 100644 --- a/.automaker/feature_list.json +++ b/.automaker/feature_list.json @@ -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" } ] \ No newline at end of file diff --git a/app/playwright.config.ts b/app/playwright.config.ts index 400a0cfe..00b7a09c 100644 --- a/app/playwright.config.ts +++ b/app/playwright.config.ts @@ -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, + }, + }), }); diff --git a/app/src/components/ui/category-autocomplete.tsx b/app/src/components/ui/category-autocomplete.tsx new file mode 100644 index 00000000..a5c248c0 --- /dev/null +++ b/app/src/components/ui/category-autocomplete.tsx @@ -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([]); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const containerRef = useRef(null); + const inputRef = useRef(null); + const listRef = useRef(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) => { + 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 ( +
+
+ + +
+ + {isOpen && filteredSuggestions.length > 0 && ( +
    + {filteredSuggestions.map((suggestion, index) => ( +
  • handleSelect(suggestion)} + onMouseEnter={() => setHighlightedIndex(index)} + data-testid={`category-option-${suggestion.toLowerCase().replace(/\s+/g, "-")}`} + > + {inputValue === suggestion && ( + + )} + + {suggestion} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/app/src/components/views/board-view.tsx b/app/src/components/views/board-view.tsx index 58ab3067..c66834e1 100644 --- a/app/src/components/views/board-view.tsx +++ b/app/src/components/views/board-view.tsx @@ -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(null); const [editingFeature, setEditingFeature] = useState(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)} /> ))} @@ -542,13 +550,13 @@ export function BoardView() {
- - 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" />
@@ -624,15 +632,16 @@ export function BoardView() {
- + onChange={(value) => setEditingFeature({ ...editingFeature, - category: e.target.value, + category: value, }) } + suggestions={categorySuggestions} + placeholder="e.g., Core, UI, API" data-testid="edit-feature-category" />