mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
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:
@@ -1727,3 +1727,528 @@
|
||||
.titlebar-no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
XML SYNTAX HIGHLIGHTING
|
||||
Theme-aware colors for XML editor
|
||||
======================================== */
|
||||
|
||||
/* Light theme - professional and readable */
|
||||
.light .xml-highlight {
|
||||
color: oklch(0.3 0 0); /* Default text */
|
||||
}
|
||||
|
||||
.light .xml-tag-bracket {
|
||||
color: oklch(0.45 0.15 250); /* Blue-gray for < > */
|
||||
}
|
||||
|
||||
.light .xml-tag-name {
|
||||
color: oklch(0.45 0.22 25); /* Red/maroon for tag names */
|
||||
}
|
||||
|
||||
.light .xml-attribute-name {
|
||||
color: oklch(0.45 0.18 280); /* Purple for attributes */
|
||||
}
|
||||
|
||||
.light .xml-attribute-equals {
|
||||
color: oklch(0.4 0 0); /* Dark gray for = */
|
||||
}
|
||||
|
||||
.light .xml-attribute-value {
|
||||
color: oklch(0.45 0.18 145); /* Green for string values */
|
||||
}
|
||||
|
||||
.light .xml-comment {
|
||||
color: oklch(0.55 0.05 100); /* Muted olive for comments */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.light .xml-cdata {
|
||||
color: oklch(0.5 0.1 200); /* Teal for CDATA */
|
||||
}
|
||||
|
||||
.light .xml-doctype {
|
||||
color: oklch(0.5 0.15 280); /* Purple for DOCTYPE */
|
||||
}
|
||||
|
||||
.light .xml-text {
|
||||
color: oklch(0.25 0 0); /* Near-black for text content */
|
||||
}
|
||||
|
||||
/* Dark theme - high contrast */
|
||||
.dark .xml-highlight {
|
||||
color: oklch(0.9 0 0); /* Default light text */
|
||||
}
|
||||
|
||||
.dark .xml-tag-bracket {
|
||||
color: oklch(0.7 0.12 220); /* Soft blue for < > */
|
||||
}
|
||||
|
||||
.dark .xml-tag-name {
|
||||
color: oklch(0.75 0.2 25); /* Coral/salmon for tag names */
|
||||
}
|
||||
|
||||
.dark .xml-attribute-name {
|
||||
color: oklch(0.8 0.15 280); /* Light purple for attributes */
|
||||
}
|
||||
|
||||
.dark .xml-attribute-equals {
|
||||
color: oklch(0.6 0 0); /* Gray for = */
|
||||
}
|
||||
|
||||
.dark .xml-attribute-value {
|
||||
color: oklch(0.8 0.18 145); /* Bright green for strings */
|
||||
}
|
||||
|
||||
.dark .xml-comment {
|
||||
color: oklch(0.55 0.05 100); /* Muted for comments */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dark .xml-cdata {
|
||||
color: oklch(0.7 0.12 200); /* Teal for CDATA */
|
||||
}
|
||||
|
||||
.dark .xml-doctype {
|
||||
color: oklch(0.7 0.15 280); /* Purple for DOCTYPE */
|
||||
}
|
||||
|
||||
.dark .xml-text {
|
||||
color: oklch(0.85 0 0); /* Off-white for text */
|
||||
}
|
||||
|
||||
/* Retro theme - neon green on black */
|
||||
.retro .xml-highlight {
|
||||
color: oklch(0.85 0.25 145); /* Neon green default */
|
||||
}
|
||||
|
||||
.retro .xml-tag-bracket {
|
||||
color: oklch(0.8 0.25 200); /* Cyan for brackets */
|
||||
}
|
||||
|
||||
.retro .xml-tag-name {
|
||||
color: oklch(0.85 0.25 145); /* Bright green for tags */
|
||||
text-shadow: 0 0 5px oklch(0.85 0.25 145 / 0.5);
|
||||
}
|
||||
|
||||
.retro .xml-attribute-name {
|
||||
color: oklch(0.8 0.25 300); /* Purple neon for attrs */
|
||||
}
|
||||
|
||||
.retro .xml-attribute-equals {
|
||||
color: oklch(0.6 0.15 145); /* Dim green for = */
|
||||
}
|
||||
|
||||
.retro .xml-attribute-value {
|
||||
color: oklch(0.8 0.25 60); /* Yellow neon for strings */
|
||||
}
|
||||
|
||||
.retro .xml-comment {
|
||||
color: oklch(0.5 0.15 145); /* Dim green for comments */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.retro .xml-cdata {
|
||||
color: oklch(0.75 0.2 200); /* Cyan for CDATA */
|
||||
}
|
||||
|
||||
.retro .xml-doctype {
|
||||
color: oklch(0.75 0.2 300); /* Purple for DOCTYPE */
|
||||
}
|
||||
|
||||
.retro .xml-text {
|
||||
color: oklch(0.7 0.2 145); /* Green text */
|
||||
}
|
||||
|
||||
/* Dracula theme */
|
||||
.dracula .xml-highlight {
|
||||
color: oklch(0.95 0.01 280); /* #f8f8f2 */
|
||||
}
|
||||
|
||||
.dracula .xml-tag-bracket {
|
||||
color: oklch(0.7 0.25 350); /* Pink #ff79c6 */
|
||||
}
|
||||
|
||||
.dracula .xml-tag-name {
|
||||
color: oklch(0.7 0.25 350); /* Pink for tags */
|
||||
}
|
||||
|
||||
.dracula .xml-attribute-name {
|
||||
color: oklch(0.8 0.2 130); /* Green #50fa7b */
|
||||
}
|
||||
|
||||
.dracula .xml-attribute-equals {
|
||||
color: oklch(0.95 0.01 280); /* White */
|
||||
}
|
||||
|
||||
.dracula .xml-attribute-value {
|
||||
color: oklch(0.85 0.2 90); /* Yellow #f1fa8c */
|
||||
}
|
||||
|
||||
.dracula .xml-comment {
|
||||
color: oklch(0.55 0.08 280); /* #6272a4 */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dracula .xml-cdata {
|
||||
color: oklch(0.75 0.2 180); /* Cyan */
|
||||
}
|
||||
|
||||
.dracula .xml-doctype {
|
||||
color: oklch(0.7 0.2 320); /* Purple #bd93f9 */
|
||||
}
|
||||
|
||||
.dracula .xml-text {
|
||||
color: oklch(0.95 0.01 280); /* White */
|
||||
}
|
||||
|
||||
/* Nord theme */
|
||||
.nord .xml-highlight {
|
||||
color: oklch(0.9 0.01 230); /* #eceff4 */
|
||||
}
|
||||
|
||||
.nord .xml-tag-bracket {
|
||||
color: oklch(0.65 0.14 220); /* #81a1c1 */
|
||||
}
|
||||
|
||||
.nord .xml-tag-name {
|
||||
color: oklch(0.65 0.14 220); /* Frost blue for tags */
|
||||
}
|
||||
|
||||
.nord .xml-attribute-name {
|
||||
color: oklch(0.7 0.12 220); /* #88c0d0 */
|
||||
}
|
||||
|
||||
.nord .xml-attribute-equals {
|
||||
color: oklch(0.75 0.02 230); /* Dim white */
|
||||
}
|
||||
|
||||
.nord .xml-attribute-value {
|
||||
color: oklch(0.7 0.15 140); /* #a3be8c green */
|
||||
}
|
||||
|
||||
.nord .xml-comment {
|
||||
color: oklch(0.5 0.04 230); /* Dim text */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.nord .xml-cdata {
|
||||
color: oklch(0.7 0.12 220); /* Frost blue */
|
||||
}
|
||||
|
||||
.nord .xml-doctype {
|
||||
color: oklch(0.7 0.2 320); /* #b48ead purple */
|
||||
}
|
||||
|
||||
.nord .xml-text {
|
||||
color: oklch(0.9 0.01 230); /* Snow white */
|
||||
}
|
||||
|
||||
/* Monokai theme */
|
||||
.monokai .xml-highlight {
|
||||
color: oklch(0.95 0.02 100); /* #f8f8f2 */
|
||||
}
|
||||
|
||||
.monokai .xml-tag-bracket {
|
||||
color: oklch(0.95 0.02 100); /* White */
|
||||
}
|
||||
|
||||
.monokai .xml-tag-name {
|
||||
color: oklch(0.8 0.2 350); /* #f92672 pink */
|
||||
}
|
||||
|
||||
.monokai .xml-attribute-name {
|
||||
color: oklch(0.8 0.2 140); /* #a6e22e green */
|
||||
}
|
||||
|
||||
.monokai .xml-attribute-equals {
|
||||
color: oklch(0.95 0.02 100); /* White */
|
||||
}
|
||||
|
||||
.monokai .xml-attribute-value {
|
||||
color: oklch(0.85 0.2 90); /* #e6db74 yellow */
|
||||
}
|
||||
|
||||
.monokai .xml-comment {
|
||||
color: oklch(0.55 0.04 100); /* #75715e */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.monokai .xml-cdata {
|
||||
color: oklch(0.75 0.2 200); /* Cyan #66d9ef */
|
||||
}
|
||||
|
||||
.monokai .xml-doctype {
|
||||
color: oklch(0.75 0.2 200); /* Cyan */
|
||||
}
|
||||
|
||||
.monokai .xml-text {
|
||||
color: oklch(0.95 0.02 100); /* White */
|
||||
}
|
||||
|
||||
/* Tokyo Night theme */
|
||||
.tokyonight .xml-highlight {
|
||||
color: oklch(0.85 0.02 250); /* #a9b1d6 */
|
||||
}
|
||||
|
||||
.tokyonight .xml-tag-bracket {
|
||||
color: oklch(0.65 0.2 15); /* #f7768e red */
|
||||
}
|
||||
|
||||
.tokyonight .xml-tag-name {
|
||||
color: oklch(0.65 0.2 15); /* Red for tags */
|
||||
}
|
||||
|
||||
.tokyonight .xml-attribute-name {
|
||||
color: oklch(0.7 0.2 320); /* #bb9af7 purple */
|
||||
}
|
||||
|
||||
.tokyonight .xml-attribute-equals {
|
||||
color: oklch(0.75 0.02 250); /* Dim text */
|
||||
}
|
||||
|
||||
.tokyonight .xml-attribute-value {
|
||||
color: oklch(0.75 0.18 140); /* #9ece6a green */
|
||||
}
|
||||
|
||||
.tokyonight .xml-comment {
|
||||
color: oklch(0.5 0.04 250); /* #565f89 */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tokyonight .xml-cdata {
|
||||
color: oklch(0.75 0.18 200); /* #7dcfff cyan */
|
||||
}
|
||||
|
||||
.tokyonight .xml-doctype {
|
||||
color: oklch(0.7 0.18 280); /* #7aa2f7 blue */
|
||||
}
|
||||
|
||||
.tokyonight .xml-text {
|
||||
color: oklch(0.85 0.02 250); /* Text color */
|
||||
}
|
||||
|
||||
/* Solarized theme */
|
||||
.solarized .xml-highlight {
|
||||
color: oklch(0.75 0.02 90); /* #839496 */
|
||||
}
|
||||
|
||||
.solarized .xml-tag-bracket {
|
||||
color: oklch(0.65 0.15 220); /* #268bd2 blue */
|
||||
}
|
||||
|
||||
.solarized .xml-tag-name {
|
||||
color: oklch(0.65 0.15 220); /* Blue for tags */
|
||||
}
|
||||
|
||||
.solarized .xml-attribute-name {
|
||||
color: oklch(0.6 0.18 180); /* #2aa198 cyan */
|
||||
}
|
||||
|
||||
.solarized .xml-attribute-equals {
|
||||
color: oklch(0.75 0.02 90); /* Base text */
|
||||
}
|
||||
|
||||
.solarized .xml-attribute-value {
|
||||
color: oklch(0.65 0.2 140); /* #859900 green */
|
||||
}
|
||||
|
||||
.solarized .xml-comment {
|
||||
color: oklch(0.5 0.04 200); /* #586e75 */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.solarized .xml-cdata {
|
||||
color: oklch(0.6 0.18 180); /* Cyan */
|
||||
}
|
||||
|
||||
.solarized .xml-doctype {
|
||||
color: oklch(0.6 0.2 290); /* #6c71c4 violet */
|
||||
}
|
||||
|
||||
.solarized .xml-text {
|
||||
color: oklch(0.75 0.02 90); /* Base text */
|
||||
}
|
||||
|
||||
/* Gruvbox theme */
|
||||
.gruvbox .xml-highlight {
|
||||
color: oklch(0.85 0.05 85); /* #ebdbb2 */
|
||||
}
|
||||
|
||||
.gruvbox .xml-tag-bracket {
|
||||
color: oklch(0.55 0.22 25); /* #fb4934 red */
|
||||
}
|
||||
|
||||
.gruvbox .xml-tag-name {
|
||||
color: oklch(0.55 0.22 25); /* Red for tags */
|
||||
}
|
||||
|
||||
.gruvbox .xml-attribute-name {
|
||||
color: oklch(0.7 0.15 200); /* #8ec07c aqua */
|
||||
}
|
||||
|
||||
.gruvbox .xml-attribute-equals {
|
||||
color: oklch(0.7 0.04 85); /* Dim text */
|
||||
}
|
||||
|
||||
.gruvbox .xml-attribute-value {
|
||||
color: oklch(0.65 0.2 140); /* #b8bb26 green */
|
||||
}
|
||||
|
||||
.gruvbox .xml-comment {
|
||||
color: oklch(0.55 0.04 85); /* #928374 gray */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.gruvbox .xml-cdata {
|
||||
color: oklch(0.7 0.15 200); /* Aqua */
|
||||
}
|
||||
|
||||
.gruvbox .xml-doctype {
|
||||
color: oklch(0.6 0.2 320); /* #d3869b purple */
|
||||
}
|
||||
|
||||
.gruvbox .xml-text {
|
||||
color: oklch(0.85 0.05 85); /* Foreground */
|
||||
}
|
||||
|
||||
/* Catppuccin theme */
|
||||
.catppuccin .xml-highlight {
|
||||
color: oklch(0.9 0.01 280); /* #cdd6f4 */
|
||||
}
|
||||
|
||||
.catppuccin .xml-tag-bracket {
|
||||
color: oklch(0.65 0.2 15); /* #f38ba8 red */
|
||||
}
|
||||
|
||||
.catppuccin .xml-tag-name {
|
||||
color: oklch(0.65 0.2 15); /* Red for tags */
|
||||
}
|
||||
|
||||
.catppuccin .xml-attribute-name {
|
||||
color: oklch(0.75 0.15 280); /* #cba6f7 mauve */
|
||||
}
|
||||
|
||||
.catppuccin .xml-attribute-equals {
|
||||
color: oklch(0.75 0.02 280); /* Subtext */
|
||||
}
|
||||
|
||||
.catppuccin .xml-attribute-value {
|
||||
color: oklch(0.8 0.15 160); /* #a6e3a1 green */
|
||||
}
|
||||
|
||||
.catppuccin .xml-comment {
|
||||
color: oklch(0.5 0.04 280); /* Overlay */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.catppuccin .xml-cdata {
|
||||
color: oklch(0.75 0.15 220); /* #89b4fa blue */
|
||||
}
|
||||
|
||||
.catppuccin .xml-doctype {
|
||||
color: oklch(0.8 0.15 350); /* #f5c2e7 pink */
|
||||
}
|
||||
|
||||
.catppuccin .xml-text {
|
||||
color: oklch(0.9 0.01 280); /* Text */
|
||||
}
|
||||
|
||||
/* One Dark theme */
|
||||
.onedark .xml-highlight {
|
||||
color: oklch(0.85 0.02 240); /* #abb2bf */
|
||||
}
|
||||
|
||||
.onedark .xml-tag-bracket {
|
||||
color: oklch(0.6 0.2 20); /* #e06c75 red */
|
||||
}
|
||||
|
||||
.onedark .xml-tag-name {
|
||||
color: oklch(0.6 0.2 20); /* Red for tags */
|
||||
}
|
||||
|
||||
.onedark .xml-attribute-name {
|
||||
color: oklch(0.8 0.15 80); /* #e5c07b yellow */
|
||||
}
|
||||
|
||||
.onedark .xml-attribute-equals {
|
||||
color: oklch(0.7 0.02 240); /* Dim text */
|
||||
}
|
||||
|
||||
.onedark .xml-attribute-value {
|
||||
color: oklch(0.75 0.18 150); /* #98c379 green */
|
||||
}
|
||||
|
||||
.onedark .xml-comment {
|
||||
color: oklch(0.5 0.03 240); /* #5c6370 */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.onedark .xml-cdata {
|
||||
color: oklch(0.7 0.15 180); /* #56b6c2 cyan */
|
||||
}
|
||||
|
||||
.onedark .xml-doctype {
|
||||
color: oklch(0.75 0.15 320); /* #c678dd magenta */
|
||||
}
|
||||
|
||||
.onedark .xml-text {
|
||||
color: oklch(0.85 0.02 240); /* Text */
|
||||
}
|
||||
|
||||
/* Synthwave theme */
|
||||
.synthwave .xml-highlight {
|
||||
color: oklch(0.95 0.02 320); /* Warm white */
|
||||
}
|
||||
|
||||
.synthwave .xml-tag-bracket {
|
||||
color: oklch(0.7 0.28 350); /* #f97e72 hot pink */
|
||||
}
|
||||
|
||||
.synthwave .xml-tag-name {
|
||||
color: oklch(0.7 0.28 350); /* Hot pink */
|
||||
text-shadow: 0 0 8px oklch(0.7 0.28 350 / 0.5);
|
||||
}
|
||||
|
||||
.synthwave .xml-attribute-name {
|
||||
color: oklch(0.7 0.25 280); /* #ff7edb purple */
|
||||
}
|
||||
|
||||
.synthwave .xml-attribute-equals {
|
||||
color: oklch(0.8 0.02 320); /* White-ish */
|
||||
}
|
||||
|
||||
.synthwave .xml-attribute-value {
|
||||
color: oklch(0.85 0.2 60); /* #fede5d yellow */
|
||||
text-shadow: 0 0 5px oklch(0.85 0.2 60 / 0.3);
|
||||
}
|
||||
|
||||
.synthwave .xml-comment {
|
||||
color: oklch(0.55 0.08 290); /* Dim purple */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.synthwave .xml-cdata {
|
||||
color: oklch(0.8 0.25 200); /* #72f1b8 cyan */
|
||||
}
|
||||
|
||||
.synthwave .xml-doctype {
|
||||
color: oklch(0.8 0.25 200); /* Cyan */
|
||||
}
|
||||
|
||||
.synthwave .xml-text {
|
||||
color: oklch(0.95 0.02 320); /* White */
|
||||
}
|
||||
|
||||
/* XML Editor container styles */
|
||||
.xml-editor {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xml-editor textarea {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xml-editor .xml-highlight {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
296
app/src/components/ui/hotkey-button.tsx
Normal file
296
app/src/components/ui/hotkey-button.tsx
Normal 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 };
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
290
app/src/components/ui/xml-syntax-editor.tsx
Normal file
290
app/src/components/ui/xml-syntax-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -186,7 +186,7 @@ export function useAutoMode() {
|
||||
}
|
||||
}, [currentProject, setAutoModeRunning, maxConcurrency]);
|
||||
|
||||
// Stop auto mode
|
||||
// Stop auto mode - only turns off the toggle, running tasks continue
|
||||
const stop = useCallback(async () => {
|
||||
if (!currentProject) {
|
||||
console.error("No project selected");
|
||||
@@ -203,8 +203,11 @@ export function useAutoMode() {
|
||||
|
||||
if (result.success) {
|
||||
setAutoModeRunning(currentProject.id, false);
|
||||
clearRunningTasks(currentProject.id);
|
||||
console.log("[AutoMode] Stopped successfully");
|
||||
// NOTE: We intentionally do NOT clear running tasks here.
|
||||
// Stopping auto mode only turns off the toggle to prevent new features
|
||||
// from being picked up. Running tasks will complete naturally and be
|
||||
// removed via the auto_mode_feature_complete event.
|
||||
console.log("[AutoMode] Stopped successfully - running tasks will continue");
|
||||
} else {
|
||||
console.error("[AutoMode] Failed to stop:", result.error);
|
||||
throw new Error(result.error || "Failed to stop auto mode");
|
||||
@@ -213,7 +216,7 @@ export function useAutoMode() {
|
||||
console.error("[AutoMode] Error stopping:", error);
|
||||
throw error;
|
||||
}
|
||||
}, [currentProject, setAutoModeRunning, clearRunningTasks]);
|
||||
}, [currentProject, setAutoModeRunning]);
|
||||
|
||||
// Stop a specific feature
|
||||
const stopFeature = useCallback(
|
||||
|
||||
Reference in New Issue
Block a user