style: fix formatting with Prettier

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
SuperComboGamer
2025-12-21 20:31:57 -05:00
parent 584f5a3426
commit 8d578558ff
295 changed files with 9088 additions and 10546 deletions

View File

@@ -1,9 +1,8 @@
import * as React from 'react';
import { Check, ChevronsUpDown, LucideIcon } from 'lucide-react';
import * as React from "react";
import { Check, ChevronsUpDown, LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
@@ -11,12 +10,8 @@ import {
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
export interface AutocompleteOption {
value: string;
@@ -38,12 +33,12 @@ interface AutocompleteProps {
icon?: LucideIcon;
allowCreate?: boolean;
createLabel?: (value: string) => string;
"data-testid"?: string;
'data-testid'?: string;
itemTestIdPrefix?: string;
}
function normalizeOption(opt: string | AutocompleteOption): AutocompleteOption {
if (typeof opt === "string") {
if (typeof opt === 'string') {
return { value: opt, label: opt };
}
return { ...opt, label: opt.label ?? opt.value };
@@ -53,27 +48,24 @@ export function Autocomplete({
value,
onChange,
options,
placeholder = "Select an option...",
searchPlaceholder = "Search...",
emptyMessage = "No results found.",
placeholder = 'Select an option...',
searchPlaceholder = 'Search...',
emptyMessage = 'No results found.',
className,
disabled = false,
error = false,
icon: Icon,
allowCreate = false,
createLabel = (v) => `Create "${v}"`,
"data-testid": testId,
itemTestIdPrefix = "option",
'data-testid': testId,
itemTestIdPrefix = 'option',
}: AutocompleteProps) {
const [open, setOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState("");
const [inputValue, setInputValue] = React.useState('');
const [triggerWidth, setTriggerWidth] = React.useState<number>(0);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const normalizedOptions = React.useMemo(
() => options.map(normalizeOption),
[options]
);
const normalizedOptions = React.useMemo(() => options.map(normalizeOption), [options]);
// Update trigger width when component mounts or value changes
React.useEffect(() => {
@@ -98,9 +90,7 @@ export function Autocomplete({
if (!inputValue) return normalizedOptions;
const lower = inputValue.toLowerCase();
return normalizedOptions.filter(
(opt) =>
opt.value.toLowerCase().includes(lower) ||
opt.label?.toLowerCase().includes(lower)
(opt) => opt.value.toLowerCase().includes(lower) || opt.label?.toLowerCase().includes(lower)
);
}, [normalizedOptions, inputValue]);
@@ -108,9 +98,7 @@ export function Autocomplete({
const isNewValue =
allowCreate &&
inputValue.trim() &&
!normalizedOptions.some(
(opt) => opt.value.toLowerCase() === inputValue.toLowerCase()
);
!normalizedOptions.some((opt) => opt.value.toLowerCase() === inputValue.toLowerCase());
// Get display value
const displayValue = React.useMemo(() => {
@@ -129,17 +117,15 @@ export function Autocomplete({
aria-expanded={open}
disabled={disabled}
className={cn(
"w-full justify-between",
Icon && "font-mono text-sm",
error && "border-destructive focus-visible:ring-destructive",
'w-full justify-between',
Icon && 'font-mono text-sm',
error && 'border-destructive focus-visible:ring-destructive',
className
)}
data-testid={testId}
>
<span className="flex items-center gap-2 truncate">
{Icon && (
<Icon className="w-4 h-4 shrink-0 text-muted-foreground" />
)}
{Icon && <Icon className="w-4 h-4 shrink-0 text-muted-foreground" />}
{displayValue || placeholder}
</span>
<ChevronsUpDown className="opacity-50 shrink-0" />
@@ -163,8 +149,7 @@ export function Autocomplete({
<CommandEmpty>
{isNewValue ? (
<div className="py-2 px-3 text-sm">
Press enter to create{" "}
<code className="bg-muted px-1 rounded">{inputValue}</code>
Press enter to create <code className="bg-muted px-1 rounded">{inputValue}</code>
</div>
) : (
emptyMessage
@@ -177,7 +162,7 @@ export function Autocomplete({
value={inputValue}
onSelect={() => {
onChange(inputValue);
setInputValue("");
setInputValue('');
setOpen(false);
}}
className="text-[var(--status-success)]"
@@ -185,9 +170,7 @@ export function Autocomplete({
>
{Icon && <Icon className="w-4 h-4 mr-2" />}
{createLabel(inputValue)}
<span className="ml-auto text-xs text-muted-foreground">
(new)
</span>
<span className="ml-auto text-xs text-muted-foreground">(new)</span>
</CommandItem>
)}
{filteredOptions.map((option) => (
@@ -195,24 +178,19 @@ export function Autocomplete({
key={option.value}
value={option.value}
onSelect={(currentValue) => {
onChange(currentValue === value ? "" : currentValue);
setInputValue("");
onChange(currentValue === value ? '' : currentValue);
setInputValue('');
setOpen(false);
}}
data-testid={`${itemTestIdPrefix}-${option.value.toLowerCase().replace(/[\s/\\]+/g, "-")}`}
data-testid={`${itemTestIdPrefix}-${option.value.toLowerCase().replace(/[\s/\\]+/g, '-')}`}
>
{Icon && <Icon className="w-4 h-4 mr-2" />}
{option.label}
<Check
className={cn(
"ml-auto",
value === option.value ? "opacity-100" : "opacity-0"
)}
className={cn('ml-auto', value === option.value ? 'opacity-100' : 'opacity-0')}
/>
{option.badge && (
<span className="ml-2 text-xs text-muted-foreground">
({option.badge})
</span>
<span className="ml-2 text-xs text-muted-foreground">({option.badge})</span>
)}
</CommandItem>
))}

View File

@@ -1,57 +1,48 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shadow-sm",
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shadow-sm',
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/90",
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/90',
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-white hover:bg-destructive/90",
outline:
"text-foreground border-border bg-background/50 backdrop-blur-sm",
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'border-transparent bg-destructive text-white hover:bg-destructive/90',
outline: 'text-foreground border-border bg-background/50 backdrop-blur-sm',
// Semantic status variants using CSS variables
success:
"border-transparent bg-[var(--status-success-bg)] text-[var(--status-success)] border border-[var(--status-success)]/30",
'border-transparent bg-[var(--status-success-bg)] text-[var(--status-success)] border border-[var(--status-success)]/30',
warning:
"border-transparent bg-[var(--status-warning-bg)] text-[var(--status-warning)] border border-[var(--status-warning)]/30",
'border-transparent bg-[var(--status-warning-bg)] text-[var(--status-warning)] border border-[var(--status-warning)]/30',
error:
"border-transparent bg-[var(--status-error-bg)] text-[var(--status-error)] border border-[var(--status-error)]/30",
info:
"border-transparent bg-[var(--status-info-bg)] text-[var(--status-info)] border border-[var(--status-info)]/30",
'border-transparent bg-[var(--status-error-bg)] text-[var(--status-error)] border border-[var(--status-error)]/30',
info: 'border-transparent bg-[var(--status-info-bg)] text-[var(--status-info)] border border-[var(--status-info)]/30',
// Muted variants for subtle indication
muted:
"border-border/50 bg-muted/50 text-muted-foreground",
brand:
"border-transparent bg-brand-500/15 text-brand-500 border border-brand-500/30",
muted: 'border-border/50 bg-muted/50 text-muted-foreground',
brand: 'border-transparent bg-brand-500/15 text-brand-500 border border-brand-500/30',
},
size: {
default: "px-2.5 py-0.5 text-xs",
sm: "px-2 py-0.5 text-[10px]",
lg: "px-3 py-1 text-sm",
default: 'px-2.5 py-0.5 text-xs',
sm: 'px-2 py-0.5 text-[10px]',
lg: 'px-3 py-1 text-sm',
},
},
defaultVariants: {
variant: "default",
size: "default",
variant: 'default',
size: 'default',
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, size, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant, size }), className)} {...props} />
);
return <div className={cn(badgeVariants({ variant, size }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -1,7 +1,6 @@
import * as React from "react";
import { GitBranch } from "lucide-react";
import { Autocomplete, AutocompleteOption } from "@/components/ui/autocomplete";
import * as React from 'react';
import { GitBranch } from 'lucide-react';
import { Autocomplete, AutocompleteOption } from '@/components/ui/autocomplete';
interface BranchAutocompleteProps {
value: string;
@@ -12,7 +11,7 @@ interface BranchAutocompleteProps {
className?: string;
disabled?: boolean;
error?: boolean;
"data-testid"?: string;
'data-testid'?: string;
}
export function BranchAutocomplete({
@@ -20,24 +19,25 @@ export function BranchAutocomplete({
onChange,
branches,
branchCardCounts,
placeholder = "Select a branch...",
placeholder = 'Select a branch...',
className,
disabled = false,
error = false,
"data-testid": testId,
'data-testid': testId,
}: BranchAutocompleteProps) {
// Always include "main" at the top of suggestions
const branchOptions: AutocompleteOption[] = React.useMemo(() => {
const branchSet = new Set(["main", ...branches]);
const branchSet = new Set(['main', ...branches]);
return Array.from(branchSet).map((branch) => {
const cardCount = branchCardCounts?.[branch];
// Show card count if available, otherwise show "default" for main branch only
const badge = branchCardCounts !== undefined
? String(cardCount ?? 0)
: branch === "main"
? "default"
: undefined;
const badge =
branchCardCounts !== undefined
? String(cardCount ?? 0)
: branch === 'main'
? 'default'
: undefined;
return {
value: branch,
label: branch,

View File

@@ -1,6 +1,5 @@
import { Tag } from "lucide-react";
import { Autocomplete } from "@/components/ui/autocomplete";
import { Tag } from 'lucide-react';
import { Autocomplete } from '@/components/ui/autocomplete';
interface CategoryAutocompleteProps {
value: string;
@@ -10,18 +9,18 @@ interface CategoryAutocompleteProps {
className?: string;
disabled?: boolean;
error?: boolean;
"data-testid"?: string;
'data-testid'?: string;
}
export function CategoryAutocomplete({
value,
onChange,
suggestions,
placeholder = "Select or type a category...",
placeholder = 'Select or type a category...',
className,
disabled = false,
error = false,
"data-testid": testId,
'data-testid': testId,
}: CategoryAutocompleteProps) {
return (
<Autocomplete

View File

@@ -1,13 +1,15 @@
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils";
interface CheckboxProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "checked" | "defaultChecked"> {
checked?: boolean | "indeterminate";
defaultChecked?: boolean | "indeterminate";
interface CheckboxProps extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
'checked' | 'defaultChecked'
> {
checked?: boolean | 'indeterminate';
defaultChecked?: boolean | 'indeterminate';
onCheckedChange?: (checked: boolean) => void;
required?: boolean;
}
@@ -31,7 +33,7 @@ const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
<CheckboxRoot
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground hover:border-primary/80",
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground hover:border-primary/80',
className
)}
onCheckedChange={(checked) => {
@@ -42,9 +44,7 @@ const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
}}
{...props}
>
<CheckboxIndicator
className={cn("flex items-center justify-center text-current")}
>
<CheckboxIndicator className={cn('flex items-center justify-center text-current')}>
<Check className="h-4 w-4" />
</CheckboxIndicator>
</CheckboxRoot>

View File

@@ -1,45 +1,41 @@
import * as React from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { SearchIcon } from 'lucide-react';
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
} from '@/components/ui/dialog';
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className
)}
{...props}
/>
)
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
title = 'Command Palette',
description = 'Search for a command to run...',
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
}) {
return (
<Dialog {...props}>
@@ -48,7 +44,7 @@ function CommandDialog({
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
className={cn('overflow-hidden p-0', className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
@@ -56,7 +52,7 @@ function CommandDialog({
</Command>
</DialogContent>
</Dialog>
)
);
}
function CommandInput({
@@ -64,49 +60,38 @@ function CommandInput({
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<div data-slot="command-input-wrapper" className="flex h-9 items-center gap-2 border-b px-3">
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
)
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
className={cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', className)}
{...props}
/>
)
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
);
}
function CommandGroup({
@@ -117,12 +102,12 @@ function CommandGroup({
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
className
)}
{...props}
/>
)
);
}
function CommandSeparator({
@@ -132,16 +117,13 @@ function CommandSeparator({
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
)
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
@@ -151,23 +133,17 @@ function CommandItem({
)}
{...props}
/>
)
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
)
);
}
export {
@@ -180,4 +156,4 @@ export {
CommandItem,
CommandShortcut,
CommandSeparator,
}
};

View File

@@ -1,6 +1,5 @@
import { useState, useEffect } from "react";
import { Clock } from "lucide-react";
import { useState, useEffect } from 'react';
import { Clock } from 'lucide-react';
interface CountUpTimerProps {
startedAt: string; // ISO timestamp string
@@ -16,8 +15,8 @@ function formatElapsedTime(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
const paddedMinutes = minutes.toString().padStart(2, "0");
const paddedSeconds = remainingSeconds.toString().padStart(2, "0");
const paddedMinutes = minutes.toString().padStart(2, '0');
const paddedSeconds = remainingSeconds.toString().padStart(2, '0');
return `${paddedMinutes}:${paddedSeconds}`;
}
@@ -26,7 +25,7 @@ function formatElapsedTime(seconds: number): string {
* CountUpTimer component that displays elapsed time since a given start time
* Updates every second to show the current elapsed time in MM:SS format
*/
export function CountUpTimer({ startedAt, className = "" }: CountUpTimerProps) {
export function CountUpTimer({ startedAt, className = '' }: CountUpTimerProps) {
const [elapsedSeconds, setElapsedSeconds] = useState(0);
useEffect(() => {

View File

@@ -1,4 +1,4 @@
import { Trash2 } from "lucide-react";
import { Trash2 } from 'lucide-react';
import {
Dialog,
DialogContent,
@@ -6,10 +6,10 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import type { ReactNode } from "react";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import type { ReactNode } from 'react';
interface DeleteConfirmDialogProps {
open: boolean;
@@ -34,9 +34,9 @@ export function DeleteConfirmDialog({
title,
description,
children,
confirmText = "Delete",
testId = "delete-confirm-dialog",
confirmTestId = "confirm-delete-button",
confirmText = 'Delete',
testId = 'delete-confirm-dialog',
confirmTestId = 'confirm-delete-button',
}: DeleteConfirmDialogProps) {
const handleConfirm = () => {
onConfirm();
@@ -45,18 +45,13 @@ export function DeleteConfirmDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="bg-popover border-border max-w-md"
data-testid={testId}
>
<DialogContent className="bg-popover border-border max-w-md" data-testid={testId}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="w-5 h-5 text-destructive" />
{title}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{description}
</DialogDescription>
<DialogDescription className="text-muted-foreground">{description}</DialogDescription>
</DialogHeader>
{children}
@@ -74,7 +69,7 @@ export function DeleteConfirmDialog({
variant="destructive"
onClick={handleConfirm}
data-testid={confirmTestId}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open}
className="px-4"
>

View File

@@ -1,9 +1,8 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-react';
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
const DialogContentPrimitive = DialogPrimitive.Content as React.ForwardRefExoticComponent<
@@ -35,27 +34,19 @@ const DialogDescriptionPrimitive = DialogPrimitive.Description as React.ForwardR
} & React.RefAttributes<HTMLParagraphElement>
>;
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
@@ -75,10 +66,10 @@ function DialogOverlay({
<DialogOverlayPrimitive
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"duration-200",
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'duration-200',
className
)}
{...props}
@@ -88,90 +79,81 @@ function DialogOverlay({
export type DialogContentProps = Omit<
React.ComponentProps<typeof DialogPrimitive.Content>,
"ref"
'ref'
> & {
showCloseButton?: boolean;
compact?: boolean;
};
const DialogContent = React.forwardRef<
HTMLDivElement,
DialogContentProps
>(({ className, children, showCloseButton = true, compact = false, ...props }, ref) => {
// Check if className contains a custom max-width
const hasCustomMaxWidth =
typeof className === "string" && className.includes("max-w-");
const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
({ className, children, showCloseButton = true, compact = false, ...props }, ref) => {
// Check if className contains a custom max-width
const hasCustomMaxWidth = typeof className === 'string' && className.includes('max-w-');
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogContentPrimitive
ref={ref}
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
"flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-4rem)]",
"bg-card border border-border rounded-xl shadow-2xl",
// Premium shadow
"shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)]",
// Animations - smoother with scale
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]",
"duration-200",
compact
? "max-w-4xl p-4"
: !hasCustomMaxWidth
? "sm:max-w-2xl p-6"
: "p-6",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogClosePrimitive
data-slot="dialog-close"
className={cn(
"absolute rounded-lg opacity-60 transition-all duration-200 cursor-pointer",
"hover:opacity-100 hover:bg-muted",
"focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-none",
"disabled:pointer-events-none disabled:cursor-not-allowed",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4",
"p-1.5",
compact ? "top-2 right-3" : "top-4 right-4"
)}
>
<XIcon />
<span className="sr-only">Close</span>
</DialogClosePrimitive>
)}
</DialogContentPrimitive>
</DialogPortal>
);
});
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogContentPrimitive
ref={ref}
data-slot="dialog-content"
className={cn(
'fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
'flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-4rem)]',
'bg-card border border-border rounded-xl shadow-2xl',
// Premium shadow
'shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)]',
// Animations - smoother with scale
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]',
'duration-200',
compact ? 'max-w-4xl p-4' : !hasCustomMaxWidth ? 'sm:max-w-2xl p-6' : 'p-6',
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogClosePrimitive
data-slot="dialog-close"
className={cn(
'absolute rounded-lg opacity-60 transition-all duration-200 cursor-pointer',
'hover:opacity-100 hover:bg-muted',
'focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-none',
'disabled:pointer-events-none disabled:cursor-not-allowed',
'[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4',
'p-1.5',
compact ? 'top-2 right-3' : 'top-4 right-4'
)}
>
<XIcon />
<span className="sr-only">Close</span>
</DialogClosePrimitive>
)}
</DialogContentPrimitive>
</DialogPortal>
);
}
);
DialogContent.displayName = "DialogContent";
DialogContent.displayName = 'DialogContent';
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end mt-6",
className
)}
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end mt-6', className)}
{...props}
/>
);
@@ -188,7 +170,7 @@ function DialogTitle({
return (
<DialogTitlePrimitive
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold tracking-tight", className)}
className={cn('text-lg leading-none font-semibold tracking-tight', className)}
{...props}
>
{children}
@@ -209,7 +191,7 @@ function DialogDescription({
return (
<DialogDescriptionPrimitive
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm leading-relaxed", className)}
className={cn('text-muted-foreground text-sm leading-relaxed', className)}
title={title}
{...props}
>

View File

@@ -1,44 +1,49 @@
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
const DropdownMenuTriggerPrimitive = DropdownMenuPrimitive.Trigger as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger> & {
children?: React.ReactNode;
asChild?: boolean;
} & React.RefAttributes<HTMLButtonElement>
>;
const DropdownMenuTriggerPrimitive =
DropdownMenuPrimitive.Trigger as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger> & {
children?: React.ReactNode;
asChild?: boolean;
} & React.RefAttributes<HTMLButtonElement>
>;
const DropdownMenuSubTriggerPrimitive = DropdownMenuPrimitive.SubTrigger as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuSubTriggerPrimitive =
DropdownMenuPrimitive.SubTrigger as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuRadioGroupPrimitive = DropdownMenuPrimitive.RadioGroup as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioGroup> & {
children?: React.ReactNode;
} & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuRadioGroupPrimitive =
DropdownMenuPrimitive.RadioGroup as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioGroup> & {
children?: React.ReactNode;
} & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuItemPrimitive = DropdownMenuPrimitive.Item as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
children?: React.ReactNode;
className?: string;
} & React.HTMLAttributes<HTMLDivElement> & React.RefAttributes<HTMLDivElement>
} & React.HTMLAttributes<HTMLDivElement> &
React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuRadioItemPrimitive = DropdownMenuPrimitive.RadioItem as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
children?: React.ReactNode;
className?: string;
} & React.HTMLAttributes<HTMLDivElement> & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuRadioItemPrimitive =
DropdownMenuPrimitive.RadioItem as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
children?: React.ReactNode;
className?: string;
} & React.HTMLAttributes<HTMLDivElement> &
React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuLabelPrimitive = DropdownMenuPrimitive.Label as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
@@ -47,26 +52,29 @@ const DropdownMenuLabelPrimitive = DropdownMenuPrimitive.Label as React.ForwardR
} & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuCheckboxItemPrimitive = DropdownMenuPrimitive.CheckboxItem as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuCheckboxItemPrimitive =
DropdownMenuPrimitive.CheckboxItem as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuItemIndicatorPrimitive = DropdownMenuPrimitive.ItemIndicator as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.ItemIndicator> & {
children?: React.ReactNode;
} & React.RefAttributes<HTMLSpanElement>
>;
const DropdownMenuItemIndicatorPrimitive =
DropdownMenuPrimitive.ItemIndicator as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.ItemIndicator> & {
children?: React.ReactNode;
} & React.RefAttributes<HTMLSpanElement>
>;
const DropdownMenuSeparatorPrimitive = DropdownMenuPrimitive.Separator as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> & {
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuSeparatorPrimitive =
DropdownMenuPrimitive.Separator as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> & {
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenu = DropdownMenuPrimitive.Root;
function DropdownMenuTrigger({
children,
@@ -80,39 +88,35 @@ function DropdownMenuTrigger({
<DropdownMenuTriggerPrimitive asChild={asChild} {...props}>
{children}
</DropdownMenuTriggerPrimitive>
)
);
}
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
function DropdownMenuRadioGroup({
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup> & { children?: React.ReactNode }) {
return (
<DropdownMenuRadioGroupPrimitive {...props}>
{children}
</DropdownMenuRadioGroupPrimitive>
)
return <DropdownMenuRadioGroupPrimitive {...props}>{children}</DropdownMenuRadioGroupPrimitive>;
}
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
children?: React.ReactNode
className?: string
inset?: boolean;
children?: React.ReactNode;
className?: string;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuSubTriggerPrimitive
ref={ref}
className={cn(
"flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent hover:bg-accent",
inset && "pl-8",
'flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent hover:bg-accent',
inset && 'pl-8',
className
)}
{...props}
@@ -120,8 +124,8 @@ const DropdownMenuSubTrigger = React.forwardRef<
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuSubTriggerPrimitive>
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@@ -133,14 +137,14 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@@ -153,35 +157,35 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
children?: React.ReactNode
inset?: boolean;
children?: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuItemPrimitive
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
inset && "pl-8",
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent',
inset && 'pl-8',
className
)}
{...props}
>
{children}
</DropdownMenuItemPrimitive>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
@@ -193,7 +197,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuCheckboxItemPrimitive
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent',
className
)}
checked={checked}
@@ -206,20 +210,19 @@ const DropdownMenuCheckboxItem = React.forwardRef<
</span>
{children}
</DropdownMenuCheckboxItemPrimitive>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
children?: React.ReactNode
children?: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => (
<DropdownMenuRadioItemPrimitive
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent',
className
)}
{...props}
@@ -231,30 +234,26 @@ const DropdownMenuRadioItem = React.forwardRef<
</span>
{children}
</DropdownMenuRadioItemPrimitive>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
children?: React.ReactNode
className?: string
inset?: boolean;
children?: React.ReactNode;
className?: string;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuLabelPrimitive
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
>
{children}
</DropdownMenuLabelPrimitive>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
@@ -264,24 +263,21 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<DropdownMenuSeparatorPrimitive
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest text-brand-400/70", className)}
className={cn('ml-auto text-xs tracking-widest text-brand-400/70', className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
@@ -299,4 +295,4 @@ export {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
};

View File

@@ -1,7 +1,6 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import { cn } from "@/lib/utils";
import { useState, useEffect, useMemo, useCallback } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { cn } from '@/lib/utils';
import {
File,
FileText,
@@ -14,9 +13,9 @@ import {
RefreshCw,
GitBranch,
AlertCircle,
} from "lucide-react";
import { Button } from "./button";
import type { FileStatus } from "@/types/electron";
} from 'lucide-react';
import { Button } from './button';
import type { FileStatus } from '@/types/electron';
interface GitDiffPanelProps {
projectPath: string;
@@ -31,7 +30,7 @@ interface GitDiffPanelProps {
interface ParsedDiffHunk {
header: string;
lines: {
type: "context" | "addition" | "deletion" | "header";
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}[];
@@ -47,16 +46,16 @@ interface ParsedFileDiff {
const getFileIcon = (status: string) => {
switch (status) {
case "A":
case "?":
case 'A':
case '?':
return <FilePlus className="w-4 h-4 text-green-500" />;
case "D":
case 'D':
return <FileX className="w-4 h-4 text-red-500" />;
case "M":
case "U":
case 'M':
case 'U':
return <FilePen className="w-4 h-4 text-amber-500" />;
case "R":
case "C":
case 'R':
case 'C':
return <File className="w-4 h-4 text-blue-500" />;
default:
return <FileText className="w-4 h-4 text-muted-foreground" />;
@@ -65,40 +64,40 @@ const getFileIcon = (status: string) => {
const getStatusBadgeColor = (status: string) => {
switch (status) {
case "A":
case "?":
return "bg-green-500/20 text-green-400 border-green-500/30";
case "D":
return "bg-red-500/20 text-red-400 border-red-500/30";
case "M":
case "U":
return "bg-amber-500/20 text-amber-400 border-amber-500/30";
case "R":
case "C":
return "bg-blue-500/20 text-blue-400 border-blue-500/30";
case 'A':
case '?':
return 'bg-green-500/20 text-green-400 border-green-500/30';
case 'D':
return 'bg-red-500/20 text-red-400 border-red-500/30';
case 'M':
case 'U':
return 'bg-amber-500/20 text-amber-400 border-amber-500/30';
case 'R':
case 'C':
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
default:
return "bg-muted text-muted-foreground border-border";
return 'bg-muted text-muted-foreground border-border';
}
};
const getStatusDisplayName = (status: string) => {
switch (status) {
case "A":
return "Added";
case "?":
return "Untracked";
case "D":
return "Deleted";
case "M":
return "Modified";
case "U":
return "Updated";
case "R":
return "Renamed";
case "C":
return "Copied";
case 'A':
return 'Added';
case '?':
return 'Untracked';
case 'D':
return 'Deleted';
case 'M':
return 'Modified';
case 'U':
return 'Updated';
case 'R':
return 'Renamed';
case 'C':
return 'Copied';
default:
return "Changed";
return 'Changed';
}
};
@@ -109,7 +108,7 @@ function parseDiff(diffText: string): ParsedFileDiff[] {
if (!diffText) return [];
const files: ParsedFileDiff[] = [];
const lines = diffText.split("\n");
const lines = diffText.split('\n');
let currentFile: ParsedFileDiff | null = null;
let currentHunk: ParsedDiffHunk | null = null;
let oldLineNum = 0;
@@ -119,7 +118,7 @@ function parseDiff(diffText: string): ParsedFileDiff[] {
const line = lines[i];
// New file diff
if (line.startsWith("diff --git")) {
if (line.startsWith('diff --git')) {
if (currentFile) {
if (currentHunk) {
currentFile.hunks.push(currentHunk);
@@ -129,7 +128,7 @@ function parseDiff(diffText: string): ParsedFileDiff[] {
// Extract file path from diff header
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
currentFile = {
filePath: match ? match[2] : "unknown",
filePath: match ? match[2] : 'unknown',
hunks: [],
};
currentHunk = null;
@@ -137,34 +136,30 @@ function parseDiff(diffText: string): ParsedFileDiff[] {
}
// New file indicator
if (line.startsWith("new file mode")) {
if (line.startsWith('new file mode')) {
if (currentFile) currentFile.isNew = true;
continue;
}
// Deleted file indicator
if (line.startsWith("deleted file mode")) {
if (line.startsWith('deleted file mode')) {
if (currentFile) currentFile.isDeleted = true;
continue;
}
// Renamed file indicator
if (line.startsWith("rename from") || line.startsWith("rename to")) {
if (line.startsWith('rename from') || line.startsWith('rename to')) {
if (currentFile) currentFile.isRenamed = true;
continue;
}
// Skip index, ---/+++ lines
if (
line.startsWith("index ") ||
line.startsWith("--- ") ||
line.startsWith("+++ ")
) {
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
continue;
}
// Hunk header
if (line.startsWith("@@")) {
if (line.startsWith('@@')) {
if (currentHunk && currentFile) {
currentFile.hunks.push(currentHunk);
}
@@ -174,31 +169,31 @@ function parseDiff(diffText: string): ParsedFileDiff[] {
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
currentHunk = {
header: line,
lines: [{ type: "header", content: line }],
lines: [{ type: 'header', content: line }],
};
continue;
}
// Diff content lines
if (currentHunk) {
if (line.startsWith("+")) {
if (line.startsWith('+')) {
currentHunk.lines.push({
type: "addition",
type: 'addition',
content: line.substring(1),
lineNumber: { new: newLineNum },
});
newLineNum++;
} else if (line.startsWith("-")) {
} else if (line.startsWith('-')) {
currentHunk.lines.push({
type: "deletion",
type: 'deletion',
content: line.substring(1),
lineNumber: { old: oldLineNum },
});
oldLineNum++;
} else if (line.startsWith(" ") || line === "") {
} else if (line.startsWith(' ') || line === '') {
currentHunk.lines.push({
type: "context",
content: line.substring(1) || "",
type: 'context',
content: line.substring(1) || '',
lineNumber: { old: oldLineNum, new: newLineNum },
});
oldLineNum++;
@@ -223,52 +218,52 @@ function DiffLine({
content,
lineNumber,
}: {
type: "context" | "addition" | "deletion" | "header";
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}) {
const bgClass = {
context: "bg-transparent",
addition: "bg-green-500/10",
deletion: "bg-red-500/10",
header: "bg-blue-500/10",
context: 'bg-transparent',
addition: 'bg-green-500/10',
deletion: 'bg-red-500/10',
header: 'bg-blue-500/10',
};
const textClass = {
context: "text-foreground-secondary",
addition: "text-green-400",
deletion: "text-red-400",
header: "text-blue-400",
context: 'text-foreground-secondary',
addition: 'text-green-400',
deletion: 'text-red-400',
header: 'text-blue-400',
};
const prefix = {
context: " ",
addition: "+",
deletion: "-",
header: "",
context: ' ',
addition: '+',
deletion: '-',
header: '',
};
if (type === "header") {
if (type === 'header') {
return (
<div className={cn("px-2 py-1 font-mono text-xs", bgClass[type], textClass[type])}>
<div className={cn('px-2 py-1 font-mono text-xs', bgClass[type], textClass[type])}>
{content}
</div>
);
}
return (
<div className={cn("flex font-mono text-xs", bgClass[type])}>
<div className={cn('flex font-mono text-xs', bgClass[type])}>
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
{lineNumber?.old ?? ""}
{lineNumber?.old ?? ''}
</span>
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
{lineNumber?.new ?? ""}
{lineNumber?.new ?? ''}
</span>
<span className={cn("w-4 flex-shrink-0 text-center select-none", textClass[type])}>
<span className={cn('w-4 flex-shrink-0 text-center select-none', textClass[type])}>
{prefix[type]}
</span>
<span className={cn("flex-1 px-2 whitespace-pre-wrap break-all", textClass[type])}>
{content || "\u00A0"}
<span className={cn('flex-1 px-2 whitespace-pre-wrap break-all', textClass[type])}>
{content || '\u00A0'}
</span>
</div>
);
@@ -284,11 +279,11 @@ function FileDiffSection({
onToggle: () => void;
}) {
const additions = fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === "addition").length,
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'addition').length,
0
);
const deletions = fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === "deletion").length,
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'deletion').length,
0
);
@@ -323,12 +318,8 @@ function FileDiffSection({
renamed
</span>
)}
{additions > 0 && (
<span className="text-xs text-green-400">+{additions}</span>
)}
{deletions > 0 && (
<span className="text-xs text-red-400">-{deletions}</span>
)}
{additions > 0 && <span className="text-xs text-green-400">+{additions}</span>}
{deletions > 0 && <span className="text-xs text-red-400">-{deletions}</span>}
</div>
</button>
{isExpanded && (
@@ -362,7 +353,7 @@ export function GitDiffPanel({
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [files, setFiles] = useState<FileStatus[]>([]);
const [diffContent, setDiffContent] = useState<string>("");
const [diffContent, setDiffContent] = useState<string>('');
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
const loadDiffs = useCallback(async () => {
@@ -374,30 +365,30 @@ export function GitDiffPanel({
// Use worktree API if worktrees are enabled, otherwise use git API for main project
if (useWorktrees) {
if (!api?.worktree?.getDiffs) {
throw new Error("Worktree API not available");
throw new Error('Worktree API not available');
}
const result = await api.worktree.getDiffs(projectPath, featureId);
if (result.success) {
setFiles(result.files || []);
setDiffContent(result.diff || "");
setDiffContent(result.diff || '');
} else {
setError(result.error || "Failed to load diffs");
setError(result.error || 'Failed to load diffs');
}
} else {
// Use git API for main project diffs
if (!api?.git?.getDiffs) {
throw new Error("Git API not available");
throw new Error('Git API not available');
}
const result = await api.git.getDiffs(projectPath);
if (result.success) {
setFiles(result.files || []);
setDiffContent(result.diff || "");
setDiffContent(result.diff || '');
} else {
setError(result.error || "Failed to load diffs");
setError(result.error || 'Failed to load diffs');
}
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load diffs");
setError(err instanceof Error ? err.message : 'Failed to load diffs');
} finally {
setIsLoading(false);
}
@@ -437,8 +428,7 @@ export function GitDiffPanel({
(acc, file) =>
acc +
file.hunks.reduce(
(hAcc, hunk) =>
hAcc + hunk.lines.filter((l) => l.type === "addition").length,
(hAcc, hunk) => hAcc + hunk.lines.filter((l) => l.type === 'addition').length,
0
),
0
@@ -447,8 +437,7 @@ export function GitDiffPanel({
(acc, file) =>
acc +
file.hunks.reduce(
(hAcc, hunk) =>
hAcc + hunk.lines.filter((l) => l.type === "deletion").length,
(hAcc, hunk) => hAcc + hunk.lines.filter((l) => l.type === 'deletion').length,
0
),
0
@@ -457,7 +446,7 @@ export function GitDiffPanel({
return (
<div
className={cn(
"rounded-xl border border-border bg-card backdrop-blur-sm overflow-hidden",
'rounded-xl border border-border bg-card backdrop-blur-sm overflow-hidden',
className
)}
data-testid="git-diff-panel"
@@ -481,14 +470,10 @@ export function GitDiffPanel({
{!isExpanded && files.length > 0 && (
<>
<span className="text-muted-foreground">
{files.length} {files.length === 1 ? "file" : "files"}
{files.length} {files.length === 1 ? 'file' : 'files'}
</span>
{totalAdditions > 0 && (
<span className="text-green-400">+{totalAdditions}</span>
)}
{totalDeletions > 0 && (
<span className="text-red-400">-{totalDeletions}</span>
)}
{totalAdditions > 0 && <span className="text-green-400">+{totalAdditions}</span>}
{totalDeletions > 0 && <span className="text-red-400">-{totalDeletions}</span>}
</>
)}
</div>
@@ -506,12 +491,7 @@ export function GitDiffPanel({
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
<AlertCircle className="w-5 h-5 text-amber-500" />
<span className="text-sm">{error}</span>
<Button
variant="ghost"
size="sm"
onClick={loadDiffs}
className="mt-2"
>
<Button variant="ghost" size="sm" onClick={loadDiffs} className="mt-2">
<RefreshCw className="w-4 h-4 mr-2" />
Retry
</Button>
@@ -528,19 +508,22 @@ export function GitDiffPanel({
<div className="flex items-center gap-4 flex-wrap">
{(() => {
// Group files by status
const statusGroups = files.reduce((acc, file) => {
const status = file.status;
if (!acc[status]) {
acc[status] = {
count: 0,
statusText: getStatusDisplayName(status),
files: []
};
}
acc[status].count += 1;
acc[status].files.push(file.path);
return acc;
}, {} as Record<string, {count: number, statusText: string, files: string[]}>);
const statusGroups = files.reduce(
(acc, file) => {
const status = file.status;
if (!acc[status]) {
acc[status] = {
count: 0,
statusText: getStatusDisplayName(status),
files: [],
};
}
acc[status].count += 1;
acc[status].files.push(file.path);
return acc;
},
{} as Record<string, { count: number; statusText: string; files: string[] }>
);
return Object.entries(statusGroups).map(([status, group]) => (
<div
@@ -552,7 +535,7 @@ export function GitDiffPanel({
{getFileIcon(status)}
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded border font-medium",
'text-xs px-1.5 py-0.5 rounded border font-medium',
getStatusBadgeColor(status)
)}
>
@@ -579,12 +562,7 @@ export function GitDiffPanel({
>
Collapse All
</Button>
<Button
variant="ghost"
size="sm"
onClick={loadDiffs}
className="text-xs h-7"
>
<Button variant="ghost" size="sm" onClick={loadDiffs} className="text-xs h-7">
<RefreshCw className="w-3 h-3 mr-1" />
Refresh
</Button>
@@ -594,17 +572,13 @@ export function GitDiffPanel({
{/* Stats */}
<div className="flex items-center gap-4 text-sm mt-2">
<span className="text-muted-foreground">
{files.length} {files.length === 1 ? "file" : "files"} changed
{files.length} {files.length === 1 ? 'file' : 'files'} changed
</span>
{totalAdditions > 0 && (
<span className="text-green-400">
+{totalAdditions} additions
</span>
<span className="text-green-400">+{totalAdditions} additions</span>
)}
{totalDeletions > 0 && (
<span className="text-red-400">
-{totalDeletions} deletions
</span>
<span className="text-red-400">-{totalDeletions} deletions</span>
)}
</div>
</div>
@@ -634,7 +608,7 @@ export function GitDiffPanel({
</span>
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded border font-medium",
'text-xs px-1.5 py-0.5 rounded border font-medium',
getStatusBadgeColor(file.status)
)}
>
@@ -642,9 +616,9 @@ export function GitDiffPanel({
</span>
</div>
<div className="px-4 py-3 text-sm text-muted-foreground bg-background border-t border-border">
{file.status === "?" ? (
{file.status === '?' ? (
<span>New file - content preview not available</span>
) : file.status === "D" ? (
) : file.status === 'D' ? (
<span>File deleted</span>
) : (
<span>Diff content not available</span>

View File

@@ -1,8 +1,8 @@
import * as React from "react"
import * as React from 'react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
interface InputProps extends React.ComponentProps<"input"> {
interface InputProps extends React.ComponentProps<'input'> {
startAddon?: React.ReactNode;
endAddon?: React.ReactNode;
}
@@ -15,17 +15,17 @@ function Input({ className, type, startAddon, endAddon, ...props }: InputProps)
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground/60 selection:bg-primary selection:text-primary-foreground bg-input border-border h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs 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/60 selection:bg-primary selection:text-primary-foreground bg-input border-border h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs 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',
// Inner shadow for depth
"shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]",
'shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]',
// Animated focus ring
"transition-[color,box-shadow,border-color] duration-200 ease-out",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
'transition-[color,box-shadow,border-color] duration-200 ease-out',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
// Adjust padding for addons
startAddon && "pl-0",
endAddon && "pr-0",
hasAddons && "border-0 shadow-none focus-visible:ring-0",
startAddon && 'pl-0',
endAddon && 'pr-0',
hasAddons && 'border-0 shadow-none focus-visible:ring-0',
className
)}
{...props}
@@ -39,12 +39,12 @@ function Input({ className, type, startAddon, endAddon, ...props }: InputProps)
return (
<div
className={cn(
"flex items-center h-9 w-full rounded-md border border-border bg-input shadow-xs",
"shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]",
"transition-[box-shadow,border-color] duration-200 ease-out",
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
"has-[input:disabled]:opacity-50 has-[input:disabled]:cursor-not-allowed",
"has-[input[aria-invalid]]:ring-destructive/20 has-[input[aria-invalid]]:border-destructive"
'flex items-center h-9 w-full rounded-md border border-border bg-input shadow-xs',
'shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]',
'transition-[box-shadow,border-color] duration-200 ease-out',
'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]',
'has-[input:disabled]:opacity-50 has-[input:disabled]:cursor-not-allowed',
'has-[input[aria-invalid]]:ring-destructive/20 has-[input[aria-invalid]]:border-destructive'
)}
>
{startAddon && (
@@ -62,4 +62,4 @@ function Input({ className, type, startAddon, endAddon, ...props }: InputProps)
);
}
export { Input }
export { Input };

View File

@@ -1,155 +1,155 @@
import * as React from "react";
import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS, parseShortcut, formatShortcut } from "@/store/app-store";
import type { KeyboardShortcuts } from "@/store/app-store";
import { cn } from "@/lib/utils";
import * as React from 'react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { CheckCircle2, X, RotateCcw, Edit2 } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
useAppStore,
DEFAULT_KEYBOARD_SHORTCUTS,
parseShortcut,
formatShortcut,
} from '@/store/app-store';
import type { KeyboardShortcuts } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { CheckCircle2, X, RotateCcw, Edit2 } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
// Detect if running on Mac
const isMac = typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const isMac =
typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0;
// Keyboard layout - US QWERTY
const KEYBOARD_ROWS = [
// Number row
[
{ key: "`", label: "`", width: 1 },
{ key: "1", label: "1", width: 1 },
{ key: "2", label: "2", width: 1 },
{ key: "3", label: "3", width: 1 },
{ key: "4", label: "4", width: 1 },
{ key: "5", label: "5", width: 1 },
{ key: "6", label: "6", width: 1 },
{ key: "7", label: "7", width: 1 },
{ key: "8", label: "8", width: 1 },
{ key: "9", label: "9", width: 1 },
{ key: "0", label: "0", width: 1 },
{ key: "-", label: "-", width: 1 },
{ key: "=", label: "=", width: 1 },
{ key: '`', label: '`', width: 1 },
{ key: '1', label: '1', width: 1 },
{ key: '2', label: '2', width: 1 },
{ key: '3', label: '3', width: 1 },
{ key: '4', label: '4', width: 1 },
{ key: '5', label: '5', width: 1 },
{ key: '6', label: '6', width: 1 },
{ key: '7', label: '7', width: 1 },
{ key: '8', label: '8', width: 1 },
{ key: '9', label: '9', width: 1 },
{ key: '0', label: '0', width: 1 },
{ key: '-', label: '-', width: 1 },
{ key: '=', label: '=', width: 1 },
],
// Top letter row
[
{ key: "Q", label: "Q", width: 1 },
{ key: "W", label: "W", width: 1 },
{ key: "E", label: "E", width: 1 },
{ key: "R", label: "R", width: 1 },
{ key: "T", label: "T", width: 1 },
{ key: "Y", label: "Y", width: 1 },
{ key: "U", label: "U", width: 1 },
{ key: "I", label: "I", width: 1 },
{ key: "O", label: "O", width: 1 },
{ key: "P", label: "P", width: 1 },
{ key: "[", label: "[", width: 1 },
{ key: "]", label: "]", width: 1 },
{ key: "\\", label: "\\", width: 1 },
{ key: 'Q', label: 'Q', width: 1 },
{ key: 'W', label: 'W', width: 1 },
{ key: 'E', label: 'E', width: 1 },
{ key: 'R', label: 'R', width: 1 },
{ key: 'T', label: 'T', width: 1 },
{ key: 'Y', label: 'Y', width: 1 },
{ key: 'U', label: 'U', width: 1 },
{ key: 'I', label: 'I', width: 1 },
{ key: 'O', label: 'O', width: 1 },
{ key: 'P', label: 'P', width: 1 },
{ key: '[', label: '[', width: 1 },
{ key: ']', label: ']', width: 1 },
{ key: '\\', label: '\\', width: 1 },
],
// Home row
[
{ key: "A", label: "A", width: 1 },
{ key: "S", label: "S", width: 1 },
{ key: "D", label: "D", width: 1 },
{ key: "F", label: "F", width: 1 },
{ key: "G", label: "G", width: 1 },
{ key: "H", label: "H", width: 1 },
{ key: "J", label: "J", width: 1 },
{ key: "K", label: "K", width: 1 },
{ key: "L", label: "L", width: 1 },
{ key: ";", label: ";", width: 1 },
{ key: 'A', label: 'A', width: 1 },
{ key: 'S', label: 'S', width: 1 },
{ key: 'D', label: 'D', width: 1 },
{ key: 'F', label: 'F', width: 1 },
{ key: 'G', label: 'G', width: 1 },
{ key: 'H', label: 'H', width: 1 },
{ key: 'J', label: 'J', width: 1 },
{ key: 'K', label: 'K', width: 1 },
{ key: 'L', label: 'L', width: 1 },
{ key: ';', label: ';', width: 1 },
{ key: "'", label: "'", width: 1 },
],
// Bottom letter row
[
{ key: "Z", label: "Z", width: 1 },
{ key: "X", label: "X", width: 1 },
{ key: "C", label: "C", width: 1 },
{ key: "V", label: "V", width: 1 },
{ key: "B", label: "B", width: 1 },
{ key: "N", label: "N", width: 1 },
{ key: "M", label: "M", width: 1 },
{ key: ",", label: ",", width: 1 },
{ key: ".", label: ".", width: 1 },
{ key: "/", label: "/", width: 1 },
{ key: 'Z', label: 'Z', width: 1 },
{ key: 'X', label: 'X', width: 1 },
{ key: 'C', label: 'C', width: 1 },
{ key: 'V', label: 'V', width: 1 },
{ key: 'B', label: 'B', width: 1 },
{ key: 'N', label: 'N', width: 1 },
{ key: 'M', label: 'M', width: 1 },
{ key: ',', label: ',', width: 1 },
{ key: '.', label: '.', width: 1 },
{ key: '/', label: '/', width: 1 },
],
];
// Map shortcut names to human-readable labels
const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
board: "Kanban Board",
agent: "Agent Runner",
spec: "Spec Editor",
context: "Context",
settings: "Settings",
profiles: "AI Profiles",
terminal: "Terminal",
toggleSidebar: "Toggle Sidebar",
addFeature: "Add Feature",
addContextFile: "Add Context File",
startNext: "Start Next",
newSession: "New Session",
openProject: "Open Project",
projectPicker: "Project Picker",
cyclePrevProject: "Prev Project",
cycleNextProject: "Next Project",
addProfile: "Add Profile",
splitTerminalRight: "Split Right",
splitTerminalDown: "Split Down",
closeTerminal: "Close Terminal",
newTerminalTab: "New Tab",
board: 'Kanban Board',
agent: 'Agent Runner',
spec: 'Spec Editor',
context: 'Context',
settings: 'Settings',
profiles: 'AI Profiles',
terminal: 'Terminal',
toggleSidebar: 'Toggle Sidebar',
addFeature: 'Add Feature',
addContextFile: 'Add Context File',
startNext: 'Start Next',
newSession: 'New Session',
openProject: 'Open Project',
projectPicker: 'Project Picker',
cyclePrevProject: 'Prev Project',
cycleNextProject: 'Next Project',
addProfile: 'Add Profile',
splitTerminalRight: 'Split Right',
splitTerminalDown: 'Split Down',
closeTerminal: 'Close Terminal',
newTerminalTab: 'New Tab',
};
// Categorize shortcuts for color coding
const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, "navigation" | "ui" | "action"> = {
board: "navigation",
agent: "navigation",
spec: "navigation",
context: "navigation",
settings: "navigation",
profiles: "navigation",
terminal: "navigation",
toggleSidebar: "ui",
addFeature: "action",
addContextFile: "action",
startNext: "action",
newSession: "action",
openProject: "action",
projectPicker: "action",
cyclePrevProject: "action",
cycleNextProject: "action",
addProfile: "action",
splitTerminalRight: "action",
splitTerminalDown: "action",
closeTerminal: "action",
newTerminalTab: "action",
const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, 'navigation' | 'ui' | 'action'> = {
board: 'navigation',
agent: 'navigation',
spec: 'navigation',
context: 'navigation',
settings: 'navigation',
profiles: 'navigation',
terminal: 'navigation',
toggleSidebar: 'ui',
addFeature: 'action',
addContextFile: 'action',
startNext: 'action',
newSession: 'action',
openProject: 'action',
projectPicker: 'action',
cyclePrevProject: 'action',
cycleNextProject: 'action',
addProfile: 'action',
splitTerminalRight: 'action',
splitTerminalDown: 'action',
closeTerminal: 'action',
newTerminalTab: 'action',
};
// Category colors
const CATEGORY_COLORS = {
navigation: {
bg: "bg-blue-500/20",
border: "border-blue-500/50",
text: "text-blue-400",
label: "Navigation",
bg: 'bg-blue-500/20',
border: 'border-blue-500/50',
text: 'text-blue-400',
label: 'Navigation',
},
ui: {
bg: "bg-purple-500/20",
border: "border-purple-500/50",
text: "text-purple-400",
label: "UI Controls",
bg: 'bg-purple-500/20',
border: 'border-purple-500/50',
text: 'text-purple-400',
label: 'UI Controls',
},
action: {
bg: "bg-green-500/20",
border: "border-green-500/50",
text: "text-green-400",
label: "Actions",
bg: 'bg-green-500/20',
border: 'border-green-500/50',
text: 'text-green-400',
label: 'Actions',
},
};
@@ -163,10 +163,13 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
const { keyboardShortcuts } = useAppStore();
// Merge with defaults to ensure new shortcuts are always shown
const mergedShortcuts = React.useMemo(() => ({
...DEFAULT_KEYBOARD_SHORTCUTS,
...keyboardShortcuts,
}), [keyboardShortcuts]);
const mergedShortcuts = React.useMemo(
() => ({
...DEFAULT_KEYBOARD_SHORTCUTS,
...keyboardShortcuts,
}),
[keyboardShortcuts]
);
// Create a reverse map: base key -> list of shortcut names (including info about modifiers)
const keyToShortcuts = React.useMemo(() => {
@@ -189,12 +192,10 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
const renderKey = (keyDef: { key: string; label: string; width: number }) => {
const normalizedKey = keyDef.key.toUpperCase();
const shortcutInfos = keyToShortcuts[normalizedKey] || [];
const shortcuts = shortcutInfos.map(s => s.name);
const shortcuts = shortcutInfos.map((s) => s.name);
const isBound = shortcuts.length > 0;
const isSelected = selectedKey?.toUpperCase() === normalizedKey;
const isModified = shortcuts.some(
(s) => mergedShortcuts[s] !== DEFAULT_KEYBOARD_SHORTCUTS[s]
);
const isModified = shortcuts.some((s) => mergedShortcuts[s] !== DEFAULT_KEYBOARD_SHORTCUTS[s]);
// Get category for coloring (use first shortcut's category if multiple)
const category = shortcuts.length > 0 ? SHORTCUT_CATEGORIES[shortcuts[0]] : null;
@@ -205,25 +206,25 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
key={keyDef.key}
onClick={() => onKeySelect?.(keyDef.key)}
className={cn(
"relative flex flex-col items-center justify-center rounded-lg border transition-all",
"h-12 min-w-11 py-1",
'relative flex flex-col items-center justify-center rounded-lg border transition-all',
'h-12 min-w-11 py-1',
keyDef.width > 1 && `w-[${keyDef.width * 2.75}rem]`,
// Base styles
!isBound && "bg-sidebar-accent/10 border-sidebar-border hover:bg-sidebar-accent/20",
!isBound && 'bg-sidebar-accent/10 border-sidebar-border hover:bg-sidebar-accent/20',
// Bound key styles
isBound && colors && `${colors.bg} ${colors.border} hover:brightness-110`,
// Selected state
isSelected && "ring-2 ring-brand-500 ring-offset-2 ring-offset-background",
isSelected && 'ring-2 ring-brand-500 ring-offset-2 ring-offset-background',
// Modified indicator
isModified && "ring-1 ring-yellow-500/50"
isModified && 'ring-1 ring-yellow-500/50'
)}
data-testid={`keyboard-key-${keyDef.key}`}
>
{/* Key label - always at top */}
<span
className={cn(
"text-sm font-mono font-bold leading-none",
isBound && colors ? colors.text : "text-muted-foreground"
'text-sm font-mono font-bold leading-none',
isBound && colors ? colors.text : 'text-muted-foreground'
)}
>
{keyDef.label}
@@ -231,17 +232,20 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
{/* Shortcut label - always takes up space to maintain consistent height */}
<span
className={cn(
"text-[9px] leading-tight text-center px-0.5 truncate max-w-full h-3 mt-0.5",
'text-[9px] leading-tight text-center px-0.5 truncate max-w-full h-3 mt-0.5',
isBound && shortcuts.length > 0
? (colors ? colors.text : "text-muted-foreground")
: "opacity-0"
? colors
? colors.text
: 'text-muted-foreground'
: 'opacity-0'
)}
>
{isBound && shortcuts.length > 0
? (shortcuts.length === 1
? (SHORTCUT_LABELS[shortcuts[0]]?.split(" ")[0] ?? shortcuts[0])
: `${shortcuts.length}x`)
: "\u00A0" // Non-breaking space to maintain height
{
isBound && shortcuts.length > 0
? shortcuts.length === 1
? (SHORTCUT_LABELS[shortcuts[0]]?.split(' ')[0] ?? shortcuts[0])
: `${shortcuts.length}x`
: '\u00A0' // Non-breaking space to maintain height
}
</span>
{isModified && (
@@ -264,10 +268,11 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
<div key={shortcut} className="flex items-center gap-2">
<span
className={cn(
"w-2 h-2 rounded-full",
SHORTCUT_CATEGORIES[shortcut] && CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]]
? CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]].bg.replace("/20", "")
: "bg-muted-foreground"
'w-2 h-2 rounded-full',
SHORTCUT_CATEGORIES[shortcut] &&
CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]]
? CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]].bg.replace('/20', '')
: 'bg-muted-foreground'
)}
/>
<span className="text-sm">{SHORTCUT_LABELS[shortcut] ?? shortcut}</span>
@@ -291,18 +296,12 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
return (
<TooltipProvider>
<div className={cn("space-y-4", className)} data-testid="keyboard-map">
<div className={cn('space-y-4', className)} data-testid="keyboard-map">
{/* Legend */}
<div className="flex flex-wrap gap-4 justify-center text-xs">
{Object.entries(CATEGORY_COLORS).map(([key, colors]) => (
<div key={key} className="flex items-center gap-2">
<div
className={cn(
"w-4 h-4 rounded border",
colors.bg,
colors.border
)}
/>
<div className={cn('w-4 h-4 rounded border', colors.bg, colors.border)} />
<span className={colors.text}>{colors.label}</span>
</div>
))}
@@ -328,19 +327,17 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
{/* Stats */}
<div className="flex justify-center gap-6 text-xs text-muted-foreground">
<span>
<strong className="text-foreground">{Object.keys(keyboardShortcuts).length}</strong> shortcuts
configured
<strong className="text-foreground">{Object.keys(keyboardShortcuts).length}</strong>{' '}
shortcuts configured
</span>
<span>
<strong className="text-foreground">
{Object.keys(keyToShortcuts).length}
</strong>{" "}
keys in use
<strong className="text-foreground">{Object.keys(keyToShortcuts).length}</strong> keys
in use
</span>
<span>
<strong className="text-foreground">
{KEYBOARD_ROWS.flat().length - Object.keys(keyToShortcuts).length}
</strong>{" "}
</strong>{' '}
keys available
</span>
</div>
@@ -356,19 +353,27 @@ interface ShortcutReferencePanelProps {
export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePanelProps) {
const { keyboardShortcuts, setKeyboardShortcut, resetKeyboardShortcuts } = useAppStore();
const [editingShortcut, setEditingShortcut] = React.useState<keyof KeyboardShortcuts | null>(null);
const [keyValue, setKeyValue] = React.useState("");
const [editingShortcut, setEditingShortcut] = React.useState<keyof KeyboardShortcuts | null>(
null
);
const [keyValue, setKeyValue] = React.useState('');
const [modifiers, setModifiers] = React.useState({ shift: false, cmdCtrl: false, alt: false });
const [shortcutError, setShortcutError] = React.useState<string | null>(null);
// Merge with defaults to ensure new shortcuts are always shown
const mergedShortcuts = React.useMemo(() => ({
...DEFAULT_KEYBOARD_SHORTCUTS,
...keyboardShortcuts,
}), [keyboardShortcuts]);
const mergedShortcuts = React.useMemo(
() => ({
...DEFAULT_KEYBOARD_SHORTCUTS,
...keyboardShortcuts,
}),
[keyboardShortcuts]
);
const groupedShortcuts = React.useMemo(() => {
const groups: Record<string, Array<{ key: keyof KeyboardShortcuts; label: string; value: string }>> = {
const groups: Record<
string,
Array<{ key: keyof KeyboardShortcuts; label: string; value: string }>
> = {
navigation: [],
ui: [],
action: [],
@@ -390,20 +395,25 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
// Build the full shortcut string from key + modifiers
const buildShortcutString = React.useCallback((key: string, mods: typeof modifiers) => {
const parts: string[] = [];
if (mods.cmdCtrl) parts.push(isMac ? "Cmd" : "Ctrl");
if (mods.alt) parts.push(isMac ? "Opt" : "Alt");
if (mods.shift) parts.push("Shift");
if (mods.cmdCtrl) parts.push(isMac ? 'Cmd' : 'Ctrl');
if (mods.alt) parts.push(isMac ? 'Opt' : 'Alt');
if (mods.shift) parts.push('Shift');
parts.push(key.toUpperCase());
return parts.join("+");
return parts.join('+');
}, []);
// Check for conflicts with other shortcuts
const checkConflict = React.useCallback((shortcutStr: string, currentKey: keyof KeyboardShortcuts) => {
const conflict = Object.entries(mergedShortcuts).find(
([k, v]) => k !== currentKey && v?.toUpperCase() === shortcutStr.toUpperCase()
);
return conflict ? (SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] ?? conflict[0]) : null;
}, [mergedShortcuts]);
const checkConflict = React.useCallback(
(shortcutStr: string, currentKey: keyof KeyboardShortcuts) => {
const conflict = Object.entries(mergedShortcuts).find(
([k, v]) => k !== currentKey && v?.toUpperCase() === shortcutStr.toUpperCase()
);
return conflict
? (SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] ?? conflict[0])
: null;
},
[mergedShortcuts]
);
const handleStartEdit = (key: keyof KeyboardShortcuts) => {
const currentValue = mergedShortcuts[key];
@@ -423,14 +433,14 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
const shortcutStr = buildShortcutString(keyValue, modifiers);
setKeyboardShortcut(editingShortcut, shortcutStr);
setEditingShortcut(null);
setKeyValue("");
setKeyValue('');
setModifiers({ shift: false, cmdCtrl: false, alt: false });
setShortcutError(null);
};
const handleCancelEdit = () => {
setEditingShortcut(null);
setKeyValue("");
setKeyValue('');
setModifiers({ shift: false, cmdCtrl: false, alt: false });
setShortcutError(null);
};
@@ -439,7 +449,7 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
setKeyValue(value);
// Check for conflicts with full shortcut string
if (!value) {
setShortcutError("Key cannot be empty");
setShortcutError('Key cannot be empty');
} else {
const shortcutStr = buildShortcutString(value, modifiers);
const conflictLabel = checkConflict(shortcutStr, currentKey);
@@ -451,7 +461,11 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
}
};
const handleModifierChange = (modifier: keyof typeof modifiers, checked: boolean, currentKey: keyof KeyboardShortcuts) => {
const handleModifierChange = (
modifier: keyof typeof modifiers,
checked: boolean,
currentKey: keyof KeyboardShortcuts
) => {
// Enforce single modifier: when checking, uncheck all others (radio-button behavior)
const newModifiers = checked
? { shift: false, cmdCtrl: false, alt: false, [modifier]: true }
@@ -472,9 +486,9 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !shortcutError && keyValue) {
if (e.key === 'Enter' && !shortcutError && keyValue) {
handleSaveShortcut();
} else if (e.key === "Escape") {
} else if (e.key === 'Escape') {
handleCancelEdit();
}
};
@@ -486,176 +500,194 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
return (
<TooltipProvider>
<div className="space-y-4" data-testid="shortcut-reference-panel">
{editable && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => resetKeyboardShortcuts()}
className="gap-2 text-xs"
data-testid="reset-all-shortcuts-button"
>
<RotateCcw className="w-3 h-3" />
Reset All to Defaults
</Button>
</div>
)}
{Object.entries(groupedShortcuts).map(([category, shortcuts]) => {
const colors = CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS];
return (
<div key={category} className="space-y-2">
<h4 className={cn("text-sm font-semibold", colors.text)}>
{colors.label}
</h4>
<div className="grid grid-cols-2 gap-2">
{shortcuts.map(({ key, label, value }) => {
const isModified = mergedShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key];
const isEditing = editingShortcut === key;
return (
<div
key={key}
className={cn(
"flex items-center justify-between p-2 rounded-lg bg-sidebar-accent/10 border transition-colors",
isEditing ? "border-brand-500" : "border-sidebar-border",
editable && !isEditing && "hover:bg-sidebar-accent/20 cursor-pointer"
)}
onClick={() => editable && !isEditing && handleStartEdit(key)}
data-testid={`shortcut-row-${key}`}
>
<span className="text-sm text-foreground">{label}</span>
<div className="flex items-center gap-2">
{isEditing ? (
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
{/* Modifier checkboxes */}
<div className="flex items-center gap-1.5 text-xs">
<div className="flex items-center gap-1">
<Checkbox
id={`mod-cmd-${key}`}
checked={modifiers.cmdCtrl}
onCheckedChange={(checked) => handleModifierChange("cmdCtrl", !!checked, key)}
className="h-3.5 w-3.5"
/>
<Label htmlFor={`mod-cmd-${key}`} className="text-xs text-muted-foreground cursor-pointer">
{isMac ? "⌘" : "Ctrl"}
</Label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id={`mod-alt-${key}`}
checked={modifiers.alt}
onCheckedChange={(checked) => handleModifierChange("alt", !!checked, key)}
className="h-3.5 w-3.5"
/>
<Label htmlFor={`mod-alt-${key}`} className="text-xs text-muted-foreground cursor-pointer">
{isMac ? "⌥" : "Alt"}
</Label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id={`mod-shift-${key}`}
checked={modifiers.shift}
onCheckedChange={(checked) => handleModifierChange("shift", !!checked, key)}
className="h-3.5 w-3.5"
/>
<Label htmlFor={`mod-shift-${key}`} className="text-xs text-muted-foreground cursor-pointer">
</Label>
</div>
</div>
<span className="text-muted-foreground">+</span>
<Input
value={keyValue}
onChange={(e) => handleKeyChange(e.target.value, key)}
onKeyDown={handleKeyDown}
className={cn(
"w-12 h-7 text-center font-mono text-xs uppercase",
shortcutError && "border-red-500 focus-visible:ring-red-500"
)}
placeholder="Key"
maxLength={1}
autoFocus
data-testid={`edit-shortcut-input-${key}`}
/>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 hover:bg-green-500/20 hover:text-green-400"
onClick={(e) => {
e.stopPropagation();
handleSaveShortcut();
}}
disabled={!!shortcutError || !keyValue}
data-testid={`save-shortcut-${key}`}
>
<CheckCircle2 className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 hover:bg-red-500/20 hover:text-red-400"
onClick={(e) => {
e.stopPropagation();
handleCancelEdit();
}}
data-testid={`cancel-shortcut-${key}`}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<>
<kbd
className={cn(
"px-2 py-1 text-xs font-mono rounded border",
colors.bg,
colors.border,
colors.text
)}
>
{formatShortcut(value, true)}
</kbd>
{isModified && editable && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 hover:bg-yellow-500/20 hover:text-yellow-400"
onClick={(e) => {
e.stopPropagation();
handleResetShortcut(key);
}}
data-testid={`reset-shortcut-${key}`}
>
<RotateCcw className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Reset to default ({DEFAULT_KEYBOARD_SHORTCUTS[key]})
</TooltipContent>
</Tooltip>
)}
{isModified && !editable && (
<span className="w-2 h-2 rounded-full bg-yellow-500" />
)}
{editable && !isModified && (
<Edit2 className="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100" />
)}
</>
)}
</div>
</div>
);
})}
</div>
{editingShortcut && shortcutError && SHORTCUT_CATEGORIES[editingShortcut] === category && (
<p className="text-xs text-red-400 mt-1">{shortcutError}</p>
)}
{editable && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => resetKeyboardShortcuts()}
className="gap-2 text-xs"
data-testid="reset-all-shortcuts-button"
>
<RotateCcw className="w-3 h-3" />
Reset All to Defaults
</Button>
</div>
);
})}
</div>
)}
{Object.entries(groupedShortcuts).map(([category, shortcuts]) => {
const colors = CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS];
return (
<div key={category} className="space-y-2">
<h4 className={cn('text-sm font-semibold', colors.text)}>{colors.label}</h4>
<div className="grid grid-cols-2 gap-2">
{shortcuts.map(({ key, label, value }) => {
const isModified = mergedShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key];
const isEditing = editingShortcut === key;
return (
<div
key={key}
className={cn(
'flex items-center justify-between p-2 rounded-lg bg-sidebar-accent/10 border transition-colors',
isEditing ? 'border-brand-500' : 'border-sidebar-border',
editable && !isEditing && 'hover:bg-sidebar-accent/20 cursor-pointer'
)}
onClick={() => editable && !isEditing && handleStartEdit(key)}
data-testid={`shortcut-row-${key}`}
>
<span className="text-sm text-foreground">{label}</span>
<div className="flex items-center gap-2">
{isEditing ? (
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
{/* Modifier checkboxes */}
<div className="flex items-center gap-1.5 text-xs">
<div className="flex items-center gap-1">
<Checkbox
id={`mod-cmd-${key}`}
checked={modifiers.cmdCtrl}
onCheckedChange={(checked) =>
handleModifierChange('cmdCtrl', !!checked, key)
}
className="h-3.5 w-3.5"
/>
<Label
htmlFor={`mod-cmd-${key}`}
className="text-xs text-muted-foreground cursor-pointer"
>
{isMac ? '⌘' : 'Ctrl'}
</Label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id={`mod-alt-${key}`}
checked={modifiers.alt}
onCheckedChange={(checked) =>
handleModifierChange('alt', !!checked, key)
}
className="h-3.5 w-3.5"
/>
<Label
htmlFor={`mod-alt-${key}`}
className="text-xs text-muted-foreground cursor-pointer"
>
{isMac ? '⌥' : 'Alt'}
</Label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id={`mod-shift-${key}`}
checked={modifiers.shift}
onCheckedChange={(checked) =>
handleModifierChange('shift', !!checked, key)
}
className="h-3.5 w-3.5"
/>
<Label
htmlFor={`mod-shift-${key}`}
className="text-xs text-muted-foreground cursor-pointer"
>
</Label>
</div>
</div>
<span className="text-muted-foreground">+</span>
<Input
value={keyValue}
onChange={(e) => handleKeyChange(e.target.value, key)}
onKeyDown={handleKeyDown}
className={cn(
'w-12 h-7 text-center font-mono text-xs uppercase',
shortcutError && 'border-red-500 focus-visible:ring-red-500'
)}
placeholder="Key"
maxLength={1}
autoFocus
data-testid={`edit-shortcut-input-${key}`}
/>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 hover:bg-green-500/20 hover:text-green-400"
onClick={(e) => {
e.stopPropagation();
handleSaveShortcut();
}}
disabled={!!shortcutError || !keyValue}
data-testid={`save-shortcut-${key}`}
>
<CheckCircle2 className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 hover:bg-red-500/20 hover:text-red-400"
onClick={(e) => {
e.stopPropagation();
handleCancelEdit();
}}
data-testid={`cancel-shortcut-${key}`}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<>
<kbd
className={cn(
'px-2 py-1 text-xs font-mono rounded border',
colors.bg,
colors.border,
colors.text
)}
>
{formatShortcut(value, true)}
</kbd>
{isModified && editable && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 hover:bg-yellow-500/20 hover:text-yellow-400"
onClick={(e) => {
e.stopPropagation();
handleResetShortcut(key);
}}
data-testid={`reset-shortcut-${key}`}
>
<RotateCcw className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Reset to default ({DEFAULT_KEYBOARD_SHORTCUTS[key]})
</TooltipContent>
</Tooltip>
)}
{isModified && !editable && (
<span className="w-2 h-2 rounded-full bg-yellow-500" />
)}
{editable && !isModified && (
<Edit2 className="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100" />
)}
</>
)}
</div>
</div>
);
})}
</div>
{editingShortcut &&
shortcutError &&
SHORTCUT_CATEGORIES[editingShortcut] === category && (
<p className="text-xs text-red-400 mt-1">{shortcutError}</p>
)}
</div>
);
})}
</div>
</TooltipProvider>
);
}

View File

@@ -1,23 +1,19 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className
)}
{...props}
/>
)
);
}
export { Label }
export { Label };

View File

@@ -1,5 +1,4 @@
import { useState, useMemo, useEffect, useRef } from "react";
import { useState, useMemo, useEffect, useRef } from 'react';
import {
ChevronDown,
ChevronRight,
@@ -24,8 +23,8 @@ import {
Circle,
Play,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
parseLogOutput,
getLogTypeColors,
@@ -33,7 +32,7 @@ import {
type LogEntry,
type LogEntryType,
type ToolCategory,
} from "@/lib/log-parser";
} from '@/lib/log-parser';
interface LogViewerProps {
output: string;
@@ -42,23 +41,23 @@ interface LogViewerProps {
const getLogIcon = (type: LogEntryType) => {
switch (type) {
case "prompt":
case 'prompt':
return <MessageSquare className="w-4 h-4" />;
case "tool_call":
case 'tool_call':
return <Wrench className="w-4 h-4" />;
case "tool_result":
case 'tool_result':
return <FileOutput className="w-4 h-4" />;
case "phase":
case 'phase':
return <Zap className="w-4 h-4" />;
case "error":
case 'error':
return <AlertCircle className="w-4 h-4" />;
case "success":
case 'success':
return <CheckCircle2 className="w-4 h-4" />;
case "warning":
case 'warning':
return <AlertTriangle className="w-4 h-4" />;
case "thinking":
case 'thinking':
return <Brain className="w-4 h-4" />;
case "debug":
case 'debug':
return <Bug className="w-4 h-4" />;
default:
return <Info className="w-4 h-4" />;
@@ -70,19 +69,19 @@ const getLogIcon = (type: LogEntryType) => {
*/
const getToolCategoryIcon = (category: ToolCategory | undefined) => {
switch (category) {
case "read":
case 'read':
return <Eye className="w-4 h-4" />;
case "edit":
case 'edit':
return <Pencil className="w-4 h-4" />;
case "write":
case 'write':
return <FileOutput className="w-4 h-4" />;
case "bash":
case 'bash':
return <Terminal className="w-4 h-4" />;
case "search":
case 'search':
return <Search className="w-4 h-4" />;
case "todo":
case 'todo':
return <ListTodo className="w-4 h-4" />;
case "task":
case 'task':
return <Layers className="w-4 h-4" />;
default:
return <Wrench className="w-4 h-4" />;
@@ -94,22 +93,22 @@ const getToolCategoryIcon = (category: ToolCategory | undefined) => {
*/
const getToolCategoryColor = (category: ToolCategory | undefined): string => {
switch (category) {
case "read":
return "text-blue-400 bg-blue-500/10 border-blue-500/30";
case "edit":
return "text-amber-400 bg-amber-500/10 border-amber-500/30";
case "write":
return "text-emerald-400 bg-emerald-500/10 border-emerald-500/30";
case "bash":
return "text-purple-400 bg-purple-500/10 border-purple-500/30";
case "search":
return "text-cyan-400 bg-cyan-500/10 border-cyan-500/30";
case "todo":
return "text-green-400 bg-green-500/10 border-green-500/30";
case "task":
return "text-indigo-400 bg-indigo-500/10 border-indigo-500/30";
case 'read':
return 'text-blue-400 bg-blue-500/10 border-blue-500/30';
case 'edit':
return 'text-amber-400 bg-amber-500/10 border-amber-500/30';
case 'write':
return 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30';
case 'bash':
return 'text-purple-400 bg-purple-500/10 border-purple-500/30';
case 'search':
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
case 'todo':
return 'text-green-400 bg-green-500/10 border-green-500/30';
case 'task':
return 'text-indigo-400 bg-indigo-500/10 border-indigo-500/30';
default:
return "text-zinc-400 bg-zinc-500/10 border-zinc-500/30";
return 'text-zinc-400 bg-zinc-500/10 border-zinc-500/30';
}
};
@@ -118,7 +117,7 @@ const getToolCategoryColor = (category: ToolCategory | undefined): string => {
*/
interface TodoItem {
content: string;
status: "pending" | "in_progress" | "completed";
status: 'pending' | 'in_progress' | 'completed';
activeForm?: string;
}
@@ -144,41 +143,41 @@ function parseTodoContent(content: string): TodoItem[] | null {
* Renders a list of todo items with status icons and colors
*/
function TodoListRenderer({ todos }: { todos: TodoItem[] }) {
const getStatusIcon = (status: TodoItem["status"]) => {
const getStatusIcon = (status: TodoItem['status']) => {
switch (status) {
case "completed":
case 'completed':
return <CheckCircle2 className="w-4 h-4 text-emerald-400" />;
case "in_progress":
case 'in_progress':
return <Loader2 className="w-4 h-4 text-amber-400 animate-spin" />;
case "pending":
case 'pending':
return <Circle className="w-4 h-4 text-zinc-500" />;
default:
return <Circle className="w-4 h-4 text-zinc-500" />;
}
};
const getStatusColor = (status: TodoItem["status"]) => {
const getStatusColor = (status: TodoItem['status']) => {
switch (status) {
case "completed":
return "text-emerald-300 line-through opacity-70";
case "in_progress":
return "text-amber-300";
case "pending":
return "text-zinc-400";
case 'completed':
return 'text-emerald-300 line-through opacity-70';
case 'in_progress':
return 'text-amber-300';
case 'pending':
return 'text-zinc-400';
default:
return "text-zinc-400";
return 'text-zinc-400';
}
};
const getStatusBadge = (status: TodoItem["status"]) => {
const getStatusBadge = (status: TodoItem['status']) => {
switch (status) {
case "completed":
case 'completed':
return (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400 ml-auto">
Done
</span>
);
case "in_progress":
case 'in_progress':
return (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400 ml-auto">
In Progress
@@ -195,21 +194,17 @@ function TodoListRenderer({ todos }: { todos: TodoItem[] }) {
<div
key={index}
className={cn(
"flex items-start gap-2 p-2 rounded-md transition-colors",
todo.status === "in_progress" && "bg-amber-500/5 border border-amber-500/20",
todo.status === "completed" && "bg-emerald-500/5",
todo.status === "pending" && "bg-zinc-800/30"
'flex items-start gap-2 p-2 rounded-md transition-colors',
todo.status === 'in_progress' && 'bg-amber-500/5 border border-amber-500/20',
todo.status === 'completed' && 'bg-emerald-500/5',
todo.status === 'pending' && 'bg-zinc-800/30'
)}
>
<div className="mt-0.5 flex-shrink-0">{getStatusIcon(todo.status)}</div>
<div className="flex-1 min-w-0">
<p className={cn("text-sm", getStatusColor(todo.status))}>
{todo.content}
</p>
{todo.status === "in_progress" && todo.activeForm && (
<p className="text-xs text-amber-400/70 mt-0.5 italic">
{todo.activeForm}
</p>
<p className={cn('text-sm', getStatusColor(todo.status))}>{todo.content}</p>
{todo.status === 'in_progress' && todo.activeForm && (
<p className="text-xs text-amber-400/70 mt-0.5 italic">{todo.activeForm}</p>
)}
</div>
{getStatusBadge(todo.status)}
@@ -230,12 +225,12 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
const hasContent = entry.content.length > 100;
// For tool_call entries, use tool-specific styling
const isToolCall = entry.type === "tool_call";
const isToolCall = entry.type === 'tool_call';
const toolCategory = entry.metadata?.toolCategory;
const toolCategoryColors = isToolCall ? getToolCategoryColor(toolCategory) : "";
const toolCategoryColors = isToolCall ? getToolCategoryColor(toolCategory) : '';
// Check if this is a TodoWrite entry and parse the todos
const isTodoWrite = entry.metadata?.toolName === "TodoWrite";
const isTodoWrite = entry.metadata?.toolName === 'TodoWrite';
const parsedTodos = useMemo(() => {
if (!isTodoWrite) return null;
return parseTodoContent(entry.content);
@@ -246,7 +241,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
// Get collapsed preview text - prefer smart summary for tool calls
const collapsedPreview = useMemo(() => {
if (isExpanded) return "";
if (isExpanded) return '';
// Use smart summary if available
if (entry.metadata?.summary) {
@@ -254,7 +249,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
}
// Fallback to truncated content
return entry.content.slice(0, 80) + (entry.content.length > 80 ? "..." : "");
return entry.content.slice(0, 80) + (entry.content.length > 80 ? '...' : '');
}, [isExpanded, entry.metadata?.summary, entry.content]);
// Format content - detect and highlight JSON
@@ -265,30 +260,30 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
// since we already show the tool name in the header badge
if (isToolCall) {
// Remove "🔧 Tool: ToolName\n" or "Tool: ToolName\n" prefix
content = content.replace(/^(?:🔧\s*)?Tool:\s*\w+\s*\n?/i, "");
content = content.replace(/^(?:🔧\s*)?Tool:\s*\w+\s*\n?/i, '');
// Remove standalone "Input:" label (keep the JSON that follows)
content = content.replace(/^Input:\s*\n?/i, "");
content = content.replace(/^Input:\s*\n?/i, '');
content = content.trim();
}
// For summary entries, remove the <summary> and </summary> tags
if (entry.title === "Summary") {
content = content.replace(/^<summary>\s*/i, "");
content = content.replace(/\s*<\/summary>\s*$/i, "");
if (entry.title === 'Summary') {
content = content.replace(/^<summary>\s*/i, '');
content = content.replace(/\s*<\/summary>\s*$/i, '');
content = content.trim();
}
// Try to find and format JSON blocks
const jsonRegex = /(\{[\s\S]*?\}|\[[\s\S]*?\])/g;
let lastIndex = 0;
const parts: { type: "text" | "json"; content: string }[] = [];
const parts: { type: 'text' | 'json'; content: string }[] = [];
let match;
while ((match = jsonRegex.exec(content)) !== null) {
// Add text before JSON
if (match.index > lastIndex) {
parts.push({
type: "text",
type: 'text',
content: content.slice(lastIndex, match.index),
});
}
@@ -297,12 +292,12 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
try {
const parsed = JSON.parse(match[1]);
parts.push({
type: "json",
type: 'json',
content: JSON.stringify(parsed, null, 2),
});
} catch {
// Not valid JSON, treat as text
parts.push({ type: "text", content: match[1] });
parts.push({ type: 'text', content: match[1] });
}
lastIndex = match.index + match[1].length;
@@ -310,25 +305,25 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
// Add remaining text
if (lastIndex < content.length) {
parts.push({ type: "text", content: content.slice(lastIndex) });
parts.push({ type: 'text', content: content.slice(lastIndex) });
}
return parts.length > 0 ? parts : [{ type: "text" as const, content }];
return parts.length > 0 ? parts : [{ type: 'text' as const, content }];
}, [entry.content, entry.title, isToolCall]);
// Get colors - use tool category colors for tool_call entries
const colorParts = toolCategoryColors.split(" ");
const textColor = isToolCall ? (colorParts[0] || "text-zinc-400") : colors.text;
const bgColor = isToolCall ? (colorParts[1] || "bg-zinc-500/10") : colors.bg;
const borderColor = isToolCall ? (colorParts[2] || "border-zinc-500/30") : colors.border;
const colorParts = toolCategoryColors.split(' ');
const textColor = isToolCall ? colorParts[0] || 'text-zinc-400' : colors.text;
const bgColor = isToolCall ? colorParts[1] || 'bg-zinc-500/10' : colors.bg;
const borderColor = isToolCall ? colorParts[2] || 'border-zinc-500/30' : colors.border;
return (
<div
className={cn(
"rounded-lg border transition-all duration-200",
'rounded-lg border transition-all duration-200',
bgColor,
borderColor,
"hover:brightness-110"
'hover:brightness-110'
)}
data-testid={`log-entry-${entry.type}`}
>
@@ -347,13 +342,18 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
<span className="w-4 flex-shrink-0" />
)}
<span className={cn("flex-shrink-0", isToolCall ? toolCategoryColors.split(" ")[0] : colors.icon)}>
<span
className={cn(
'flex-shrink-0',
isToolCall ? toolCategoryColors.split(' ')[0] : colors.icon
)}
>
{icon}
</span>
<span
className={cn(
"text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0",
'text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0',
isToolCall ? toolCategoryColors : colors.badge
)}
data-testid="log-entry-badge"
@@ -361,16 +361,11 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
{entry.title}
</span>
<span className="text-xs text-zinc-400 truncate flex-1 ml-2">
{collapsedPreview}
</span>
<span className="text-xs text-zinc-400 truncate flex-1 ml-2">{collapsedPreview}</span>
</button>
{(isExpanded || !hasContent) && (
<div
className="px-4 pb-3 pt-1"
data-testid={`log-entry-content-${entry.id}`}
>
<div className="px-4 pb-3 pt-1" data-testid={`log-entry-content-${entry.id}`}>
{/* Render TodoWrite entries with special formatting */}
{parsedTodos ? (
<TodoListRenderer todos={parsedTodos} />
@@ -378,17 +373,12 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
<div className="font-mono text-xs space-y-1">
{formattedContent.map((part, index) => (
<div key={index}>
{part.type === "json" ? (
{part.type === 'json' ? (
<pre className="bg-zinc-900/50 rounded p-2 overflow-x-auto scrollbar-styled text-xs text-primary">
{part.content}
</pre>
) : (
<pre
className={cn(
"whitespace-pre-wrap break-words",
textColor
)}
>
<pre className={cn('whitespace-pre-wrap break-words', textColor)}>
{part.content}
</pre>
)}
@@ -415,7 +405,7 @@ interface ToolCategoryStats {
export function LogViewer({ output, className }: LogViewerProps) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [searchQuery, setSearchQuery] = useState('');
const [hiddenTypes, setHiddenTypes] = useState<Set<LogEntryType>>(new Set());
const [hiddenCategories, setHiddenCategories] = useState<Set<ToolCategory>>(new Set());
// Track if user has "Expand All" mode active - new entries will auto-expand when this is true
@@ -468,7 +458,7 @@ export function LogViewer({ output, className }: LogViewerProps) {
// Calculate stats for tool categories
const stats = useMemo(() => {
const toolCalls = entries.filter((e) => e.type === "tool_call");
const toolCalls = entries.filter((e) => e.type === 'tool_call');
const byCategory: ToolCategoryStats = {
read: 0,
edit: 0,
@@ -481,14 +471,14 @@ export function LogViewer({ output, className }: LogViewerProps) {
};
toolCalls.forEach((tc) => {
const cat = tc.metadata?.toolCategory || "other";
const cat = tc.metadata?.toolCategory || 'other';
byCategory[cat]++;
});
return {
total: toolCalls.length,
byCategory,
errors: entries.filter((e) => e.type === "error").length,
errors: entries.filter((e) => e.type === 'error').length,
};
}, [entries]);
@@ -499,7 +489,7 @@ export function LogViewer({ output, className }: LogViewerProps) {
if (hiddenTypes.has(entry.type)) return false;
// Filter by hidden tool categories (for tool_call entries)
if (entry.type === "tool_call" && entry.metadata?.toolCategory) {
if (entry.type === 'tool_call' && entry.metadata?.toolCategory) {
if (hiddenCategories.has(entry.metadata.toolCategory)) return false;
}
@@ -572,7 +562,7 @@ export function LogViewer({ output, className }: LogViewerProps) {
};
const clearFilters = () => {
setSearchQuery("");
setSearchQuery('');
setHiddenTypes(new Set());
setHiddenCategories(new Set());
};
@@ -596,151 +586,156 @@ export function LogViewer({ output, className }: LogViewerProps) {
}
// Count entries by type
const typeCounts = entries.reduce((acc, entry) => {
acc[entry.type] = (acc[entry.type] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const typeCounts = entries.reduce(
(acc, entry) => {
acc[entry.type] = (acc[entry.type] || 0) + 1;
return acc;
},
{} as Record<string, number>
);
// Tool categories to display in stats bar
const toolCategoryLabels: { key: ToolCategory; label: string }[] = [
{ key: "read", label: "Read" },
{ key: "edit", label: "Edit" },
{ key: "write", label: "Write" },
{ key: "bash", label: "Bash" },
{ key: "search", label: "Search" },
{ key: "todo", label: "Todo" },
{ key: "task", label: "Task" },
{ key: "other", label: "Other" },
{ key: 'read', label: 'Read' },
{ key: 'edit', label: 'Edit' },
{ key: 'write', label: 'Write' },
{ key: 'bash', label: 'Bash' },
{ key: 'search', label: 'Search' },
{ key: 'todo', label: 'Todo' },
{ key: 'task', label: 'Task' },
{ key: 'other', label: 'Other' },
];
return (
<div className={cn("flex flex-col", className)}>
<div className={cn('flex flex-col', className)}>
{/* Sticky header with search, stats, and filters */}
{/* Use -top-4 to compensate for parent's p-4 padding, pt-4 to restore visual spacing */}
<div className="sticky -top-4 z-10 bg-zinc-950/95 backdrop-blur-sm pt-4 pb-2 space-y-2 -mx-4 px-4">
{/* Search bar */}
<div className="flex items-center gap-2 px-1" data-testid="log-search-bar">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search logs..."
className="w-full pl-8 pr-8 py-1.5 text-xs bg-zinc-900/50 border border-zinc-700/50 rounded-md text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-zinc-600"
data-testid="log-search-input"
/>
{searchQuery && (
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search logs..."
className="w-full pl-8 pr-8 py-1.5 text-xs bg-zinc-900/50 border border-zinc-700/50 rounded-md text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-zinc-600"
data-testid="log-search-input"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
data-testid="log-search-clear"
>
<X className="w-3 h-3" />
</button>
)}
</div>
{hasActiveFilters && (
<button
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
data-testid="log-search-clear"
onClick={clearFilters}
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors flex items-center gap-1"
data-testid="log-clear-filters"
>
<X className="w-3 h-3" />
Clear Filters
</button>
)}
</div>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors flex items-center gap-1"
data-testid="log-clear-filters"
>
<X className="w-3 h-3" />
Clear Filters
</button>
)}
</div>
{/* Tool category stats bar */}
{stats.total > 0 && (
<div className="flex items-center gap-1 px-1 flex-wrap" data-testid="log-stats-bar">
<span className="text-xs text-zinc-500 mr-1">
<Wrench className="w-3 h-3 inline mr-1" />
{stats.total} tools:
</span>
{toolCategoryLabels.map(({ key, label }) => {
const count = stats.byCategory[key];
if (count === 0) return null;
const isHidden = hiddenCategories.has(key);
const colorClasses = getToolCategoryColor(key);
return (
<button
key={key}
onClick={() => toggleCategoryFilter(key)}
className={cn(
"text-xs px-2 py-0.5 rounded-full border transition-all flex items-center gap-1",
colorClasses,
isHidden && "opacity-40 line-through"
)}
title={isHidden ? `Show ${label} tools` : `Hide ${label} tools`}
data-testid={`log-category-filter-${key}`}
>
{getToolCategoryIcon(key)}
<span>{count}</span>
</button>
);
})}
{stats.errors > 0 && (
<span className="text-xs px-2 py-0.5 rounded-full bg-red-500/10 text-red-400 border border-red-500/30 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{stats.errors}
{/* Tool category stats bar */}
{stats.total > 0 && (
<div className="flex items-center gap-1 px-1 flex-wrap" data-testid="log-stats-bar">
<span className="text-xs text-zinc-500 mr-1">
<Wrench className="w-3 h-3 inline mr-1" />
{stats.total} tools:
</span>
)}
</div>
)}
{/* Header with type filters and controls */}
<div className="flex items-center justify-between px-1" data-testid="log-viewer-header">
<div className="flex items-center gap-1 flex-wrap">
<Filter className="w-3 h-3 text-zinc-500 mr-1" />
{Object.entries(typeCounts).map(([type, count]) => {
const colors = getLogTypeColors(type as LogEntryType);
const isHidden = hiddenTypes.has(type as LogEntryType);
return (
<button
key={type}
onClick={() => toggleTypeFilter(type as LogEntryType)}
className={cn(
"text-xs px-2 py-0.5 rounded-full transition-all",
colors.badge,
isHidden && "opacity-40 line-through"
)}
title={isHidden ? `Show ${type}` : `Hide ${type}`}
data-testid={`log-type-filter-${type}`}
>
{type}: {count}
</button>
);
})}
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-zinc-500">
{filteredEntries.length}/{entries.length}
</span>
<button
onClick={expandAll}
className={cn(
"text-xs px-2 py-1 rounded transition-colors",
expandAllMode
? "text-primary bg-primary/20 hover:bg-primary/30"
: "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50"
{toolCategoryLabels.map(({ key, label }) => {
const count = stats.byCategory[key];
if (count === 0) return null;
const isHidden = hiddenCategories.has(key);
const colorClasses = getToolCategoryColor(key);
return (
<button
key={key}
onClick={() => toggleCategoryFilter(key)}
className={cn(
'text-xs px-2 py-0.5 rounded-full border transition-all flex items-center gap-1',
colorClasses,
isHidden && 'opacity-40 line-through'
)}
title={isHidden ? `Show ${label} tools` : `Hide ${label} tools`}
data-testid={`log-category-filter-${key}`}
>
{getToolCategoryIcon(key)}
<span>{count}</span>
</button>
);
})}
{stats.errors > 0 && (
<span className="text-xs px-2 py-0.5 rounded-full bg-red-500/10 text-red-400 border border-red-500/30 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{stats.errors}
</span>
)}
data-testid="log-expand-all"
title={expandAllMode ? "Expand All (Active - new items will auto-expand)" : "Expand All"}
>
Expand All{expandAllMode ? " (On)" : ""}
</button>
<button
onClick={collapseAll}
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
data-testid="log-collapse-all"
>
Collapse All
</button>
</div>
)}
{/* Header with type filters and controls */}
<div className="flex items-center justify-between px-1" data-testid="log-viewer-header">
<div className="flex items-center gap-1 flex-wrap">
<Filter className="w-3 h-3 text-zinc-500 mr-1" />
{Object.entries(typeCounts).map(([type, count]) => {
const colors = getLogTypeColors(type as LogEntryType);
const isHidden = hiddenTypes.has(type as LogEntryType);
return (
<button
key={type}
onClick={() => toggleTypeFilter(type as LogEntryType)}
className={cn(
'text-xs px-2 py-0.5 rounded-full transition-all',
colors.badge,
isHidden && 'opacity-40 line-through'
)}
title={isHidden ? `Show ${type}` : `Hide ${type}`}
data-testid={`log-type-filter-${type}`}
>
{type}: {count}
</button>
);
})}
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-zinc-500">
{filteredEntries.length}/{entries.length}
</span>
<button
onClick={expandAll}
className={cn(
'text-xs px-2 py-1 rounded transition-colors',
expandAllMode
? 'text-primary bg-primary/20 hover:bg-primary/30'
: 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50'
)}
data-testid="log-expand-all"
title={
expandAllMode ? 'Expand All (Active - new items will auto-expand)' : 'Expand All'
}
>
Expand All{expandAllMode ? ' (On)' : ''}
</button>
<button
onClick={collapseAll}
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
data-testid="log-collapse-all"
>
Collapse All
</button>
</div>
</div>
</div>
</div>
{/* Log entries */}
<div className="space-y-2 mt-2" data-testid="log-entries-container">
@@ -748,10 +743,7 @@ export function LogViewer({ output, className }: LogViewerProps) {
<div className="text-center py-4 text-zinc-500 text-sm">
No entries match your filters.
{hasActiveFilters && (
<button
onClick={clearFilters}
className="ml-2 text-primary hover:underline"
>
<button onClick={clearFilters} className="ml-2 text-primary hover:underline">
Clear filters
</button>
)}

View File

@@ -1,6 +1,5 @@
import ReactMarkdown from "react-markdown";
import { cn } from "@/lib/utils";
import ReactMarkdown from 'react-markdown';
import { cn } from '@/lib/utils';
interface MarkdownProps {
children: string;
@@ -15,29 +14,29 @@ export function Markdown({ children, className }: MarkdownProps) {
return (
<div
className={cn(
"prose prose-sm prose-invert max-w-none",
'prose prose-sm prose-invert max-w-none',
// Headings
"[&_h1]:text-xl [&_h1]:text-foreground [&_h1]:font-semibold [&_h1]:mt-4 [&_h1]:mb-2",
"[&_h2]:text-lg [&_h2]:text-foreground [&_h2]:font-semibold [&_h2]:mt-4 [&_h2]:mb-2",
"[&_h3]:text-base [&_h3]:text-foreground [&_h3]:font-semibold [&_h3]:mt-3 [&_h3]:mb-2",
"[&_h4]:text-sm [&_h4]:text-foreground [&_h4]:font-semibold [&_h4]:mt-2 [&_h4]:mb-1",
'[&_h1]:text-xl [&_h1]:text-foreground [&_h1]:font-semibold [&_h1]:mt-4 [&_h1]:mb-2',
'[&_h2]:text-lg [&_h2]:text-foreground [&_h2]:font-semibold [&_h2]:mt-4 [&_h2]:mb-2',
'[&_h3]:text-base [&_h3]:text-foreground [&_h3]:font-semibold [&_h3]:mt-3 [&_h3]:mb-2',
'[&_h4]:text-sm [&_h4]:text-foreground [&_h4]:font-semibold [&_h4]:mt-2 [&_h4]:mb-1',
// Paragraphs
"[&_p]:text-foreground-secondary [&_p]:leading-relaxed [&_p]:my-2",
'[&_p]:text-foreground-secondary [&_p]:leading-relaxed [&_p]:my-2',
// Lists
"[&_ul]:my-2 [&_ul]:pl-4 [&_ol]:my-2 [&_ol]:pl-4",
"[&_li]:text-foreground-secondary [&_li]:my-0.5",
'[&_ul]:my-2 [&_ul]:pl-4 [&_ol]:my-2 [&_ol]:pl-4',
'[&_li]:text-foreground-secondary [&_li]:my-0.5',
// Code
"[&_code]:text-chart-2 [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-sm",
"[&_pre]:bg-card [&_pre]:border [&_pre]:border-border [&_pre]:rounded-lg [&_pre]:my-2 [&_pre]:p-3 [&_pre]:overflow-x-auto",
"[&_pre_code]:bg-transparent [&_pre_code]:p-0",
'[&_code]:text-chart-2 [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-sm',
'[&_pre]:bg-card [&_pre]:border [&_pre]:border-border [&_pre]:rounded-lg [&_pre]:my-2 [&_pre]:p-3 [&_pre]:overflow-x-auto',
'[&_pre_code]:bg-transparent [&_pre_code]:p-0',
// Strong/Bold
"[&_strong]:text-foreground [&_strong]:font-semibold",
'[&_strong]:text-foreground [&_strong]:font-semibold',
// Links
"[&_a]:text-brand-500 [&_a]:no-underline hover:[&_a]:underline",
'[&_a]:text-brand-500 [&_a]:no-underline hover:[&_a]:underline',
// Blockquotes
"[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground [&_blockquote]:italic [&_blockquote]:my-2",
'[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground [&_blockquote]:italic [&_blockquote]:my-2',
// Horizontal rules
"[&_hr]:border-border [&_hr]:my-4",
'[&_hr]:border-border [&_hr]:my-4',
className
)}
>

View File

@@ -1,8 +1,7 @@
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
const PopoverTriggerPrimitive = PopoverPrimitive.Trigger as React.ForwardRefExoticComponent<
@@ -18,10 +17,8 @@ const PopoverContentPrimitive = PopoverPrimitive.Content as React.ForwardRefExot
} & React.RefAttributes<HTMLDivElement>
>;
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
@@ -36,12 +33,12 @@ function PopoverTrigger({
<PopoverTriggerPrimitive data-slot="popover-trigger" asChild={asChild} {...props}>
{children}
</PopoverTriggerPrimitive>
)
);
}
function PopoverContent({
className,
align = "center",
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content> & {
@@ -54,19 +51,17 @@ function PopoverContent({
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -1,22 +1,16 @@
"use client";
'use client';
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import * as React from 'react';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { Circle } from 'lucide-react';
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
);
return <RadioGroupPrimitive.Root className={cn('grid gap-2', className)} {...props} ref={ref} />;
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
@@ -28,7 +22,7 @@ const RadioGroupItem = React.forwardRef<
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
'aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
@@ -42,5 +36,3 @@ const RadioGroupItem = React.forwardRef<
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View File

@@ -1,10 +1,10 @@
"use client";
'use client';
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
@@ -38,10 +38,7 @@ const SelectScrollUpButton = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
@@ -55,29 +52,25 @@ const SelectScrollDownButton = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
@@ -86,9 +79,9 @@ const SelectContent = React.forwardRef<
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
@@ -105,7 +98,7 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
));
@@ -118,7 +111,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
@@ -140,7 +133,7 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));

View File

@@ -1,7 +1,6 @@
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
import * as React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider';
import { cn } from '@/lib/utils';
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
const SliderRootPrimitive = SliderPrimitive.Root as React.ForwardRefExoticComponent<
@@ -30,7 +29,7 @@ const SliderThumbPrimitive = SliderPrimitive.Thumb as React.ForwardRefExoticComp
} & React.RefAttributes<HTMLSpanElement>
>;
interface SliderProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "defaultValue" | "dir"> {
interface SliderProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, 'defaultValue' | 'dir'> {
value?: number[];
defaultValue?: number[];
onValueChange?: (value: number[]) => void;
@@ -39,29 +38,24 @@ interface SliderProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "defau
max?: number;
step?: number;
disabled?: boolean;
orientation?: "horizontal" | "vertical";
dir?: "ltr" | "rtl";
orientation?: 'horizontal' | 'vertical';
dir?: 'ltr' | 'rtl';
inverted?: boolean;
minStepsBetweenThumbs?: number;
}
const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(
({ className, ...props }, ref) => (
<SliderRootPrimitive
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderTrackPrimitive className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
<SliderRangePrimitive className="slider-range absolute h-full bg-primary" />
</SliderTrackPrimitive>
<SliderThumbPrimitive className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
</SliderRootPrimitive>
)
);
const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(({ className, ...props }, ref) => (
<SliderRootPrimitive
ref={ref}
className={cn('relative flex w-full touch-none select-none items-center', className)}
{...props}
>
<SliderTrackPrimitive className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
<SliderRangePrimitive className="slider-range absolute h-full bg-primary" />
</SliderTrackPrimitive>
<SliderThumbPrimitive className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
</SliderRootPrimitive>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@@ -1,9 +1,9 @@
"use client";
'use client';
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
{...props}
@@ -19,7 +19,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-foreground shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
'pointer-events-none block h-5 w-5 rounded-full bg-foreground shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>
@@ -27,5 +27,3 @@ const Switch = React.forwardRef<
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@@ -1,8 +1,7 @@
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
const TabsRootPrimitive = TabsPrimitive.Root as React.ForwardRefExoticComponent<
@@ -42,14 +41,10 @@ function Tabs({
className?: string;
}) {
return (
<TabsRootPrimitive
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
>
<TabsRootPrimitive data-slot="tabs" className={cn('flex flex-col gap-2', className)} {...props}>
{children}
</TabsRootPrimitive>
)
);
}
function TabsList({
@@ -64,14 +59,14 @@ function TabsList({
<TabsListPrimitive
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px] border border-border",
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px] border border-border',
className
)}
{...props}
>
{children}
</TabsListPrimitive>
)
);
}
function TabsTrigger({
@@ -86,11 +81,11 @@ function TabsTrigger({
<TabsTriggerPrimitive
data-slot="tabs-trigger"
className={cn(
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all duration-200 cursor-pointer",
"text-foreground/70 hover:text-foreground hover:bg-accent",
"data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:shadow-md data-[state=active]:border-primary/50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:outline-1",
"disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed",
'inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all duration-200 cursor-pointer',
'text-foreground/70 hover:text-foreground hover:bg-accent',
'data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:shadow-md data-[state=active]:border-primary/50',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:outline-1',
'disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed',
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
@@ -98,7 +93,7 @@ function TabsTrigger({
>
{children}
</TabsTriggerPrimitive>
)
);
}
function TabsContent({
@@ -112,12 +107,12 @@ function TabsContent({
return (
<TabsContentPrimitive
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
className={cn('flex-1 outline-none', className)}
{...props}
>
{children}
</TabsContentPrimitive>
)
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -1,16 +1,16 @@
"use client";
'use client';
import { useState, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils";
import { Check, Loader2, Circle, ChevronDown, ChevronRight, FileCode } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import type { AutoModeEvent } from "@/types/electron";
import { Badge } from "@/components/ui/badge";
import { useState, useEffect, useCallback } from 'react';
import { cn } from '@/lib/utils';
import { Check, Loader2, Circle, ChevronDown, ChevronRight, FileCode } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import type { AutoModeEvent } from '@/types/electron';
import { Badge } from '@/components/ui/badge';
interface TaskInfo {
id: string;
description: string;
status: "pending" | "in_progress" | "completed";
status: 'pending' | 'in_progress' | 'completed';
filePath?: string;
phase?: string;
}
@@ -53,18 +53,19 @@ export function TaskProgressPanel({ featureId, projectPath, className }: TaskPro
description: t.description,
filePath: t.filePath,
phase: t.phase,
status: index < completedCount
? "completed" as const
: t.id === currentId
? "in_progress" as const
: "pending" as const,
status:
index < completedCount
? ('completed' as const)
: t.id === currentId
? ('in_progress' as const)
: ('pending' as const),
}));
setTasks(initialTasks);
setCurrentTaskId(currentId || null);
}
} catch (error) {
console.error("Failed to load initial tasks:", error);
console.error('Failed to load initial tasks:', error);
} finally {
setIsLoading(false);
}
@@ -82,52 +83,52 @@ export function TaskProgressPanel({ featureId, projectPath, className }: TaskPro
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
// Only handle events for this feature
if (!("featureId" in event) || event.featureId !== featureId) return;
if (!('featureId' in event) || event.featureId !== featureId) return;
switch (event.type) {
case "auto_mode_task_started":
if ("taskId" in event && "taskDescription" in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_started" }>;
case 'auto_mode_task_started':
if ('taskId' in event && 'taskDescription' in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_started' }>;
setCurrentTaskId(taskEvent.taskId);
setTasks((prev) => {
// Check if task already exists
const existingIndex = prev.findIndex((t) => t.id === taskEvent.taskId);
if (existingIndex !== -1) {
// Update status to in_progress and mark previous as completed
return prev.map((t, idx) => {
if (t.id === taskEvent.taskId) {
return { ...t, status: "in_progress" as const };
}
// If we are moving to a task that is further down the list, assume previous ones are completed
// This is a heuristic, but usually correct for sequential execution
if (idx < existingIndex && t.status !== "completed") {
return { ...t, status: "completed" as const };
}
return t;
if (t.id === taskEvent.taskId) {
return { ...t, status: 'in_progress' as const };
}
// If we are moving to a task that is further down the list, assume previous ones are completed
// This is a heuristic, but usually correct for sequential execution
if (idx < existingIndex && t.status !== 'completed') {
return { ...t, status: 'completed' as const };
}
return t;
});
}
// Add new task if it doesn't exist (fallback)
return [
...prev,
{
id: taskEvent.taskId,
description: taskEvent.taskDescription,
status: "in_progress" as const,
status: 'in_progress' as const,
},
];
});
}
break;
case "auto_mode_task_complete":
if ("taskId" in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_complete" }>;
case 'auto_mode_task_complete':
if ('taskId' in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_complete' }>;
setTasks((prev) =>
prev.map((t) =>
t.id === taskEvent.taskId ? { ...t, status: "completed" as const } : t
t.id === taskEvent.taskId ? { ...t, status: 'completed' as const } : t
)
);
setCurrentTaskId(null);
@@ -139,7 +140,7 @@ export function TaskProgressPanel({ featureId, projectPath, className }: TaskPro
return unsubscribe;
}, [featureId]);
const completedCount = tasks.filter((t) => t.status === "completed").length;
const completedCount = tasks.filter((t) => t.status === 'completed').length;
const totalCount = tasks.length;
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
@@ -148,20 +149,27 @@ export function TaskProgressPanel({ featureId, projectPath, className }: TaskPro
}
return (
<div className={cn("group rounded-xl border bg-card/50 shadow-sm overflow-hidden transition-all duration-200", className)}>
<div
className={cn(
'group rounded-xl border bg-card/50 shadow-sm overflow-hidden transition-all duration-200',
className
)}
>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between p-4 bg-muted/10 hover:bg-muted/20 transition-colors"
>
<div className="flex items-center gap-3">
<div className={cn(
"flex h-8 w-8 items-center justify-center rounded-lg border shadow-sm transition-colors",
isExpanded ? "bg-background border-border" : "bg-muted border-transparent"
)}>
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-lg border shadow-sm transition-colors',
isExpanded ? 'bg-background border-border' : 'bg-muted border-transparent'
)}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-foreground/70" />
<ChevronDown className="h-4 w-4 text-foreground/70" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="flex flex-col items-start gap-0.5">
@@ -175,77 +183,104 @@ export function TaskProgressPanel({ featureId, projectPath, className }: TaskPro
<div className="flex items-center gap-3">
{/* Circular Progress (Mini) */}
<div className="relative h-8 w-8 flex items-center justify-center">
<svg className="h-full w-full -rotate-90 text-muted/20" viewBox="0 0 24 24">
<circle className="text-muted/20" cx="12" cy="12" r="10" strokeWidth="3" fill="none" stroke="currentColor" />
<circle
className="text-primary transition-all duration-500 ease-in-out"
cx="12" cy="12" r="10" strokeWidth="3" fill="none" stroke="currentColor"
strokeDasharray={63}
strokeDashoffset={63 - (63 * progressPercent) / 100}
strokeLinecap="round"
/>
</svg>
<span className="absolute text-[9px] font-bold">{progressPercent}%</span>
<svg className="h-full w-full -rotate-90 text-muted/20" viewBox="0 0 24 24">
<circle
className="text-muted/20"
cx="12"
cy="12"
r="10"
strokeWidth="3"
fill="none"
stroke="currentColor"
/>
<circle
className="text-primary transition-all duration-500 ease-in-out"
cx="12"
cy="12"
r="10"
strokeWidth="3"
fill="none"
stroke="currentColor"
strokeDasharray={63}
strokeDashoffset={63 - (63 * progressPercent) / 100}
strokeLinecap="round"
/>
</svg>
<span className="absolute text-[9px] font-bold">{progressPercent}%</span>
</div>
</div>
</button>
<div className={cn(
"grid transition-all duration-300 ease-in-out",
isExpanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
)}>
<div
className={cn(
'grid transition-all duration-300 ease-in-out',
isExpanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
)}
>
<div className="overflow-hidden">
<div className="p-5 pt-2 relative max-h-[300px] overflow-y-auto scrollbar-visible">
{/* Vertical Connector Line */}
<div className="absolute left-[2.35rem] top-4 bottom-8 w-px bg-gradient-to-b from-border/80 via-border/40 to-transparent" />
<div className="space-y-5">
{tasks.map((task, index) => {
const isActive = task.status === "in_progress";
const isCompleted = task.status === "completed";
const isPending = task.status === "pending";
const isActive = task.status === 'in_progress';
const isCompleted = task.status === 'completed';
const isPending = task.status === 'pending';
return (
<div
key={task.id}
<div
key={task.id}
className={cn(
"relative flex gap-4 group/item transition-all duration-300",
isPending && "opacity-60 hover:opacity-100"
'relative flex gap-4 group/item transition-all duration-300',
isPending && 'opacity-60 hover:opacity-100'
)}
>
{/* Icon Status */}
<div className={cn(
"relative z-10 flex h-7 w-7 items-center justify-center rounded-full border shadow-sm transition-all duration-300",
isCompleted && "bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400",
isActive && "bg-primary border-primary text-primary-foreground ring-4 ring-primary/10 scale-110",
isPending && "bg-muted border-border text-muted-foreground"
)}>
<div
className={cn(
'relative z-10 flex h-7 w-7 items-center justify-center rounded-full border shadow-sm transition-all duration-300',
isCompleted &&
'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400',
isActive &&
'bg-primary border-primary text-primary-foreground ring-4 ring-primary/10 scale-110',
isPending && 'bg-muted border-border text-muted-foreground'
)}
>
{isCompleted && <Check className="h-3.5 w-3.5" />}
{isActive && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{isPending && <Circle className="h-2 w-2 fill-current opacity-50" />}
</div>
{/* Task Content */}
<div className={cn(
"flex-1 pt-1 min-w-0 transition-all",
isActive && "translate-x-1"
)}>
<div
className={cn(
'flex-1 pt-1 min-w-0 transition-all',
isActive && 'translate-x-1'
)}
>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-4">
<p className={cn(
"text-sm font-medium leading-none truncate pr-4",
isCompleted && "text-muted-foreground line-through decoration-border/60",
isActive && "text-primary font-semibold"
)}>
{task.description}
</p>
{isActive && (
<Badge variant="outline" className="h-5 px-1.5 text-[10px] bg-primary/5 text-primary border-primary/20 animate-pulse">
Active
</Badge>
)}
<p
className={cn(
'text-sm font-medium leading-none truncate pr-4',
isCompleted &&
'text-muted-foreground line-through decoration-border/60',
isActive && 'text-primary font-semibold'
)}
>
{task.description}
</p>
{isActive && (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] bg-primary/5 text-primary border-primary/20 animate-pulse"
>
Active
</Badge>
)}
</div>
{(task.filePath || isActive) && (
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono">
{task.filePath ? (
@@ -256,7 +291,7 @@ export function TaskProgressPanel({ featureId, projectPath, className }: TaskPro
</span>
</>
) : (
<span className="h-3 block" /> /* Spacer */
<span className="h-3 block" /> /* Spacer */
)}
</div>
)}
@@ -271,4 +306,4 @@ export function TaskProgressPanel({ featureId, projectPath, className }: TaskPro
</div>
</div>
);
}
}

View File

@@ -1,24 +1,24 @@
import * as React from "react"
import * as React from 'react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
"placeholder:text-muted-foreground/60 selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-border min-h-[80px] w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm resize-none",
'placeholder:text-muted-foreground/60 selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-border min-h-[80px] w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm resize-none',
// Inner shadow for depth
"shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]",
'shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]',
// Animated focus ring
"transition-[color,box-shadow,border-color] duration-200 ease-out",
"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",
'transition-[color,box-shadow,border-color] duration-200 ease-out',
'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',
className
)}
{...props}
/>
)
);
}
export { Textarea }
export { Textarea };

View File

@@ -1,8 +1,7 @@
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
const TooltipTriggerPrimitive = TooltipPrimitive.Trigger as React.ForwardRefExoticComponent<
@@ -18,9 +17,9 @@ const TooltipContentPrimitive = TooltipPrimitive.Content as React.ForwardRefExot
} & React.RefAttributes<HTMLDivElement>
>;
const TooltipProvider = TooltipPrimitive.Provider
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root
const Tooltip = TooltipPrimitive.Root;
function TooltipTrigger({
children,
@@ -34,7 +33,7 @@ function TooltipTrigger({
<TooltipTriggerPrimitive asChild={asChild} {...props}>
{children}
</TooltipTriggerPrimitive>
)
);
}
const TooltipContent = React.forwardRef<
@@ -48,23 +47,23 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-lg border border-border bg-popover px-3 py-1.5 text-xs font-medium text-popover-foreground",
'z-50 overflow-hidden rounded-lg border border-border bg-popover px-3 py-1.5 text-xs font-medium text-popover-foreground',
// Premium shadow
"shadow-lg shadow-black/10",
'shadow-lg shadow-black/10',
// Faster, snappier animations
"animate-in fade-in-0 zoom-in-95 duration-150",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:duration-100",
'animate-in fade-in-0 zoom-in-95 duration-150',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:duration-100',
// Slide from edge
"data-[side=bottom]:slide-in-from-top-1",
"data-[side=left]:slide-in-from-right-1",
"data-[side=right]:slide-in-from-left-1",
"data-[side=top]:slide-in-from-bottom-1",
'data-[side=bottom]:slide-in-from-top-1',
'data-[side=left]:slide-in-from-right-1',
'data-[side=right]:slide-in-from-left-1',
'data-[side=top]:slide-in-from-bottom-1',
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -1,102 +1,96 @@
import CodeMirror from "@uiw/react-codemirror";
import { xml } from "@codemirror/lang-xml";
import { EditorView } from "@codemirror/view";
import { Extension } from "@codemirror/state";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags as t } from "@lezer/highlight";
import { cn } from "@/lib/utils";
import CodeMirror from '@uiw/react-codemirror';
import { xml } from '@codemirror/lang-xml';
import { EditorView } from '@codemirror/view';
import { Extension } from '@codemirror/state';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags as t } from '@lezer/highlight';
import { cn } from '@/lib/utils';
interface XmlSyntaxEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
"data-testid"?: string;
'data-testid'?: string;
}
// Syntax highlighting that uses CSS variables from the app's theme system
// This automatically adapts to any theme (dark, light, dracula, nord, etc.)
const syntaxColors = HighlightStyle.define([
// XML tags - use primary color
{ tag: t.tagName, color: "var(--primary)" },
{ tag: t.angleBracket, color: "var(--muted-foreground)" },
{ tag: t.tagName, color: 'var(--primary)' },
{ tag: t.angleBracket, color: 'var(--muted-foreground)' },
// Attributes
{ tag: t.attributeName, color: "var(--chart-2, oklch(0.6 0.118 184.704))" },
{ tag: t.attributeValue, color: "var(--chart-1, oklch(0.646 0.222 41.116))" },
{ tag: t.attributeName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
{ tag: t.attributeValue, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
// Strings and content
{ tag: t.string, color: "var(--chart-1, oklch(0.646 0.222 41.116))" },
{ tag: t.content, color: "var(--foreground)" },
{ tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
{ tag: t.content, color: 'var(--foreground)' },
// Comments
{ tag: t.comment, color: "var(--muted-foreground)", fontStyle: "italic" },
{ tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' },
// Special
{ tag: t.processingInstruction, color: "var(--muted-foreground)" },
{ tag: t.documentMeta, color: "var(--muted-foreground)" },
{ tag: t.processingInstruction, color: 'var(--muted-foreground)' },
{ tag: t.documentMeta, color: 'var(--muted-foreground)' },
]);
// Editor theme using CSS variables
const editorTheme = EditorView.theme({
"&": {
height: "100%",
fontSize: "0.875rem",
fontFamily: "ui-monospace, monospace",
backgroundColor: "transparent",
color: "var(--foreground)",
'&': {
height: '100%',
fontSize: '0.875rem',
fontFamily: 'ui-monospace, monospace',
backgroundColor: 'transparent',
color: 'var(--foreground)',
},
".cm-scroller": {
overflow: "auto",
fontFamily: "ui-monospace, monospace",
'.cm-scroller': {
overflow: 'auto',
fontFamily: 'ui-monospace, monospace',
},
".cm-content": {
padding: "1rem",
minHeight: "100%",
caretColor: "var(--primary)",
'.cm-content': {
padding: '1rem',
minHeight: '100%',
caretColor: 'var(--primary)',
},
".cm-cursor, .cm-dropCursor": {
borderLeftColor: "var(--primary)",
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--primary)',
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
{
backgroundColor: "oklch(0.55 0.25 265 / 0.3)",
},
".cm-activeLine": {
backgroundColor: "transparent",
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
},
".cm-line": {
padding: "0",
'.cm-activeLine': {
backgroundColor: 'transparent',
},
"&.cm-focused": {
outline: "none",
'.cm-line': {
padding: '0',
},
".cm-gutters": {
display: "none",
'&.cm-focused': {
outline: 'none',
},
".cm-placeholder": {
color: "var(--muted-foreground)",
fontStyle: "italic",
'.cm-gutters': {
display: 'none',
},
'.cm-placeholder': {
color: 'var(--muted-foreground)',
fontStyle: 'italic',
},
});
// Combine all extensions
const extensions: Extension[] = [
xml(),
syntaxHighlighting(syntaxColors),
editorTheme,
];
const extensions: Extension[] = [xml(), syntaxHighlighting(syntaxColors), editorTheme];
export function XmlSyntaxEditor({
value,
onChange,
placeholder,
className,
"data-testid": testId,
'data-testid': testId,
}: XmlSyntaxEditorProps) {
return (
<div className={cn("w-full h-full", className)} data-testid={testId}>
<div className={cn('w-full h-full', className)} data-testid={testId}>
<CodeMirror
value={value}
onChange={onChange}

View File

@@ -1,16 +1,9 @@
import { useState, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useState, useCallback } from 'react';
import { useAppStore } from '@/store/app-store';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
FileText,
FolderOpen,
@@ -22,9 +15,9 @@ import {
File,
Pencil,
Wrench,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getElectronAPI } from "@/lib/electron";
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
interface ToolResult {
success: boolean;
@@ -45,20 +38,18 @@ export function AgentToolsView() {
const api = getElectronAPI();
// Read File Tool State
const [readFilePath, setReadFilePath] = useState("");
const [readFilePath, setReadFilePath] = useState('');
const [readFileResult, setReadFileResult] = useState<ToolResult | null>(null);
const [isReadingFile, setIsReadingFile] = useState(false);
// Write File Tool State
const [writeFilePath, setWriteFilePath] = useState("");
const [writeFileContent, setWriteFileContent] = useState("");
const [writeFileResult, setWriteFileResult] = useState<ToolResult | null>(
null
);
const [writeFilePath, setWriteFilePath] = useState('');
const [writeFileContent, setWriteFileContent] = useState('');
const [writeFileResult, setWriteFileResult] = useState<ToolResult | null>(null);
const [isWritingFile, setIsWritingFile] = useState(false);
// Terminal Tool State
const [terminalCommand, setTerminalCommand] = useState("ls");
const [terminalCommand, setTerminalCommand] = useState('ls');
const [terminalResult, setTerminalResult] = useState<ToolResult | null>(null);
const [isRunningCommand, setIsRunningCommand] = useState(false);
@@ -85,7 +76,7 @@ export function AgentToolsView() {
} else {
setReadFileResult({
success: false,
error: result.error || "Failed to read file",
error: result.error || 'Failed to read file',
timestamp: new Date(),
});
console.log(`[Agent Tool] File read failed: ${result.error}`);
@@ -93,7 +84,7 @@ export function AgentToolsView() {
} catch (error) {
setReadFileResult({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date(),
});
} finally {
@@ -124,7 +115,7 @@ export function AgentToolsView() {
} else {
setWriteFileResult({
success: false,
error: result.error || "Failed to write file",
error: result.error || 'Failed to write file',
timestamp: new Date(),
});
console.log(`[Agent Tool] File write failed: ${result.error}`);
@@ -132,7 +123,7 @@ export function AgentToolsView() {
} catch (error) {
setWriteFileResult({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date(),
});
} finally {
@@ -154,13 +145,12 @@ export function AgentToolsView() {
// Simulated outputs for common commands (preview mode)
// In production, the agent executes commands via Claude SDK
const simulatedOutputs: Record<string, string> = {
ls: "app_spec.txt\nfeatures\nnode_modules\npackage.json\nsrc\ntests\ntsconfig.json",
pwd: currentProject?.path || "/Users/demo/project",
"echo hello": "hello",
whoami: "automaker-agent",
ls: 'app_spec.txt\nfeatures\nnode_modules\npackage.json\nsrc\ntests\ntsconfig.json',
pwd: currentProject?.path || '/Users/demo/project',
'echo hello': 'hello',
whoami: 'automaker-agent',
date: new Date().toString(),
"cat package.json":
'{\n "name": "demo-project",\n "version": "1.0.0"\n}',
'cat package.json': '{\n "name": "demo-project",\n "version": "1.0.0"\n}',
};
// Simulate command execution delay
@@ -175,13 +165,11 @@ export function AgentToolsView() {
output: output,
timestamp: new Date(),
});
console.log(
`[Agent Tool] Command executed successfully: ${terminalCommand}`
);
console.log(`[Agent Tool] Command executed successfully: ${terminalCommand}`);
} catch (error) {
setTerminalResult({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date(),
});
} finally {
@@ -191,26 +179,18 @@ export function AgentToolsView() {
if (!currentProject) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="agent-tools-no-project"
>
<div className="flex-1 flex items-center justify-center" data-testid="agent-tools-no-project">
<div className="text-center">
<Wrench className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">No Project Selected</h2>
<p className="text-muted-foreground">
Open or create a project to test agent tools.
</p>
<p className="text-muted-foreground">Open or create a project to test agent tools.</p>
</div>
</div>
);
}
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="agent-tools-view"
>
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="agent-tools-view">
{/* Header */}
<div className="flex items-center gap-3 p-4 border-b border-border bg-glass backdrop-blur-md">
<Wrench className="w-5 h-5 text-primary" />
@@ -232,9 +212,7 @@ export function AgentToolsView() {
<File className="w-5 h-5 text-blue-500" />
<CardTitle className="text-lg">Read File</CardTitle>
</div>
<CardDescription>
Agent requests to read a file from the filesystem
</CardDescription>
<CardDescription>Agent requests to read a file from the filesystem</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
@@ -270,10 +248,10 @@ export function AgentToolsView() {
{readFileResult && (
<div
className={cn(
"p-3 rounded-md border",
'p-3 rounded-md border',
readFileResult.success
? "bg-green-500/10 border-green-500/20"
: "bg-red-500/10 border-red-500/20"
? 'bg-green-500/10 border-green-500/20'
: 'bg-red-500/10 border-red-500/20'
)}
data-testid="read-file-result"
>
@@ -284,13 +262,11 @@ export function AgentToolsView() {
<XCircle className="w-4 h-4 text-red-500" />
)}
<span className="text-sm font-medium">
{readFileResult.success ? "Success" : "Failed"}
{readFileResult.success ? 'Success' : 'Failed'}
</span>
</div>
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
{readFileResult.success
? readFileResult.output
: readFileResult.error}
{readFileResult.success ? readFileResult.output : readFileResult.error}
</pre>
</div>
)}
@@ -304,9 +280,7 @@ export function AgentToolsView() {
<Pencil className="w-5 h-5 text-green-500" />
<CardTitle className="text-lg">Write File</CardTitle>
</div>
<CardDescription>
Agent requests to write content to a file
</CardDescription>
<CardDescription>Agent requests to write content to a file</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
@@ -332,11 +306,7 @@ export function AgentToolsView() {
</div>
<Button
onClick={handleWriteFile}
disabled={
isWritingFile ||
!writeFilePath.trim() ||
!writeFileContent.trim()
}
disabled={isWritingFile || !writeFilePath.trim() || !writeFileContent.trim()}
className="w-full"
data-testid="write-file-button"
>
@@ -357,10 +327,10 @@ export function AgentToolsView() {
{writeFileResult && (
<div
className={cn(
"p-3 rounded-md border",
'p-3 rounded-md border',
writeFileResult.success
? "bg-green-500/10 border-green-500/20"
: "bg-red-500/10 border-red-500/20"
? 'bg-green-500/10 border-green-500/20'
: 'bg-red-500/10 border-red-500/20'
)}
data-testid="write-file-result"
>
@@ -371,13 +341,11 @@ export function AgentToolsView() {
<XCircle className="w-4 h-4 text-red-500" />
)}
<span className="text-sm font-medium">
{writeFileResult.success ? "Success" : "Failed"}
{writeFileResult.success ? 'Success' : 'Failed'}
</span>
</div>
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
{writeFileResult.success
? writeFileResult.output
: writeFileResult.error}
{writeFileResult.success ? writeFileResult.output : writeFileResult.error}
</pre>
</div>
)}
@@ -391,9 +359,7 @@ export function AgentToolsView() {
<Terminal className="w-5 h-5 text-purple-500" />
<CardTitle className="text-lg">Run Terminal</CardTitle>
</div>
<CardDescription>
Agent requests to execute a terminal command
</CardDescription>
<CardDescription>Agent requests to execute a terminal command</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
@@ -429,10 +395,10 @@ export function AgentToolsView() {
{terminalResult && (
<div
className={cn(
"p-3 rounded-md border",
'p-3 rounded-md border',
terminalResult.success
? "bg-green-500/10 border-green-500/20"
: "bg-red-500/10 border-red-500/20"
? 'bg-green-500/10 border-green-500/20'
: 'bg-red-500/10 border-red-500/20'
)}
data-testid="terminal-result"
>
@@ -443,15 +409,13 @@ export function AgentToolsView() {
<XCircle className="w-4 h-4 text-red-500" />
)}
<span className="text-sm font-medium">
{terminalResult.success ? "Success" : "Failed"}
{terminalResult.success ? 'Success' : 'Failed'}
</span>
</div>
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/50 text-green-400 p-2 rounded">
$ {terminalCommand}
{"\n"}
{terminalResult.success
? terminalResult.output
: terminalResult.error}
{'\n'}
{terminalResult.success ? terminalResult.output : terminalResult.error}
</pre>
</div>
)}
@@ -463,15 +427,12 @@ export function AgentToolsView() {
<Card className="mt-6" data-testid="tool-log">
<CardHeader>
<CardTitle className="text-lg">Tool Execution Log</CardTitle>
<CardDescription>
View agent tool requests and responses
</CardDescription>
<CardDescription>View agent tool requests and responses</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<p className="text-muted-foreground">
Open your browser&apos;s developer console to see detailed agent
tool logs.
Open your browser&apos;s developer console to see detailed agent tool logs.
</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Read File - Agent requests file content from filesystem</li>

View File

@@ -1,16 +1,15 @@
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ImageIcon, Archive, Minimize2, Square, Maximize2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ImageIcon, Archive, Minimize2, Square, Maximize2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface BoardControlsProps {
isMounted: boolean;
onShowBoardBackground: () => void;
onShowCompletedModal: () => void;
completedCount: number;
kanbanCardDetailLevel: "minimal" | "standard" | "detailed";
onDetailLevelChange: (level: "minimal" | "standard" | "detailed") => void;
kanbanCardDetailLevel: 'minimal' | 'standard' | 'detailed';
onDetailLevelChange: (level: 'minimal' | 'standard' | 'detailed') => void;
}
export function BoardControls({
@@ -57,7 +56,7 @@ export function BoardControls({
<Archive className="w-4 h-4" />
{completedCount > 0 && (
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center">
{completedCount > 99 ? "99+" : completedCount}
{completedCount > 99 ? '99+' : completedCount}
</span>
)}
</Button>
@@ -75,12 +74,12 @@ export function BoardControls({
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onDetailLevelChange("minimal")}
onClick={() => onDetailLevelChange('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"
'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"
>
@@ -94,12 +93,12 @@ export function BoardControls({
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onDetailLevelChange("standard")}
onClick={() => onDetailLevelChange('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"
'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"
>
@@ -113,12 +112,12 @@ export function BoardControls({
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onDetailLevelChange("detailed")}
onClick={() => onDetailLevelChange('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"
'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"
>

View File

@@ -1,7 +1,6 @@
import { useRef, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Search, X, Loader2 } from "lucide-react";
import { useRef, useEffect } from 'react';
import { Input } from '@/components/ui/input';
import { Search, X, Loader2 } from 'lucide-react';
interface BoardSearchBarProps {
searchQuery: string;
@@ -25,7 +24,7 @@ export function BoardSearchBar({
const handleKeyDown = (e: KeyboardEvent) => {
// Only focus if not typing in an input/textarea
if (
e.key === "/" &&
e.key === '/' &&
!(e.target instanceof HTMLInputElement) &&
!(e.target instanceof HTMLTextAreaElement)
) {
@@ -34,8 +33,8 @@ export function BoardSearchBar({
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
return (
@@ -53,7 +52,7 @@ export function BoardSearchBar({
/>
{searchQuery ? (
<button
onClick={() => onSearchChange("")}
onClick={() => onSearchChange('')}
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"
@@ -70,19 +69,18 @@ export function BoardSearchBar({
)}
</div>
{/* Spec Creation Loading Badge */}
{isCreatingSpec &&
currentProjectPath === creatingSpecProjectPath && (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-brand-500/10 border border-brand-500/20 shrink-0"
title="Creating App Specification"
data-testid="spec-creation-badge"
>
<Loader2 className="w-3 h-3 animate-spin text-brand-500 shrink-0" />
<span className="text-xs font-medium text-brand-500 whitespace-nowrap">
Creating spec
</span>
</div>
)}
{isCreatingSpec && currentProjectPath === creatingSpecProjectPath && (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-brand-500/10 border border-brand-500/20 shrink-0"
title="Creating App Specification"
data-testid="spec-creation-badge"
>
<Loader2 className="w-3 h-3 animate-spin text-brand-500 shrink-0" />
<span className="text-xs font-medium text-brand-500 whitespace-nowrap">
Creating spec
</span>
</div>
)}
</div>
);
}

View File

@@ -1,8 +1,7 @@
import { memo } from "react";
import { useDroppable } from "@dnd-kit/core";
import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
import { memo } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { cn } from '@/lib/utils';
import type { ReactNode } from 'react';
interface KanbanColumnProps {
id: string;
@@ -39,10 +38,10 @@ export const KanbanColumn = memo(function KanbanColumn({
<div
ref={setNodeRef}
className={cn(
"relative flex flex-col h-full rounded-xl transition-all duration-200",
!width && "w-72", // Only apply w-72 if no custom width
showBorder && "border border-border/60",
isOver && "ring-2 ring-primary/30 ring-offset-1 ring-offset-background"
'relative flex flex-col h-full rounded-xl transition-all duration-200',
!width && 'w-72', // Only apply w-72 if no custom width
showBorder && 'border border-border/60',
isOver && 'ring-2 ring-primary/30 ring-offset-1 ring-offset-background'
)}
style={widthStyle}
data-testid={`kanban-column-${id}`}
@@ -50,8 +49,8 @@ export const KanbanColumn = memo(function KanbanColumn({
{/* Background layer with opacity */}
<div
className={cn(
"absolute inset-0 rounded-xl backdrop-blur-sm transition-colors duration-200",
isOver ? "bg-accent/80" : "bg-card/80"
'absolute inset-0 rounded-xl backdrop-blur-sm transition-colors duration-200',
isOver ? 'bg-accent/80' : 'bg-card/80'
)}
style={{ opacity: opacity / 100 }}
/>
@@ -59,11 +58,11 @@ export const KanbanColumn = memo(function KanbanColumn({
{/* Column Header */}
<div
className={cn(
"relative z-10 flex items-center gap-3 px-3 py-2.5",
showBorder && "border-b border-border/40"
'relative z-10 flex items-center gap-3 px-3 py-2.5',
showBorder && 'border-b border-border/40'
)}
>
<div className={cn("w-2.5 h-2.5 rounded-full shrink-0", colorClass)} />
<div className={cn('w-2.5 h-2.5 rounded-full shrink-0', colorClass)} />
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3>
{headerAction}
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
@@ -74,11 +73,11 @@ export const KanbanColumn = memo(function KanbanColumn({
{/* Column Content */}
<div
className={cn(
"relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5",
'relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5',
hideScrollbar &&
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
// Smooth scrolling
"scroll-smooth"
'scroll-smooth'
)}
>
{children}

View File

@@ -1,22 +1,22 @@
import { Feature } from "@/store/app-store";
import { Feature } from '@/store/app-store';
export type ColumnId = Feature["status"];
export type ColumnId = Feature['status'];
export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
{ id: "backlog", title: "Backlog", colorClass: "bg-[var(--status-backlog)]" },
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-[var(--status-backlog)]' },
{
id: "in_progress",
title: "In Progress",
colorClass: "bg-[var(--status-in-progress)]",
id: 'in_progress',
title: 'In Progress',
colorClass: 'bg-[var(--status-in-progress)]',
},
{
id: "waiting_approval",
title: "Waiting Approval",
colorClass: "bg-[var(--status-waiting)]",
id: 'waiting_approval',
title: 'Waiting Approval',
colorClass: 'bg-[var(--status-waiting)]',
},
{
id: "verified",
title: "Verified",
colorClass: "bg-[var(--status-success)]",
id: 'verified',
title: 'Verified',
colorClass: 'bg-[var(--status-success)]',
},
];

View File

@@ -1,5 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -7,18 +6,18 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
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 { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
} from '@/components/ui/dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
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 { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
import {
DescriptionImageDropZone,
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from "@/components/ui/description-image-dropzone";
} from '@/components/ui/description-image-dropzone';
import {
MessageSquare,
Settings2,
@@ -26,10 +25,10 @@ import {
FlaskConical,
Sparkles,
ChevronDown,
} from "lucide-react";
import { toast } from "sonner";
import { getElectronAPI } from "@/lib/electron";
import { modelSupportsThinking } from "@/lib/utils";
} from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { modelSupportsThinking } from '@/lib/utils';
import {
useAppStore,
AgentModel,
@@ -37,7 +36,7 @@ import {
FeatureImage,
AIProfile,
PlanningMode,
} from "@/store/app-store";
} from '@/store/app-store';
import {
ModelSelector,
ThinkingLevelSelector,
@@ -46,14 +45,14 @@ import {
PrioritySelector,
BranchSelector,
PlanningModeSelector,
} from "../shared";
} from '../shared';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useNavigate } from "@tanstack/react-router";
} from '@/components/ui/dropdown-menu';
import { useNavigate } from '@tanstack/react-router';
interface AddFeatureDialogProps {
open: boolean;
@@ -92,7 +91,7 @@ export function AddFeatureDialog({
branchSuggestions,
branchCardCounts,
defaultSkipTests,
defaultBranch = "main",
defaultBranch = 'main',
currentBranch,
isMaximized,
showProfilesOnly,
@@ -101,27 +100,28 @@ export function AddFeatureDialog({
const navigate = useNavigate();
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
const [newFeature, setNewFeature] = useState({
title: "",
category: "",
description: "",
steps: [""],
title: '',
category: '',
description: '',
steps: [''],
images: [] as FeatureImage[],
imagePaths: [] as DescriptionImagePath[],
skipTests: false,
model: "opus" as AgentModel,
thinkingLevel: "none" as ThinkingLevel,
branchName: "",
model: 'opus' as AgentModel,
thinkingLevel: 'none' as ThinkingLevel,
branchName: '',
priority: 2 as number, // Default to medium priority
});
const [newFeaturePreviewMap, setNewFeaturePreviewMap] =
useState<ImagePreviewMap>(() => new Map());
const [newFeaturePreviewMap, setNewFeaturePreviewMap] = useState<ImagePreviewMap>(
() => new Map()
);
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const [descriptionError, setDescriptionError] = useState(false);
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<
"improve" | "technical" | "simplify" | "acceptance"
>("improve");
const [planningMode, setPlanningMode] = useState<PlanningMode>("skip");
'improve' | 'technical' | 'simplify' | 'acceptance'
>('improve');
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
// Get enhancement model, planning mode defaults, and worktrees setting from store
@@ -144,10 +144,10 @@ export function AddFeatureDialog({
setNewFeature((prev) => ({
...prev,
skipTests: defaultSkipTests,
branchName: defaultBranch || "",
branchName: defaultBranch || '',
// Use default profile's model/thinkingLevel if set, else fallback to defaults
model: defaultProfile?.model ?? "opus",
thinkingLevel: defaultProfile?.thinkingLevel ?? "none",
model: defaultProfile?.model ?? 'opus',
thinkingLevel: defaultProfile?.thinkingLevel ?? 'none',
}));
setUseCurrentBranch(true);
setPlanningMode(defaultPlanningMode);
@@ -171,22 +171,20 @@ export function AddFeatureDialog({
// Validate branch selection when "other branch" is selected
if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) {
toast.error("Please select a branch name");
toast.error('Please select a branch name');
return;
}
const category = newFeature.category || "Uncategorized";
const category = newFeature.category || 'Uncategorized';
const selectedModel = newFeature.model;
const normalizedThinking = modelSupportsThinking(selectedModel)
? newFeature.thinkingLevel
: "none";
: 'none';
// Use current branch if toggle is on
// If currentBranch is provided (non-primary worktree), use it
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
const finalBranchName = useCurrentBranch
? currentBranch || ""
: newFeature.branchName || "";
const finalBranchName = useCurrentBranch ? currentBranch || '' : newFeature.branchName || '';
onAdd({
title: newFeature.title,
@@ -206,17 +204,17 @@ export function AddFeatureDialog({
// Reset form
setNewFeature({
title: "",
category: "",
description: "",
steps: [""],
title: '',
category: '',
description: '',
steps: [''],
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: "opus",
model: 'opus',
priority: 2,
thinkingLevel: "none",
branchName: "",
thinkingLevel: 'none',
branchName: '',
});
setUseCurrentBranch(true);
setPlanningMode(defaultPlanningMode);
@@ -251,13 +249,13 @@ export function AddFeatureDialog({
if (result?.success && result.enhancedText) {
const enhancedText = result.enhancedText;
setNewFeature((prev) => ({ ...prev, description: enhancedText }));
toast.success("Description enhanced!");
toast.success('Description enhanced!');
} else {
toast.error(result?.error || "Failed to enhance description");
toast.error(result?.error || 'Failed to enhance description');
}
} catch (error) {
console.error("Enhancement failed:", error);
toast.error("Failed to enhance description");
console.error('Enhancement failed:', error);
toast.error('Failed to enhance description');
} finally {
setIsEnhancing(false);
}
@@ -267,16 +265,11 @@ export function AddFeatureDialog({
setNewFeature({
...newFeature,
model,
thinkingLevel: modelSupportsThinking(model)
? newFeature.thinkingLevel
: "none",
thinkingLevel: modelSupportsThinking(model) ? newFeature.thinkingLevel : 'none',
});
};
const handleProfileSelect = (
model: AgentModel,
thinkingLevel: ThinkingLevel
) => {
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
setNewFeature({
...newFeature,
model,
@@ -306,14 +299,9 @@ export function AddFeatureDialog({
>
<DialogHeader>
<DialogTitle>Add New Feature</DialogTitle>
<DialogDescription>
Create a new feature card for the Kanban board.
</DialogDescription>
<DialogDescription>Create a new feature card for the Kanban board.</DialogDescription>
</DialogHeader>
<Tabs
defaultValue="prompt"
className="py-4 flex-1 min-h-0 flex flex-col"
>
<Tabs defaultValue="prompt" className="py-4 flex-1 min-h-0 flex flex-col">
<TabsList className="w-full grid grid-cols-3 mb-4">
<TabsTrigger value="prompt" data-testid="tab-prompt">
<MessageSquare className="w-4 h-4 mr-2" />
@@ -330,10 +318,7 @@ export function AddFeatureDialog({
</TabsList>
{/* Prompt Tab */}
<TabsContent
value="prompt"
className="space-y-4 overflow-y-auto cursor-default"
>
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default">
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<DescriptionImageDropZone
@@ -345,9 +330,7 @@ export function AddFeatureDialog({
}
}}
images={newFeature.imagePaths}
onImagesChange={(images) =>
setNewFeature({ ...newFeature, imagePaths: images })
}
onImagesChange={(images) => setNewFeature({ ...newFeature, imagePaths: images })}
placeholder="Describe the feature..."
previewMap={newFeaturePreviewMap}
onPreviewMapChange={setNewFeaturePreviewMap}
@@ -360,47 +343,32 @@ export function AddFeatureDialog({
<Input
id="title"
value={newFeature.title}
onChange={(e) =>
setNewFeature({ ...newFeature, title: e.target.value })
}
onChange={(e) => setNewFeature({ ...newFeature, title: e.target.value })}
placeholder="Leave blank to auto-generate"
/>
</div>
<div className="flex w-fit items-center gap-3 select-none cursor-default">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-[200px] justify-between"
>
{enhancementMode === "improve" && "Improve Clarity"}
{enhancementMode === "technical" && "Add Technical Details"}
{enhancementMode === "simplify" && "Simplify"}
{enhancementMode === "acceptance" &&
"Add Acceptance Criteria"}
<Button variant="outline" size="sm" className="w-[200px] justify-between">
{enhancementMode === 'improve' && 'Improve Clarity'}
{enhancementMode === 'technical' && 'Add Technical Details'}
{enhancementMode === 'simplify' && 'Simplify'}
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
<ChevronDown className="w-4 h-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => setEnhancementMode("improve")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("technical")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("simplify")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
Simplify
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("acceptance")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
Add Acceptance Criteria
</DropdownMenuItem>
</DropdownMenuContent>
@@ -422,9 +390,7 @@ export function AddFeatureDialog({
<Label htmlFor="category">Category (optional)</Label>
<CategoryAutocomplete
value={newFeature.category}
onChange={(value) =>
setNewFeature({ ...newFeature, category: value })
}
onChange={(value) => setNewFeature({ ...newFeature, category: value })}
suggestions={categorySuggestions}
placeholder="e.g., Core, UI, API"
data-testid="feature-category-input"
@@ -435,9 +401,7 @@ export function AddFeatureDialog({
useCurrentBranch={useCurrentBranch}
onUseCurrentBranchChange={setUseCurrentBranch}
branchName={newFeature.branchName}
onBranchNameChange={(value) =>
setNewFeature({ ...newFeature, branchName: value })
}
onBranchNameChange={(value) => setNewFeature({ ...newFeature, branchName: value })}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch}
@@ -448,25 +412,18 @@ export function AddFeatureDialog({
{/* Priority Selector */}
<PrioritySelector
selectedPriority={newFeature.priority}
onPrioritySelect={(priority) =>
setNewFeature({ ...newFeature, priority })
}
onPrioritySelect={(priority) => setNewFeature({ ...newFeature, priority })}
testIdPrefix="priority"
/>
</TabsContent>
{/* Model Tab */}
<TabsContent
value="model"
className="space-y-4 overflow-y-auto cursor-default"
>
<TabsContent value="model" className="space-y-4 overflow-y-auto cursor-default">
{/* Show Advanced Options Toggle */}
{showProfilesOnly && (
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
Simple Mode Active
</p>
<p className="text-sm font-medium text-foreground">Simple Mode Active</p>
<p className="text-xs text-muted-foreground">
Only showing AI profiles. Advanced model tweaking is hidden.
</p>
@@ -478,7 +435,7 @@ export function AddFeatureDialog({
data-testid="show-advanced-options-toggle"
>
<Settings2 className="w-4 h-4 mr-2" />
{showAdvancedOptions ? "Hide" : "Show"} Advanced
{showAdvancedOptions ? 'Hide' : 'Show'} Advanced
</Button>
</div>
)}
@@ -492,23 +449,19 @@ export function AddFeatureDialog({
showManageLink
onManageLinkClick={() => {
onOpenChange(false);
navigate({ to: "/profiles" });
navigate({ to: '/profiles' });
}}
/>
{/* Separator */}
{aiProfiles.length > 0 &&
(!showProfilesOnly || showAdvancedOptions) && (
<div className="border-t border-border" />
)}
{aiProfiles.length > 0 && (!showProfilesOnly || showAdvancedOptions) && (
<div className="border-t border-border" />
)}
{/* Claude Models Section */}
{(!showProfilesOnly || showAdvancedOptions) && (
<>
<ModelSelector
selectedModel={newFeature.model}
onModelSelect={handleModelSelect}
/>
<ModelSelector selectedModel={newFeature.model} onModelSelect={handleModelSelect} />
{newModelAllowsThinking && (
<ThinkingLevelSelector
selectedLevel={newFeature.thinkingLevel}
@@ -522,10 +475,7 @@ export function AddFeatureDialog({
</TabsContent>
{/* Options Tab */}
<TabsContent
value="options"
className="space-y-4 overflow-y-auto cursor-default"
>
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default">
{/* Planning Mode Section */}
<PlanningModeSelector
mode={planningMode}
@@ -542,9 +492,7 @@ export function AddFeatureDialog({
{/* Testing Section */}
<TestingTabContent
skipTests={newFeature.skipTests}
onSkipTestsChange={(skipTests) =>
setNewFeature({ ...newFeature, skipTests })
}
onSkipTestsChange={(skipTests) => setNewFeature({ ...newFeature, skipTests })}
steps={newFeature.steps}
onStepsChange={(steps) => setNewFeature({ ...newFeature, steps })}
/>
@@ -556,12 +504,10 @@ export function AddFeatureDialog({
</Button>
<HotkeyButton
onClick={handleAdd}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open}
data-testid="confirm-add-feature"
disabled={
useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()
}
disabled={useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()}
>
Add Feature
</HotkeyButton>

View File

@@ -1,4 +1,4 @@
"use client";
'use client';
import {
Dialog,
@@ -7,9 +7,9 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Archive } from "lucide-react";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Archive } from 'lucide-react';
interface ArchiveAllVerifiedDialogProps {
open: boolean;
@@ -30,8 +30,8 @@ export function ArchiveAllVerifiedDialog({
<DialogHeader>
<DialogTitle>Archive All Verified Features</DialogTitle>
<DialogDescription>
Are you sure you want to archive all verified features? They will be
moved to the archive box.
Are you sure you want to archive all verified features? They will be moved to the
archive box.
{verifiedCount > 0 && (
<span className="block mt-2 text-yellow-500">
{verifiedCount} feature(s) will be archived.
@@ -52,5 +52,3 @@ export function ArchiveAllVerifiedDialog({
</Dialog>
);
}

View File

@@ -1,5 +1,4 @@
import { useState } from "react";
import { useState } from 'react';
import {
Dialog,
DialogContent,
@@ -7,13 +6,13 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { GitCommit, Loader2 } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { GitCommit, Loader2 } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
interface WorktreeInfo {
path: string;
@@ -36,7 +35,7 @@ export function CommitWorktreeDialog({
worktree,
onCommitted,
}: CommitWorktreeDialogProps) {
const [message, setMessage] = useState("");
const [message, setMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -49,36 +48,36 @@ export function CommitWorktreeDialog({
try {
const api = getElectronAPI();
if (!api?.worktree?.commit) {
setError("Worktree API not available");
setError('Worktree API not available');
return;
}
const result = await api.worktree.commit(worktree.path, message);
if (result.success && result.result) {
if (result.result.committed) {
toast.success("Changes committed", {
toast.success('Changes committed', {
description: `Commit ${result.result.commitHash} on ${result.result.branch}`,
});
onCommitted();
onOpenChange(false);
setMessage("");
setMessage('');
} else {
toast.info("No changes to commit", {
toast.info('No changes to commit', {
description: result.result.message,
});
}
} else {
setError(result.error || "Failed to commit changes");
setError(result.error || 'Failed to commit changes');
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to commit");
setError(err instanceof Error ? err.message : 'Failed to commit');
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && e.metaKey && !isLoading && message.trim()) {
if (e.key === 'Enter' && e.metaKey && !isLoading && message.trim()) {
handleCommit();
}
};
@@ -94,15 +93,12 @@ export function CommitWorktreeDialog({
Commit Changes
</DialogTitle>
<DialogDescription>
Commit changes in the{" "}
<code className="font-mono bg-muted px-1 rounded">
{worktree.branch}
</code>{" "}
worktree.
Commit changes in the{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> worktree.
{worktree.changedFilesCount && (
<span className="ml-1">
({worktree.changedFilesCount} file
{worktree.changedFilesCount > 1 ? "s" : ""} changed)
{worktree.changedFilesCount > 1 ? 's' : ''} changed)
</span>
)}
</DialogDescription>
@@ -132,17 +128,10 @@ export function CommitWorktreeDialog({
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
<Button
onClick={handleCommit}
disabled={isLoading || !message.trim()}
>
<Button onClick={handleCommit} disabled={isLoading || !message.trim()}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />

View File

@@ -1,4 +1,3 @@
import {
Dialog,
DialogContent,
@@ -6,11 +5,11 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { ArchiveRestore, Trash2 } from "lucide-react";
import { Feature } from "@/store/app-store";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { ArchiveRestore, Trash2 } from 'lucide-react';
import { Feature } from '@/store/app-store';
interface CompletedFeaturesModalProps {
open: boolean;
@@ -37,9 +36,9 @@ export function CompletedFeaturesModal({
<DialogTitle>Completed Features</DialogTitle>
<DialogDescription>
{completedFeatures.length === 0
? "No completed features yet."
? 'No completed features yet.'
: `${completedFeatures.length} completed feature${
completedFeatures.length > 1 ? "s" : ""
completedFeatures.length > 1 ? 's' : ''
}`}
</DialogDescription>
</DialogHeader>
@@ -62,7 +61,7 @@ export function CompletedFeaturesModal({
{feature.description || feature.summary || feature.id}
</CardTitle>
<CardDescription className="text-xs mt-1 truncate">
{feature.category || "Uncategorized"}
{feature.category || 'Uncategorized'}
</CardDescription>
</CardHeader>
<div className="p-3 pt-0 flex gap-2">

View File

@@ -1,5 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -7,13 +6,13 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
import { GitBranchPlus, Loader2 } from "lucide-react";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { GitBranchPlus, Loader2 } from 'lucide-react';
interface WorktreeInfo {
path: string;
@@ -36,14 +35,14 @@ export function CreateBranchDialog({
worktree,
onCreated,
}: CreateBranchDialogProps) {
const [branchName, setBranchName] = useState("");
const [branchName, setBranchName] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset state when dialog opens/closes
useEffect(() => {
if (open) {
setBranchName("");
setBranchName('');
setError(null);
}
}, [open]);
@@ -54,7 +53,7 @@ export function CreateBranchDialog({
// Basic validation
const invalidChars = /[\s~^:?*[\]\\]/;
if (invalidChars.test(branchName)) {
setError("Branch name contains invalid characters");
setError('Branch name contains invalid characters');
return;
}
@@ -64,7 +63,7 @@ export function CreateBranchDialog({
try {
const api = getElectronAPI();
if (!api?.worktree?.checkoutBranch) {
toast.error("Branch API not available");
toast.error('Branch API not available');
return;
}
@@ -75,11 +74,11 @@ export function CreateBranchDialog({
onCreated();
onOpenChange(false);
} else {
setError(result.error || "Failed to create branch");
setError(result.error || 'Failed to create branch');
}
} catch (err) {
console.error("Create branch failed:", err);
setError("Failed to create branch");
console.error('Create branch failed:', err);
setError('Failed to create branch');
} finally {
setIsCreating(false);
}
@@ -94,7 +93,10 @@ export function CreateBranchDialog({
Create New Branch
</DialogTitle>
<DialogDescription>
Create a new branch from <span className="font-mono text-foreground">{worktree?.branch || "current branch"}</span>
Create a new branch from{' '}
<span className="font-mono text-foreground">
{worktree?.branch || 'current branch'}
</span>
</DialogDescription>
</DialogHeader>
@@ -110,38 +112,29 @@ export function CreateBranchDialog({
setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && branchName.trim() && !isCreating) {
if (e.key === 'Enter' && branchName.trim() && !isCreating) {
handleCreate();
}
}}
disabled={isCreating}
autoFocus
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isCreating}
>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isCreating}>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={!branchName.trim() || isCreating}
>
<Button onClick={handleCreate} disabled={!branchName.trim() || isCreating}>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
"Create Branch"
'Create Branch'
)}
</Button>
</DialogFooter>

View File

@@ -1,5 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef } from 'react';
import {
Dialog,
DialogContent,
@@ -7,15 +6,15 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { GitPullRequest, Loader2, ExternalLink } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { GitPullRequest, Loader2, ExternalLink } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
interface WorktreeInfo {
path: string;
@@ -40,10 +39,10 @@ export function CreatePRDialog({
projectPath,
onCreated,
}: CreatePRDialogProps) {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [baseBranch, setBaseBranch] = useState("main");
const [commitMessage, setCommitMessage] = useState("");
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [baseBranch, setBaseBranch] = useState('main');
const [commitMessage, setCommitMessage] = useState('');
const [isDraft, setIsDraft] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -57,10 +56,10 @@ export function CreatePRDialog({
useEffect(() => {
if (open) {
// Reset form fields
setTitle("");
setBody("");
setCommitMessage("");
setBaseBranch("main");
setTitle('');
setBody('');
setCommitMessage('');
setBaseBranch('main');
setIsDraft(false);
setError(null);
// Also reset result states when opening for a new worktree
@@ -72,10 +71,10 @@ export function CreatePRDialog({
operationCompletedRef.current = false;
} else {
// Reset everything when dialog closes
setTitle("");
setBody("");
setCommitMessage("");
setBaseBranch("main");
setTitle('');
setBody('');
setCommitMessage('');
setBaseBranch('main');
setIsDraft(false);
setError(null);
setPrUrl(null);
@@ -94,7 +93,7 @@ export function CreatePRDialog({
try {
const api = getElectronAPI();
if (!api?.worktree?.createPR) {
setError("Worktree API not available");
setError('Worktree API not available');
return;
}
const result = await api.worktree.createPR(worktree.path, {
@@ -114,19 +113,19 @@ export function CreatePRDialog({
// Show different message based on whether PR already existed
if (result.result.prAlreadyExisted) {
toast.success("Pull request found!", {
toast.success('Pull request found!', {
description: `PR already exists for ${result.result.branch}`,
action: {
label: "View PR",
onClick: () => window.open(result.result!.prUrl!, "_blank"),
label: 'View PR',
onClick: () => window.open(result.result!.prUrl!, '_blank'),
},
});
} else {
toast.success("Pull request created!", {
toast.success('Pull request created!', {
description: `PR created from ${result.result.branch}`,
action: {
label: "View PR",
onClick: () => window.open(result.result!.prUrl!, "_blank"),
label: 'View PR',
onClick: () => window.open(result.result!.prUrl!, '_blank'),
},
});
}
@@ -140,12 +139,12 @@ export function CreatePRDialog({
// Check if we should show browser fallback
if (!result.result.prCreated && hasBrowserUrl) {
// If gh CLI is not available, show browser fallback UI
if (prError === "gh_cli_not_available" || !result.result.ghCliAvailable) {
if (prError === 'gh_cli_not_available' || !result.result.ghCliAvailable) {
setBrowserUrl(result.result.browserUrl ?? null);
setShowBrowserFallback(true);
// Mark operation as completed - branch was pushed successfully
operationCompletedRef.current = true;
toast.success("Branch pushed", {
toast.success('Branch pushed', {
description: result.result.committed
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
: `Branch ${result.result.branch} pushed`,
@@ -159,11 +158,12 @@ export function CreatePRDialog({
if (prError) {
// Parse common gh CLI errors for better messages
let errorMessage = prError;
if (prError.includes("No commits between")) {
errorMessage = "No new commits to create PR. Make sure your branch has changes compared to the base branch.";
} else if (prError.includes("already exists")) {
errorMessage = "A pull request already exists for this branch.";
} else if (prError.includes("not logged in") || prError.includes("auth")) {
if (prError.includes('No commits between')) {
errorMessage =
'No new commits to create PR. Make sure your branch has changes compared to the base branch.';
} else if (prError.includes('already exists')) {
errorMessage = 'A pull request already exists for this branch.';
} else if (prError.includes('not logged in') || prError.includes('auth')) {
errorMessage = "GitHub CLI not authenticated. Run 'gh auth login' in terminal.";
}
@@ -172,7 +172,7 @@ export function CreatePRDialog({
setShowBrowserFallback(true);
// Mark operation as completed - branch was pushed even though PR creation failed
operationCompletedRef.current = true;
toast.error("PR creation failed", {
toast.error('PR creation failed', {
description: errorMessage,
duration: 8000,
});
@@ -183,7 +183,7 @@ export function CreatePRDialog({
}
// Show success toast for push
toast.success("Branch pushed", {
toast.success('Branch pushed', {
description: result.result.committed
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
: `Branch ${result.result.branch} pushed`,
@@ -192,8 +192,9 @@ export function CreatePRDialog({
// No browser URL available, just close
if (!result.result.prCreated) {
if (!hasBrowserUrl) {
toast.info("PR not created", {
description: "Could not determine repository URL. GitHub CLI (gh) may not be installed or authenticated.",
toast.info('PR not created', {
description:
'Could not determine repository URL. GitHub CLI (gh) may not be installed or authenticated.',
duration: 8000,
});
}
@@ -202,10 +203,10 @@ export function CreatePRDialog({
onOpenChange(false);
}
} else {
setError(result.error || "Failed to create pull request");
setError(result.error || 'Failed to create pull request');
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create PR");
setError(err instanceof Error ? err.message : 'Failed to create PR');
} finally {
setIsLoading(false);
}
@@ -235,10 +236,8 @@ export function CreatePRDialog({
Create Pull Request
</DialogTitle>
<DialogDescription>
Push changes and create a pull request from{" "}
<code className="font-mono bg-muted px-1 rounded">
{worktree.branch}
</code>
Push changes and create a pull request from{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
</DialogDescription>
</DialogHeader>
@@ -249,15 +248,10 @@ export function CreatePRDialog({
</div>
<div>
<h3 className="text-lg font-semibold">Pull Request Created!</h3>
<p className="text-sm text-muted-foreground mt-1">
Your PR is ready for review
</p>
<p className="text-sm text-muted-foreground mt-1">Your PR is ready for review</p>
</div>
<div className="flex gap-2 justify-center">
<Button
onClick={() => window.open(prUrl, "_blank")}
className="gap-2"
>
<Button onClick={() => window.open(prUrl, '_blank')} className="gap-2">
<ExternalLink className="w-4 h-4" />
View Pull Request
</Button>
@@ -283,7 +277,7 @@ export function CreatePRDialog({
<Button
onClick={() => {
if (browserUrl) {
window.open(browserUrl, "_blank");
window.open(browserUrl, '_blank');
}
}}
className="gap-2 w-full"
@@ -292,11 +286,10 @@ export function CreatePRDialog({
<ExternalLink className="w-4 h-4" />
Create PR in Browser
</Button>
<div className="p-2 bg-muted rounded text-xs break-all font-mono">
{browserUrl}
</div>
<div className="p-2 bg-muted rounded text-xs break-all font-mono">{browserUrl}</div>
<p className="text-xs text-muted-foreground">
Tip: Install the GitHub CLI (<code className="bg-muted px-1 rounded">gh</code>) to create PRs directly from the app
Tip: Install the GitHub CLI (<code className="bg-muted px-1 rounded">gh</code>) to
create PRs directly from the app
</p>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={handleClose}>
@@ -311,8 +304,7 @@ export function CreatePRDialog({
{worktree.hasChanges && (
<div className="grid gap-2">
<Label htmlFor="commit-message">
Commit Message{" "}
<span className="text-muted-foreground">(optional)</span>
Commit Message <span className="text-muted-foreground">(optional)</span>
</Label>
<Input
id="commit-message"
@@ -322,8 +314,7 @@ export function CreatePRDialog({
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
{worktree.changedFilesCount} uncommitted file(s) will be
committed
{worktree.changedFilesCount} uncommitted file(s) will be committed
</p>
</div>
)}
@@ -374,9 +365,7 @@ export function CreatePRDialog({
</div>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>

View File

@@ -1,5 +1,4 @@
import { useState } from "react";
import { useState } from 'react';
import {
Dialog,
DialogContent,
@@ -7,13 +6,13 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { GitBranch, Loader2 } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { GitBranch, Loader2 } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
interface CreatedWorktreeInfo {
path: string;
@@ -33,13 +32,13 @@ export function CreateWorktreeDialog({
projectPath,
onCreated,
}: CreateWorktreeDialogProps) {
const [branchName, setBranchName] = useState("");
const [branchName, setBranchName] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCreate = async () => {
if (!branchName.trim()) {
setError("Branch name is required");
setError('Branch name is required');
return;
}
@@ -47,7 +46,7 @@ export function CreateWorktreeDialog({
const validBranchRegex = /^[a-zA-Z0-9._/-]+$/;
if (!validBranchRegex.test(branchName)) {
setError(
"Invalid branch name. Use only letters, numbers, dots, underscores, hyphens, and slashes."
'Invalid branch name. Use only letters, numbers, dots, underscores, hyphens, and slashes.'
);
return;
}
@@ -58,35 +57,30 @@ export function CreateWorktreeDialog({
try {
const api = getElectronAPI();
if (!api?.worktree?.create) {
setError("Worktree API not available");
setError('Worktree API not available');
return;
}
const result = await api.worktree.create(projectPath, branchName);
if (result.success && result.worktree) {
toast.success(
`Worktree created for branch "${result.worktree.branch}"`,
{
description: result.worktree.isNew
? "New branch created"
: "Using existing branch",
}
);
toast.success(`Worktree created for branch "${result.worktree.branch}"`, {
description: result.worktree.isNew ? 'New branch created' : 'Using existing branch',
});
onCreated({ path: result.worktree.path, branch: result.worktree.branch });
onOpenChange(false);
setBranchName("");
setBranchName('');
} else {
setError(result.error || "Failed to create worktree");
setError(result.error || 'Failed to create worktree');
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create worktree");
setError(err instanceof Error ? err.message : 'Failed to create worktree');
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isLoading && branchName.trim()) {
if (e.key === 'Enter' && !isLoading && branchName.trim()) {
handleCreate();
}
};
@@ -100,8 +94,8 @@ export function CreateWorktreeDialog({
Create New Worktree
</DialogTitle>
<DialogDescription>
Create a new git worktree with its own branch. This allows you to
work on multiple features in parallel.
Create a new git worktree with its own branch. This allows you to work on multiple
features in parallel.
</DialogDescription>
</DialogHeader>
@@ -140,17 +134,10 @@ export function CreateWorktreeDialog({
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={isLoading || !branchName.trim()}
>
<Button onClick={handleCreate} disabled={isLoading || !branchName.trim()}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />

View File

@@ -1,4 +1,3 @@
import {
Dialog,
DialogContent,
@@ -6,9 +5,9 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Trash2 } from 'lucide-react';
interface DeleteAllVerifiedDialogProps {
open: boolean;
@@ -29,8 +28,7 @@ export function DeleteAllVerifiedDialog({
<DialogHeader>
<DialogTitle>Delete All Verified Features</DialogTitle>
<DialogDescription>
Are you sure you want to delete all verified features? This action
cannot be undone.
Are you sure you want to delete all verified features? This action cannot be undone.
{verifiedCount > 0 && (
<span className="block mt-2 text-yellow-500">
{verifiedCount} feature(s) will be deleted.
@@ -42,7 +40,11 @@ export function DeleteAllVerifiedDialog({
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm} data-testid="confirm-delete-all-verified">
<Button
variant="destructive"
onClick={onConfirm}
data-testid="confirm-delete-all-verified"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete All
</Button>

View File

@@ -1,4 +1,3 @@
import {
Dialog,
DialogContent,
@@ -6,10 +5,10 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { Feature } from "@/store/app-store";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Trash2 } from 'lucide-react';
import { Feature } from '@/store/app-store';
interface DeleteCompletedFeatureDialogProps {
feature: Feature | null;
@@ -36,7 +35,7 @@ export function DeleteCompletedFeatureDialog({
Are you sure you want to permanently delete this feature?
<span className="block mt-2 font-medium text-foreground">
&quot;{feature.description?.slice(0, 100)}
{(feature.description?.length ?? 0) > 100 ? "..." : ""}&quot;
{(feature.description?.length ?? 0) > 100 ? '...' : ''}&quot;
</span>
<span className="block mt-2 text-destructive font-medium">
This action cannot be undone.
@@ -44,11 +43,7 @@ export function DeleteCompletedFeatureDialog({
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={onClose}
data-testid="cancel-delete-completed-button"
>
<Button variant="ghost" onClick={onClose} data-testid="cancel-delete-completed-button">
Cancel
</Button>
<Button

View File

@@ -1,5 +1,4 @@
import { useState } from "react";
import { useState } from 'react';
import {
Dialog,
DialogContent,
@@ -7,13 +6,13 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Loader2, Trash2, AlertTriangle, FileWarning } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Loader2, Trash2, AlertTriangle, FileWarning } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
interface WorktreeInfo {
path: string;
@@ -51,14 +50,10 @@ export function DeleteWorktreeDialog({
try {
const api = getElectronAPI();
if (!api?.worktree?.delete) {
toast.error("Worktree API not available");
toast.error('Worktree API not available');
return;
}
const result = await api.worktree.delete(
projectPath,
worktree.path,
deleteBranch
);
const result = await api.worktree.delete(projectPath, worktree.path, deleteBranch);
if (result.success) {
toast.success(`Worktree deleted`, {
@@ -70,13 +65,13 @@ export function DeleteWorktreeDialog({
onOpenChange(false);
setDeleteBranch(false);
} else {
toast.error("Failed to delete worktree", {
toast.error('Failed to delete worktree', {
description: result.error,
});
}
} catch (err) {
toast.error("Failed to delete worktree", {
description: err instanceof Error ? err.message : "Unknown error",
toast.error('Failed to delete worktree', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setIsLoading(false);
@@ -95,21 +90,18 @@ export function DeleteWorktreeDialog({
</DialogTitle>
<DialogDescription className="space-y-3">
<span>
Are you sure you want to delete the worktree for branch{" "}
<code className="font-mono bg-muted px-1 rounded">
{worktree.branch}
</code>
?
Are you sure you want to delete the worktree for branch{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>?
</span>
{affectedFeatureCount > 0 && (
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20 mt-2">
<FileWarning className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
<span className="text-orange-500 text-sm">
{affectedFeatureCount} feature{affectedFeatureCount !== 1 ? "s" : ""}{" "}
{affectedFeatureCount !== 1 ? "are" : "is"} assigned to this
branch. {affectedFeatureCount !== 1 ? "They" : "It"} will be
unassigned and moved to the main worktree.
{affectedFeatureCount} feature{affectedFeatureCount !== 1 ? 's' : ''}{' '}
{affectedFeatureCount !== 1 ? 'are' : 'is'} assigned to this branch.{' '}
{affectedFeatureCount !== 1 ? 'They' : 'It'} will be unassigned and moved to the
main worktree.
</span>
</div>
)}
@@ -118,8 +110,8 @@ export function DeleteWorktreeDialog({
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20 mt-2">
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
<span className="text-yellow-500 text-sm">
This worktree has {worktree.changedFilesCount} uncommitted
change(s). These will be lost if you proceed.
This worktree has {worktree.changedFilesCount} uncommitted change(s). These will
be lost if you proceed.
</span>
</div>
)}
@@ -133,26 +125,16 @@ export function DeleteWorktreeDialog({
onCheckedChange={(checked) => setDeleteBranch(checked === true)}
/>
<Label htmlFor="delete-branch" className="text-sm cursor-pointer">
Also delete the branch{" "}
<code className="font-mono bg-muted px-1 rounded">
{worktree.branch}
</code>
Also delete the branch{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
</Label>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isLoading}
>
<Button variant="destructive" onClick={handleDelete} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />

View File

@@ -1,14 +1,8 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Feature } from "@/store/app-store";
import { AlertCircle, CheckCircle2, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Feature } from '@/store/app-store';
import { AlertCircle, CheckCircle2, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
interface DependencyTreeDialogProps {
open: boolean;
@@ -37,22 +31,20 @@ export function DependencyTreeDialog({
.filter((f): f is Feature => f !== undefined);
// Find features that depend on this one
const dependents = allFeatures.filter((f) =>
f.dependencies?.includes(feature.id)
);
const dependents = allFeatures.filter((f) => f.dependencies?.includes(feature.id));
setDependencyTree({ dependencies, dependents });
}, [feature, allFeatures]);
if (!feature) return null;
const getStatusIcon = (status: Feature["status"]) => {
const getStatusIcon = (status: Feature['status']) => {
switch (status) {
case "completed":
case "verified":
case 'completed':
case 'verified':
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
case "in_progress":
case "waiting_approval":
case 'in_progress':
case 'waiting_approval':
return <Circle className="w-4 h-4 text-blue-500 fill-blue-500/20" />;
default:
return <Circle className="w-4 h-4 text-muted-foreground/50" />;
@@ -64,10 +56,10 @@ export function DependencyTreeDialog({
return (
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded font-medium",
priority === 1 && "bg-red-500/20 text-red-500",
priority === 2 && "bg-yellow-500/20 text-yellow-500",
priority === 3 && "bg-blue-500/20 text-blue-500"
'text-xs px-1.5 py-0.5 rounded font-medium',
priority === 1 && 'bg-red-500/20 text-red-500',
priority === 2 && 'bg-yellow-500/20 text-yellow-500',
priority === 3 && 'bg-blue-500/20 text-blue-500'
)}
>
P{priority}
@@ -91,9 +83,7 @@ export function DependencyTreeDialog({
{getPriorityBadge(feature.priority)}
</div>
<p className="text-sm text-muted-foreground">{feature.description}</p>
<p className="text-xs text-muted-foreground/70 mt-2">
Category: {feature.category}
</p>
<p className="text-xs text-muted-foreground/70 mt-2">Category: {feature.category}</p>
</div>
{/* Dependencies (what this feature needs) */}
@@ -102,9 +92,7 @@ export function DependencyTreeDialog({
<h3 className="font-semibold text-sm">
Dependencies ({dependencyTree.dependencies.length})
</h3>
<span className="text-xs text-muted-foreground">
This feature requires:
</span>
<span className="text-xs text-muted-foreground">This feature requires:</span>
</div>
{dependencyTree.dependencies.length === 0 ? (
@@ -117,35 +105,33 @@ export function DependencyTreeDialog({
<div
key={dep.id}
className={cn(
"border rounded-lg p-3 transition-colors",
dep.status === "completed" || dep.status === "verified"
? "bg-green-500/5 border-green-500/20"
: "bg-muted/30 border-border"
'border rounded-lg p-3 transition-colors',
dep.status === 'completed' || dep.status === 'verified'
? 'bg-green-500/5 border-green-500/20'
: 'bg-muted/30 border-border'
)}
>
<div className="flex items-center gap-3 mb-1">
{getStatusIcon(dep.status)}
<span className="text-sm font-medium flex-1">
{dep.description.slice(0, 100)}
{dep.description.length > 100 && "..."}
{dep.description.length > 100 && '...'}
</span>
{getPriorityBadge(dep.priority)}
</div>
<div className="flex items-center gap-3 ml-7">
<span className="text-xs text-muted-foreground">
{dep.category}
</span>
<span className="text-xs text-muted-foreground">{dep.category}</span>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
dep.status === "completed" || dep.status === "verified"
? "bg-green-500/20 text-green-600"
: dep.status === "in_progress"
? "bg-blue-500/20 text-blue-600"
: "bg-muted text-muted-foreground"
'text-xs px-2 py-0.5 rounded-full',
dep.status === 'completed' || dep.status === 'verified'
? 'bg-green-500/20 text-green-600'
: dep.status === 'in_progress'
? 'bg-blue-500/20 text-blue-600'
: 'bg-muted text-muted-foreground'
)}
>
{dep.status.replace(/_/g, " ")}
{dep.status.replace(/_/g, ' ')}
</span>
</div>
</div>
@@ -160,9 +146,7 @@ export function DependencyTreeDialog({
<h3 className="font-semibold text-sm">
Dependents ({dependencyTree.dependents.length})
</h3>
<span className="text-xs text-muted-foreground">
Features blocked by this:
</span>
<span className="text-xs text-muted-foreground">Features blocked by this:</span>
</div>
{dependencyTree.dependents.length === 0 ? (
@@ -172,34 +156,28 @@ export function DependencyTreeDialog({
) : (
<div className="space-y-2">
{dependencyTree.dependents.map((dependent) => (
<div
key={dependent.id}
className="border rounded-lg p-3 bg-muted/30"
>
<div key={dependent.id} className="border rounded-lg p-3 bg-muted/30">
<div className="flex items-center gap-3 mb-1">
{getStatusIcon(dependent.status)}
<span className="text-sm font-medium flex-1">
{dependent.description.slice(0, 100)}
{dependent.description.length > 100 && "..."}
{dependent.description.length > 100 && '...'}
</span>
{getPriorityBadge(dependent.priority)}
</div>
<div className="flex items-center gap-3 ml-7">
<span className="text-xs text-muted-foreground">
{dependent.category}
</span>
<span className="text-xs text-muted-foreground">{dependent.category}</span>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
dependent.status === "completed" ||
dependent.status === "verified"
? "bg-green-500/20 text-green-600"
: dependent.status === "in_progress"
? "bg-blue-500/20 text-blue-600"
: "bg-muted text-muted-foreground"
'text-xs px-2 py-0.5 rounded-full',
dependent.status === 'completed' || dependent.status === 'verified'
? 'bg-green-500/20 text-green-600'
: dependent.status === 'in_progress'
? 'bg-blue-500/20 text-blue-600'
: 'bg-muted text-muted-foreground'
)}
>
{dependent.status.replace(/_/g, " ")}
{dependent.status.replace(/_/g, ' ')}
</span>
</div>
</div>
@@ -210,7 +188,7 @@ export function DependencyTreeDialog({
{/* Warning for incomplete dependencies */}
{dependencyTree.dependencies.some(
(d) => d.status !== "completed" && d.status !== "verified"
(d) => d.status !== 'completed' && d.status !== 'verified'
) && (
<div className="flex items-start gap-3 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<AlertCircle className="w-5 h-5 text-yellow-600 shrink-0 mt-0.5" />
@@ -219,8 +197,8 @@ export function DependencyTreeDialog({
Incomplete Dependencies
</p>
<p className="text-yellow-600 dark:text-yellow-400 mt-1">
This feature has dependencies that aren't completed yet.
Consider completing them first for a smoother implementation.
This feature has dependencies that aren't completed yet. Consider completing them
first for a smoother implementation.
</p>
</div>
</div>

View File

@@ -1,5 +1,4 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { useEffect, useRef, useState, useCallback } from 'react';
import {
Dialog,
DialogContent,
@@ -7,11 +6,11 @@ import {
DialogFooter,
DialogHeader,
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";
} 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 {
Loader2,
Lightbulb,
@@ -22,10 +21,15 @@ import {
RefreshCw,
Shield,
Zap,
} from "lucide-react";
import { getElectronAPI, FeatureSuggestion, SuggestionsEvent, SuggestionType } from "@/lib/electron";
import { useAppStore, Feature } from "@/store/app-store";
import { toast } from "sonner";
} from 'lucide-react';
import {
getElectronAPI,
FeatureSuggestion,
SuggestionsEvent,
SuggestionType,
} from '@/lib/electron';
import { useAppStore, Feature } from '@/store/app-store';
import { toast } from 'sonner';
interface FeatureSuggestionsDialogProps {
open: boolean;
@@ -39,35 +43,38 @@ interface FeatureSuggestionsDialogProps {
}
// Configuration for each suggestion type
const suggestionTypeConfig: Record<SuggestionType, {
label: string;
icon: React.ComponentType<{ className?: string }>;
description: string;
color: string;
}> = {
const suggestionTypeConfig: Record<
SuggestionType,
{
label: string;
icon: React.ComponentType<{ className?: string }>;
description: string;
color: string;
}
> = {
features: {
label: "Feature Suggestions",
label: 'Feature Suggestions',
icon: Lightbulb,
description: "Discover missing features and improvements",
color: "text-yellow-500",
description: 'Discover missing features and improvements',
color: 'text-yellow-500',
},
refactoring: {
label: "Refactoring Suggestions",
label: 'Refactoring Suggestions',
icon: RefreshCw,
description: "Find code smells and refactoring opportunities",
color: "text-blue-500",
description: 'Find code smells and refactoring opportunities',
color: 'text-blue-500',
},
security: {
label: "Security Suggestions",
label: 'Security Suggestions',
icon: Shield,
description: "Identify security vulnerabilities and issues",
color: "text-red-500",
description: 'Identify security vulnerabilities and issues',
color: 'text-red-500',
},
performance: {
label: "Performance Suggestions",
label: 'Performance Suggestions',
icon: Zap,
description: "Discover performance bottlenecks and optimizations",
color: "text-green-500",
description: 'Discover performance bottlenecks and optimizations',
color: 'text-green-500',
},
};
@@ -112,23 +119,25 @@ export function FeatureSuggestionsDialog({
if (!api?.suggestions) return;
const unsubscribe = api.suggestions.onEvent((event: SuggestionsEvent) => {
if (event.type === "suggestions_progress") {
setProgress((prev) => [...prev, event.content || ""]);
} else if (event.type === "suggestions_tool") {
const toolName = event.tool || "Unknown Tool";
if (event.type === 'suggestions_progress') {
setProgress((prev) => [...prev, event.content || '']);
} else if (event.type === 'suggestions_tool') {
const toolName = event.tool || 'Unknown Tool';
setProgress((prev) => [...prev, `Using tool: ${toolName}\n`]);
} else if (event.type === "suggestions_complete") {
} else if (event.type === 'suggestions_complete') {
setIsGenerating(false);
if (event.suggestions && event.suggestions.length > 0) {
setSuggestions(event.suggestions);
// Select all by default
setSelectedIds(new Set(event.suggestions.map((s) => s.id)));
const typeLabel = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType].label.toLowerCase() : "suggestions";
const typeLabel = currentSuggestionType
? suggestionTypeConfig[currentSuggestionType].label.toLowerCase()
: 'suggestions';
toast.success(`Generated ${event.suggestions.length} ${typeLabel}!`);
} else {
toast.info("No suggestions generated. Try again.");
toast.info('No suggestions generated. Try again.');
}
} else if (event.type === "suggestions_error") {
} else if (event.type === 'suggestions_error') {
setIsGenerating(false);
toast.error(`Error: ${event.error}`);
}
@@ -140,31 +149,34 @@ export function FeatureSuggestionsDialog({
}, [open, setSuggestions, setIsGenerating, currentSuggestionType]);
// Start generating suggestions for a specific type
const handleGenerate = useCallback(async (suggestionType: SuggestionType) => {
const api = getElectronAPI();
if (!api?.suggestions) {
toast.error("Suggestions API not available");
return;
}
const handleGenerate = useCallback(
async (suggestionType: SuggestionType) => {
const api = getElectronAPI();
if (!api?.suggestions) {
toast.error('Suggestions API not available');
return;
}
setIsGenerating(true);
setProgress([]);
setSuggestions([]);
setSelectedIds(new Set());
setCurrentSuggestionType(suggestionType);
setIsGenerating(true);
setProgress([]);
setSuggestions([]);
setSelectedIds(new Set());
setCurrentSuggestionType(suggestionType);
try {
const result = await api.suggestions.generate(projectPath, suggestionType);
if (!result.success) {
toast.error(result.error || "Failed to start generation");
try {
const result = await api.suggestions.generate(projectPath, suggestionType);
if (!result.success) {
toast.error(result.error || 'Failed to start generation');
setIsGenerating(false);
}
} catch (error) {
console.error('Failed to generate suggestions:', error);
toast.error('Failed to start generation');
setIsGenerating(false);
}
} catch (error) {
console.error("Failed to generate suggestions:", error);
toast.error("Failed to start generation");
setIsGenerating(false);
}
}, [projectPath, setIsGenerating, setSuggestions]);
},
[projectPath, setIsGenerating, setSuggestions]
);
// Stop generating
const handleStop = useCallback(async () => {
@@ -174,9 +186,9 @@ export function FeatureSuggestionsDialog({
try {
await api.suggestions.stop();
setIsGenerating(false);
toast.info("Generation stopped");
toast.info('Generation stopped');
} catch (error) {
console.error("Failed to stop generation:", error);
console.error('Failed to stop generation:', error);
}
}, [setIsGenerating]);
@@ -218,7 +230,7 @@ export function FeatureSuggestionsDialog({
// Import selected suggestions as features
const handleImport = useCallback(async () => {
if (selectedIds.size === 0) {
toast.warning("No suggestions selected");
toast.warning('No suggestions selected');
return;
}
@@ -226,9 +238,7 @@ export function FeatureSuggestionsDialog({
try {
const api = getElectronAPI();
const selectedSuggestions = suggestions.filter((s) =>
selectedIds.has(s.id)
);
const selectedSuggestions = suggestions.filter((s) => selectedIds.has(s.id));
// Create new features from selected suggestions
const newFeatures: Feature[] = selectedSuggestions.map((s) => ({
@@ -236,7 +246,7 @@ export function FeatureSuggestionsDialog({
category: s.category,
description: s.description,
steps: s.steps,
status: "backlog" as const,
status: 'backlog' as const,
skipTests: true, // As specified, testing mode true
priority: s.priority, // Preserve priority from suggestion
}));
@@ -264,8 +274,8 @@ export function FeatureSuggestionsDialog({
onClose();
} catch (error) {
console.error("Failed to import features:", error);
toast.error("Failed to import features");
console.error('Failed to import features:', error);
toast.error('Failed to import features');
} finally {
setIsImporting(false);
}
@@ -315,7 +325,7 @@ export function FeatureSuggestionsDialog({
<DialogDescription>
{currentConfig
? currentConfig.description
: "Analyze your project to discover improvements. Choose a suggestion type below."}
: 'Analyze your project to discover improvements. Choose a suggestion type below.'}
</DialogDescription>
</DialogHeader>
@@ -323,32 +333,35 @@ export function FeatureSuggestionsDialog({
// Initial state - show suggestion type buttons
<div className="flex-1 flex flex-col items-center justify-center py-8">
<p className="text-muted-foreground text-center max-w-lg mb-8">
Our AI will analyze your project and generate actionable suggestions.
Choose what type of analysis you want to perform:
Our AI will analyze your project and generate actionable suggestions. Choose what type
of analysis you want to perform:
</p>
<div className="grid grid-cols-2 gap-4 w-full max-w-2xl">
{(Object.entries(suggestionTypeConfig) as [SuggestionType, typeof suggestionTypeConfig[SuggestionType]][]).map(
([type, config]) => {
const Icon = config.icon;
return (
<Button
key={type}
variant="outline"
className="h-auto py-6 px-6 flex flex-col items-center gap-3 hover:border-primary/50 transition-colors"
onClick={() => handleGenerate(type)}
data-testid={`generate-${type}-btn`}
>
<Icon className={`w-8 h-8 ${config.color}`} />
<div className="text-center">
<div className="font-semibold">{config.label.replace(" Suggestions", "")}</div>
<div className="text-xs text-muted-foreground mt-1">
{config.description}
</div>
{(
Object.entries(suggestionTypeConfig) as [
SuggestionType,
(typeof suggestionTypeConfig)[SuggestionType],
][]
).map(([type, config]) => {
const Icon = config.icon;
return (
<Button
key={type}
variant="outline"
className="h-auto py-6 px-6 flex flex-col items-center gap-3 hover:border-primary/50 transition-colors"
onClick={() => handleGenerate(type)}
data-testid={`generate-${type}-btn`}
>
<Icon className={`w-8 h-8 ${config.color}`} />
<div className="text-center">
<div className="font-semibold">
{config.label.replace(' Suggestions', '')}
</div>
</Button>
);
}
)}
<div className="text-xs text-muted-foreground mt-1">{config.description}</div>
</div>
</Button>
);
})}
</div>
</div>
) : isGenerating ? (
@@ -370,7 +383,7 @@ export function FeatureSuggestionsDialog({
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[200px] max-h-[400px]"
>
<div className="whitespace-pre-wrap break-words text-zinc-300">
{progress.join("")}
{progress.join('')}
</div>
</div>
</div>
@@ -383,14 +396,10 @@ export function FeatureSuggestionsDialog({
{suggestions.length} suggestions generated
</span>
<Button variant="ghost" size="sm" onClick={toggleSelectAll}>
{selectedIds.size === suggestions.length
? "Deselect All"
: "Select All"}
{selectedIds.size === suggestions.length ? 'Deselect All' : 'Select All'}
</Button>
</div>
<span className="text-sm font-medium">
{selectedIds.size} selected
</span>
<span className="text-sm font-medium">{selectedIds.size} selected</span>
</div>
<div
@@ -406,8 +415,8 @@ export function FeatureSuggestionsDialog({
key={suggestion.id}
className={`border rounded-lg p-3 transition-colors ${
isSelected
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
data-testid={`suggestion-${suggestion.id}`}
>
@@ -447,9 +456,7 @@ export function FeatureSuggestionsDialog({
{isExpanded && (
<div className="mt-3 space-y-2 text-sm">
{suggestion.reasoning && (
<p className="text-muted-foreground italic">
{suggestion.reasoning}
</p>
<p className="text-muted-foreground italic">{suggestion.reasoning}</p>
)}
{suggestion.steps.length > 0 && (
<div>
@@ -513,7 +520,7 @@ export function FeatureSuggestionsDialog({
<HotkeyButton
onClick={handleImport}
disabled={selectedIds.size === 0 || isImporting}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open && hasSuggestions}
>
{isImporting ? (
@@ -522,7 +529,7 @@ export function FeatureSuggestionsDialog({
<Download className="w-4 h-4 mr-2" />
)}
Import {selectedIds.size} Feature
{selectedIds.size !== 1 ? "s" : ""}
{selectedIds.size !== 1 ? 's' : ''}
</HotkeyButton>
</div>
</div>

View File

@@ -1,5 +1,4 @@
import { useState } from "react";
import { useState } from 'react';
import {
Dialog,
DialogContent,
@@ -7,17 +6,17 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { HotkeyButton } from "@/components/ui/hotkey-button";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import {
DescriptionImageDropZone,
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from "@/components/ui/description-image-dropzone";
import { MessageSquare } from "lucide-react";
import { Feature } from "@/store/app-store";
} from '@/components/ui/description-image-dropzone';
import { MessageSquare } from 'lucide-react';
import { Feature } from '@/store/app-store';
interface FollowUpDialogProps {
open: boolean;
@@ -58,7 +57,7 @@ export function FollowUpDialog({
compact={!isMaximized}
data-testid="follow-up-dialog"
onKeyDown={(e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && prompt.trim()) {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && prompt.trim()) {
e.preventDefault();
onSend();
}
@@ -71,7 +70,7 @@ export function FollowUpDialog({
{feature && (
<span className="block mt-2 text-primary">
Feature: {feature.description.slice(0, 100)}
{feature.description.length > 100 ? "..." : ""}
{feature.description.length > 100 ? '...' : ''}
</span>
)}
</DialogDescription>
@@ -90,8 +89,8 @@ export function FollowUpDialog({
/>
</div>
<p className="text-xs text-muted-foreground">
The agent will continue from where it left off, using the existing
context. You can attach screenshots to help explain the issue.
The agent will continue from where it left off, using the existing context. You can
attach screenshots to help explain the issue.
</p>
</div>
<DialogFooter>
@@ -106,7 +105,7 @@ export function FollowUpDialog({
<HotkeyButton
onClick={onSend}
disabled={!prompt.trim()}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open}
data-testid="confirm-follow-up"
>

View File

@@ -1,9 +1,9 @@
export { AddFeatureDialog } from "./add-feature-dialog";
export { AgentOutputModal } from "./agent-output-modal";
export { CompletedFeaturesModal } from "./completed-features-modal";
export { ArchiveAllVerifiedDialog } from "./archive-all-verified-dialog";
export { DeleteCompletedFeatureDialog } from "./delete-completed-feature-dialog";
export { EditFeatureDialog } from "./edit-feature-dialog";
export { FeatureSuggestionsDialog } from "./feature-suggestions-dialog";
export { FollowUpDialog } from "./follow-up-dialog";
export { PlanApprovalDialog } from "./plan-approval-dialog";
export { AddFeatureDialog } from './add-feature-dialog';
export { AgentOutputModal } from './agent-output-modal';
export { CompletedFeaturesModal } from './completed-features-modal';
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
export { EditFeatureDialog } from './edit-feature-dialog';
export { FeatureSuggestionsDialog } from './feature-suggestions-dialog';
export { FollowUpDialog } from './follow-up-dialog';
export { PlanApprovalDialog } from './plan-approval-dialog';

View File

@@ -1,6 +1,6 @@
"use client";
'use client';
import { useState, useEffect } from "react";
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -8,13 +8,13 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Markdown } from "@/components/ui/markdown";
import { Label } from "@/components/ui/label";
import { Feature } from "@/store/app-store";
import { Check, RefreshCw, Edit2, Eye, Loader2 } from "lucide-react";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Markdown } from '@/components/ui/markdown';
import { Label } from '@/components/ui/label';
import { Feature } from '@/store/app-store';
import { Check, RefreshCw, Edit2, Eye, Loader2 } from 'lucide-react';
interface PlanApprovalDialogProps {
open: boolean;
@@ -40,7 +40,7 @@ export function PlanApprovalDialog({
const [isEditMode, setIsEditMode] = useState(false);
const [editedPlan, setEditedPlan] = useState(planContent);
const [showRejectFeedback, setShowRejectFeedback] = useState(false);
const [rejectFeedback, setRejectFeedback] = useState("");
const [rejectFeedback, setRejectFeedback] = useState('');
// Reset state when dialog opens or plan content changes
useEffect(() => {
@@ -48,7 +48,7 @@ export function PlanApprovalDialog({
setEditedPlan(planContent);
setIsEditMode(false);
setShowRejectFeedback(false);
setRejectFeedback("");
setRejectFeedback('');
}
}, [open, planContent]);
@@ -68,7 +68,7 @@ export function PlanApprovalDialog({
const handleCancelReject = () => {
setShowRejectFeedback(false);
setRejectFeedback("");
setRejectFeedback('');
};
const handleClose = (open: boolean) => {
@@ -79,20 +79,17 @@ export function PlanApprovalDialog({
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent
className="max-w-4xl"
data-testid="plan-approval-dialog"
>
<DialogContent className="max-w-4xl" data-testid="plan-approval-dialog">
<DialogHeader>
<DialogTitle>{viewOnly ? "View Plan" : "Review Plan"}</DialogTitle>
<DialogTitle>{viewOnly ? 'View Plan' : 'Review Plan'}</DialogTitle>
<DialogDescription>
{viewOnly
? "View the generated plan for this feature."
: "Review the generated plan before implementation begins."}
? 'View the generated plan for this feature.'
: 'Review the generated plan before implementation begins.'}
{feature && (
<span className="block mt-2 text-primary">
Feature: {feature.description.slice(0, 150)}
{feature.description.length > 150 ? "..." : ""}
{feature.description.length > 150 ? '...' : ''}
</span>
)}
</DialogDescription>
@@ -103,7 +100,7 @@ export function PlanApprovalDialog({
{!viewOnly && (
<div className="flex items-center justify-between mb-3">
<Label className="text-sm text-muted-foreground">
{isEditMode ? "Edit Mode" : "View Mode"}
{isEditMode ? 'Edit Mode' : 'View Mode'}
</Label>
<Button
variant="outline"
@@ -138,7 +135,7 @@ export function PlanApprovalDialog({
/>
) : (
<div className="p-4 overflow-auto">
<Markdown>{editedPlan || "No plan content available."}</Markdown>
<Markdown>{editedPlan || 'No plan content available.'}</Markdown>
</div>
)}
</div>
@@ -169,33 +166,21 @@ export function PlanApprovalDialog({
</Button>
) : showRejectFeedback ? (
<>
<Button
variant="ghost"
onClick={handleCancelReject}
disabled={isLoading}
>
<Button variant="ghost" onClick={handleCancelReject} disabled={isLoading}>
Back
</Button>
<Button
variant="secondary"
onClick={handleReject}
disabled={isLoading}
>
<Button variant="secondary" onClick={handleReject} disabled={isLoading}>
{isLoading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<RefreshCw className="w-4 h-4 mr-2" />
)}
{rejectFeedback.trim() ? "Revise Plan" : "Cancel Feature"}
{rejectFeedback.trim() ? 'Revise Plan' : 'Cancel Feature'}
</Button>
</>
) : (
<>
<Button
variant="outline"
onClick={handleReject}
disabled={isLoading}
>
<Button variant="outline" onClick={handleReject} disabled={isLoading}>
<RefreshCw className="w-4 h-4 mr-2" />
Request Changes
</Button>

View File

@@ -1,10 +1,10 @@
export { useBoardFeatures } from "./use-board-features";
export { useBoardDragDrop } from "./use-board-drag-drop";
export { useBoardActions } from "./use-board-actions";
export { useBoardKeyboardShortcuts } from "./use-board-keyboard-shortcuts";
export { useBoardColumnFeatures } from "./use-board-column-features";
export { useBoardEffects } from "./use-board-effects";
export { useBoardBackground } from "./use-board-background";
export { useBoardPersistence } from "./use-board-persistence";
export { useFollowUpState } from "./use-follow-up-state";
export { useSuggestionsState } from "./use-suggestions-state";
export { useBoardFeatures } from './use-board-features';
export { useBoardDragDrop } from './use-board-drag-drop';
export { useBoardActions } from './use-board-actions';
export { useBoardKeyboardShortcuts } from './use-board-keyboard-shortcuts';
export { useBoardColumnFeatures } from './use-board-column-features';
export { useBoardEffects } from './use-board-effects';
export { useBoardBackground } from './use-board-background';
export { useBoardPersistence } from './use-board-persistence';
export { useFollowUpState } from './use-follow-up-state';
export { useSuggestionsState } from './use-suggestions-state';

View File

@@ -1,20 +1,17 @@
import { useMemo } from "react";
import { useAppStore, defaultBackgroundSettings } from "@/store/app-store";
import { useMemo } from 'react';
import { useAppStore, defaultBackgroundSettings } from '@/store/app-store';
interface UseBoardBackgroundProps {
currentProject: { path: string; id: string } | null;
}
export function useBoardBackground({ currentProject }: UseBoardBackgroundProps) {
const boardBackgroundByProject = useAppStore(
(state) => state.boardBackgroundByProject
);
const boardBackgroundByProject = useAppStore((state) => state.boardBackgroundByProject);
// Get background settings for current project
const backgroundSettings = useMemo(() => {
return (
(currentProject && boardBackgroundByProject[currentProject.path]) ||
defaultBackgroundSettings
(currentProject && boardBackgroundByProject[currentProject.path]) || defaultBackgroundSettings
);
}, [currentProject, boardBackgroundByProject]);
@@ -26,17 +23,15 @@ export function useBoardBackground({ currentProject }: UseBoardBackgroundProps)
return {
backgroundImage: `url(${
import.meta.env.VITE_SERVER_URL || "http://localhost:3008"
import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'
}/api/fs/image?path=${encodeURIComponent(
backgroundSettings.imagePath
)}&projectPath=${encodeURIComponent(currentProject.path)}${
backgroundSettings.imageVersion
? `&v=${backgroundSettings.imageVersion}`
: ""
backgroundSettings.imageVersion ? `&v=${backgroundSettings.imageVersion}` : ''
})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
} as React.CSSProperties;
}, [backgroundSettings, currentProject]);

View File

@@ -1,18 +1,15 @@
import { useState, useCallback } from "react";
import { DragStartEvent, DragEndEvent } from "@dnd-kit/core";
import { Feature } from "@/store/app-store";
import { useAppStore } from "@/store/app-store";
import { toast } from "sonner";
import { COLUMNS, ColumnId } from "../constants";
import { useState, useCallback } from 'react';
import { DragStartEvent, DragEndEvent } from '@dnd-kit/core';
import { Feature } from '@/store/app-store';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import { COLUMNS, ColumnId } from '../constants';
interface UseBoardDragDropProps {
features: Feature[];
currentProject: { path: string; id: string } | null;
runningAutoTasks: string[];
persistFeatureUpdate: (
featureId: string,
updates: Partial<Feature>
) => Promise<void>;
persistFeatureUpdate: (featureId: string, updates: Partial<Feature>) => Promise<void>;
handleStartImplementation: (feature: Feature) => Promise<boolean>;
}
@@ -63,12 +60,10 @@ export function useBoardDragDrop({
// - verified items can always be dragged (to allow moving back to waiting_approval)
// - in_progress items can be dragged (but not if they're currently running)
// - Non-skipTests (TDD) items that are in progress cannot be dragged if they are running
if (draggedFeature.status === "in_progress") {
if (draggedFeature.status === 'in_progress') {
// Only allow dragging in_progress if it's not currently running
if (isRunningTask) {
console.log(
"[Board] Cannot drag feature - currently running"
);
console.log('[Board] Cannot drag feature - currently running');
return;
}
}
@@ -94,9 +89,9 @@ export function useBoardDragDrop({
// Handle different drag scenarios
// Note: Worktrees are created server-side at execution time based on feature.branchName
if (draggedFeature.status === "backlog") {
if (draggedFeature.status === 'backlog') {
// From backlog
if (targetStatus === "in_progress") {
if (targetStatus === 'in_progress') {
// Use helper function to handle concurrency check and start implementation
// Server will derive workDir from feature.branchName
await handleStartImplementation(draggedFeature);
@@ -104,122 +99,110 @@ export function useBoardDragDrop({
moveFeature(featureId, targetStatus);
persistFeatureUpdate(featureId, { status: targetStatus });
}
} else if (draggedFeature.status === "waiting_approval") {
} 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");
if (targetStatus === 'verified') {
moveFeature(featureId, 'verified');
// Clear justFinishedAt timestamp when manually verifying via drag
persistFeatureUpdate(featureId, {
status: "verified",
status: 'verified',
justFinishedAt: undefined,
});
toast.success("Feature verified", {
toast.success('Feature verified', {
description: `Manually verified: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
});
} else if (targetStatus === "backlog") {
} else if (targetStatus === 'backlog') {
// Allow moving waiting_approval cards back to backlog
moveFeature(featureId, "backlog");
moveFeature(featureId, 'backlog');
// Clear justFinishedAt timestamp when moving back to backlog
persistFeatureUpdate(featureId, {
status: "backlog",
status: 'backlog',
justFinishedAt: undefined,
});
toast.info("Feature moved to backlog", {
toast.info('Feature moved to backlog', {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
});
}
} else if (draggedFeature.status === "in_progress") {
} else if (draggedFeature.status === 'in_progress') {
// Handle in_progress features being moved
if (targetStatus === "backlog") {
if (targetStatus === 'backlog') {
// Allow moving in_progress cards back to backlog
moveFeature(featureId, "backlog");
persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved to backlog", {
moveFeature(featureId, 'backlog');
persistFeatureUpdate(featureId, { status: 'backlog' });
toast.info('Feature moved to backlog', {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
});
} else if (
targetStatus === "verified" &&
draggedFeature.skipTests
) {
} else if (targetStatus === 'verified' && draggedFeature.skipTests) {
// Manual verify via drag (only for skipTests features)
moveFeature(featureId, "verified");
persistFeatureUpdate(featureId, { status: "verified" });
toast.success("Feature verified", {
moveFeature(featureId, 'verified');
persistFeatureUpdate(featureId, { status: 'verified' });
toast.success('Feature verified', {
description: `Marked as verified: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
});
}
} else if (draggedFeature.skipTests) {
// skipTests feature being moved between verified and waiting_approval
if (
targetStatus === "waiting_approval" &&
draggedFeature.status === "verified"
) {
if (targetStatus === 'waiting_approval' && draggedFeature.status === 'verified') {
// Move verified feature back to waiting_approval
moveFeature(featureId, "waiting_approval");
persistFeatureUpdate(featureId, { status: "waiting_approval" });
toast.info("Feature moved back", {
moveFeature(featureId, 'waiting_approval');
persistFeatureUpdate(featureId, { status: 'waiting_approval' });
toast.info('Feature moved back', {
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
});
} else if (targetStatus === "backlog") {
} else if (targetStatus === 'backlog') {
// Allow moving skipTests cards back to backlog (from verified)
moveFeature(featureId, "backlog");
persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved to backlog", {
moveFeature(featureId, 'backlog');
persistFeatureUpdate(featureId, { status: 'backlog' });
toast.info('Feature moved to backlog', {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
});
}
} else if (draggedFeature.status === "verified") {
} else if (draggedFeature.status === 'verified') {
// Handle verified TDD (non-skipTests) features being moved back
if (targetStatus === "waiting_approval") {
if (targetStatus === 'waiting_approval') {
// Move verified feature back to waiting_approval
moveFeature(featureId, "waiting_approval");
persistFeatureUpdate(featureId, { status: "waiting_approval" });
toast.info("Feature moved back", {
moveFeature(featureId, 'waiting_approval');
persistFeatureUpdate(featureId, { status: 'waiting_approval' });
toast.info('Feature moved back', {
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
});
} else if (targetStatus === "backlog") {
} else if (targetStatus === 'backlog') {
// Allow moving verified cards back to backlog
moveFeature(featureId, "backlog");
persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved to backlog", {
moveFeature(featureId, 'backlog');
persistFeatureUpdate(featureId, { status: 'backlog' });
toast.info('Feature moved to backlog', {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
});
}
}
},
[
features,
runningAutoTasks,
moveFeature,
persistFeatureUpdate,
handleStartImplementation,
]
[features, runningAutoTasks, moveFeature, persistFeatureUpdate, handleStartImplementation]
);
return {

View File

@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { getElectronAPI } from "@/lib/electron";
import { useAppStore } from "@/store/app-store";
import { useEffect } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
interface UseBoardEffectsProps {
currentProject: { path: string; id: string } | null;
@@ -43,11 +43,11 @@ export function useBoardEffects({
if (!api?.suggestions) return;
const unsubscribe = api.suggestions.onEvent((event) => {
if (event.type === "suggestions_complete" && event.suggestions) {
if (event.type === 'suggestions_complete' && event.suggestions) {
setSuggestionsCount(event.suggestions.length);
setFeatureSuggestions(event.suggestions);
setIsGeneratingSuggestions(false);
} else if (event.type === "suggestions_error") {
} else if (event.type === 'suggestions_error') {
setIsGeneratingSuggestions(false);
}
});
@@ -64,9 +64,9 @@ export function useBoardEffects({
const unsubscribe = api.specRegeneration.onEvent((event) => {
console.log(
"[BoardView] Spec regeneration event:",
'[BoardView] Spec regeneration event:',
event.type,
"for project:",
'for project:',
event.projectPath
);
@@ -74,9 +74,9 @@ export function useBoardEffects({
return;
}
if (event.type === "spec_regeneration_complete") {
if (event.type === 'spec_regeneration_complete') {
setSpecCreatingForProject(null);
} else if (event.type === "spec_regeneration_error") {
} else if (event.type === 'spec_regeneration_error') {
setSpecCreatingForProject(null);
}
});
@@ -101,10 +101,7 @@ export function useBoardEffects({
const { clearRunningTasks, addRunningTask } = useAppStore.getState();
if (status.runningFeatures) {
console.log(
"[Board] Syncing running tasks from backend:",
status.runningFeatures
);
console.log('[Board] Syncing running tasks from backend:', status.runningFeatures);
clearRunningTasks(projectId);
@@ -114,7 +111,7 @@ export function useBoardEffects({
}
}
} catch (error) {
console.error("[Board] Failed to sync running tasks:", error);
console.error('[Board] Failed to sync running tasks:', error);
}
};
@@ -126,9 +123,7 @@ export function useBoardEffects({
const checkAllContexts = async () => {
const featuresWithPotentialContext = features.filter(
(f) =>
f.status === "in_progress" ||
f.status === "waiting_approval" ||
f.status === "verified"
f.status === 'in_progress' || f.status === 'waiting_approval' || f.status === 'verified'
);
const contextChecks = await Promise.all(
featuresWithPotentialContext.map(async (f) => ({

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useAppStore, Feature } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
import { useState, useCallback, useEffect, useRef } from 'react';
import { useAppStore, Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
interface UseBoardFeaturesProps {
currentProject: { path: string; id: string } | null;
@@ -24,8 +24,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const currentPath = currentProject.path;
const previousPath = prevProjectPathRef.current;
const isProjectSwitch =
previousPath !== null && currentPath !== previousPath;
const isProjectSwitch = previousPath !== null && currentPath !== previousPath;
// Get cached features from store (without adding to dependencies)
const cachedFeatures = useAppStore.getState().features;
@@ -33,9 +32,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
// If project switched, mark it but don't clear features yet
// We'll clear after successful API load to prevent data loss
if (isProjectSwitch) {
console.log(
`[BoardView] Project switch detected: ${previousPath} -> ${currentPath}`
);
console.log(`[BoardView] Project switch detected: ${previousPath} -> ${currentPath}`);
isSwitchingProjectRef.current = true;
isInitialLoadRef.current = true;
}
@@ -51,7 +48,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
try {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
console.error('[BoardView] Features API not available');
// Keep cached features if API is unavailable
return;
}
@@ -59,17 +56,15 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const result = await api.features.getAll(currentProject.path);
if (result.success && result.features) {
const featuresWithIds = result.features.map(
(f: any, index: number) => ({
...f,
id: f.id || `feature-${index}-${Date.now()}`,
status: f.status || "backlog",
startedAt: f.startedAt, // Preserve startedAt timestamp
// Ensure model and thinkingLevel are set for backward compatibility
model: f.model || "opus",
thinkingLevel: f.thinkingLevel || "none",
})
);
const featuresWithIds = result.features.map((f: any, index: number) => ({
...f,
id: f.id || `feature-${index}-${Date.now()}`,
status: f.status || 'backlog',
startedAt: f.startedAt, // Preserve startedAt timestamp
// Ensure model and thinkingLevel are set for backward compatibility
model: f.model || 'opus',
thinkingLevel: f.thinkingLevel || 'none',
}));
// Successfully loaded features - now safe to set them
setFeatures(featuresWithIds);
@@ -78,7 +73,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
setPersistedCategories([]);
}
} else if (!result.success && result.error) {
console.error("[BoardView] API returned error:", result.error);
console.error('[BoardView] API returned error:', result.error);
// If it's a new project or the error indicates no features found,
// that's expected - start with empty array
if (isProjectSwitch) {
@@ -88,7 +83,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
// Otherwise keep cached features
}
} catch (error) {
console.error("Failed to load features:", error);
console.error('Failed to load features:', error);
// On error, keep existing cached features for the current project
// Only clear on project switch if we have no features from server
if (isProjectSwitch && cachedFeatures.length === 0) {
@@ -108,9 +103,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
try {
const api = getElectronAPI();
const result = await api.readFile(
`${currentProject.path}/.automaker/categories.json`
);
const result = await api.readFile(`${currentProject.path}/.automaker/categories.json`);
if (result.success && result.content) {
const parsed = JSON.parse(result.content);
@@ -122,7 +115,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
setPersistedCategories([]);
}
} catch (error) {
console.error("Failed to load categories:", error);
console.error('Failed to load categories:', error);
// If file doesn't exist, ensure categories are cleared
setPersistedCategories([]);
}
@@ -154,7 +147,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
setPersistedCategories(categories);
}
} catch (error) {
console.error("Failed to save category:", error);
console.error('Failed to save category:', error);
}
},
[currentProject, persistedCategories]
@@ -168,13 +161,11 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const unsubscribe = api.specRegeneration.onEvent((event) => {
// Refresh the kanban board when spec regeneration completes for the current project
if (
event.type === "spec_regeneration_complete" &&
event.type === 'spec_regeneration_complete' &&
currentProject &&
event.projectPath === currentProject.path
) {
console.log(
"[BoardView] Spec regeneration complete, refreshing features"
);
console.log('[BoardView] Spec regeneration complete, refreshing features');
loadFeatures();
}
});
@@ -195,32 +186,26 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const unsubscribe = api.autoMode.onEvent((event) => {
// Use event's projectPath or projectId if available, otherwise use current project
// Board view only reacts to events for the currently selected project
const eventProjectId =
("projectId" in event && event.projectId) || projectId;
const eventProjectId = ('projectId' in event && event.projectId) || projectId;
if (event.type === "auto_mode_feature_complete") {
if (event.type === 'auto_mode_feature_complete') {
// Reload features when a feature is completed
console.log("[Board] Feature completed, reloading features...");
console.log('[Board] Feature completed, reloading features...');
loadFeatures();
// Play ding sound when feature is done (unless muted)
const { muteDoneSound } = useAppStore.getState();
if (!muteDoneSound) {
const audio = new Audio("/sounds/ding.mp3");
audio
.play()
.catch((err) => console.warn("Could not play ding sound:", err));
const audio = new Audio('/sounds/ding.mp3');
audio.play().catch((err) => console.warn('Could not play ding sound:', err));
}
} else if (event.type === "plan_approval_required") {
} else if (event.type === 'plan_approval_required') {
// Reload features when plan is generated and requires approval
// This ensures the feature card shows the "Approve Plan" button
console.log("[Board] Plan approval required, reloading features...");
console.log('[Board] Plan approval required, reloading features...');
loadFeatures();
} else if (event.type === "auto_mode_error") {
} else if (event.type === 'auto_mode_error') {
// Reload features when an error occurs (feature moved to waiting_approval)
console.log(
"[Board] Feature error, reloading features...",
event.error
);
console.log('[Board] Feature error, reloading features...', event.error);
// Remove from running tasks so it moves to the correct column
if (event.featureId) {
@@ -231,20 +216,20 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
// Check for authentication errors and show a more helpful message
const isAuthError =
event.errorType === "authentication" ||
event.errorType === 'authentication' ||
(event.error &&
(event.error.includes("Authentication failed") ||
event.error.includes("Invalid API key")));
(event.error.includes('Authentication failed') ||
event.error.includes('Invalid API key')));
if (isAuthError) {
toast.error("Authentication Failed", {
toast.error('Authentication Failed', {
description:
"Your API key is invalid or expired. Please check Settings or run 'claude login' in terminal.",
duration: 10000,
});
} else {
toast.error("Agent encountered an error", {
description: event.error || "Check the logs for details",
toast.error('Agent encountered an error', {
description: event.error || 'Check the logs for details',
});
}
}

View File

@@ -1,10 +1,10 @@
import { useMemo, useRef, useEffect } from "react";
import { useMemo, useRef, useEffect } from 'react';
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
import { Feature } from "@/store/app-store";
} from '@/hooks/use-keyboard-shortcuts';
import { Feature } from '@/store/app-store';
interface UseBoardKeyboardShortcutsProps {
features: Feature[];
@@ -27,7 +27,7 @@ export function useBoardKeyboardShortcuts({
const inProgressFeaturesForShortcuts = useMemo(() => {
return features.filter((f) => {
const isRunning = runningAutoTasks.includes(f.id);
return isRunning || f.status === "in_progress";
return isRunning || f.status === 'in_progress';
});
}, [features, runningAutoTasks]);
@@ -45,19 +45,19 @@ export function useBoardKeyboardShortcuts({
{
key: shortcuts.addFeature,
action: onAddFeature,
description: "Add new feature",
description: 'Add new feature',
},
{
key: shortcuts.startNext,
action: () => startNextFeaturesRef.current(),
description: "Start next features from backlog",
description: 'Start next features from backlog',
},
];
// Add shortcuts for in-progress cards (1-9 and 0 for 10th)
inProgressFeaturesForShortcuts.slice(0, 10).forEach((feature, index) => {
// Keys 1-9 for first 9 cards, 0 for 10th card
const key = index === 9 ? "0" : String(index + 1);
const key = index === 9 ? '0' : String(index + 1);
shortcutsList.push({
key,
action: () => {

View File

@@ -1,15 +1,13 @@
import { useCallback } from "react";
import { Feature } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { useAppStore } from "@/store/app-store";
import { useCallback } from 'react';
import { Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
interface UseBoardPersistenceProps {
currentProject: { path: string; id: string } | null;
}
export function useBoardPersistence({
currentProject,
}: UseBoardPersistenceProps) {
export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps) {
const { updateFeature } = useAppStore();
// Persist feature update to API (replaces saveFeatures)
@@ -20,20 +18,16 @@ export function useBoardPersistence({
try {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
console.error('[BoardView] Features API not available');
return;
}
const result = await api.features.update(
currentProject.path,
featureId,
updates
);
const result = await api.features.update(currentProject.path, featureId, updates);
if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature);
}
} catch (error) {
console.error("Failed to persist feature update:", error);
console.error('Failed to persist feature update:', error);
}
},
[currentProject, updateFeature]
@@ -47,7 +41,7 @@ export function useBoardPersistence({
try {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
console.error('[BoardView] Features API not available');
return;
}
@@ -56,7 +50,7 @@ export function useBoardPersistence({
updateFeature(result.feature.id, result.feature);
}
} catch (error) {
console.error("Failed to persist feature creation:", error);
console.error('Failed to persist feature creation:', error);
}
},
[currentProject, updateFeature]
@@ -70,13 +64,13 @@ export function useBoardPersistence({
try {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
console.error('[BoardView] Features API not available');
return;
}
await api.features.delete(currentProject.path, featureId);
} catch (error) {
console.error("Failed to persist feature deletion:", error);
console.error('Failed to persist feature deletion:', error);
}
},
[currentProject]

View File

@@ -1,32 +1,35 @@
import { useState, useCallback } from "react";
import { Feature } from "@/store/app-store";
import { useState, useCallback } from 'react';
import { Feature } from '@/store/app-store';
import {
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from "@/components/ui/description-image-dropzone";
} from '@/components/ui/description-image-dropzone';
export function useFollowUpState() {
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
const [followUpPrompt, setFollowUpPrompt] = useState("");
const [followUpPrompt, setFollowUpPrompt] = useState('');
const [followUpImagePaths, setFollowUpImagePaths] = useState<DescriptionImagePath[]>([]);
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<ImagePreviewMap>(() => new Map());
const resetFollowUpState = useCallback(() => {
setShowFollowUpDialog(false);
setFollowUpFeature(null);
setFollowUpPrompt("");
setFollowUpPrompt('');
setFollowUpImagePaths([]);
setFollowUpPreviewMap(new Map());
}, []);
const handleFollowUpDialogChange = useCallback((open: boolean) => {
if (!open) {
resetFollowUpState();
} else {
setShowFollowUpDialog(open);
}
}, [resetFollowUpState]);
const handleFollowUpDialogChange = useCallback(
(open: boolean) => {
if (!open) {
resetFollowUpState();
} else {
setShowFollowUpDialog(open);
}
},
[resetFollowUpState]
);
return {
// State

View File

@@ -1,5 +1,5 @@
import { useState, useCallback } from "react";
import type { FeatureSuggestion } from "@/lib/electron";
import { useState, useCallback } from 'react';
import type { FeatureSuggestion } from '@/lib/electron';
export function useSuggestionsState() {
const [showSuggestionsDialog, setShowSuggestionsDialog] = useState(false);

View File

@@ -1,8 +1,8 @@
"use client";
'use client';
import { Label } from "@/components/ui/label";
import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from '@/components/ui/label';
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
interface BranchSelectorProps {
useCurrentBranch: boolean;
@@ -25,7 +25,7 @@ export function BranchSelector({
branchCardCounts,
currentBranch,
disabled = false,
testIdPrefix = "branch",
testIdPrefix = 'branch',
}: BranchSelectorProps) {
// Validate: if "other branch" is selected, branch name is required
const isBranchRequired = !useCurrentBranch;
@@ -35,32 +35,22 @@ export function BranchSelector({
<div className="space-y-2">
<Label id={`${testIdPrefix}-label`}>Target Branch</Label>
<RadioGroup
value={useCurrentBranch ? "current" : "other"}
onValueChange={(value: string) => onUseCurrentBranchChange(value === "current")}
value={useCurrentBranch ? 'current' : 'other'}
onValueChange={(value: string) => onUseCurrentBranchChange(value === 'current')}
disabled={disabled}
data-testid={`${testIdPrefix}-radio-group`}
aria-labelledby={`${testIdPrefix}-label`}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="current" id={`${testIdPrefix}-current`} />
<Label
htmlFor={`${testIdPrefix}-current`}
className="font-normal cursor-pointer"
>
<Label htmlFor={`${testIdPrefix}-current`} className="font-normal cursor-pointer">
Use current selected branch
{currentBranch && (
<span className="text-muted-foreground ml-1">
({currentBranch})
</span>
)}
{currentBranch && <span className="text-muted-foreground ml-1">({currentBranch})</span>}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="other" id={`${testIdPrefix}-other`} />
<Label
htmlFor={`${testIdPrefix}-other`}
className="font-normal cursor-pointer"
>
<Label htmlFor={`${testIdPrefix}-other`} className="font-normal cursor-pointer">
Other branch
</Label>
</div>
@@ -91,11 +81,10 @@ export function BranchSelector({
) : (
<p className="text-xs text-muted-foreground">
{useCurrentBranch
? "Work will be done in the currently selected branch. A worktree will be created if needed."
: "Work will be done in this branch. A worktree will be created if needed."}
? 'Work will be done in the currently selected branch. A worktree will be created if needed.'
: 'Work will be done in this branch. A worktree will be created if needed.'}
</p>
)}
</div>
);
}

View File

@@ -1,8 +1,8 @@
export * from "./model-constants";
export * from "./model-selector";
export * from "./thinking-level-selector";
export * from "./profile-quick-select";
export * from "./testing-tab-content";
export * from "./priority-selector";
export * from "./branch-selector";
export * from "./planning-mode-selector";
export * from './model-constants';
export * from './model-selector';
export * from './thinking-level-selector';
export * from './profile-quick-select';
export * from './testing-tab-content';
export * from './priority-selector';
export * from './branch-selector';
export * from './planning-mode-selector';

View File

@@ -1,9 +1,8 @@
import { Label } from "@/components/ui/label";
import { Brain } from "lucide-react";
import { cn } from "@/lib/utils";
import { AgentModel } from "@/store/app-store";
import { CLAUDE_MODELS, ModelOption } from "./model-constants";
import { Label } from '@/components/ui/label';
import { Brain } from 'lucide-react';
import { cn } from '@/lib/utils';
import { AgentModel } from '@/store/app-store';
import { CLAUDE_MODELS, ModelOption } from './model-constants';
interface ModelSelectorProps {
selectedModel: AgentModel;
@@ -14,7 +13,7 @@ interface ModelSelectorProps {
export function ModelSelector({
selectedModel,
onModelSelect,
testIdPrefix = "model-select",
testIdPrefix = 'model-select',
}: ModelSelectorProps) {
return (
<div className="space-y-3">
@@ -30,7 +29,7 @@ export function ModelSelector({
<div className="flex gap-2 flex-wrap">
{CLAUDE_MODELS.map((option) => {
const isSelected = selectedModel === option.id;
const shortName = option.label.replace("Claude ", "");
const shortName = option.label.replace('Claude ', '');
return (
<button
key={option.id}
@@ -38,10 +37,10 @@ export function ModelSelector({
onClick={() => onModelSelect(option.id)}
title={option.description}
className={cn(
"flex-1 min-w-[80px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
'flex-1 min-w-[80px] px-3 py-2 rounded-md border text-sm font-medium transition-colors',
isSelected
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-input"
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-input'
)}
data-testid={`${testIdPrefix}-${option.id}`}
>

View File

@@ -1,20 +1,27 @@
"use client";
'use client';
import { useState } from "react";
import { useState } from 'react';
import {
Zap, ClipboardList, FileText, ScrollText,
Loader2, Check, Eye, RefreshCw, Sparkles
} from "lucide-react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import type { PlanSpec } from "@/store/app-store";
Zap,
ClipboardList,
FileText,
ScrollText,
Loader2,
Check,
Eye,
RefreshCw,
Sparkles,
} from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
import type { PlanSpec } from '@/store/app-store';
export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
// Re-export for backwards compatibility
export type { ParsedTask, PlanSpec } from "@/store/app-store";
export type { ParsedTask, PlanSpec } from '@/store/app-store';
interface PlanningModeSelectorProps {
mode: PlanningMode;
@@ -90,7 +97,7 @@ export function PlanningModeSelector({
compact = false,
}: PlanningModeSelectorProps) {
const [showPreview, setShowPreview] = useState(false);
const selectedMode = modes.find(m => m.value === mode);
const selectedMode = modes.find((m) => m.value === mode);
const requiresApproval = mode === 'spec' || mode === 'full';
const canGenerate = requiresApproval && featureDescription?.trim() && !isGenerating;
const hasSpec = planSpec && planSpec.content;
@@ -100,11 +107,13 @@ export function PlanningModeSelector({
{/* Header with icon */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={cn(
"w-8 h-8 rounded-lg flex items-center justify-center",
selectedMode?.bgColor || "bg-muted"
)}>
{selectedMode && <selectedMode.icon className={cn("h-4 w-4", selectedMode.color)} />}
<div
className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center',
selectedMode?.bgColor || 'bg-muted'
)}
>
{selectedMode && <selectedMode.icon className={cn('h-4 w-4', selectedMode.color)} />}
</div>
<div>
<Label className="text-sm font-medium">Planning Mode</Label>
@@ -117,12 +126,7 @@ export function PlanningModeSelector({
{/* Quick action buttons when spec/full mode */}
{requiresApproval && hasSpec && (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={onViewSpec}
className="h-7 px-2"
>
<Button variant="ghost" size="sm" onClick={onViewSpec} className="h-7 px-2">
<Eye className="h-3.5 w-3.5 mr-1" />
View
</Button>
@@ -131,12 +135,7 @@ export function PlanningModeSelector({
</div>
{/* Mode Selection Cards */}
<div
className={cn(
"grid gap-2",
compact ? "grid-cols-2" : "grid-cols-2 sm:grid-cols-4"
)}
>
<div className={cn('grid gap-2', compact ? 'grid-cols-2' : 'grid-cols-2 sm:grid-cols-4')}>
{modes.map((m) => {
const isSelected = mode === m.value;
const Icon = m.icon;
@@ -147,37 +146,45 @@ export function PlanningModeSelector({
onClick={() => onModeChange(m.value)}
data-testid={`${testIdPrefix}-mode-${m.value}`}
className={cn(
"flex flex-col items-center gap-2 p-3 rounded-xl cursor-pointer transition-all duration-200",
"border-2 hover:border-primary/50",
'flex flex-col items-center gap-2 p-3 rounded-xl cursor-pointer transition-all duration-200',
'border-2 hover:border-primary/50',
isSelected
? cn("border-primary", m.bgColor)
: "border-border/50 bg-card/50 hover:bg-accent/30"
? cn('border-primary', m.bgColor)
: 'border-border/50 bg-card/50 hover:bg-accent/30'
)}
>
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center transition-colors",
isSelected ? m.bgColor : "bg-muted"
)}>
<Icon className={cn(
"h-5 w-5 transition-colors",
isSelected ? m.color : "text-muted-foreground"
)} />
<div
className={cn(
'w-10 h-10 rounded-full flex items-center justify-center transition-colors',
isSelected ? m.bgColor : 'bg-muted'
)}
>
<Icon
className={cn(
'h-5 w-5 transition-colors',
isSelected ? m.color : 'text-muted-foreground'
)}
/>
</div>
<div className="text-center">
<div className="flex items-center justify-center gap-1">
<span className={cn(
"font-medium text-sm",
isSelected ? "text-foreground" : "text-muted-foreground"
)}>
<span
className={cn(
'font-medium text-sm',
isSelected ? 'text-foreground' : 'text-muted-foreground'
)}
>
{m.label}
</span>
{m.badge && (
<span className={cn(
"text-[9px] px-1 py-0.5 rounded font-medium",
m.badge === 'Default'
? "bg-emerald-500/15 text-emerald-500"
: "bg-amber-500/15 text-amber-500"
)}>
<span
className={cn(
'text-[9px] px-1 py-0.5 rounded font-medium',
m.badge === 'Default'
? 'bg-emerald-500/15 text-emerald-500'
: 'bg-amber-500/15 text-amber-500'
)}
>
{m.badge === 'Default' ? 'Default' : 'Review'}
</span>
)}
@@ -213,14 +220,16 @@ export function PlanningModeSelector({
{/* Spec Preview/Actions Panel - Only for spec/full modes */}
{requiresApproval && (
<div className={cn(
"rounded-xl border transition-all duration-300",
planSpec?.status === 'approved'
? "border-emerald-500/30 bg-emerald-500/5"
: planSpec?.status === 'generated'
? "border-amber-500/30 bg-amber-500/5"
: "border-border/50 bg-muted/30"
)}>
<div
className={cn(
'rounded-xl border transition-all duration-300',
planSpec?.status === 'approved'
? 'border-emerald-500/30 bg-emerald-500/5'
: planSpec?.status === 'generated'
? 'border-amber-500/30 bg-amber-500/5'
: 'border-border/50 bg-muted/30'
)}
>
<div className="p-4 space-y-3">
{/* Status indicator */}
<div className="flex items-center justify-between">
@@ -228,7 +237,9 @@ export function PlanningModeSelector({
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">Generating {mode === 'full' ? 'comprehensive spec' : 'spec'}...</span>
<span className="text-sm text-muted-foreground">
Generating {mode === 'full' ? 'comprehensive spec' : 'spec'}...
</span>
</>
) : planSpec?.status === 'approved' ? (
<>
@@ -238,7 +249,9 @@ export function PlanningModeSelector({
) : planSpec?.status === 'generated' ? (
<>
<Eye className="h-4 w-4 text-amber-500" />
<span className="text-sm text-amber-500 font-medium">Spec Ready for Review</span>
<span className="text-sm text-amber-500 font-medium">
Spec Ready for Review
</span>
</>
) : (
<>
@@ -293,12 +306,7 @@ export function PlanningModeSelector({
{/* Action buttons when spec is generated */}
{planSpec?.status === 'generated' && (
<div className="flex items-center gap-2 pt-2 border-t border-border/30">
<Button
variant="outline"
size="sm"
onClick={onRejectSpec}
className="flex-1"
>
<Button variant="outline" size="sm" onClick={onRejectSpec} className="flex-1">
Request Changes
</Button>
<Button
@@ -315,12 +323,7 @@ export function PlanningModeSelector({
{/* Regenerate option when approved */}
{planSpec?.status === 'approved' && onGenerateSpec && (
<div className="flex items-center justify-end pt-2 border-t border-border/30">
<Button
variant="ghost"
size="sm"
onClick={onGenerateSpec}
className="h-7"
>
<Button variant="ghost" size="sm" onClick={onGenerateSpec} className="h-7">
<RefreshCw className="h-3.5 w-3.5 mr-1" />
Regenerate
</Button>
@@ -334,7 +337,7 @@ export function PlanningModeSelector({
{!requiresApproval && (
<p className="text-xs text-muted-foreground bg-muted/30 rounded-lg p-3">
{mode === 'skip'
? "The agent will start implementing immediately without creating a plan or spec."
? 'The agent will start implementing immediately without creating a plan or spec.'
: "The agent will create a planning outline before implementing, but won't wait for approval."}
</p>
)}

View File

@@ -1,6 +1,5 @@
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
interface PrioritySelectorProps {
selectedPriority: number;
@@ -11,7 +10,7 @@ interface PrioritySelectorProps {
export function PrioritySelector({
selectedPriority,
onPrioritySelect,
testIdPrefix = "priority",
testIdPrefix = 'priority',
}: PrioritySelectorProps) {
return (
<div className="space-y-2">
@@ -21,10 +20,10 @@ export function PrioritySelector({
type="button"
onClick={() => onPrioritySelect(1)}
className={cn(
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
selectedPriority === 1
? "bg-red-500/20 text-red-500 border-2 border-red-500/50"
: "bg-muted/50 text-muted-foreground border border-border hover:bg-muted"
? 'bg-red-500/20 text-red-500 border-2 border-red-500/50'
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
)}
data-testid={`${testIdPrefix}-high-button`}
>
@@ -34,10 +33,10 @@ export function PrioritySelector({
type="button"
onClick={() => onPrioritySelect(2)}
className={cn(
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
selectedPriority === 2
? "bg-yellow-500/20 text-yellow-500 border-2 border-yellow-500/50"
: "bg-muted/50 text-muted-foreground border border-border hover:bg-muted"
? 'bg-yellow-500/20 text-yellow-500 border-2 border-yellow-500/50'
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
)}
data-testid={`${testIdPrefix}-medium-button`}
>
@@ -47,10 +46,10 @@ export function PrioritySelector({
type="button"
onClick={() => onPrioritySelect(3)}
className={cn(
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
selectedPriority === 3
? "bg-blue-500/20 text-blue-500 border-2 border-blue-500/50"
: "bg-muted/50 text-muted-foreground border border-border hover:bg-muted"
? 'bg-blue-500/20 text-blue-500 border-2 border-blue-500/50'
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
)}
data-testid={`${testIdPrefix}-low-button`}
>

View File

@@ -1,9 +1,8 @@
import { Label } from "@/components/ui/label";
import { Brain, UserCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { AgentModel, ThinkingLevel, AIProfile } from "@/store/app-store";
import { PROFILE_ICONS } from "./model-constants";
import { Label } from '@/components/ui/label';
import { Brain, UserCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { AgentModel, ThinkingLevel, AIProfile } from '@/store/app-store';
import { PROFILE_ICONS } from './model-constants';
interface ProfileQuickSelectProps {
profiles: AIProfile[];
@@ -20,7 +19,7 @@ export function ProfileQuickSelect({
selectedModel,
selectedThinkingLevel,
onSelect,
testIdPrefix = "profile-quick-select",
testIdPrefix = 'profile-quick-select',
showManageLink = false,
onManageLinkClick,
}: ProfileQuickSelectProps) {
@@ -41,36 +40,30 @@ export function ProfileQuickSelect({
</div>
<div className="grid grid-cols-2 gap-2">
{profiles.slice(0, 6).map((profile) => {
const IconComponent = profile.icon
? PROFILE_ICONS[profile.icon]
: Brain;
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
const isSelected =
selectedModel === profile.model &&
selectedThinkingLevel === profile.thinkingLevel;
selectedModel === profile.model && selectedThinkingLevel === profile.thinkingLevel;
return (
<button
key={profile.id}
type="button"
onClick={() => onSelect(profile.model, profile.thinkingLevel)}
className={cn(
"flex items-center gap-2 p-2 rounded-lg border text-left transition-all",
'flex items-center gap-2 p-2 rounded-lg border text-left transition-all',
isSelected
? "bg-brand-500/10 border-brand-500 text-foreground"
: "bg-background hover:bg-accent border-input"
? 'bg-brand-500/10 border-brand-500 text-foreground'
: 'bg-background hover:bg-accent border-input'
)}
data-testid={`${testIdPrefix}-${profile.id}`}
>
<div className="w-7 h-7 rounded flex items-center justify-center shrink-0 bg-primary/10">
{IconComponent && (
<IconComponent className="w-4 h-4 text-primary" />
)}
{IconComponent && <IconComponent className="w-4 h-4 text-primary" />}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{profile.name}</p>
<p className="text-[10px] text-muted-foreground truncate">
{profile.model}
{profile.thinkingLevel !== "none" &&
` + ${profile.thinkingLevel}`}
{profile.thinkingLevel !== 'none' && ` + ${profile.thinkingLevel}`}
</p>
</div>
</button>
@@ -81,8 +74,8 @@ export function ProfileQuickSelect({
Or customize below.
{showManageLink && onManageLinkClick && (
<>
{" "}
Manage profiles in{" "}
{' '}
Manage profiles in{' '}
<button
type="button"
onClick={onManageLinkClick}

View File

@@ -1,9 +1,8 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { FlaskConical, Plus } from "lucide-react";
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { FlaskConical, Plus } from 'lucide-react';
interface TestingTabContentProps {
skipTests: boolean;
@@ -18,9 +17,9 @@ export function TestingTabContent({
onSkipTestsChange,
steps,
onStepsChange,
testIdPrefix = "",
testIdPrefix = '',
}: TestingTabContentProps) {
const checkboxId = testIdPrefix ? `${testIdPrefix}-skip-tests` : "skip-tests";
const checkboxId = testIdPrefix ? `${testIdPrefix}-skip-tests` : 'skip-tests';
const handleStepChange = (index: number, value: string) => {
const newSteps = [...steps];
@@ -29,7 +28,7 @@ export function TestingTabContent({
};
const handleAddStep = () => {
onStepsChange([...steps, ""]);
onStepsChange([...steps, '']);
};
return (
@@ -39,7 +38,7 @@ export function TestingTabContent({
id={checkboxId}
checked={!skipTests}
onCheckedChange={(checked) => onSkipTestsChange(checked !== true)}
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}skip-tests-checkbox`}
data-testid={`${testIdPrefix ? testIdPrefix + '-' : ''}skip-tests-checkbox`}
/>
<div className="flex items-center gap-2">
<Label htmlFor={checkboxId} className="text-sm cursor-pointer">
@@ -49,8 +48,8 @@ export function TestingTabContent({
</div>
</div>
<p className="text-xs text-muted-foreground">
When enabled, this feature will use automated TDD. When disabled, it
will require manual verification.
When enabled, this feature will use automated TDD. When disabled, it will require manual
verification.
</p>
{/* Verification Steps - Only shown when skipTests is enabled */}
@@ -66,14 +65,14 @@ export function TestingTabContent({
value={step}
placeholder={`Verification step ${index + 1}`}
onChange={(e) => handleStepChange(index, e.target.value)}
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}feature-step-${index}${testIdPrefix ? "" : "-input"}`}
data-testid={`${testIdPrefix ? testIdPrefix + '-' : ''}feature-step-${index}${testIdPrefix ? '' : '-input'}`}
/>
))}
<Button
variant="outline"
size="sm"
onClick={handleAddStep}
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}add-step-button`}
data-testid={`${testIdPrefix ? testIdPrefix + '-' : ''}add-step-button`}
>
<Plus className="w-4 h-4 mr-2" />
Add Verification Step

View File

@@ -1,9 +1,8 @@
import { Label } from "@/components/ui/label";
import { Brain } from "lucide-react";
import { cn } from "@/lib/utils";
import { ThinkingLevel } from "@/store/app-store";
import { THINKING_LEVELS, THINKING_LEVEL_LABELS } from "./model-constants";
import { Label } from '@/components/ui/label';
import { Brain } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ThinkingLevel } from '@/store/app-store';
import { THINKING_LEVELS, THINKING_LEVEL_LABELS } from './model-constants';
interface ThinkingLevelSelectorProps {
selectedLevel: ThinkingLevel;
@@ -14,7 +13,7 @@ interface ThinkingLevelSelectorProps {
export function ThinkingLevelSelector({
selectedLevel,
onLevelSelect,
testIdPrefix = "thinking-level",
testIdPrefix = 'thinking-level',
}: ThinkingLevelSelectorProps) {
return (
<div className="space-y-2 pt-2 border-t border-border">
@@ -29,10 +28,10 @@ export function ThinkingLevelSelector({
type="button"
onClick={() => onLevelSelect(level)}
className={cn(
"flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors min-w-[60px]",
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors min-w-[60px]',
selectedLevel === level
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-input"
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-input'
)}
data-testid={`${testIdPrefix}-${level}`}
>

View File

@@ -1,6 +1,5 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
DropdownMenu,
DropdownMenuContent,
@@ -8,16 +7,10 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
import {
GitBranch,
RefreshCw,
GitBranchPlus,
Check,
Search,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { WorktreeInfo, BranchInfo } from "../types";
} from '@/components/ui/dropdown-menu';
import { GitBranch, RefreshCw, GitBranchPlus, Check, Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { WorktreeInfo, BranchInfo } from '../types';
interface BranchSwitchDropdownProps {
worktree: WorktreeInfo;
@@ -49,12 +42,12 @@ export function BranchSwitchDropdown({
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? "default" : "outline"}
variant={isSelected ? 'default' : 'outline'}
size="sm"
className={cn(
"h-7 w-7 p-0 rounded-none border-r-0",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary"
'h-7 w-7 p-0 rounded-none border-r-0',
isSelected && 'bg-primary text-primary-foreground',
!isSelected && 'bg-secondary/50 hover:bg-secondary'
)}
title="Switch branch"
>
@@ -88,7 +81,7 @@ export function BranchSwitchDropdown({
</DropdownMenuItem>
) : filteredBranches.length === 0 ? (
<DropdownMenuItem disabled className="text-xs">
{branchFilter ? "No matching branches" : "No branches found"}
{branchFilter ? 'No matching branches' : 'No branches found'}
</DropdownMenuItem>
) : (
filteredBranches.map((branch) => (
@@ -109,10 +102,7 @@ export function BranchSwitchDropdown({
)}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onCreateBranch(worktree)}
className="text-xs"
>
<DropdownMenuItem onClick={() => onCreateBranch(worktree)} className="text-xs">
<GitBranchPlus className="w-3.5 h-3.5 mr-2" />
Create New Branch...
</DropdownMenuItem>

View File

@@ -1,3 +1,3 @@
export { BranchSwitchDropdown } from "./branch-switch-dropdown";
export { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
export { WorktreeTab } from "./worktree-tab";
export { BranchSwitchDropdown } from './branch-switch-dropdown';
export { WorktreeActionsDropdown } from './worktree-actions-dropdown';
export { WorktreeTab } from './worktree-tab';

View File

@@ -1,5 +1,4 @@
import { Button } from "@/components/ui/button";
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
@@ -7,7 +6,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
} from '@/components/ui/dropdown-menu';
import {
Trash2,
MoreHorizontal,
@@ -21,9 +20,9 @@ import {
Globe,
MessageSquare,
GitMerge,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { WorktreeInfo, DevServerInfo, PRInfo } from "../types";
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { WorktreeInfo, DevServerInfo, PRInfo } from '../types';
interface WorktreeActionsDropdownProps {
worktree: WorktreeInfo;
@@ -81,12 +80,12 @@ export function WorktreeActionsDropdown({
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? "default" : "outline"}
variant={isSelected ? 'default' : 'outline'}
size="sm"
className={cn(
"h-7 w-7 p-0 rounded-l-none",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary"
'h-7 w-7 p-0 rounded-l-none',
isSelected && 'bg-primary text-primary-foreground',
!isSelected && 'bg-secondary/50 hover:bg-secondary'
)}
>
<MoreHorizontal className="w-3 h-3" />
@@ -99,10 +98,7 @@ export function WorktreeActionsDropdown({
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
Dev Server Running (:{devServerInfo?.port})
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => onOpenDevServerUrl(worktree)}
className="text-xs"
>
<DropdownMenuItem onClick={() => onOpenDevServerUrl(worktree)} className="text-xs">
<Globe className="w-3.5 h-3.5 mr-2" />
Open in Browser
</DropdownMenuItem>
@@ -122,26 +118,15 @@ export function WorktreeActionsDropdown({
disabled={isStartingDevServer}
className="text-xs"
>
<Play
className={cn(
"w-3.5 h-3.5 mr-2",
isStartingDevServer && "animate-pulse"
)}
/>
{isStartingDevServer ? "Starting..." : "Start Dev Server"}
<Play className={cn('w-3.5 h-3.5 mr-2', isStartingDevServer && 'animate-pulse')} />
{isStartingDevServer ? 'Starting...' : 'Start Dev Server'}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => onPull(worktree)}
disabled={isPulling}
className="text-xs"
>
<Download
className={cn("w-3.5 h-3.5 mr-2", isPulling && "animate-pulse")}
/>
{isPulling ? "Pulling..." : "Pull"}
<DropdownMenuItem onClick={() => onPull(worktree)} disabled={isPulling} className="text-xs">
<Download className={cn('w-3.5 h-3.5 mr-2', isPulling && 'animate-pulse')} />
{isPulling ? 'Pulling...' : 'Pull'}
{behindCount > 0 && (
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
{behindCount} behind
@@ -153,10 +138,8 @@ export function WorktreeActionsDropdown({
disabled={isPushing || aheadCount === 0}
className="text-xs"
>
<Upload
className={cn("w-3.5 h-3.5 mr-2", isPushing && "animate-pulse")}
/>
{isPushing ? "Pushing..." : "Push"}
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
{isPushing ? 'Pushing...' : 'Push'}
{aheadCount > 0 && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
{aheadCount} ahead
@@ -173,10 +156,7 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onOpenInEditor(worktree)}
className="text-xs"
>
<DropdownMenuItem onClick={() => onOpenInEditor(worktree)} className="text-xs">
<ExternalLink className="w-3.5 h-3.5 mr-2" />
Open in {defaultEditorName}
</DropdownMenuItem>
@@ -199,7 +179,7 @@ export function WorktreeActionsDropdown({
<>
<DropdownMenuItem
onClick={() => {
window.open(worktree.pr!.url, "_blank");
window.open(worktree.pr!.url, '_blank');
}}
className="text-xs"
>
@@ -218,8 +198,8 @@ export function WorktreeActionsDropdown({
title: worktree.pr!.title,
url: worktree.pr!.url,
state: worktree.pr!.state,
author: "", // Will be fetched
body: "", // Will be fetched
author: '', // Will be fetched
body: '', // Will be fetched
comments: [],
reviewComments: [],
};

View File

@@ -1,6 +1,6 @@
export { useWorktrees } from "./use-worktrees";
export { useDevServers } from "./use-dev-servers";
export { useBranches } from "./use-branches";
export { useWorktreeActions } from "./use-worktree-actions";
export { useDefaultEditor } from "./use-default-editor";
export { useRunningFeatures } from "./use-running-features";
export { useWorktrees } from './use-worktrees';
export { useDevServers } from './use-dev-servers';
export { useBranches } from './use-branches';
export { useWorktreeActions } from './use-worktree-actions';
export { useDefaultEditor } from './use-default-editor';
export { useRunningFeatures } from './use-running-features';

View File

@@ -1,21 +1,20 @@
import { useState, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import type { BranchInfo } from "../types";
import { useState, useCallback } from 'react';
import { getElectronAPI } from '@/lib/electron';
import type { BranchInfo } from '../types';
export function useBranches() {
const [branches, setBranches] = useState<BranchInfo[]>([]);
const [aheadCount, setAheadCount] = useState(0);
const [behindCount, setBehindCount] = useState(0);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [branchFilter, setBranchFilter] = useState("");
const [branchFilter, setBranchFilter] = useState('');
const fetchBranches = useCallback(async (worktreePath: string) => {
setIsLoadingBranches(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.listBranches) {
console.warn("List branches API not available");
console.warn('List branches API not available');
return;
}
const result = await api.worktree.listBranches(worktreePath);
@@ -25,14 +24,14 @@ export function useBranches() {
setBehindCount(result.result.behindCount || 0);
}
} catch (error) {
console.error("Failed to fetch branches:", error);
console.error('Failed to fetch branches:', error);
} finally {
setIsLoadingBranches(false);
}
}, []);
const resetBranchFilter = useCallback(() => {
setBranchFilter("");
setBranchFilter('');
}, []);
const filteredBranches = branches.filter((b) =>

View File

@@ -1,9 +1,8 @@
import { useState, useEffect, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import { useState, useEffect, useCallback } from 'react';
import { getElectronAPI } from '@/lib/electron';
export function useDefaultEditor() {
const [defaultEditorName, setDefaultEditorName] = useState<string>("Editor");
const [defaultEditorName, setDefaultEditorName] = useState<string>('Editor');
const fetchDefaultEditor = useCallback(async () => {
try {
@@ -16,7 +15,7 @@ export function useDefaultEditor() {
setDefaultEditorName(result.result.editorName);
}
} catch (error) {
console.error("Failed to fetch default editor:", error);
console.error('Failed to fetch default editor:', error);
}
}, []);

View File

@@ -1,9 +1,8 @@
import { useState, useEffect, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import { normalizePath } from "@/lib/utils";
import { toast } from "sonner";
import type { DevServerInfo, WorktreeInfo } from "../types";
import { useState, useEffect, useCallback } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { normalizePath } from '@/lib/utils';
import { toast } from 'sonner';
import type { DevServerInfo, WorktreeInfo } from '../types';
interface UseDevServersOptions {
projectPath: string;
@@ -11,9 +10,7 @@ interface UseDevServersOptions {
export function useDevServers({ projectPath }: UseDevServersOptions) {
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(
new Map()
);
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(new Map());
const fetchDevServers = useCallback(async () => {
try {
@@ -30,7 +27,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
setRunningDevServers(serversMap);
}
} catch (error) {
console.error("Failed to fetch dev servers:", error);
console.error('Failed to fetch dev servers:', error);
}
}, []);
@@ -54,7 +51,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
try {
const api = getElectronAPI();
if (!api?.worktree?.startDevServer) {
toast.error("Start dev server API not available");
toast.error('Start dev server API not available');
return;
}
@@ -73,11 +70,11 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
});
toast.success(`Dev server started on port ${result.result.port}`);
} else {
toast.error(result.error || "Failed to start dev server");
toast.error(result.error || 'Failed to start dev server');
}
} catch (error) {
console.error("Start dev server failed:", error);
toast.error("Failed to start dev server");
console.error('Start dev server failed:', error);
toast.error('Failed to start dev server');
} finally {
setIsStartingDevServer(false);
}
@@ -90,7 +87,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
try {
const api = getElectronAPI();
if (!api?.worktree?.stopDevServer) {
toast.error("Stop dev server API not available");
toast.error('Stop dev server API not available');
return;
}
@@ -103,13 +100,13 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
next.delete(normalizePath(targetPath));
return next;
});
toast.success(result.result?.message || "Dev server stopped");
toast.success(result.result?.message || 'Dev server stopped');
} else {
toast.error(result.error || "Failed to stop dev server");
toast.error(result.error || 'Failed to stop dev server');
}
} catch (error) {
console.error("Stop dev server failed:", error);
toast.error("Failed to stop dev server");
console.error('Stop dev server failed:', error);
toast.error('Failed to stop dev server');
}
},
[projectPath]
@@ -120,7 +117,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
const targetPath = worktree.isMain ? projectPath : worktree.path;
const serverInfo = runningDevServers.get(targetPath);
if (serverInfo) {
window.open(serverInfo.url, "_blank");
window.open(serverInfo.url, '_blank');
}
},
[projectPath, runningDevServers]

View File

@@ -1,16 +1,12 @@
import { useCallback } from "react";
import type { WorktreeInfo, FeatureInfo } from "../types";
import { useCallback } from 'react';
import type { WorktreeInfo, FeatureInfo } from '../types';
interface UseRunningFeaturesOptions {
runningFeatureIds: string[];
features: FeatureInfo[];
}
export function useRunningFeatures({
runningFeatureIds,
features,
}: UseRunningFeaturesOptions) {
export function useRunningFeatures({ runningFeatureIds, features }: UseRunningFeaturesOptions) {
const hasRunningFeatures = useCallback(
(worktree: WorktreeInfo) => {
if (runningFeatureIds.length === 0) return false;

View File

@@ -1,18 +1,14 @@
import { useState, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
import type { WorktreeInfo } from "../types";
import { useState, useCallback } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import type { WorktreeInfo } from '../types';
interface UseWorktreeActionsOptions {
fetchWorktrees: () => Promise<Array<{ path: string; branch: string }> | undefined>;
fetchBranches: (worktreePath: string) => Promise<void>;
}
export function useWorktreeActions({
fetchWorktrees,
fetchBranches,
}: UseWorktreeActionsOptions) {
export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktreeActionsOptions) {
const [isPulling, setIsPulling] = useState(false);
const [isPushing, setIsPushing] = useState(false);
const [isSwitching, setIsSwitching] = useState(false);
@@ -25,7 +21,7 @@ export function useWorktreeActions({
try {
const api = getElectronAPI();
if (!api?.worktree?.switchBranch) {
toast.error("Switch branch API not available");
toast.error('Switch branch API not available');
return;
}
const result = await api.worktree.switchBranch(worktree.path, branchName);
@@ -33,11 +29,11 @@ export function useWorktreeActions({
toast.success(result.result.message);
fetchWorktrees();
} else {
toast.error(result.error || "Failed to switch branch");
toast.error(result.error || 'Failed to switch branch');
}
} catch (error) {
console.error("Switch branch failed:", error);
toast.error("Failed to switch branch");
console.error('Switch branch failed:', error);
toast.error('Failed to switch branch');
} finally {
setIsSwitching(false);
}
@@ -52,7 +48,7 @@ export function useWorktreeActions({
try {
const api = getElectronAPI();
if (!api?.worktree?.pull) {
toast.error("Pull API not available");
toast.error('Pull API not available');
return;
}
const result = await api.worktree.pull(worktree.path);
@@ -60,11 +56,11 @@ export function useWorktreeActions({
toast.success(result.result.message);
fetchWorktrees();
} else {
toast.error(result.error || "Failed to pull latest changes");
toast.error(result.error || 'Failed to pull latest changes');
}
} catch (error) {
console.error("Pull failed:", error);
toast.error("Failed to pull latest changes");
console.error('Pull failed:', error);
toast.error('Failed to pull latest changes');
} finally {
setIsPulling(false);
}
@@ -79,7 +75,7 @@ export function useWorktreeActions({
try {
const api = getElectronAPI();
if (!api?.worktree?.push) {
toast.error("Push API not available");
toast.error('Push API not available');
return;
}
const result = await api.worktree.push(worktree.path);
@@ -88,11 +84,11 @@ export function useWorktreeActions({
fetchBranches(worktree.path);
fetchWorktrees();
} else {
toast.error(result.error || "Failed to push changes");
toast.error(result.error || 'Failed to push changes');
}
} catch (error) {
console.error("Push failed:", error);
toast.error("Failed to push changes");
console.error('Push failed:', error);
toast.error('Failed to push changes');
} finally {
setIsPushing(false);
}
@@ -104,7 +100,7 @@ export function useWorktreeActions({
try {
const api = getElectronAPI();
if (!api?.worktree?.openInEditor) {
console.warn("Open in editor API not available");
console.warn('Open in editor API not available');
return;
}
const result = await api.worktree.openInEditor(worktree.path);
@@ -114,7 +110,7 @@ export function useWorktreeActions({
toast.error(result.error);
}
} catch (error) {
console.error("Open in editor failed:", error);
console.error('Open in editor failed:', error);
}
}, []);

View File

@@ -1,8 +1,8 @@
export { WorktreePanel } from "./worktree-panel";
export { WorktreePanel } from './worktree-panel';
export type {
WorktreeInfo,
BranchInfo,
DevServerInfo,
FeatureInfo,
WorktreePanelProps,
} from "./types";
} from './types';

View File

@@ -1,8 +1,7 @@
import { useState } from "react";
import { useAppStore } from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from 'react';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Plus,
MessageSquare,
@@ -12,16 +11,16 @@ import {
Search,
ChevronLeft,
ArchiveRestore,
} from "lucide-react";
import { cn } from "@/lib/utils";
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
export function ChatHistory() {
const {
@@ -37,7 +36,7 @@ export function ChatHistory() {
setChatHistoryOpen,
} = useAppStore();
const [searchQuery, setSearchQuery] = useState("");
const [searchQuery, setSearchQuery] = useState('');
const [showArchived, setShowArchived] = useState(false);
if (!currentProject) {
@@ -45,18 +44,12 @@ export function ChatHistory() {
}
// Filter sessions for current project
const projectSessions = chatSessions.filter(
(session) => session.projectId === currentProject.id
);
const projectSessions = chatSessions.filter((session) => session.projectId === currentProject.id);
// Filter by search query and archived status
const filteredSessions = projectSessions.filter((session) => {
const matchesSearch = session.title
.toLowerCase()
.includes(searchQuery.toLowerCase());
const matchesArchivedStatus = showArchived
? session.archived
: !session.archived;
const matchesSearch = session.title.toLowerCase().includes(searchQuery.toLowerCase());
const matchesArchivedStatus = showArchived ? session.archived : !session.archived;
return matchesSearch && matchesArchivedStatus;
});
@@ -85,7 +78,7 @@ export function ChatHistory() {
const handleDeleteSession = (sessionId: string, e: React.MouseEvent) => {
e.stopPropagation();
if (confirm("Are you sure you want to delete this chat session?")) {
if (confirm('Are you sure you want to delete this chat session?')) {
deleteChatSession(sessionId);
}
};
@@ -93,8 +86,8 @@ export function ChatHistory() {
return (
<div
className={cn(
"flex flex-col h-full bg-zinc-950/50 backdrop-blur-md border-r border-white/10 transition-all duration-200",
chatHistoryOpen ? "w-80" : "w-0 overflow-hidden"
'flex flex-col h-full bg-zinc-950/50 backdrop-blur-md border-r border-white/10 transition-all duration-200',
chatHistoryOpen ? 'w-80' : 'w-0 overflow-hidden'
)}
>
{chatHistoryOpen && (
@@ -105,11 +98,7 @@ export function ChatHistory() {
<MessageSquare className="w-5 h-5" />
<h2 className="font-semibold">Chat History</h2>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setChatHistoryOpen(false)}
>
<Button variant="ghost" size="sm" onClick={() => setChatHistoryOpen(false)}>
<ChevronLeft className="w-4 h-4" />
</Button>
</div>
@@ -152,7 +141,7 @@ export function ChatHistory() {
) : (
<Archive className="w-4 h-4" />
)}
{showArchived ? "Show Active" : "Show Archived"}
{showArchived ? 'Show Active' : 'Show Archived'}
{showArchived && (
<Badge variant="outline" className="ml-auto">
{projectSessions.filter((s) => s.archived).length}
@@ -179,15 +168,13 @@ export function ChatHistory() {
<div
key={session.id}
className={cn(
"flex items-center gap-2 p-3 rounded-lg cursor-pointer hover:bg-accent transition-colors group",
currentChatSession?.id === session.id && "bg-accent"
'flex items-center gap-2 p-3 rounded-lg cursor-pointer hover:bg-accent transition-colors group',
currentChatSession?.id === session.id && 'bg-accent'
)}
onClick={() => handleSelectSession(session)}
>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm truncate">
{session.title}
</h3>
<h3 className="font-medium text-sm truncate">{session.title}</h3>
<p className="text-xs text-muted-foreground truncate">
{session.messages.length} messages
</p>
@@ -199,30 +186,20 @@ export function ChatHistory() {
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreVertical className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{session.archived ? (
<DropdownMenuItem
onClick={(e) =>
handleUnarchiveSession(session.id, e)
}
onClick={(e) => handleUnarchiveSession(session.id, e)}
>
<ArchiveRestore className="w-4 h-4 mr-2" />
Unarchive
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={(e) =>
handleArchiveSession(session.id, e)
}
>
<DropdownMenuItem onClick={(e) => handleArchiveSession(session.id, e)}>
<Archive className="w-4 h-4 mr-2" />
Archive
</DropdownMenuItem>

View File

@@ -1,19 +1,10 @@
import { useEffect, useState, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
File,
Folder,
FolderOpen,
ChevronRight,
ChevronDown,
RefreshCw,
Code,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useEffect, useState, useCallback } from 'react';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { File, Folder, FolderOpen, ChevronRight, ChevronDown, RefreshCw, Code } from 'lucide-react';
import { cn } from '@/lib/utils';
interface FileTreeNode {
name: string;
@@ -23,19 +14,11 @@ interface FileTreeNode {
isExpanded?: boolean;
}
const IGNORE_PATTERNS = [
"node_modules",
".git",
".next",
"dist",
"build",
".DS_Store",
"*.log",
];
const IGNORE_PATTERNS = ['node_modules', '.git', '.next', 'dist', 'build', '.DS_Store', '*.log'];
const shouldIgnore = (name: string) => {
return IGNORE_PATTERNS.some((pattern) => {
if (pattern.startsWith("*")) {
if (pattern.startsWith('*')) {
return name.endsWith(pattern.slice(1));
}
return name === pattern;
@@ -46,11 +29,9 @@ export function CodeView() {
const { currentProject } = useAppStore();
const [fileTree, setFileTree] = useState<FileTreeNode[]>([]);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string>("");
const [fileContent, setFileContent] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set()
);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
// Load directory tree
const loadTree = useCallback(async () => {
@@ -79,7 +60,7 @@ export function CodeView() {
setFileTree(entries);
}
} catch (error) {
console.error("Failed to load file tree:", error);
console.error('Failed to load file tree:', error);
} finally {
setIsLoading(false);
}
@@ -110,7 +91,7 @@ export function CodeView() {
}));
}
} catch (error) {
console.error("Failed to load subdirectory:", error);
console.error('Failed to load subdirectory:', error);
}
return [];
};
@@ -126,7 +107,7 @@ export function CodeView() {
setSelectedFile(path);
}
} catch (error) {
console.error("Failed to load file:", error);
console.error('Failed to load file:', error);
}
};
@@ -170,8 +151,8 @@ export function CodeView() {
<div key={node.path}>
<div
className={cn(
"flex items-center gap-2 py-1 px-2 rounded cursor-pointer hover:bg-muted/50",
isSelected && "bg-muted"
'flex items-center gap-2 py-1 px-2 rounded cursor-pointer hover:bg-muted/50',
isSelected && 'bg-muted'
)}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => {
@@ -205,9 +186,7 @@ export function CodeView() {
<span className="text-sm truncate">{node.name}</span>
</div>
{node.isDirectory && isExpanded && node.children && (
<div>
{node.children.map((child) => renderNode(child, depth + 1))}
</div>
<div>{node.children.map((child) => renderNode(child, depth + 1))}</div>
)}
</div>
);
@@ -215,10 +194,7 @@ export function CodeView() {
if (!currentProject) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="code-view-no-project"
>
<div className="flex-1 flex items-center justify-center" data-testid="code-view-no-project">
<p className="text-muted-foreground">No project selected</p>
</div>
);
@@ -226,37 +202,24 @@ export function CodeView() {
if (isLoading) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="code-view-loading"
>
<div className="flex-1 flex items-center justify-center" data-testid="code-view-loading">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="code-view"
>
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="code-view">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10 bg-zinc-950/50 backdrop-blur-md">
<div className="flex items-center gap-3">
<Code className="w-5 h-5 text-muted-foreground" />
<div>
<h1 className="text-xl font-bold">Code Explorer</h1>
<p className="text-sm text-muted-foreground">
{currentProject.name}
</p>
<p className="text-sm text-muted-foreground">{currentProject.name}</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={loadTree}
data-testid="refresh-tree"
>
<Button variant="outline" size="sm" onClick={loadTree} data-testid="refresh-tree">
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
@@ -275,7 +238,7 @@ export function CodeView() {
<div className="h-full flex flex-col">
<div className="px-4 py-2 border-b bg-muted/30">
<p className="text-sm font-mono text-muted-foreground truncate">
{selectedFile.replace(currentProject.path, "")}
{selectedFile.replace(currentProject.path, '')}
</p>
</div>
<Card className="flex-1 m-4 overflow-hidden">
@@ -288,9 +251,7 @@ export function CodeView() {
</div>
) : (
<div className="flex-1 flex items-center justify-center">
<p className="text-muted-foreground">
Select a file to view its contents
</p>
<p className="text-muted-foreground">Select a file to view its contents</p>
</div>
)}
</div>

View File

@@ -1,24 +1,20 @@
import { useState, useMemo, useCallback } from "react";
import {
useAppStore,
AIProfile,
} from "@/store/app-store";
import { useState, useMemo, useCallback } from 'react';
import { useAppStore, AIProfile } from '@/store/app-store';
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
} from '@/hooks/use-keyboard-shortcuts';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Sparkles } from "lucide-react";
import { toast } from "sonner";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
} from '@/components/ui/dialog';
import { Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
import {
DndContext,
DragEndEvent,
@@ -26,16 +22,9 @@ import {
useSensor,
useSensors,
closestCenter,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import {
SortableProfileCard,
ProfileForm,
ProfilesHeader,
} from "./profiles-view/components";
} from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { SortableProfileCard, ProfileForm, ProfilesHeader } from './profiles-view/components';
export function ProfilesView() {
const {
@@ -62,14 +51,8 @@ export function ProfilesView() {
);
// Separate built-in and custom profiles
const builtInProfiles = useMemo(
() => aiProfiles.filter((p) => p.isBuiltIn),
[aiProfiles]
);
const customProfiles = useMemo(
() => aiProfiles.filter((p) => !p.isBuiltIn),
[aiProfiles]
);
const builtInProfiles = useMemo(() => aiProfiles.filter((p) => p.isBuiltIn), [aiProfiles]);
const customProfiles = useMemo(() => aiProfiles.filter((p) => !p.isBuiltIn), [aiProfiles]);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
@@ -87,19 +70,19 @@ export function ProfilesView() {
[aiProfiles, reorderAIProfiles]
);
const handleAddProfile = (profile: Omit<AIProfile, "id">) => {
const handleAddProfile = (profile: Omit<AIProfile, 'id'>) => {
addAIProfile(profile);
setShowAddDialog(false);
toast.success("Profile created", {
toast.success('Profile created', {
description: `Created "${profile.name}" profile`,
});
};
const handleUpdateProfile = (profile: Omit<AIProfile, "id">) => {
const handleUpdateProfile = (profile: Omit<AIProfile, 'id'>) => {
if (editingProfile) {
updateAIProfile(editingProfile.id, profile);
setEditingProfile(null);
toast.success("Profile updated", {
toast.success('Profile updated', {
description: `Updated "${profile.name}" profile`,
});
}
@@ -109,7 +92,7 @@ export function ProfilesView() {
if (!profileToDelete) return;
removeAIProfile(profileToDelete.id);
toast.success("Profile deleted", {
toast.success('Profile deleted', {
description: `Deleted "${profileToDelete.name}" profile`,
});
setProfileToDelete(null);
@@ -117,8 +100,8 @@ export function ProfilesView() {
const handleResetProfiles = () => {
resetAIProfiles();
toast.success("Profiles refreshed", {
description: "Default profiles have been updated to the latest version",
toast.success('Profiles refreshed', {
description: 'Default profiles have been updated to the latest version',
});
};
@@ -130,7 +113,7 @@ export function ProfilesView() {
shortcutsList.push({
key: shortcuts.addProfile,
action: () => setShowAddDialog(true),
description: "Create new profile",
description: 'Create new profile',
});
return shortcutsList;
@@ -140,10 +123,7 @@ export function ProfilesView() {
useKeyboardShortcuts(profilesShortcuts);
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="profiles-view"
>
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="profiles-view">
{/* Header Section */}
<ProfilesHeader
onResetProfiles={handleResetProfiles}
@@ -157,9 +137,7 @@ export function ProfilesView() {
{/* Custom Profiles Section */}
<div>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-semibold text-foreground">
Custom Profiles
</h2>
<h2 className="text-lg font-semibold text-foreground">Custom Profiles</h2>
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary">
{customProfiles.length}
</span>
@@ -202,16 +180,13 @@ export function ProfilesView() {
{/* Built-in Profiles Section */}
<div>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-semibold text-foreground">
Built-in Profiles
</h2>
<h2 className="text-lg font-semibold text-foreground">Built-in Profiles</h2>
<span className="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
{builtInProfiles.length}
</span>
</div>
<p className="text-sm text-muted-foreground mb-4">
Pre-configured profiles for common use cases. These cannot be
edited or deleted.
Pre-configured profiles for common use cases. These cannot be edited or deleted.
</p>
<DndContext
sensors={sensors}
@@ -240,12 +215,13 @@ export function ProfilesView() {
{/* Add Profile Dialog */}
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent data-testid="add-profile-dialog" className="flex flex-col max-h-[calc(100vh-4rem)]">
<DialogContent
data-testid="add-profile-dialog"
className="flex flex-col max-h-[calc(100vh-4rem)]"
>
<DialogHeader className="shrink-0">
<DialogTitle>Create New Profile</DialogTitle>
<DialogDescription>
Define a reusable model configuration preset.
</DialogDescription>
<DialogDescription>Define a reusable model configuration preset.</DialogDescription>
</DialogHeader>
<ProfileForm
profile={{}}
@@ -258,11 +234,11 @@ export function ProfilesView() {
</Dialog>
{/* Edit Profile Dialog */}
<Dialog
open={!!editingProfile}
onOpenChange={() => setEditingProfile(null)}
>
<DialogContent data-testid="edit-profile-dialog" className="flex flex-col max-h-[calc(100vh-4rem)]">
<Dialog open={!!editingProfile} onOpenChange={() => setEditingProfile(null)}>
<DialogContent
data-testid="edit-profile-dialog"
className="flex flex-col max-h-[calc(100vh-4rem)]"
>
<DialogHeader className="shrink-0">
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Modify your profile settings.</DialogDescription>
@@ -288,7 +264,7 @@ export function ProfilesView() {
description={
profileToDelete
? `Are you sure you want to delete "${profileToDelete.name}"? This action cannot be undone.`
: ""
: ''
}
confirmText="Delete Profile"
testId="delete-profile-confirm-dialog"

View File

@@ -1,3 +1,3 @@
export { SortableProfileCard } from "./sortable-profile-card";
export { ProfileForm } from "./profile-form";
export { ProfilesHeader } from "./profiles-header";
export { SortableProfileCard } from './sortable-profile-card';
export { ProfileForm } from './profile-form';
export { ProfilesHeader } from './profiles-header';

View File

@@ -1,21 +1,20 @@
import { useState } 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 { Textarea } from "@/components/ui/textarea";
import { cn, modelSupportsThinking } from "@/lib/utils";
import { DialogFooter } from "@/components/ui/dialog";
import { Brain } from "lucide-react";
import { toast } from "sonner";
import type { AIProfile, AgentModel, ThinkingLevel } from "@/store/app-store";
import { CLAUDE_MODELS, THINKING_LEVELS, ICON_OPTIONS } from "../constants";
import { getProviderFromModel } from "../utils";
import { useState } 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 { Textarea } from '@/components/ui/textarea';
import { cn, modelSupportsThinking } from '@/lib/utils';
import { DialogFooter } from '@/components/ui/dialog';
import { Brain } from 'lucide-react';
import { toast } from 'sonner';
import type { AIProfile, AgentModel, ThinkingLevel } from '@/store/app-store';
import { CLAUDE_MODELS, THINKING_LEVELS, ICON_OPTIONS } from '../constants';
import { getProviderFromModel } from '../utils';
interface ProfileFormProps {
profile: Partial<AIProfile>;
onSave: (profile: Omit<AIProfile, "id">) => void;
onSave: (profile: Omit<AIProfile, 'id'>) => void;
onCancel: () => void;
isEditing: boolean;
hotkeyActive: boolean;
@@ -29,11 +28,11 @@ export function ProfileForm({
hotkeyActive,
}: ProfileFormProps) {
const [formData, setFormData] = useState({
name: profile.name || "",
description: profile.description || "",
model: profile.model || ("opus" as AgentModel),
thinkingLevel: profile.thinkingLevel || ("none" as ThinkingLevel),
icon: profile.icon || "Brain",
name: profile.name || '',
description: profile.description || '',
model: profile.model || ('opus' as AgentModel),
thinkingLevel: profile.thinkingLevel || ('none' as ThinkingLevel),
icon: profile.icon || 'Brain',
});
const provider = getProviderFromModel(formData.model);
@@ -48,7 +47,7 @@ export function ProfileForm({
const handleSubmit = () => {
if (!formData.name.trim()) {
toast.error("Please enter a profile name");
toast.error('Please enter a profile name');
return;
}
@@ -56,7 +55,7 @@ export function ProfileForm({
name: formData.name.trim(),
description: formData.description.trim(),
model: formData.model,
thinkingLevel: supportsThinking ? formData.thinkingLevel : "none",
thinkingLevel: supportsThinking ? formData.thinkingLevel : 'none',
provider,
isBuiltIn: false,
icon: formData.icon,
@@ -84,9 +83,7 @@ export function ProfileForm({
<Textarea
id="profile-description"
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Describe when to use this profile..."
rows={2}
data-testid="profile-description-input"
@@ -103,10 +100,10 @@ export function ProfileForm({
type="button"
onClick={() => setFormData({ ...formData, icon: name })}
className={cn(
"w-10 h-10 rounded-lg flex items-center justify-center border transition-colors",
'w-10 h-10 rounded-lg flex items-center justify-center border transition-colors',
formData.icon === name
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-border"
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`icon-select-${name}`}
>
@@ -129,14 +126,14 @@ export function ProfileForm({
type="button"
onClick={() => handleModelChange(id)}
className={cn(
"flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
'flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors',
formData.model === id
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-border"
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`model-select-${id}`}
>
{label.replace("Claude ", "")}
{label.replace('Claude ', '')}
</button>
))}
</div>
@@ -156,19 +153,19 @@ export function ProfileForm({
type="button"
onClick={() => {
setFormData({ ...formData, thinkingLevel: id });
if (id === "ultrathink") {
toast.warning("Ultrathink uses extensive reasoning", {
if (id === 'ultrathink') {
toast.warning('Ultrathink uses extensive reasoning', {
description:
"Best for complex architecture, migrations, or deep debugging (~$0.48/task).",
'Best for complex architecture, migrations, or deep debugging (~$0.48/task).',
duration: 4000,
});
}
}}
className={cn(
"flex-1 min-w-[70px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
'flex-1 min-w-[70px] px-3 py-2 rounded-md border text-sm font-medium transition-colors',
formData.thinkingLevel === id
? "bg-amber-500 text-white border-amber-400"
: "bg-background hover:bg-accent border-border"
? 'bg-amber-500 text-white border-amber-400'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`thinking-select-${id}`}
>
@@ -190,14 +187,13 @@ export function ProfileForm({
</Button>
<HotkeyButton
onClick={handleSubmit}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={hotkeyActive}
data-testid="save-profile-button"
>
{isEditing ? "Save Changes" : "Create Profile"}
{isEditing ? 'Save Changes' : 'Create Profile'}
</HotkeyButton>
</DialogFooter>
</>
);
}

View File

@@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { UserCircle, Plus, RefreshCw } from "lucide-react";
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { UserCircle, Plus, RefreshCw } from 'lucide-react';
interface ProfilesHeaderProps {
onResetProfiles: () => void;
@@ -22,9 +22,7 @@ export function ProfilesHeader({
<UserCircle className="w-5 h-5 text-primary-foreground" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">
AI Profiles
</h1>
<h1 className="text-2xl font-bold text-foreground">AI Profiles</h1>
<p className="text-sm text-muted-foreground">
Create and manage model configuration presets
</p>
@@ -55,4 +53,3 @@ export function ProfilesHeader({
</div>
);
}

View File

@@ -1,10 +1,10 @@
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { GripVertical, Lock, Pencil, Trash2, Brain } from "lucide-react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { AIProfile } from "@/store/app-store";
import { PROFILE_ICONS } from "../constants";
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { GripVertical, Lock, Pencil, Trash2, Brain } from 'lucide-react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { AIProfile } from '@/store/app-store';
import { PROFILE_ICONS } from '../constants';
interface SortableProfileCardProps {
profile: AIProfile;
@@ -12,19 +12,10 @@ interface SortableProfileCardProps {
onDelete: () => void;
}
export function SortableProfileCard({
profile,
onEdit,
onDelete,
}: SortableProfileCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: profile.id });
export function SortableProfileCard({ profile, onEdit, onDelete }: SortableProfileCardProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: profile.id,
});
const style = {
transform: CSS.Transform.toString(transform),
@@ -39,11 +30,11 @@ export function SortableProfileCard({
ref={setNodeRef}
style={style}
className={cn(
"group relative flex items-start gap-4 p-4 rounded-xl border bg-card transition-all",
isDragging && "shadow-lg",
'group relative flex items-start gap-4 p-4 rounded-xl border bg-card transition-all',
isDragging && 'shadow-lg',
profile.isBuiltIn
? "border-border/50"
: "border-border hover:border-primary/50 hover:shadow-sm"
? 'border-border/50'
: 'border-border hover:border-primary/50 hover:shadow-sm'
)}
data-testid={`profile-card-${profile.id}`}
>
@@ -60,12 +51,8 @@ export function SortableProfileCard({
</button>
{/* Icon */}
<div
className="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center bg-primary/10"
>
{IconComponent && (
<IconComponent className="w-5 h-5 text-primary" />
)}
<div className="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center bg-primary/10">
{IconComponent && <IconComponent className="w-5 h-5 text-primary" />}
</div>
{/* Content */}
@@ -79,16 +66,12 @@ export function SortableProfileCard({
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">
{profile.description}
</p>
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">{profile.description}</p>
<div className="flex items-center gap-2 mt-2 flex-wrap">
<span
className="text-xs px-2 py-0.5 rounded-full border border-primary/30 text-primary bg-primary/10"
>
<span className="text-xs px-2 py-0.5 rounded-full border border-primary/30 text-primary bg-primary/10">
{profile.model}
</span>
{profile.thinkingLevel !== "none" && (
{profile.thinkingLevel !== 'none' && (
<span className="text-xs px-2 py-0.5 rounded-full border border-amber-500/30 text-amber-600 dark:text-amber-400 bg-amber-500/10">
{profile.thinkingLevel}
</span>
@@ -124,4 +107,3 @@ export function SortableProfileCard({
</div>
);
}

View File

@@ -1,18 +1,8 @@
import {
Brain,
Zap,
Scale,
Cpu,
Rocket,
Sparkles,
} from "lucide-react";
import type { AgentModel, ThinkingLevel } from "@/store/app-store";
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
import type { AgentModel, ThinkingLevel } from '@/store/app-store';
// Icon mapping for profiles
export const PROFILE_ICONS: Record<
string,
React.ComponentType<{ className?: string }>
> = {
export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
Brain,
Zap,
Scale,
@@ -23,27 +13,25 @@ export const PROFILE_ICONS: Record<
// Available icons for selection
export const ICON_OPTIONS = [
{ name: "Brain", icon: Brain },
{ name: "Zap", icon: Zap },
{ name: "Scale", icon: Scale },
{ name: "Cpu", icon: Cpu },
{ name: "Rocket", icon: Rocket },
{ name: "Sparkles", icon: Sparkles },
{ name: 'Brain', icon: Brain },
{ name: 'Zap', icon: Zap },
{ name: 'Scale', icon: Scale },
{ name: 'Cpu', icon: Cpu },
{ name: 'Rocket', icon: Rocket },
{ name: 'Sparkles', icon: Sparkles },
];
// Model options for the form
export const CLAUDE_MODELS: { id: AgentModel; label: string }[] = [
{ id: "haiku", label: "Claude Haiku" },
{ id: "sonnet", label: "Claude Sonnet" },
{ id: "opus", label: "Claude Opus" },
{ id: 'haiku', label: 'Claude Haiku' },
{ id: 'sonnet', label: 'Claude Sonnet' },
{ id: 'opus', label: 'Claude Opus' },
];
export const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [
{ id: "none", label: "None" },
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
{ id: "ultrathink", label: "Ultrathink" },
{ id: 'none', label: 'None' },
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'ultrathink', label: 'Ultrathink' },
];

View File

@@ -1,8 +1,6 @@
import type { AgentModel, ModelProvider } from "@/store/app-store";
import type { AgentModel, ModelProvider } from '@/store/app-store';
// Helper to determine provider from model
export function getProviderFromModel(model: AgentModel): ModelProvider {
return "claude";
return 'claude';
}

View File

@@ -1,11 +1,10 @@
import { useState, useEffect, useCallback } from "react";
import { Bot, Folder, Loader2, RefreshCw, Square, Activity } from "lucide-react";
import { getElectronAPI, RunningAgent } from "@/lib/electron";
import { useAppStore } from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useNavigate } from "@tanstack/react-router";
import { useState, useEffect, useCallback } from 'react';
import { Bot, Folder, Loader2, RefreshCw, Square, Activity } from 'lucide-react';
import { getElectronAPI, RunningAgent } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useNavigate } from '@tanstack/react-router';
export function RunningAgentsView() {
const [runningAgents, setRunningAgents] = useState<RunningAgent[]>([]);
@@ -24,7 +23,7 @@ export function RunningAgentsView() {
}
}
} catch (error) {
console.error("[RunningAgentsView] Error fetching running agents:", error);
console.error('[RunningAgentsView] Error fetching running agents:', error);
} finally {
setLoading(false);
setRefreshing(false);
@@ -52,10 +51,7 @@ export function RunningAgentsView() {
const unsubscribe = api.autoMode.onEvent((event) => {
// When a feature completes or errors, refresh the list
if (
event.type === "auto_mode_feature_complete" ||
event.type === "auto_mode_error"
) {
if (event.type === 'auto_mode_feature_complete' || event.type === 'auto_mode_error') {
fetchRunningAgents();
}
});
@@ -70,27 +66,33 @@ export function RunningAgentsView() {
fetchRunningAgents();
}, [fetchRunningAgents]);
const handleStopAgent = useCallback(async (featureId: string) => {
try {
const api = getElectronAPI();
if (api.autoMode) {
await api.autoMode.stopFeature(featureId);
// Refresh list after stopping
fetchRunningAgents();
const handleStopAgent = useCallback(
async (featureId: string) => {
try {
const api = getElectronAPI();
if (api.autoMode) {
await api.autoMode.stopFeature(featureId);
// Refresh list after stopping
fetchRunningAgents();
}
} catch (error) {
console.error('[RunningAgentsView] Error stopping agent:', error);
}
} catch (error) {
console.error("[RunningAgentsView] Error stopping agent:", error);
}
}, [fetchRunningAgents]);
},
[fetchRunningAgents]
);
const handleNavigateToProject = useCallback((agent: RunningAgent) => {
// Find the project by path
const project = projects.find((p) => p.path === agent.projectPath);
if (project) {
setCurrentProject(project);
navigate({ to: "/board" });
}
}, [projects, setCurrentProject, navigate]);
const handleNavigateToProject = useCallback(
(agent: RunningAgent) => {
// Find the project by path
const project = projects.find((p) => p.path === agent.projectPath);
if (project) {
setCurrentProject(project);
navigate({ to: '/board' });
}
},
[projects, setCurrentProject, navigate]
);
if (loading) {
return (
@@ -112,20 +114,13 @@ export function RunningAgentsView() {
<h1 className="text-2xl font-bold">Running Agents</h1>
<p className="text-sm text-muted-foreground">
{runningAgents.length === 0
? "No agents currently running"
: `${runningAgents.length} agent${runningAgents.length === 1 ? "" : "s"} running across all projects`}
? 'No agents currently running'
: `${runningAgents.length} agent${runningAgents.length === 1 ? '' : 's'} running across all projects`}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={refreshing}
>
<RefreshCw
className={cn("h-4 w-4 mr-2", refreshing && "animate-spin")}
/>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
<RefreshCw className={cn('h-4 w-4 mr-2', refreshing && 'animate-spin')} />
Refresh
</Button>
</div>
@@ -138,8 +133,8 @@ export function RunningAgentsView() {
</div>
<h2 className="text-lg font-medium mb-2">No Running Agents</h2>
<p className="text-muted-foreground max-w-md">
Agents will appear here when they are actively working on features.
Start an agent from the Kanban board by dragging a feature to "In Progress".
Agents will appear here when they are actively working on features. Start an agent from
the Kanban board by dragging a feature to "In Progress".
</p>
</div>
) : (
@@ -163,9 +158,7 @@ export function RunningAgentsView() {
{/* Agent info */}
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">
{agent.featureId}
</span>
<span className="font-medium truncate">{agent.featureId}</span>
{agent.isAutoMode && (
<span className="px-2 py-0.5 text-[10px] font-medium rounded-full bg-brand-500/10 text-brand-500 border border-brand-500/30">
AUTO

View File

@@ -1,8 +1,8 @@
import { Label } from "@/components/ui/label";
import { Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
import { useAppStore } from "@/store/app-store";
import { CLAUDE_MODELS } from "@/components/views/board-view/shared/model-constants";
import { Label } from '@/components/ui/label';
import { Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { CLAUDE_MODELS } from '@/components/views/board-view/shared/model-constants';
export function AIEnhancementSection() {
const { enhancementModel, setEnhancementModel } = useAppStore();
@@ -10,10 +10,10 @@ export function AIEnhancementSection() {
return (
<div
className={cn(
"rounded-2xl overflow-hidden",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
@@ -29,9 +29,7 @@ export function AIEnhancementSection() {
</div>
<div className="p-6 space-y-4">
<div className="space-y-4">
<Label className="text-foreground font-medium">
Enhancement Model
</Label>
<Label className="text-foreground font-medium">Enhancement Model</Label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{CLAUDE_MODELS.map(({ id, label, description, badge }) => {
const isActive = enhancementModel === id;
@@ -40,46 +38,48 @@ export function AIEnhancementSection() {
key={id}
onClick={() => setEnhancementModel(id)}
className={cn(
"group flex flex-col items-start gap-2 px-4 py-4 rounded-xl text-left",
"transition-all duration-200 ease-out",
'group flex flex-col items-start gap-2 px-4 py-4 rounded-xl text-left',
'transition-all duration-200 ease-out',
isActive
? [
"bg-gradient-to-br from-brand-500/15 to-brand-600/10",
"border-2 border-brand-500/40",
"text-foreground",
"shadow-md shadow-brand-500/10",
'bg-gradient-to-br from-brand-500/15 to-brand-600/10',
'border-2 border-brand-500/40',
'text-foreground',
'shadow-md shadow-brand-500/10',
]
: [
"bg-accent/30 hover:bg-accent/50",
"border border-border/50 hover:border-border",
"text-muted-foreground hover:text-foreground",
"hover:shadow-sm",
'bg-accent/30 hover:bg-accent/50',
'border border-border/50 hover:border-border',
'text-muted-foreground hover:text-foreground',
'hover:shadow-sm',
],
"hover:scale-[1.02] active:scale-[0.98]"
'hover:scale-[1.02] active:scale-[0.98]'
)}
data-testid={`enhancement-model-${id}`}
>
<div className="flex items-center gap-2 w-full">
<span className={cn(
"font-medium text-sm",
isActive ? "text-foreground" : "group-hover:text-foreground"
)}>
<span
className={cn(
'font-medium text-sm',
isActive ? 'text-foreground' : 'group-hover:text-foreground'
)}
>
{label}
</span>
{badge && (
<span className={cn(
"ml-auto text-xs px-2 py-0.5 rounded-full",
isActive
? "bg-brand-500/20 text-brand-500"
: "bg-accent text-muted-foreground"
)}>
<span
className={cn(
'ml-auto text-xs px-2 py-0.5 rounded-full',
isActive
? 'bg-brand-500/20 text-brand-500'
: 'bg-accent text-muted-foreground'
)}
>
{badge}
</span>
)}
</div>
<span className="text-xs text-muted-foreground/80">
{description}
</span>
<span className="text-xs text-muted-foreground/80">{description}</span>
</button>
);
})}

View File

@@ -1 +1 @@
export { AIEnhancementSection } from "./ai-enhancement-section";
export { AIEnhancementSection } from './ai-enhancement-section';

View File

@@ -1,8 +1,8 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AlertCircle, CheckCircle2, Eye, EyeOff, Loader2, Zap } from "lucide-react";
import type { ProviderConfig } from "@/config/api-providers";
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { AlertCircle, CheckCircle2, Eye, EyeOff, Loader2, Zap } from 'lucide-react';
import type { ProviderConfig } from '@/config/api-providers';
interface ApiKeyFieldProps {
config: ProviderConfig;
@@ -42,7 +42,7 @@ export function ApiKeyField({ config }: ApiKeyFieldProps) {
<div className="relative flex-1">
<Input
id={inputId}
type={showValue ? "text" : "password"}
type={showValue ? 'text' : 'password'}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
@@ -82,7 +82,7 @@ export function ApiKeyField({ config }: ApiKeyFieldProps) {
</Button>
</div>
<p className="text-xs text-muted-foreground">
{descriptionPrefix}{" "}
{descriptionPrefix}{' '}
<a
href={descriptionLinkHref}
target="_blank"
@@ -97,8 +97,8 @@ export function ApiKeyField({ config }: ApiKeyFieldProps) {
<div
className={`flex items-center gap-2 p-3 rounded-lg ${
result.success
? "bg-green-500/10 border border-green-500/20 text-green-400"
: "bg-red-500/10 border border-red-500/20 text-red-400"
? 'bg-green-500/10 border border-green-500/20 text-green-400'
: 'bg-red-500/10 border border-red-500/20 text-red-400'
}`}
data-testid={resultTestId}
>

View File

@@ -1,17 +1,17 @@
import { useAppStore } from "@/store/app-store";
import { useSetupStore } from "@/store/setup-store";
import { Button } from "@/components/ui/button";
import { Key, CheckCircle2, Settings, Trash2, Loader2 } from "lucide-react";
import { ApiKeyField } from "./api-key-field";
import { buildProviderConfigs } from "@/config/api-providers";
import { AuthenticationStatusDisplay } from "./authentication-status-display";
import { SecurityNotice } from "./security-notice";
import { useApiKeyManagement } from "./hooks/use-api-key-management";
import { cn } from "@/lib/utils";
import { useState, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
import { useNavigate } from "@tanstack/react-router";
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { Button } from '@/components/ui/button';
import { Key, CheckCircle2, Settings, Trash2, Loader2 } from 'lucide-react';
import { ApiKeyField } from './api-key-field';
import { buildProviderConfigs } from '@/config/api-providers';
import { AuthenticationStatusDisplay } from './authentication-status-display';
import { SecurityNotice } from './security-notice';
import { useApiKeyManagement } from './hooks/use-api-key-management';
import { cn } from '@/lib/utils';
import { useState, useCallback } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { useNavigate } from '@tanstack/react-router';
export function ApiKeysSection() {
const { apiKeys, setApiKeys } = useAppStore();
@@ -19,8 +19,7 @@ export function ApiKeysSection() {
const [isDeletingAnthropicKey, setIsDeletingAnthropicKey] = useState(false);
const navigate = useNavigate();
const { providerConfigParams, apiKeyStatus, handleSave, saved } =
useApiKeyManagement();
const { providerConfigParams, apiKeyStatus, handleSave, saved } = useApiKeyManagement();
const providerConfigs = buildProviderConfigs(providerConfigParams);
@@ -30,24 +29,24 @@ export function ApiKeysSection() {
try {
const api = getElectronAPI();
if (!api.setup?.deleteApiKey) {
toast.error("Delete API not available");
toast.error('Delete API not available');
return;
}
const result = await api.setup.deleteApiKey("anthropic");
const result = await api.setup.deleteApiKey('anthropic');
if (result.success) {
setApiKeys({ ...apiKeys, anthropic: "" });
setApiKeys({ ...apiKeys, anthropic: '' });
setClaudeAuthStatus({
authenticated: false,
method: "none",
method: 'none',
hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false,
});
toast.success("Anthropic API key deleted");
toast.success('Anthropic API key deleted');
} else {
toast.error(result.error || "Failed to delete API key");
toast.error(result.error || 'Failed to delete API key');
}
} catch (error) {
toast.error("Failed to delete API key");
toast.error('Failed to delete API key');
} finally {
setIsDeletingAnthropicKey(false);
}
@@ -56,16 +55,16 @@ export function ApiKeysSection() {
// Open setup wizard
const openSetupWizard = useCallback(() => {
setSetupComplete(false);
navigate({ to: "/setup" });
navigate({ to: '/setup' });
}, [setSetupComplete, navigate]);
return (
<div
className={cn(
"rounded-2xl overflow-hidden",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
@@ -101,13 +100,13 @@ export function ApiKeysSection() {
onClick={handleSave}
data-testid="save-settings"
className={cn(
"min-w-[140px] h-10",
"bg-gradient-to-r from-brand-500 to-brand-600",
"hover:from-brand-600 hover:to-brand-600",
"text-white font-medium border-0",
"shadow-md shadow-brand-500/20 hover:shadow-lg hover:shadow-brand-500/25",
"transition-all duration-200 ease-out",
"hover:scale-[1.02] active:scale-[0.98]"
'min-w-[140px] h-10',
'bg-gradient-to-r from-brand-500 to-brand-600',
'hover:from-brand-600 hover:to-brand-600',
'text-white font-medium border-0',
'shadow-md shadow-brand-500/20 hover:shadow-lg hover:shadow-brand-500/25',
'transition-all duration-200 ease-out',
'hover:scale-[1.02] active:scale-[0.98]'
)}
>
{saved ? (
@@ -116,7 +115,7 @@ export function ApiKeysSection() {
Saved!
</>
) : (
"Save API Keys"
'Save API Keys'
)}
</Button>

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import type { ProviderConfigParams } from "@/config/api-providers";
import { useState, useEffect } from 'react';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import type { ProviderConfigParams } from '@/config/api-providers';
interface TestResult {
success: boolean;
@@ -32,9 +32,7 @@ export function useApiKeyManagement() {
const [testingConnection, setTestingConnection] = useState(false);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [testingGeminiConnection, setTestingGeminiConnection] = useState(false);
const [geminiTestResult, setGeminiTestResult] = useState<TestResult | null>(
null
);
const [geminiTestResult, setGeminiTestResult] = useState<TestResult | null>(null);
// API key status from environment
const [apiKeyStatus, setApiKeyStatus] = useState<ApiKeyStatus | null>(null);
@@ -62,7 +60,7 @@ export function useApiKeyManagement() {
});
}
} catch (error) {
console.error("Failed to check API key status:", error);
console.error('Failed to check API key status:', error);
}
}
};
@@ -76,23 +74,23 @@ export function useApiKeyManagement() {
try {
const api = getElectronAPI();
const data = await api.setup.verifyClaudeAuth("api_key");
const data = await api.setup.verifyClaudeAuth('api_key');
if (data.success && data.authenticated) {
setTestResult({
success: true,
message: "Connection successful! Claude responded.",
message: 'Connection successful! Claude responded.',
});
} else {
setTestResult({
success: false,
message: data.error || "Failed to connect to Claude API.",
message: data.error || 'Failed to connect to Claude API.',
});
}
} catch {
setTestResult({
success: false,
message: "Network error. Please check your connection.",
message: 'Network error. Please check your connection.',
});
} finally {
setTestingConnection(false);
@@ -109,7 +107,7 @@ export function useApiKeyManagement() {
if (!googleKey || googleKey.trim().length < 10) {
setGeminiTestResult({
success: false,
message: "Please enter a valid API key.",
message: 'Please enter a valid API key.',
});
setTestingGeminiConnection(false);
return;
@@ -119,7 +117,7 @@ export function useApiKeyManagement() {
// Full verification requires a backend endpoint
setGeminiTestResult({
success: true,
message: "API key saved. Connection test not yet available.",
message: 'API key saved. Connection test not yet available.',
});
setTestingGeminiConnection(false);
};

View File

@@ -1,4 +1,4 @@
import { AlertCircle } from "lucide-react";
import { AlertCircle } from 'lucide-react';
interface SecurityNoticeProps {
title?: string;
@@ -6,7 +6,7 @@ interface SecurityNoticeProps {
}
export function SecurityNotice({
title = "Security Notice",
title = 'Security Notice',
message = "API keys are stored in your browser's local storage. Never share your API keys or commit them to version control.",
}: SecurityNoticeProps) {
return (

View File

@@ -1,24 +1,21 @@
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Volume2, VolumeX } from "lucide-react";
import { cn } from "@/lib/utils";
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Volume2, VolumeX } from 'lucide-react';
import { cn } from '@/lib/utils';
interface AudioSectionProps {
muteDoneSound: boolean;
onMuteDoneSoundChange: (value: boolean) => void;
}
export function AudioSection({
muteDoneSound,
onMuteDoneSoundChange,
}: AudioSectionProps) {
export function AudioSection({ muteDoneSound, onMuteDoneSoundChange }: AudioSectionProps) {
return (
<div
className={cn(
"rounded-2xl overflow-hidden",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
@@ -26,9 +23,7 @@ export function AudioSection({
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Volume2 className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Audio
</h2>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Audio</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure audio and notification settings.
@@ -52,9 +47,9 @@ export function AudioSection({
Mute notification sound when agents complete
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, disables the &quot;ding&quot; sound that plays when
an agent completes a feature. The feature will still move to the
completed column, but without audio notification.
When enabled, disables the &quot;ding&quot; sound that plays when an agent completes a
feature. The feature will still move to the completed column, but without audio
notification.
</p>
</div>
</div>

View File

@@ -1,12 +1,7 @@
import { Button } from "@/components/ui/button";
import {
Terminal,
CheckCircle2,
AlertCircle,
RefreshCw,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { CliStatus } from "../shared/types";
import { Button } from '@/components/ui/button';
import { Terminal, CheckCircle2, AlertCircle, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
interface CliStatusProps {
status: CliStatus | null;
@@ -14,20 +9,16 @@ interface CliStatusProps {
onRefresh: () => void;
}
export function ClaudeCliStatus({
status,
isChecking,
onRefresh,
}: CliStatusProps) {
export function ClaudeCliStatus({ status, isChecking, onRefresh }: CliStatusProps) {
if (!status) return null;
return (
<div
className={cn(
"rounded-2xl overflow-hidden",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
@@ -48,32 +39,28 @@ export function ClaudeCliStatus({
data-testid="refresh-claude-cli"
title="Refresh Claude CLI detection"
className={cn(
"h-9 w-9 rounded-lg",
"hover:bg-accent/50 hover:scale-105",
"transition-all duration-200"
'h-9 w-9 rounded-lg',
'hover:bg-accent/50 hover:scale-105',
'transition-all duration-200'
)}
>
<RefreshCw
className={cn("w-4 h-4", isChecking && "animate-spin")}
/>
<RefreshCw className={cn('w-4 h-4', isChecking && 'animate-spin')} />
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Claude Code CLI provides better performance for long-running tasks,
especially with ultrathink.
Claude Code CLI provides better performance for long-running tasks, especially with
ultrathink.
</p>
</div>
<div className="p-6 space-y-4">
{status.success && status.status === "installed" ? (
{status.success && status.status === 'installed' ? (
<div className="space-y-3">
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">
Claude Code CLI Installed
</p>
<p className="text-sm font-medium text-emerald-400">Claude Code CLI Installed</p>
<div className="text-xs text-emerald-400/70 mt-1.5 space-y-0.5">
{status.method && (
<p>
@@ -94,9 +81,7 @@ export function ClaudeCliStatus({
</div>
</div>
{status.recommendation && (
<p className="text-xs text-muted-foreground/70 ml-1">
{status.recommendation}
</p>
<p className="text-xs text-muted-foreground/70 ml-1">{status.recommendation}</p>
)}
</div>
) : (
@@ -106,24 +91,22 @@ export function ClaudeCliStatus({
<AlertCircle className="w-5 h-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-amber-400">
Claude Code CLI Not Detected
</p>
<p className="text-sm font-medium text-amber-400">Claude Code CLI Not Detected</p>
<p className="text-xs text-amber-400/70 mt-1">
{status.recommendation ||
"Consider installing Claude Code CLI for optimal performance with ultrathink."}
'Consider installing Claude Code CLI for optimal performance with ultrathink.'}
</p>
</div>
</div>
{status.installCommands && (
<div className="space-y-3">
<p className="text-xs font-medium text-foreground/80">
Installation Commands:
</p>
<p className="text-xs font-medium text-foreground/80">Installation Commands:</p>
<div className="space-y-2">
{status.installCommands.npm && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">npm</p>
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
npm
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.npm}
</code>
@@ -131,7 +114,9 @@ export function ClaudeCliStatus({
)}
{status.installCommands.macos && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">macOS/Linux</p>
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
macOS/Linux
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.macos}
</code>
@@ -139,7 +124,9 @@ export function ClaudeCliStatus({
)}
{status.installCommands.windows && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">Windows (PowerShell)</p>
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
Windows (PowerShell)
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.windows}
</code>

View File

@@ -1,12 +1,12 @@
import { Keyboard } from "lucide-react";
import { Keyboard } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { KeyboardMap, ShortcutReferencePanel } from "@/components/ui/keyboard-map";
} from '@/components/ui/dialog';
import { KeyboardMap, ShortcutReferencePanel } from '@/components/ui/keyboard-map';
interface KeyboardMapDialogProps {
open: boolean;
@@ -23,8 +23,8 @@ export function KeyboardMapDialog({ open, onOpenChange }: KeyboardMapDialogProps
Keyboard Shortcut Map
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Visual overview of all keyboard shortcuts. Keys in color are bound to
shortcuts. Click on any shortcut below to edit it.
Visual overview of all keyboard shortcuts. Keys in color are bound to shortcuts. Click
on any shortcut below to edit it.
</DialogDescription>
</DialogHeader>

View File

@@ -1,5 +1,5 @@
import { Settings } from "lucide-react";
import { cn } from "@/lib/utils";
import { Settings } from 'lucide-react';
import { cn } from '@/lib/utils';
interface SettingsHeaderProps {
title?: string;
@@ -7,23 +7,27 @@ interface SettingsHeaderProps {
}
export function SettingsHeader({
title = "Settings",
description = "Configure your API keys and preferences",
title = 'Settings',
description = 'Configure your API keys and preferences',
}: SettingsHeaderProps) {
return (
<div className={cn(
"shrink-0",
"border-b border-border/50",
"bg-gradient-to-r from-card/90 via-card/70 to-card/80 backdrop-blur-xl"
)}>
<div
className={cn(
'shrink-0',
'border-b border-border/50',
'bg-gradient-to-r from-card/90 via-card/70 to-card/80 backdrop-blur-xl'
)}
>
<div className="px-8 py-6">
<div className="flex items-center gap-4">
<div className={cn(
"w-12 h-12 rounded-2xl flex items-center justify-center",
"bg-gradient-to-br from-brand-500 to-brand-600",
"shadow-lg shadow-brand-500/25",
"ring-1 ring-white/10"
)}>
<div
className={cn(
'w-12 h-12 rounded-2xl flex items-center justify-center',
'bg-gradient-to-br from-brand-500 to-brand-600',
'shadow-lg shadow-brand-500/25',
'ring-1 ring-white/10'
)}
>
<Settings className="w-6 h-6 text-white" />
</div>
<div>

View File

@@ -1,7 +1,7 @@
import { cn } from "@/lib/utils";
import type { Project } from "@/lib/electron";
import type { NavigationItem } from "../config/navigation";
import type { SettingsViewId } from "../hooks/use-settings-view";
import { cn } from '@/lib/utils';
import type { Project } from '@/lib/electron';
import type { NavigationItem } from '../config/navigation';
import type { SettingsViewId } from '../hooks/use-settings-view';
interface SettingsNavigationProps {
navItems: NavigationItem[];
@@ -17,14 +17,16 @@ export function SettingsNavigation({
onNavigate,
}: SettingsNavigationProps) {
return (
<nav className={cn(
"hidden lg:block w-52 shrink-0",
"border-r border-border/50",
"bg-gradient-to-b from-card/80 via-card/60 to-card/40 backdrop-blur-xl"
)}>
<nav
className={cn(
'hidden lg:block w-52 shrink-0',
'border-r border-border/50',
'bg-gradient-to-b from-card/80 via-card/60 to-card/40 backdrop-blur-xl'
)}
>
<div className="sticky top-0 p-4 space-y-1.5">
{navItems
.filter((item) => item.id !== "danger" || currentProject)
.filter((item) => item.id !== 'danger' || currentProject)
.map((item) => {
const Icon = item.icon;
const isActive = activeSection === item.id;
@@ -33,20 +35,20 @@ export function SettingsNavigation({
key={item.id}
onClick={() => onNavigate(item.id)}
className={cn(
"group w-full flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ease-out text-left relative overflow-hidden",
'group w-full flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ease-out text-left relative overflow-hidden',
isActive
? [
"bg-gradient-to-r from-brand-500/15 via-brand-500/10 to-brand-600/5",
"text-foreground",
"border border-brand-500/25",
"shadow-sm shadow-brand-500/5",
'bg-gradient-to-r from-brand-500/15 via-brand-500/10 to-brand-600/5',
'text-foreground',
'border border-brand-500/25',
'shadow-sm shadow-brand-500/5',
]
: [
"text-muted-foreground hover:text-foreground",
"hover:bg-accent/50",
"border border-transparent hover:border-border/40",
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50',
'border border-transparent hover:border-border/40',
],
"hover:scale-[1.01] active:scale-[0.98]"
'hover:scale-[1.01] active:scale-[0.98]'
)}
>
{/* Active indicator bar */}
@@ -55,10 +57,8 @@ export function SettingsNavigation({
)}
<Icon
className={cn(
"w-4 h-4 shrink-0 transition-all duration-200",
isActive
? "text-brand-500"
: "group-hover:text-brand-400 group-hover:scale-110"
'w-4 h-4 shrink-0 transition-all duration-200',
isActive ? 'text-brand-500' : 'group-hover:text-brand-400 group-hover:scale-110'
)}
/>
<span className="truncate">{item.label}</span>

View File

@@ -1,4 +1,4 @@
import type { LucideIcon } from "lucide-react";
import type { LucideIcon } from 'lucide-react';
import {
Key,
Terminal,
@@ -9,8 +9,8 @@ import {
FlaskConical,
Trash2,
Sparkles,
} from "lucide-react";
import type { SettingsViewId } from "../hooks/use-settings-view";
} from 'lucide-react';
import type { SettingsViewId } from '../hooks/use-settings-view';
export interface NavigationItem {
id: SettingsViewId;
@@ -20,13 +20,13 @@ export interface NavigationItem {
// Navigation items for the settings side panel
export const NAV_ITEMS: NavigationItem[] = [
{ id: "api-keys", label: "API Keys", icon: Key },
{ id: "claude", label: "Claude", icon: Terminal },
{ id: "ai-enhancement", label: "AI Enhancement", icon: Sparkles },
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "terminal", label: "Terminal", icon: SquareTerminal },
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
{ id: "audio", label: "Audio", icon: Volume2 },
{ id: "defaults", label: "Feature Defaults", icon: FlaskConical },
{ id: "danger", label: "Danger Zone", icon: Trash2 },
{ id: 'api-keys', label: 'API Keys', icon: Key },
{ id: 'claude', label: 'Claude', icon: Terminal },
{ id: 'ai-enhancement', label: 'AI Enhancement', icon: Sparkles },
{ id: 'appearance', label: 'Appearance', icon: Palette },
{ id: 'terminal', label: 'Terminal', icon: SquareTerminal },
{ id: 'keyboard', label: 'Keyboard Shortcuts', icon: Settings2 },
{ id: 'audio', label: 'Audio', icon: Volume2 },
{ id: 'defaults', label: 'Feature Defaults', icon: FlaskConical },
{ id: 'danger', label: 'Danger Zone', icon: Trash2 },
];

View File

@@ -1,26 +1,23 @@
import { Button } from "@/components/ui/button";
import { Trash2, Folder, AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Project } from "../shared/types";
import { Button } from '@/components/ui/button';
import { Trash2, Folder, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { Project } from '../shared/types';
interface DangerZoneSectionProps {
project: Project | null;
onDeleteClick: () => void;
}
export function DangerZoneSection({
project,
onDeleteClick,
}: DangerZoneSectionProps) {
export function DangerZoneSection({ project, onDeleteClick }: DangerZoneSectionProps) {
if (!project) return null;
return (
<div
className={cn(
"rounded-2xl overflow-hidden",
"border border-destructive/30",
"bg-gradient-to-br from-destructive/5 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-destructive/5"
'rounded-2xl overflow-hidden',
'border border-destructive/30',
'bg-gradient-to-br from-destructive/5 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-destructive/5'
)}
>
<div className="p-6 border-b border-destructive/20 bg-gradient-to-r from-destructive/5 via-transparent to-transparent">
@@ -41,12 +38,8 @@ export function DangerZoneSection({
<Folder className="w-5 h-5 text-brand-500" />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">
{project.name}
</p>
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">
{project.path}
</p>
<p className="font-medium text-foreground truncate">{project.name}</p>
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">{project.path}</p>
</div>
</div>
<Button
@@ -54,10 +47,10 @@ export function DangerZoneSection({
onClick={onDeleteClick}
data-testid="delete-project-button"
className={cn(
"shrink-0",
"shadow-md shadow-destructive/20 hover:shadow-lg hover:shadow-destructive/25",
"transition-all duration-200 ease-out",
"hover:scale-[1.02] active:scale-[0.98]"
'shrink-0',
'shadow-md shadow-destructive/20 hover:shadow-lg hover:shadow-destructive/25',
'transition-all duration-200 ease-out',
'hover:scale-[1.02] active:scale-[0.98]'
)}
>
<Trash2 className="w-4 h-4 mr-2" />

View File

@@ -1,18 +1,27 @@
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import {
FlaskConical, Settings2, TestTube, GitBranch, AlertCircle,
Zap, ClipboardList, FileText, ScrollText, ShieldCheck, User
} from "lucide-react";
import { cn } from "@/lib/utils";
FlaskConical,
Settings2,
TestTube,
GitBranch,
AlertCircle,
Zap,
ClipboardList,
FileText,
ScrollText,
ShieldCheck,
User,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { AIProfile } from "@/store/app-store";
} from '@/components/ui/select';
import type { AIProfile } from '@/store/app-store';
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
@@ -58,10 +67,10 @@ export function FeatureDefaultsSection({
return (
<div
className={cn(
"rounded-2xl overflow-hidden",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
@@ -69,9 +78,7 @@ export function FeatureDefaultsSection({
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<FlaskConical className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Feature Defaults
</h2>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Feature Defaults</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure default settings for new features.
@@ -80,13 +87,18 @@ export function FeatureDefaultsSection({
<div className="p-6 space-y-5">
{/* Planning Mode Default */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<div className={cn(
"w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0",
defaultPlanningMode === 'skip' ? "bg-emerald-500/10" :
defaultPlanningMode === 'lite' ? "bg-blue-500/10" :
defaultPlanningMode === 'spec' ? "bg-purple-500/10" :
"bg-amber-500/10"
)}>
<div
className={cn(
'w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0',
defaultPlanningMode === 'skip'
? 'bg-emerald-500/10'
: defaultPlanningMode === 'lite'
? 'bg-blue-500/10'
: defaultPlanningMode === 'spec'
? 'bg-purple-500/10'
: 'bg-amber-500/10'
)}
>
{defaultPlanningMode === 'skip' && <Zap className="w-5 h-5 text-emerald-500" />}
{defaultPlanningMode === 'lite' && <ClipboardList className="w-5 h-5 text-blue-500" />}
{defaultPlanningMode === 'spec' && <FileText className="w-5 h-5 text-purple-500" />}
@@ -94,17 +106,12 @@ export function FeatureDefaultsSection({
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">
Default Planning Mode
</Label>
<Label className="text-foreground font-medium">Default Planning Mode</Label>
<Select
value={defaultPlanningMode}
onValueChange={(v: string) => onDefaultPlanningModeChange(v as PlanningMode)}
>
<SelectTrigger
className="w-[160px] h-8"
data-testid="default-planning-mode-select"
>
<SelectTrigger className="w-[160px] h-8" data-testid="default-planning-mode-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -137,10 +144,14 @@ export function FeatureDefaultsSection({
</Select>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
{defaultPlanningMode === 'skip' && "Jump straight to implementation without upfront planning."}
{defaultPlanningMode === 'lite' && "Create a quick planning outline with tasks before building."}
{defaultPlanningMode === 'spec' && "Generate a specification with acceptance criteria for approval."}
{defaultPlanningMode === 'full' && "Create comprehensive spec with phased implementation plan."}
{defaultPlanningMode === 'skip' &&
'Jump straight to implementation without upfront planning.'}
{defaultPlanningMode === 'lite' &&
'Create a quick planning outline with tasks before building.'}
{defaultPlanningMode === 'spec' &&
'Generate a specification with acceptance criteria for approval.'}
{defaultPlanningMode === 'full' &&
'Create comprehensive spec with phased implementation plan.'}
</p>
</div>
</div>
@@ -152,9 +163,7 @@ export function FeatureDefaultsSection({
<Checkbox
id="default-require-plan-approval"
checked={defaultRequirePlanApproval}
onCheckedChange={(checked) =>
onDefaultRequirePlanApprovalChange(checked === true)
}
onCheckedChange={(checked) => onDefaultRequirePlanApprovalChange(checked === true)}
className="mt-1"
data-testid="default-require-plan-approval-checkbox"
/>
@@ -187,17 +196,12 @@ export function FeatureDefaultsSection({
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">
Default AI Profile
</Label>
<Label className="text-foreground font-medium">Default AI Profile</Label>
<Select
value={defaultAIProfileId ?? "none"}
onValueChange={(v: string) => onDefaultAIProfileIdChange(v === "none" ? null : v)}
value={defaultAIProfileId ?? 'none'}
onValueChange={(v: string) => onDefaultAIProfileIdChange(v === 'none' ? null : v)}
>
<SelectTrigger
className="w-[180px] h-8"
data-testid="default-ai-profile-select"
>
<SelectTrigger className="w-[180px] h-8" data-testid="default-ai-profile-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -215,7 +219,7 @@ export function FeatureDefaultsSection({
<p className="text-xs text-muted-foreground/80 leading-relaxed">
{selectedProfile
? `New features will use the "${selectedProfile.name}" profile (${selectedProfile.model}, ${selectedProfile.thinkingLevel} thinking).`
: "Pre-select an AI profile when creating new features. Choose \"None\" to pick manually each time."}
: 'Pre-select an AI profile when creating new features. Choose "None" to pick manually each time.'}
</p>
</div>
</div>
@@ -228,9 +232,7 @@ export function FeatureDefaultsSection({
<Checkbox
id="show-profiles-only"
checked={showProfilesOnly}
onCheckedChange={(checked) =>
onShowProfilesOnlyChange(checked === true)
}
onCheckedChange={(checked) => onShowProfilesOnlyChange(checked === true)}
className="mt-1"
data-testid="show-profiles-only-checkbox"
/>
@@ -243,9 +245,8 @@ export function FeatureDefaultsSection({
Show profiles only by default
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, the Add Feature dialog will show only AI profiles
and hide advanced model tweaking options. This creates a cleaner, less
overwhelming UI.
When enabled, the Add Feature dialog will show only AI profiles and hide advanced
model tweaking options. This creates a cleaner, less overwhelming UI.
</p>
</div>
</div>
@@ -258,9 +259,7 @@ export function FeatureDefaultsSection({
<Checkbox
id="default-skip-tests"
checked={!defaultSkipTests}
onCheckedChange={(checked) =>
onDefaultSkipTestsChange(checked !== true)
}
onCheckedChange={(checked) => onDefaultSkipTestsChange(checked !== true)}
className="mt-1"
data-testid="default-skip-tests-checkbox"
/>
@@ -273,8 +272,8 @@ export function FeatureDefaultsSection({
Enable automated testing by default
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, new features will use TDD with automated tests. When disabled, features will
require manual verification.
When enabled, new features will use TDD with automated tests. When disabled, features
will require manual verification.
</p>
</div>
</div>
@@ -287,9 +286,7 @@ export function FeatureDefaultsSection({
<Checkbox
id="enable-dependency-blocking"
checked={enableDependencyBlocking}
onCheckedChange={(checked) =>
onEnableDependencyBlockingChange(checked === true)
}
onCheckedChange={(checked) => onEnableDependencyBlockingChange(checked === true)}
className="mt-1"
data-testid="enable-dependency-blocking-checkbox"
/>
@@ -302,9 +299,9 @@ export function FeatureDefaultsSection({
Enable Dependency Blocking
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, features with incomplete dependencies will show blocked badges
and warnings. Auto mode and backlog ordering always respect dependencies
regardless of this setting.
When enabled, features with incomplete dependencies will show blocked badges and
warnings. Auto mode and backlog ordering always respect dependencies regardless of
this setting.
</p>
</div>
</div>
@@ -317,9 +314,7 @@ export function FeatureDefaultsSection({
<Checkbox
id="use-worktrees"
checked={useWorktrees}
onCheckedChange={(checked) =>
onUseWorktreesChange(checked === true)
}
onCheckedChange={(checked) => onUseWorktreesChange(checked === true)}
className="mt-1"
data-testid="use-worktrees-checkbox"
/>
@@ -335,8 +330,8 @@ export function FeatureDefaultsSection({
</span>
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Creates isolated git branches for each feature. When disabled,
agents work directly in the main project directory.
Creates isolated git branches for each feature. When disabled, agents work directly in
the main project directory.
</p>
</div>
</div>

View File

@@ -1,2 +1,2 @@
export { useCliStatus } from "./use-cli-status";
export { useSettingsView, type SettingsViewId } from "./use-settings-view";
export { useCliStatus } from './use-cli-status';
export { useSettingsView, type SettingsViewId } from './use-settings-view';

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { useSetupStore } from "@/store/setup-store";
import { getElectronAPI } from "@/lib/electron";
import { useState, useEffect, useCallback } from 'react';
import { useSetupStore } from '@/store/setup-store';
import { getElectronAPI } from '@/lib/electron';
interface CliStatusResult {
success: boolean;
@@ -25,8 +25,7 @@ interface CliStatusResult {
export function useCliStatus() {
const { setClaudeAuthStatus } = useSetupStore();
const [claudeCliStatus, setClaudeCliStatus] =
useState<CliStatusResult | null>(null);
const [claudeCliStatus, setClaudeCliStatus] = useState<CliStatusResult | null>(null);
const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false);
@@ -41,7 +40,7 @@ export function useCliStatus() {
const status = await api.checkClaudeCli();
setClaudeCliStatus(status);
} catch (error) {
console.error("Failed to check Claude CLI status:", error);
console.error('Failed to check Claude CLI status:', error);
}
}
@@ -57,16 +56,27 @@ export function useCliStatus() {
};
// Map server method names to client method types
// Server returns: oauth_token_env, oauth_token, api_key_env, api_key, credentials_file, cli_authenticated, none
const validMethods = ["oauth_token_env", "oauth_token", "api_key", "api_key_env", "credentials_file", "cli_authenticated", "none"] as const;
type AuthMethod = typeof validMethods[number];
const validMethods = [
'oauth_token_env',
'oauth_token',
'api_key',
'api_key_env',
'credentials_file',
'cli_authenticated',
'none',
] as const;
type AuthMethod = (typeof validMethods)[number];
const method: AuthMethod = validMethods.includes(auth.method as AuthMethod)
? (auth.method as AuthMethod)
: auth.authenticated ? "api_key" : "none"; // Default authenticated to api_key, not none
: auth.authenticated
? 'api_key'
: 'none'; // Default authenticated to api_key, not none
const authStatus = {
authenticated: auth.authenticated,
method,
hasCredentialsFile: auth.hasCredentialsFile ?? false,
oauthTokenValid: auth.oauthTokenValid || auth.hasStoredOAuthToken || auth.hasEnvOAuthToken,
oauthTokenValid:
auth.oauthTokenValid || auth.hasStoredOAuthToken || auth.hasEnvOAuthToken,
apiKeyValid: auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey,
hasEnvOAuthToken: auth.hasEnvOAuthToken,
hasEnvApiKey: auth.hasEnvApiKey,
@@ -74,7 +84,7 @@ export function useCliStatus() {
setClaudeAuthStatus(authStatus);
}
} catch (error) {
console.error("Failed to check Claude auth status:", error);
console.error('Failed to check Claude auth status:', error);
}
}
};
@@ -92,7 +102,7 @@ export function useCliStatus() {
setClaudeCliStatus(status);
}
} catch (error) {
console.error("Failed to refresh Claude CLI status:", error);
console.error('Failed to refresh Claude CLI status:', error);
} finally {
setIsCheckingClaudeCli(false);
}

View File

@@ -1,23 +1,21 @@
import { useState, useCallback } from "react";
import { useState, useCallback } from 'react';
export type SettingsViewId =
| "api-keys"
| "claude"
| "ai-enhancement"
| "appearance"
| "terminal"
| "keyboard"
| "audio"
| "defaults"
| "danger";
| 'api-keys'
| 'claude'
| 'ai-enhancement'
| 'appearance'
| 'terminal'
| 'keyboard'
| 'audio'
| 'defaults'
| 'danger';
interface UseSettingsViewOptions {
initialView?: SettingsViewId;
}
export function useSettingsView({
initialView = "api-keys",
}: UseSettingsViewOptions = {}) {
export function useSettingsView({ initialView = 'api-keys' }: UseSettingsViewOptions = {}) {
const [activeView, setActiveView] = useState<SettingsViewId>(initialView);
const navigateTo = useCallback((viewId: SettingsViewId) => {

Some files were not shown because too many files have changed in this diff Show More