feat(backup): add backup.json for feature tracking and status updates

- Introduced a new `backup.json` file to track feature statuses, descriptions, and summaries for better project management.
- Updated `.automaker/feature_list.json` to reflect verified statuses for several features, ensuring accurate representation of progress.
- Enhanced `memory.md` with details on drag-and-drop functionality for features in `waiting_approval` status.
- Improved auto mode service to allow running tasks to complete when auto mode is stopped, enhancing user experience.
This commit is contained in:
Cody Seibert
2025-12-10 14:29:05 -05:00
parent d83eb86f22
commit c502fbc57a
26 changed files with 2497 additions and 298 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo, useEffect, useCallback } from "react";
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { cn } from "@/lib/utils";
import { useAppStore } from "@/store/app-store";
import {
@@ -39,6 +39,7 @@ import {
Atom,
Radio,
Monitor,
Search,
} from "lucide-react";
import {
DropdownMenu,
@@ -109,15 +110,15 @@ interface NavItem {
// Sortable Project Item Component
interface SortableProjectItemProps {
project: Project;
index: number;
currentProjectId: string | undefined;
isHighlighted: boolean;
onSelect: (project: Project) => void;
}
function SortableProjectItem({
project,
index,
currentProjectId,
isHighlighted,
onSelect,
}: SortableProjectItemProps) {
const {
@@ -141,7 +142,8 @@ function SortableProjectItem({
style={style}
className={cn(
"flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer text-muted-foreground hover:text-foreground hover:bg-accent",
isDragging && "bg-accent shadow-lg"
isDragging && "bg-accent shadow-lg",
isHighlighted && "bg-brand-500/10 text-foreground"
)}
data-testid={`project-option-${project.id}`}
>
@@ -156,16 +158,6 @@ function SortableProjectItem({
<GripVertical className="h-3.5 w-3.5 text-muted-foreground" />
</button>
{/* Hotkey indicator */}
{index < 9 && (
<span
className="flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
data-testid={`project-hotkey-${index + 1}`}
>
{index + 1}
</span>
)}
{/* Project content - clickable area */}
<div
className="flex items-center gap-2 flex-1 min-w-0"
@@ -223,6 +215,8 @@ export function Sidebar() {
// State for project picker dropdown
const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false);
const [projectSearchQuery, setProjectSearchQuery] = useState("");
const [selectedProjectIndex, setSelectedProjectIndex] = useState(0);
const [showTrashDialog, setShowTrashDialog] = useState(false);
const [activeTrashId, setActiveTrashId] = useState<string | null>(null);
const [isEmptyingTrash, setIsEmptyingTrash] = useState(false);
@@ -238,6 +232,43 @@ export function Sidebar() {
const [generateFeatures, setGenerateFeatures] = useState(true);
const [showSpecIndicator, setShowSpecIndicator] = useState(true);
// Ref for project search input
const projectSearchInputRef = useRef<HTMLInputElement>(null);
// Filtered projects based on search query
const filteredProjects = useMemo(() => {
if (!projectSearchQuery.trim()) {
return projects;
}
const query = projectSearchQuery.toLowerCase();
return projects.filter((project) =>
project.name.toLowerCase().includes(query)
);
}, [projects, projectSearchQuery]);
// Reset selection when filtered results change
useEffect(() => {
setSelectedProjectIndex(0);
}, [filteredProjects.length, projectSearchQuery]);
// Reset search query when dropdown closes
useEffect(() => {
if (!isProjectPickerOpen) {
setProjectSearchQuery("");
setSelectedProjectIndex(0);
}
}, [isProjectPickerOpen]);
// Focus the search input when dropdown opens
useEffect(() => {
if (isProjectPickerOpen) {
// Small delay to ensure the dropdown is rendered
setTimeout(() => {
projectSearchInputRef.current?.focus();
}, 0);
}
}, [isProjectPickerOpen]);
// Sensors for drag-and-drop
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -537,39 +568,45 @@ export function Sidebar() {
},
];
// Handler for selecting a project by number key
const selectProjectByNumber = useCallback(
(num: number) => {
const projectIndex = num - 1;
if (projectIndex >= 0 && projectIndex < projects.length) {
setCurrentProject(projects[projectIndex]);
setIsProjectPickerOpen(false);
}
},
[projects, setCurrentProject]
);
// Handle selecting the currently highlighted project
const selectHighlightedProject = useCallback(() => {
if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) {
setCurrentProject(filteredProjects[selectedProjectIndex]);
setIsProjectPickerOpen(false);
}
}, [filteredProjects, selectedProjectIndex, setCurrentProject]);
// Handle keyboard events when project picker is open
useEffect(() => {
if (!isProjectPickerOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
const num = parseInt(event.key, 10);
if (num >= 1 && num <= 9) {
event.preventDefault();
selectProjectByNumber(num);
} else if (event.key === "Escape") {
if (event.key === "Escape") {
setIsProjectPickerOpen(false);
} else if (event.key.toLowerCase() === "p") {
// Toggle off when P is pressed while dropdown is open
} else if (event.key === "Enter") {
event.preventDefault();
setIsProjectPickerOpen(false);
selectHighlightedProject();
} else if (event.key === "ArrowDown") {
event.preventDefault();
setSelectedProjectIndex((prev) =>
prev < filteredProjects.length - 1 ? prev + 1 : prev
);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setSelectedProjectIndex((prev) => (prev > 0 ? prev - 1 : prev));
} else if (event.key.toLowerCase() === "p" && !event.metaKey && !event.ctrlKey) {
// Toggle off when P is pressed (not with modifiers) while dropdown is open
// Only if not typing in the search input
if (document.activeElement !== projectSearchInputRef.current) {
event.preventDefault();
setIsProjectPickerOpen(false);
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isProjectPickerOpen, selectProjectByNumber]);
}, [isProjectPickerOpen, selectHighlightedProject, filteredProjects.length]);
// Build keyboard shortcuts for navigation
const navigationShortcuts: KeyboardShortcut[] = useMemo(() => {
@@ -793,29 +830,58 @@ export function Sidebar() {
align="start"
data-testid="project-picker-dropdown"
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={projects.map((p) => p.id)}
strategy={verticalListSortingStrategy}
{/* Search input for type-ahead filtering */}
<div className="px-2 pb-2">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<input
ref={projectSearchInputRef}
type="text"
placeholder="Search projects..."
value={projectSearchQuery}
onChange={(e) => setProjectSearchQuery(e.target.value)}
className="w-full h-8 pl-7 pr-2 text-sm rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-0"
data-testid="project-search-input"
/>
</div>
</div>
{filteredProjects.length === 0 ? (
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
No projects found
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
{projects.map((project, index) => (
<SortableProjectItem
key={project.id}
project={project}
index={index}
currentProjectId={currentProject?.id}
onSelect={(p) => {
setCurrentProject(p);
setIsProjectPickerOpen(false);
}}
/>
))}
</SortableContext>
</DndContext>
<SortableContext
items={filteredProjects.map((p) => p.id)}
strategy={verticalListSortingStrategy}
>
{filteredProjects.map((project, index) => (
<SortableProjectItem
key={project.id}
project={project}
currentProjectId={currentProject?.id}
isHighlighted={index === selectedProjectIndex}
onSelect={(p) => {
setCurrentProject(p);
setIsProjectPickerOpen(false);
}}
/>
))}
</SortableContext>
</DndContext>
)}
{/* Keyboard hint */}
<div className="px-2 pt-2 mt-1 border-t border-border">
<p className="text-[10px] text-muted-foreground text-center">
navigate Enter select Esc close
</p>
</div>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -9,6 +9,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
@@ -241,19 +242,18 @@ export function SessionManager({
<div className="flex items-center justify-between mb-4">
<CardTitle>Agent Sessions</CardTitle>
{activeTab === "active" && (
<Button
<HotkeyButton
variant="default"
size="sm"
onClick={handleQuickCreateSession}
hotkey={ACTION_SHORTCUTS.newSession}
hotkeyActive={false}
data-testid="new-session-button"
title={`New Session (${ACTION_SHORTCUTS.newSession})`}
>
<Plus className="w-4 h-4 mr-1" />
New
<span className="ml-1.5 px-1.5 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/20 border border-primary-foreground/30 text-primary-foreground">
{ACTION_SHORTCUTS.newSession}
</span>
</Button>
</HotkeyButton>
)}
</div>

View File

@@ -2,6 +2,7 @@
import * as React from "react";
import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { Input } from "./input";
import { Check, ChevronDown } from "lucide-react";
@@ -29,6 +30,7 @@ export function CategoryAutocomplete({
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);
@@ -52,12 +54,39 @@ export function CategoryAutocomplete({
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)
!containerRef.current.contains(event.target as Node) &&
listRef.current &&
!listRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
@@ -175,40 +204,47 @@ export function CategoryAutocomplete({
</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-background p-1 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"
)}
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")}>
{suggestion}
</span>
</li>
))}
</ul>
)}
{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")}>
{suggestion}
</span>
</li>
))}
</ul>,
document.body
)}
</div>
);
}

View File

@@ -31,6 +31,7 @@ interface DescriptionImageDropZoneProps {
previewMap?: ImagePreviewMap;
onPreviewMapChange?: (map: ImagePreviewMap) => void;
autoFocus?: boolean;
error?: boolean; // Show error state with red border
}
const ACCEPTED_IMAGE_TYPES = [
@@ -55,6 +56,7 @@ export function DescriptionImageDropZone({
previewMap,
onPreviewMapChange,
autoFocus = false,
error = false,
}: DescriptionImageDropZoneProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
@@ -306,6 +308,7 @@ export function DescriptionImageDropZone({
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
autoFocus={autoFocus}
aria-invalid={error}
className={cn(
"min-h-[120px]",
isProcessing && "opacity-50 pointer-events-none"

View File

@@ -0,0 +1,296 @@
"use client";
import * as React from "react";
import { useEffect, useCallback, useRef } from "react";
import { Button, buttonVariants } from "./button";
import { cn } from "@/lib/utils";
import type { VariantProps } from "class-variance-authority";
export interface HotkeyConfig {
/** The key to trigger the hotkey (e.g., "Enter", "s", "n") */
key: string;
/** Whether the Cmd/Ctrl modifier is required */
cmdCtrl?: boolean;
/** Whether the Shift modifier is required */
shift?: boolean;
/** Whether the Alt/Option modifier is required */
alt?: boolean;
/** Custom display label for the hotkey (overrides auto-generated label) */
label?: string;
}
export interface HotkeyButtonProps
extends React.ComponentProps<"button">,
VariantProps<typeof buttonVariants> {
/** Hotkey configuration - can be a simple key string or a full config object */
hotkey?: string | HotkeyConfig;
/** Whether to show the hotkey indicator badge */
showHotkeyIndicator?: boolean;
/** Whether the hotkey listener is active (registers keyboard listener). Set to false if hotkey is already handled elsewhere. */
hotkeyActive?: boolean;
/** Optional scope element ref - hotkey will only work when this element is visible */
scopeRef?: React.RefObject<HTMLElement | null>;
/** Callback when hotkey is triggered */
onHotkeyTrigger?: () => void;
/** Whether to use the Slot component for composition */
asChild?: boolean;
}
/**
* Get the modifier key symbol based on platform
*/
function getModifierSymbol(isMac: boolean): string {
return isMac ? "⌘" : "Ctrl";
}
/**
* Parse hotkey config into a normalized format
*/
function parseHotkeyConfig(hotkey: string | HotkeyConfig): HotkeyConfig {
if (typeof hotkey === "string") {
return { key: hotkey };
}
return hotkey;
}
/**
* Generate the display label for the hotkey
*/
function getHotkeyDisplayLabel(config: HotkeyConfig, isMac: boolean): React.ReactNode {
if (config.label) {
return config.label;
}
const parts: React.ReactNode[] = [];
if (config.cmdCtrl) {
parts.push(
<span key="mod" className="leading-none flex items-center justify-center">
{getModifierSymbol(isMac)}
</span>
);
}
if (config.shift) {
parts.push(
<span key="shift" className="leading-none flex items-center justify-center">
</span>
);
}
if (config.alt) {
parts.push(
<span key="alt" className="leading-none flex items-center justify-center">
{isMac ? "⌥" : "Alt"}
</span>
);
}
// Convert key to display format
let keyDisplay = config.key;
switch (config.key.toLowerCase()) {
case "enter":
keyDisplay = "↵";
break;
case "escape":
case "esc":
keyDisplay = "Esc";
break;
case "arrowup":
keyDisplay = "↑";
break;
case "arrowdown":
keyDisplay = "↓";
break;
case "arrowleft":
keyDisplay = "←";
break;
case "arrowright":
keyDisplay = "→";
break;
case "backspace":
keyDisplay = "⌫";
break;
case "delete":
keyDisplay = "⌦";
break;
case "tab":
keyDisplay = "⇥";
break;
case " ":
keyDisplay = "Space";
break;
default:
// Capitalize single letters
if (config.key.length === 1) {
keyDisplay = config.key.toUpperCase();
}
}
parts.push(
<span key="key" className="leading-none flex items-center justify-center">
{keyDisplay}
</span>
);
return (
<span className="inline-flex items-center gap-1.5">
{parts}
</span>
);
}
/**
* Check if an element is a form input
*/
function isInputElement(element: Element | null): boolean {
if (!element) return false;
const tagName = element.tagName.toLowerCase();
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
return true;
}
if (element.getAttribute("contenteditable") === "true") {
return true;
}
const role = element.getAttribute("role");
if (role === "textbox" || role === "searchbox" || role === "combobox") {
return true;
}
return false;
}
/**
* A button component that supports keyboard hotkeys
*
* Features:
* - Automatic hotkey listening when mounted
* - Visual hotkey indicator badge
* - Support for modifier keys (Cmd/Ctrl, Shift, Alt)
* - Respects focus context (doesn't trigger when typing in inputs)
* - Scoped activation via scopeRef
*/
export function HotkeyButton({
hotkey,
showHotkeyIndicator = true,
hotkeyActive = true,
scopeRef,
onHotkeyTrigger,
onClick,
disabled,
children,
className,
variant,
size,
asChild = false,
...props
}: HotkeyButtonProps) {
const buttonRef = useRef<HTMLButtonElement>(null);
const [isMac, setIsMac] = React.useState(true);
// Detect platform on mount
useEffect(() => {
setIsMac(navigator.platform.toLowerCase().includes("mac"));
}, []);
const config = hotkey ? parseHotkeyConfig(hotkey) : null;
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!config || !hotkeyActive || disabled) return;
// Don't trigger when typing in inputs (unless explicitly scoped)
if (!scopeRef && isInputElement(document.activeElement)) {
return;
}
// Check modifier keys
const cmdCtrlPressed = event.metaKey || event.ctrlKey;
const shiftPressed = event.shiftKey;
const altPressed = event.altKey;
// Validate modifier requirements
if (config.cmdCtrl && !cmdCtrlPressed) return;
if (!config.cmdCtrl && cmdCtrlPressed) return;
if (config.shift && !shiftPressed) return;
if (!config.shift && shiftPressed) return;
if (config.alt && !altPressed) return;
if (!config.alt && altPressed) return;
// Check if the key matches
if (event.key.toLowerCase() !== config.key.toLowerCase()) return;
// If scoped, check that the scope element is visible
if (scopeRef && scopeRef.current) {
const scopeEl = scopeRef.current;
const isVisible = scopeEl.offsetParent !== null ||
getComputedStyle(scopeEl).display !== "none";
if (!isVisible) return;
}
event.preventDefault();
event.stopPropagation();
// Trigger the click handler or custom onHotkeyTrigger
if (onHotkeyTrigger) {
onHotkeyTrigger();
} else if (onClick) {
onClick(event as unknown as React.MouseEvent<HTMLButtonElement>);
} else if (buttonRef.current) {
buttonRef.current.click();
}
},
[config, hotkeyActive, disabled, scopeRef, onHotkeyTrigger, onClick]
);
// Set up global key listener
useEffect(() => {
if (!config || !hotkeyActive) return;
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [config, hotkeyActive, handleKeyDown]);
// Render the hotkey indicator
const hotkeyIndicator = config && showHotkeyIndicator ? (
<span
className="ml-3 px-2 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/10 border border-primary-foreground/20 inline-flex items-center gap-1.5"
data-testid="hotkey-indicator"
>
{getHotkeyDisplayLabel(config, isMac)}
</span>
) : null;
return (
<Button
ref={buttonRef}
variant={variant}
size={size}
disabled={disabled}
onClick={onClick}
className={cn(className)}
asChild={asChild}
{...props}
>
{typeof children === "string" ? (
<>
{children}
{hotkeyIndicator}
</>
) : (
<>
{children}
{hotkeyIndicator}
</>
)}
</Button>
);
}
export { getHotkeyDisplayLabel, parseHotkeyConfig };

View File

@@ -12,6 +12,7 @@ interface ImageDropZoneProps {
className?: string;
children?: React.ReactNode;
disabled?: boolean;
images?: ImageAttachment[]; // Optional controlled images prop
}
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
@@ -24,12 +25,24 @@ export function ImageDropZone({
className,
children,
disabled = false,
images,
}: ImageDropZoneProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
const [internalImages, setInternalImages] = useState<ImageAttachment[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
// Use controlled images if provided, otherwise use internal state
const selectedImages = images ?? internalImages;
// Update images - for controlled mode, just call the callback; for uncontrolled, also update internal state
const updateImages = useCallback((newImages: ImageAttachment[]) => {
if (images === undefined) {
setInternalImages(newImages);
}
onImagesSelected(newImages);
}, [images, onImagesSelected]);
const processFiles = useCallback(async (files: FileList) => {
if (disabled || isProcessing) return;
@@ -79,12 +92,11 @@ export function ImageDropZone({
if (newImages.length > 0) {
const allImages = [...selectedImages, ...newImages];
setSelectedImages(allImages);
onImagesSelected(allImages);
updateImages(allImages);
}
setIsProcessing(false);
}, [disabled, isProcessing, maxFiles, maxFileSize, selectedImages, onImagesSelected]);
}, [disabled, isProcessing, maxFiles, maxFileSize, selectedImages, updateImages]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
@@ -132,14 +144,12 @@ export function ImageDropZone({
const removeImage = useCallback((imageId: string) => {
const updated = selectedImages.filter(img => img.id !== imageId);
setSelectedImages(updated);
onImagesSelected(updated);
}, [selectedImages, onImagesSelected]);
updateImages(updated);
}, [selectedImages, updateImages]);
const clearAllImages = useCallback(() => {
setSelectedImages([]);
onImagesSelected([]);
}, [onImagesSelected]);
updateImages([]);
}, [updateImages]);
return (
<div className={cn("relative", className)}>

View File

@@ -8,9 +8,9 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-input border-input h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
className
)}
{...props}

View File

@@ -0,0 +1,290 @@
"use client";
import { useRef, useCallback, useMemo } from "react";
import { cn } from "@/lib/utils";
interface XmlSyntaxEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
"data-testid"?: string;
}
// Tokenize XML content into parts for highlighting
interface Token {
type:
| "tag-bracket"
| "tag-name"
| "attribute-name"
| "attribute-equals"
| "attribute-value"
| "text"
| "comment"
| "cdata"
| "doctype";
value: string;
}
function tokenizeXml(text: string): Token[] {
const tokens: Token[] = [];
let i = 0;
while (i < text.length) {
// Comment: <!-- ... -->
if (text.slice(i, i + 4) === "<!--") {
const end = text.indexOf("-->", i + 4);
if (end !== -1) {
tokens.push({ type: "comment", value: text.slice(i, end + 3) });
i = end + 3;
continue;
}
}
// CDATA: <![CDATA[ ... ]]>
if (text.slice(i, i + 9) === "<![CDATA[") {
const end = text.indexOf("]]>", i + 9);
if (end !== -1) {
tokens.push({ type: "cdata", value: text.slice(i, end + 3) });
i = end + 3;
continue;
}
}
// DOCTYPE: <!DOCTYPE ... >
if (text.slice(i, i + 9).toUpperCase() === "<!DOCTYPE") {
const end = text.indexOf(">", i + 9);
if (end !== -1) {
tokens.push({ type: "doctype", value: text.slice(i, end + 1) });
i = end + 1;
continue;
}
}
// Tag: < ... >
if (text[i] === "<") {
// Find the end of the tag
let tagEnd = i + 1;
let inString: string | null = null;
while (tagEnd < text.length) {
const char = text[tagEnd];
if (inString) {
if (char === inString && text[tagEnd - 1] !== "\\") {
inString = null;
}
} else {
if (char === '"' || char === "'") {
inString = char;
} else if (char === ">") {
tagEnd++;
break;
}
}
tagEnd++;
}
const tagContent = text.slice(i, tagEnd);
const tagTokens = tokenizeTag(tagContent);
tokens.push(...tagTokens);
i = tagEnd;
continue;
}
// Text content between tags
const nextTag = text.indexOf("<", i);
if (nextTag === -1) {
tokens.push({ type: "text", value: text.slice(i) });
break;
} else if (nextTag > i) {
tokens.push({ type: "text", value: text.slice(i, nextTag) });
i = nextTag;
}
}
return tokens;
}
function tokenizeTag(tag: string): Token[] {
const tokens: Token[] = [];
let i = 0;
// Opening bracket (< or </ or <?)
if (tag.startsWith("</")) {
tokens.push({ type: "tag-bracket", value: "</" });
i = 2;
} else if (tag.startsWith("<?")) {
tokens.push({ type: "tag-bracket", value: "<?" });
i = 2;
} else {
tokens.push({ type: "tag-bracket", value: "<" });
i = 1;
}
// Skip whitespace
while (i < tag.length && /\s/.test(tag[i])) {
tokens.push({ type: "text", value: tag[i] });
i++;
}
// Tag name
let tagName = "";
while (i < tag.length && /[a-zA-Z0-9_:-]/.test(tag[i])) {
tagName += tag[i];
i++;
}
if (tagName) {
tokens.push({ type: "tag-name", value: tagName });
}
// Attributes and closing
while (i < tag.length) {
// Skip whitespace
if (/\s/.test(tag[i])) {
let ws = "";
while (i < tag.length && /\s/.test(tag[i])) {
ws += tag[i];
i++;
}
tokens.push({ type: "text", value: ws });
continue;
}
// Closing bracket
if (tag[i] === ">" || tag.slice(i, i + 2) === "/>" || tag.slice(i, i + 2) === "?>") {
tokens.push({ type: "tag-bracket", value: tag.slice(i) });
break;
}
// Attribute name
let attrName = "";
while (i < tag.length && /[a-zA-Z0-9_:-]/.test(tag[i])) {
attrName += tag[i];
i++;
}
if (attrName) {
tokens.push({ type: "attribute-name", value: attrName });
}
// Skip whitespace around =
while (i < tag.length && /\s/.test(tag[i])) {
tokens.push({ type: "text", value: tag[i] });
i++;
}
// Equals sign
if (tag[i] === "=") {
tokens.push({ type: "attribute-equals", value: "=" });
i++;
}
// Skip whitespace after =
while (i < tag.length && /\s/.test(tag[i])) {
tokens.push({ type: "text", value: tag[i] });
i++;
}
// Attribute value
if (tag[i] === '"' || tag[i] === "'") {
const quote = tag[i];
let value = quote;
i++;
while (i < tag.length && tag[i] !== quote) {
value += tag[i];
i++;
}
if (i < tag.length) {
value += tag[i];
i++;
}
tokens.push({ type: "attribute-value", value });
}
}
return tokens;
}
export function XmlSyntaxEditor({
value,
onChange,
placeholder,
className,
"data-testid": testId,
}: XmlSyntaxEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const highlightRef = useRef<HTMLDivElement>(null);
// Sync scroll between textarea and highlight layer
const handleScroll = useCallback(() => {
if (textareaRef.current && highlightRef.current) {
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
}
}, []);
// Handle tab key for indentation
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Tab") {
e.preventDefault();
const textarea = e.currentTarget;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newValue =
value.substring(0, start) + " " + value.substring(end);
onChange(newValue);
// Reset cursor position after state update
requestAnimationFrame(() => {
textarea.selectionStart = textarea.selectionEnd = start + 2;
});
}
},
[value, onChange]
);
// Memoize the highlighted content
const highlightedContent = useMemo(() => {
const tokens = tokenizeXml(value);
return tokens.map((token, index) => {
const className = `xml-${token.type}`;
// React handles escaping automatically, just render the raw value
return (
<span key={index} className={className}>
{token.value}
</span>
);
});
}, [value]);
return (
<div className={cn("relative w-full h-full xml-editor", className)}>
{/* Syntax highlighted layer (read-only, behind textarea) */}
<div
ref={highlightRef}
className="absolute inset-0 overflow-auto pointer-events-none font-mono text-sm p-4 whitespace-pre-wrap break-words"
aria-hidden="true"
>
{value ? (
<code className="xml-highlight">{highlightedContent}</code>
) : (
<span className="text-muted-foreground opacity-50">{placeholder}</span>
)}
</div>
{/* Actual textarea (transparent text, handles input) */}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onScroll={handleScroll}
onKeyDown={handleKeyDown}
placeholder=""
spellCheck={false}
className="absolute inset-0 w-full h-full font-mono text-sm p-4 bg-transparent resize-none focus:outline-none text-transparent caret-foreground selection:bg-primary/30"
data-testid={testId}
/>
</div>
);
}

View File

@@ -641,11 +641,12 @@ export function AgentView() {
{/* Input */}
{currentSessionId && (
<div className="border-t p-4 space-y-3">
<div className="border-t border-border p-4 space-y-3 bg-background">
{/* Image Drop Zone (when visible) */}
{showImageDropZone && (
<ImageDropZone
onImagesSelected={handleImagesSelected}
images={selectedImages}
maxFiles={5}
className="mb-3"
disabled={isProcessing || !isConnected}
@@ -657,7 +658,7 @@ export function AgentView() {
className={cn(
"flex gap-2 transition-all duration-200 rounded-lg",
isDragOver &&
"bg-blue-50 dark:bg-blue-950/20 ring-2 ring-blue-400 ring-offset-2"
"bg-primary/10 ring-2 ring-primary ring-offset-2 ring-offset-background"
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
@@ -679,20 +680,21 @@ export function AgentView() {
disabled={isProcessing || !isConnected}
data-testid="agent-input"
className={cn(
"bg-input border-border",
selectedImages.length > 0 &&
"border-blue-200 bg-blue-50/50 dark:bg-blue-950/20",
"border-primary/50 bg-primary/5",
isDragOver &&
"border-blue-400 bg-blue-50/50 dark:bg-blue-950/20"
"border-primary bg-primary/10"
)}
/>
{selectedImages.length > 0 && !isDragOver && (
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 text-xs text-blue-600 bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded">
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 text-xs text-primary-foreground bg-primary px-2 py-1 rounded">
{selectedImages.length} image
{selectedImages.length > 1 ? "s" : ""}
</div>
)}
{isDragOver && (
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 text-xs text-blue-600 bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded flex items-center gap-1">
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 text-xs text-primary-foreground bg-primary px-2 py-1 rounded flex items-center gap-1">
<Paperclip className="w-3 h-3" />
Drop here
</div>
@@ -707,8 +709,8 @@ export function AgentView() {
disabled={isProcessing || !isConnected}
className={cn(
showImageDropZone &&
"bg-blue-100 text-blue-600 dark:bg-blue-900/50 dark:text-blue-400",
selectedImages.length > 0 && "border-blue-400"
"bg-primary/20 text-primary border-primary",
selectedImages.length > 0 && "border-primary"
)}
title="Attach images"
>

View File

@@ -34,6 +34,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
@@ -79,9 +80,20 @@ import {
Sparkles,
UserCircle,
Lightbulb,
Search,
X,
Minimize2,
Square,
Maximize2,
} from "lucide-react";
import { toast } from "sonner";
import { Slider } from "@/components/ui/slider";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Checkbox } from "@/components/ui/checkbox";
import { useAutoMode } from "@/hooks/use-auto-mode";
import {
@@ -188,6 +200,8 @@ export function BoardView() {
useWorktrees,
showProfilesOnly,
aiProfiles,
kanbanCardDetailLevel,
setKanbanCardDetailLevel,
} = useAppStore();
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
const [editingFeature, setEditingFeature] = useState<Feature | null>(null);
@@ -234,6 +248,10 @@ export function BoardView() {
import("@/lib/electron").FeatureSuggestion[]
>([]);
const [isGeneratingSuggestions, setIsGeneratingSuggestions] = useState(false);
// Search filter for Kanban cards
const [searchQuery, setSearchQuery] = useState("");
// Validation state for add feature form
const [descriptionError, setDescriptionError] = useState(false);
// Make current project available globally for modal
useEffect(() => {
@@ -290,6 +308,9 @@ export function BoardView() {
// Ref to hold the start next callback (to avoid dependency issues)
const startNextFeaturesRef = useRef<() => void>(() => {});
// Ref for search input to enable keyboard shortcut focus
const searchInputRef = useRef<HTMLInputElement>(null);
// Keyboard shortcuts for this view
const boardShortcuts: KeyboardShortcut[] = useMemo(() => {
const shortcuts: KeyboardShortcut[] = [
@@ -303,6 +324,11 @@ export function BoardView() {
action: () => startNextFeaturesRef.current(),
description: "Start next features from backlog",
},
{
key: "/",
action: () => searchInputRef.current?.focus(),
description: "Focus search input",
},
];
// Add shortcuts for in-progress cards (1-9 and 0 for 10th)
@@ -660,9 +686,13 @@ 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)
// - 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
if (draggedFeature.status !== "backlog") {
if (
draggedFeature.status !== "backlog" &&
draggedFeature.status !== "waiting_approval"
) {
// Only allow dragging in_progress/verified if it's a skipTests feature and not currently running
if (!draggedFeature.skipTests || isRunningTask) {
console.log(
@@ -720,6 +750,28 @@ export function BoardView() {
} else {
moveFeature(featureId, targetStatus);
}
} else if (draggedFeature.status === "waiting_approval") {
// waiting_approval features can be dragged to verified for manual verification
// NOTE: This check must come BEFORE skipTests check because waiting_approval
// features often have skipTests=true, and we want status-based handling first
if (targetStatus === "verified") {
moveFeature(featureId, "verified");
toast.success("Feature verified", {
description: `Manually verified: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
} else if (targetStatus === "backlog") {
// Allow moving waiting_approval cards back to backlog
moveFeature(featureId, "backlog");
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
}
} else if (draggedFeature.skipTests) {
// skipTests feature being moved between in_progress and verified
if (
@@ -763,6 +815,11 @@ export function BoardView() {
};
const handleAddFeature = () => {
// Validate description is required
if (!newFeature.description.trim()) {
setDescriptionError(true);
return;
}
const category = newFeature.category || "Uncategorized";
const selectedModel = newFeature.model;
const normalizedThinking = modelSupportsThinking(selectedModel)
@@ -1288,7 +1345,17 @@ export function BoardView() {
verified: [],
};
features.forEach((f) => {
// Filter features by search query (case-insensitive)
const normalizedQuery = searchQuery.toLowerCase().trim();
const filteredFeatures = normalizedQuery
? features.filter(
(f) =>
f.description.toLowerCase().includes(normalizedQuery) ||
f.category.toLowerCase().includes(normalizedQuery)
)
: features;
filteredFeatures.forEach((f) => {
// If feature has a running agent, always show it in "in_progress"
const isRunning = runningAutoTasks.includes(f.id);
if (isRunning) {
@@ -1300,7 +1367,7 @@ export function BoardView() {
});
return map;
}, [features, runningAutoTasks]);
}, [features, runningAutoTasks, searchQuery]);
const getColumnFeatures = useCallback(
(columnId: ColumnId) => {
@@ -1556,27 +1623,123 @@ export function BoardView() {
</>
)}
<Button
<HotkeyButton
size="sm"
onClick={() => setShowAddDialog(true)}
hotkey={ACTION_SHORTCUTS.addFeature}
hotkeyActive={false}
data-testid="add-feature-button"
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
<span
className="ml-3 px-2 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/20 border border-primary-foreground/30 text-primary-foreground inline-flex items-center justify-center"
data-testid="shortcut-add-feature"
>
{ACTION_SHORTCUTS.addFeature}
</span>
</Button>
</HotkeyButton>
</div>
</div>
{/* Main Content Area */}
<div className="flex-1 flex overflow-hidden">
<div className="flex-1 flex flex-col overflow-hidden">
{/* Search Bar Row */}
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
<div className="relative max-w-md flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<Input
ref={searchInputRef}
type="text"
placeholder="Search features by keyword..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-12 border-border"
data-testid="kanban-search-input"
/>
{searchQuery ? (
<button
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-sm hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
data-testid="kanban-search-clear"
aria-label="Clear search"
>
<X className="w-4 h-4" />
</button>
) : (
<span
className="absolute right-2 top-1/2 -translate-y-1/2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
data-testid="kanban-search-hotkey"
>
/
</span>
)}
</div>
{/* Kanban Card Detail Level Toggle */}
{isMounted && (
<TooltipProvider>
<div
className="flex items-center rounded-lg bg-secondary border border-border ml-4"
data-testid="kanban-detail-toggle"
>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setKanbanCardDetailLevel("minimal")}
className={cn(
"p-2 rounded-l-lg transition-colors",
kanbanCardDetailLevel === "minimal"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-minimal"
>
<Minimize2 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Minimal - Title & category only</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setKanbanCardDetailLevel("standard")}
className={cn(
"p-2 transition-colors",
kanbanCardDetailLevel === "standard"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-standard"
>
<Square className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Standard - Steps & progress</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setKanbanCardDetailLevel("detailed")}
className={cn(
"p-2 rounded-r-lg transition-colors",
kanbanCardDetailLevel === "detailed"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-detailed"
>
<Maximize2 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Detailed - Model, tools & tasks</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
)}
</div>
{/* Kanban Columns */}
<div className="flex-1 overflow-x-auto p-4">
<div className="flex-1 overflow-x-auto px-4 pb-4">
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
@@ -1626,19 +1789,18 @@ export function BoardView() {
)}
</Button>
{columnFeatures.length > 0 && (
<Button
<HotkeyButton
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
onClick={handleStartNextFeatures}
hotkey={ACTION_SHORTCUTS.startNext}
hotkeyActive={false}
data-testid="start-next-button"
>
<FastForward className="w-3 h-3 mr-1" />
Start Next
<span className="ml-1 px-1 py-0.5 text-[9px] font-mono rounded bg-accent border border-border-glass">
{ACTION_SHORTCUTS.startNext}
</span>
</Button>
</HotkeyButton>
)}
</div>
) : undefined
@@ -1707,25 +1869,16 @@ export function BoardView() {
{/* Add Feature Dialog */}
<Dialog open={showAddDialog} onOpenChange={(open) => {
setShowAddDialog(open);
// Clear preview map and reset advanced options when dialog closes
// Clear preview map, validation error, and reset advanced options when dialog closes
if (!open) {
setNewFeaturePreviewMap(new Map());
setShowAdvancedOptions(false);
setDescriptionError(false);
}
}}>
<DialogContent
compact={!isMaximized}
data-testid="add-feature-dialog"
onKeyDown={(e) => {
if (
(e.metaKey || e.ctrlKey) &&
e.key === "Enter" &&
newFeature.description
) {
e.preventDefault();
handleAddFeature();
}
}}
>
<DialogHeader>
<DialogTitle>Add New Feature</DialogTitle>
@@ -1755,9 +1908,12 @@ export function BoardView() {
<Label htmlFor="description">Description</Label>
<DescriptionImageDropZone
value={newFeature.description}
onChange={(value) =>
setNewFeature({ ...newFeature, description: value })
}
onChange={(value) => {
setNewFeature({ ...newFeature, description: value });
if (value.trim()) {
setDescriptionError(false);
}
}}
images={newFeature.imagePaths}
onImagesChange={(images) =>
setNewFeature({ ...newFeature, imagePaths: images })
@@ -1766,6 +1922,7 @@ export function BoardView() {
previewMap={newFeaturePreviewMap}
onPreviewMapChange={setNewFeaturePreviewMap}
autoFocus
error={descriptionError}
/>
</div>
<div className="space-y-2">
@@ -2057,20 +2214,14 @@ export function BoardView() {
<Button variant="ghost" onClick={() => setShowAddDialog(false)}>
Cancel
</Button>
<Button
<HotkeyButton
onClick={handleAddFeature}
disabled={!newFeature.description}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={showAddDialog}
data-testid="confirm-add-feature"
>
Add Feature
<span
className="ml-3 px-2 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/10 border border-primary-foreground/20 inline-flex items-center gap-1.5"
data-testid="shortcut-confirm-add-feature"
>
<span className="leading-none flex items-center justify-center"></span>
<span className="leading-none flex items-center justify-center"></span>
</span>
</Button>
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -2414,12 +2565,14 @@ export function BoardView() {
<Button variant="ghost" onClick={() => setEditingFeature(null)}>
Cancel
</Button>
<Button
<HotkeyButton
onClick={handleUpdateFeature}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={!!editingFeature}
data-testid="confirm-edit-feature"
>
Save Changes
</Button>
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -2584,17 +2737,16 @@ export function BoardView() {
>
Cancel
</Button>
<Button
<HotkeyButton
onClick={handleSendFollowUp}
disabled={!followUpPrompt.trim()}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={showFollowUpDialog}
data-testid="confirm-follow-up"
>
<MessageSquare className="w-4 h-4 mr-2" />
Send Follow-Up
<span className="ml-2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/10 border border-primary-foreground/20">
</span>
</Button>
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -4,6 +4,7 @@ import { useEffect, useState, useCallback, useMemo } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Card } from "@/components/ui/card";
import {
Plus,
@@ -363,20 +364,16 @@ export function ContextView() {
</div>
</div>
<div className="flex gap-2">
<Button
<HotkeyButton
size="sm"
onClick={() => setIsAddDialogOpen(true)}
hotkey={ACTION_SHORTCUTS.addContextFile}
hotkeyActive={false}
data-testid="add-context-file"
>
<Plus className="w-4 h-4 mr-2" />
Add File
<span
className="ml-2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-secondary border border-border"
data-testid="shortcut-add-context-file"
>
{ACTION_SHORTCUTS.addContextFile}
</span>
</Button>
</HotkeyButton>
</div>
</div>
@@ -650,16 +647,18 @@ export function ContextView() {
>
Cancel
</Button>
<Button
<HotkeyButton
onClick={handleAddFile}
disabled={
!newFileName.trim() ||
(newFileType === "image" && !uploadedImageData)
}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={isAddDialogOpen}
data-testid="confirm-add-file"
>
Add File
</Button>
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -10,6 +10,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
@@ -426,9 +427,11 @@ export function FeatureSuggestionsDialog({
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
<HotkeyButton
onClick={handleImport}
disabled={selectedIds.size === 0 || isImporting}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={open && hasSuggestions}
>
{isImporting ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
@@ -437,7 +440,7 @@ export function FeatureSuggestionsDialog({
)}
Import {selectedIds.size} Feature
{selectedIds.size !== 1 ? "s" : ""}
</Button>
</HotkeyButton>
</div>
</div>
)}

View File

@@ -188,9 +188,12 @@ export const KanbanCard = memo(function KanbanCard({
// Dragging logic:
// - Backlog items can always be dragged
// - skipTests items can be dragged even when in_progress or verified (unless currently running)
// - waiting_approval items can always be dragged (to allow manual verification via drag)
// - Non-skipTests (TDD) items in progress or verified cannot be dragged
const isDraggable =
feature.status === "backlog" || (feature.skipTests && !isCurrentAutoTask);
feature.status === "backlog" ||
feature.status === "waiting_approval" ||
(feature.skipTests && !isCurrentAutoTask);
const {
attributes,
listeners,
@@ -336,7 +339,7 @@ export const KanbanCard = memo(function KanbanCard({
<Edit className="w-3 h-3 mr-2" />
Edit
</DropdownMenuItem>
{onViewOutput && (
{onViewOutput && feature.status !== "backlog" && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
@@ -737,25 +740,6 @@ export const KanbanCard = memo(function KanbanCard({
)}
</>
)}
{!isCurrentAutoTask && feature.status === "backlog" && (
<>
{onViewOutput && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
data-testid={`view-logs-backlog-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1" />
Logs
</Button>
)}
</>
)}
</div>
</CardContent>

View File

@@ -3,6 +3,7 @@
import { useState, useMemo, useCallback, useEffect } from "react";
import { useAppStore, AIProfile, AgentModel, ThinkingLevel, ModelProvider } from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
@@ -236,11 +237,13 @@ function ProfileForm({
onSave,
onCancel,
isEditing,
hotkeyActive,
}: {
profile: Partial<AIProfile>;
onSave: (profile: Omit<AIProfile, "id">) => void;
onCancel: () => void;
isEditing: boolean;
hotkeyActive: boolean;
}) {
const [formData, setFormData] = useState({
name: profile.name || "",
@@ -429,9 +432,14 @@ function ProfileForm({
<Button variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button onClick={handleSubmit} data-testid="save-profile-button">
<HotkeyButton
onClick={handleSubmit}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={hotkeyActive}
data-testid="save-profile-button"
>
{isEditing ? "Save Changes" : "Create Profile"}
</Button>
</HotkeyButton>
</DialogFooter>
</div>
);
@@ -545,13 +553,15 @@ export function ProfilesView() {
</p>
</div>
</div>
<Button onClick={() => setShowAddDialog(true)} data-testid="add-profile-button" className="relative">
<HotkeyButton
onClick={() => setShowAddDialog(true)}
hotkey={ACTION_SHORTCUTS.addProfile}
hotkeyActive={false}
data-testid="add-profile-button"
>
<Plus className="w-4 h-4 mr-2" />
New Profile
<span className="hidden lg:flex items-center justify-center ml-2 px-2 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/20 border border-primary-foreground/30 text-primary-foreground">
{ACTION_SHORTCUTS.addProfile}
</span>
</Button>
</HotkeyButton>
</div>
</div>
</div>
@@ -662,6 +672,7 @@ export function ProfilesView() {
onSave={handleAddProfile}
onCancel={() => setShowAddDialog(false)}
isEditing={false}
hotkeyActive={showAddDialog}
/>
</DialogContent>
</Dialog>
@@ -682,6 +693,7 @@ export function ProfilesView() {
onSave={handleUpdateProfile}
onCancel={() => setEditingProfile(null)}
isEditing={true}
hotkeyActive={!!editingProfile}
/>
)}
</DialogContent>

View File

@@ -68,6 +68,7 @@ export function SettingsView() {
setCurrentView,
theme,
setTheme,
setProjectTheme,
kanbanCardDetailLevel,
setKanbanCardDetailLevel,
defaultSkipTests,
@@ -79,6 +80,18 @@ export function SettingsView() {
currentProject,
moveProjectToTrash,
} = useAppStore();
// Compute the effective theme for the current project
const effectiveTheme = currentProject?.theme || theme;
// Handler to set theme - saves to project if one is selected, otherwise to global
const handleSetTheme = (newTheme: typeof theme) => {
if (currentProject) {
setProjectTheme(currentProject.id, newTheme);
} else {
setTheme(newTheme);
}
};
const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic);
const [googleKey, setGoogleKey] = useState(apiKeys.google);
const [openaiKey, setOpenaiKey] = useState(apiKeys.openai);
@@ -171,13 +184,28 @@ export function SettingsView() {
if (!container) return;
const handleScroll = () => {
const sections = NAV_ITEMS.map((item) => ({
id: item.id,
element: document.getElementById(item.id),
})).filter((s) => s.element);
const sections = NAV_ITEMS.filter(
(item) => item.id !== "danger" || currentProject
)
.map((item) => ({
id: item.id,
element: document.getElementById(item.id),
}))
.filter((s) => s.element);
const containerRect = container.getBoundingClientRect();
const scrollTop = container.scrollTop;
const scrollHeight = container.scrollHeight;
const clientHeight = container.clientHeight;
// Check if scrolled to bottom (within a small threshold)
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50;
if (isAtBottom && sections.length > 0) {
// If at bottom, highlight the last visible section
setActiveSection(sections[sections.length - 1].id);
return;
}
for (let i = sections.length - 1; i >= 0; i--) {
const section = sections[i];
@@ -194,7 +222,7 @@ export function SettingsView() {
container.addEventListener("scroll", handleScroll);
return () => container.removeEventListener("scroll", handleScroll);
}, []);
}, [currentProject]);
const scrollToSection = useCallback((sectionId: string) => {
const element = document.getElementById(sectionId);
@@ -407,7 +435,7 @@ export function SettingsView() {
{/* Scrollable Content */}
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-8">
<div className="max-w-4xl mx-auto space-y-6">
<div className="max-w-4xl mx-auto space-y-6 pb-96">
{/* API Keys Section */}
<div
id="api-keys"
@@ -1012,13 +1040,20 @@ export function SettingsView() {
</div>
<div className="p-6 space-y-4">
<div className="space-y-3">
<Label className="text-foreground">Theme</Label>
<Label className="text-foreground">
Theme{" "}
{currentProject
? `(for ${currentProject.name})`
: "(Global)"}
</Label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Button
variant={theme === "dark" ? "secondary" : "outline"}
onClick={() => setTheme("dark")}
variant={
effectiveTheme === "dark" ? "secondary" : "outline"
}
onClick={() => handleSetTheme("dark")}
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
theme === "dark"
effectiveTheme === "dark"
? "border-brand-500 ring-1 ring-brand-500/50"
: ""
}`}
@@ -1028,10 +1063,12 @@ export function SettingsView() {
<span className="font-medium text-sm">Dark</span>
</Button>
<Button
variant={theme === "light" ? "secondary" : "outline"}
onClick={() => setTheme("light")}
variant={
effectiveTheme === "light" ? "secondary" : "outline"
}
onClick={() => handleSetTheme("light")}
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
theme === "light"
effectiveTheme === "light"
? "border-brand-500 ring-1 ring-brand-500/50"
: ""
}`}
@@ -1041,10 +1078,12 @@ export function SettingsView() {
<span className="font-medium text-sm">Light</span>
</Button>
<Button
variant={theme === "retro" ? "secondary" : "outline"}
onClick={() => setTheme("retro")}
variant={
effectiveTheme === "retro" ? "secondary" : "outline"
}
onClick={() => handleSetTheme("retro")}
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
theme === "retro"
effectiveTheme === "retro"
? "border-brand-500 ring-1 ring-brand-500/50"
: ""
}`}
@@ -1054,10 +1093,12 @@ export function SettingsView() {
<span className="font-medium text-sm">Retro</span>
</Button>
<Button
variant={theme === "dracula" ? "secondary" : "outline"}
onClick={() => setTheme("dracula")}
variant={
effectiveTheme === "dracula" ? "secondary" : "outline"
}
onClick={() => handleSetTheme("dracula")}
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
theme === "dracula"
effectiveTheme === "dracula"
? "border-brand-500 ring-1 ring-brand-500/50"
: ""
}`}
@@ -1067,10 +1108,12 @@ export function SettingsView() {
<span className="font-medium text-sm">Dracula</span>
</Button>
<Button
variant={theme === "nord" ? "secondary" : "outline"}
onClick={() => setTheme("nord")}
variant={
effectiveTheme === "nord" ? "secondary" : "outline"
}
onClick={() => handleSetTheme("nord")}
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
theme === "nord"
effectiveTheme === "nord"
? "border-brand-500 ring-1 ring-brand-500/50"
: ""
}`}
@@ -1080,10 +1123,12 @@ export function SettingsView() {
<span className="font-medium text-sm">Nord</span>
</Button>
<Button
variant={theme === "monokai" ? "secondary" : "outline"}
onClick={() => setTheme("monokai")}
variant={
effectiveTheme === "monokai" ? "secondary" : "outline"
}
onClick={() => handleSetTheme("monokai")}
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
theme === "monokai"
effectiveTheme === "monokai"
? "border-brand-500 ring-1 ring-brand-500/50"
: ""
}`}
@@ -1093,10 +1138,14 @@ export function SettingsView() {
<span className="font-medium text-sm">Monokai</span>
</Button>
<Button
variant={theme === "tokyonight" ? "secondary" : "outline"}
onClick={() => setTheme("tokyonight")}
variant={
effectiveTheme === "tokyonight"
? "secondary"
: "outline"
}
onClick={() => handleSetTheme("tokyonight")}
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
theme === "tokyonight"
effectiveTheme === "tokyonight"
? "border-brand-500 ring-1 ring-brand-500/50"
: ""
}`}
@@ -1106,10 +1155,12 @@ export function SettingsView() {
<span className="font-medium text-sm">Tokyo Night</span>
</Button>
<Button
variant={theme === "solarized" ? "secondary" : "outline"}
onClick={() => setTheme("solarized")}
variant={
effectiveTheme === "solarized" ? "secondary" : "outline"
}
onClick={() => handleSetTheme("solarized")}
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
theme === "solarized"
effectiveTheme === "solarized"
? "border-brand-500 ring-1 ring-brand-500/50"
: ""
}`}
@@ -1119,10 +1170,12 @@ export function SettingsView() {
<span className="font-medium text-sm">Solarized</span>
</Button>
<Button
variant={theme === "gruvbox" ? "secondary" : "outline"}
onClick={() => setTheme("gruvbox")}
variant={
effectiveTheme === "gruvbox" ? "secondary" : "outline"
}
onClick={() => handleSetTheme("gruvbox")}
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
theme === "gruvbox"
effectiveTheme === "gruvbox"
? "border-brand-500 ring-1 ring-brand-500/50"
: ""
}`}
@@ -1132,10 +1185,14 @@ export function SettingsView() {
<span className="font-medium text-sm">Gruvbox</span>
</Button>
<Button
variant={theme === "catppuccin" ? "secondary" : "outline"}
onClick={() => setTheme("catppuccin")}
variant={
effectiveTheme === "catppuccin"
? "secondary"
: "outline"
}
onClick={() => handleSetTheme("catppuccin")}
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
theme === "catppuccin"
effectiveTheme === "catppuccin"
? "border-brand-500 ring-1 ring-brand-500/50"
: ""
}`}
@@ -1145,10 +1202,12 @@ export function SettingsView() {
<span className="font-medium text-sm">Catppuccin</span>
</Button>
<Button
variant={theme === "onedark" ? "secondary" : "outline"}
onClick={() => setTheme("onedark")}
variant={
effectiveTheme === "onedark" ? "secondary" : "outline"
}
onClick={() => handleSetTheme("onedark")}
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
theme === "onedark"
effectiveTheme === "onedark"
? "border-brand-500 ring-1 ring-brand-500/50"
: ""
}`}
@@ -1158,10 +1217,12 @@ export function SettingsView() {
<span className="font-medium text-sm">One Dark</span>
</Button>
<Button
variant={theme === "synthwave" ? "secondary" : "outline"}
onClick={() => setTheme("synthwave")}
variant={
effectiveTheme === "synthwave" ? "secondary" : "outline"
}
onClick={() => handleSetTheme("synthwave")}
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
theme === "synthwave"
effectiveTheme === "synthwave"
? "border-brand-500 ring-1 ring-brand-500/50"
: ""
}`}
@@ -1307,10 +1368,11 @@ export function SettingsView() {
Show profiles only by default
</Label>
<p className="text-xs text-muted-foreground">
When enabled, the Add Feature dialog will show only AI profiles
and hide advanced model tweaking options (Claude SDK, thinking levels,
and OpenAI Codex CLI). This creates a cleaner, less overwhelming UI.
You can always disable this to access advanced settings.
When enabled, the Add Feature dialog will show only AI
profiles and hide advanced model tweaking options
(Claude SDK, thinking levels, and OpenAI Codex CLI).
This creates a cleaner, less overwhelming UI. You can
always disable this to access advanced settings.
</p>
</div>
</div>

View File

@@ -4,6 +4,7 @@ import { useEffect, useState, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Card } from "@/components/ui/card";
import {
Dialog,
@@ -15,6 +16,7 @@ import {
} from "@/components/ui/dialog";
import { Save, RefreshCw, FileText, Sparkles, Loader2, FilePlus2 } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { XmlSyntaxEditor } from "@/components/ui/xml-syntax-editor";
import type { SpecRegenerationEvent } from "@/types/electron";
export function SpecView() {
@@ -299,13 +301,15 @@ export function SpecView() {
>
Cancel
</Button>
<Button
<HotkeyButton
onClick={handleCreateSpec}
disabled={!projectOverview.trim()}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={showCreateDialog}
>
<Sparkles className="w-4 h-4 mr-2" />
Generate Spec
</Button>
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -359,12 +363,10 @@ export function SpecView() {
{/* Editor */}
<div className="flex-1 p-4 overflow-hidden">
<Card className="h-full overflow-hidden">
<textarea
className="w-full h-full p-4 font-mono text-sm bg-transparent resize-none focus:outline-none"
<XmlSyntaxEditor
value={appSpec}
onChange={(e) => handleChange(e.target.value)}
onChange={handleChange}
placeholder="Write your app specification here..."
spellCheck={false}
data-testid="spec-editor"
/>
</Card>
@@ -409,9 +411,11 @@ export function SpecView() {
>
Cancel
</Button>
<Button
<HotkeyButton
onClick={handleRegenerate}
disabled={!projectDefinition.trim() || isRegenerating}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={showRegenerateDialog}
>
{isRegenerating ? (
<>
@@ -424,7 +428,7 @@ export function SpecView() {
Regenerate Spec
</>
)}
</Button>
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -2,6 +2,7 @@
import { useState, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@@ -512,14 +513,16 @@ export function WelcomeView() {
>
Cancel
</Button>
<Button
<HotkeyButton
onClick={handleCreateProject}
disabled={!newProjectName || !newProjectPath || isCreating}
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={showNewProjectDialog}
data-testid="confirm-create-project"
>
{isCreating ? "Creating..." : "Create Project"}
</Button>
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>