mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
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:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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's developer console to see detailed agent
|
||||
tool logs.
|
||||
Open your browser'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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)]',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
"{feature.description?.slice(0, 100)}
|
||||
{(feature.description?.length ?? 0) > 100 ? "..." : ""}"
|
||||
{(feature.description?.length ?? 0) > 100 ? '...' : ''}"
|
||||
</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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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`}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { AIEnhancementSection } from "./ai-enhancement-section";
|
||||
export { AIEnhancementSection } from './ai-enhancement-section';
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 "ding" 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 "ding" 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user