Merge branch 'main' of github.com:AutoMaker-Org/automaker into improve-context-page

This commit is contained in:
Test User
2025-12-22 00:50:55 -05:00
501 changed files with 17637 additions and 17437 deletions

View File

@@ -1,27 +1,16 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import {
RefreshCw,
AlertTriangle,
CheckCircle,
XCircle,
Clock,
ExternalLink,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getElectronAPI } from "@/lib/electron";
import { useAppStore } from "@/store/app-store";
import { useState, useEffect, useMemo, useCallback } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
// Error codes for distinguishing failure modes
const ERROR_CODES = {
API_BRIDGE_UNAVAILABLE: "API_BRIDGE_UNAVAILABLE",
AUTH_ERROR: "AUTH_ERROR",
UNKNOWN: "UNKNOWN",
API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE',
AUTH_ERROR: 'AUTH_ERROR',
UNKNOWN: 'UNKNOWN',
} as const;
type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
@@ -116,16 +105,10 @@ export function ClaudeUsagePopover() {
};
// Helper component for the progress bar
const ProgressBar = ({
percentage,
colorClass,
}: {
percentage: number;
colorClass: string;
}) => (
const ProgressBar = ({ percentage, colorClass }: { percentage: number; colorClass: string }) => (
<div className="h-2 w-full bg-secondary/50 rounded-full overflow-hidden">
<div
className={cn("h-full transition-all duration-500", colorClass)}
className={cn('h-full transition-all duration-500', colorClass)}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
@@ -147,7 +130,8 @@ export function ClaudeUsagePopover() {
stale?: boolean;
}) => {
// Check if percentage is valid (not NaN, not undefined, is a finite number)
const isValidPercentage = typeof percentage === "number" && !isNaN(percentage) && isFinite(percentage);
const isValidPercentage =
typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage);
const safePercentage = isValidPercentage ? percentage : 0;
const status = getStatusInfo(safePercentage);
@@ -156,26 +140,24 @@ export function ClaudeUsagePopover() {
return (
<div
className={cn(
"rounded-xl border bg-card/50 p-4 transition-opacity",
isPrimary ? "border-border/60 shadow-sm" : "border-border/40",
(stale || !isValidPercentage) && "opacity-50"
'rounded-xl border bg-card/50 p-4 transition-opacity',
isPrimary ? 'border-border/60 shadow-sm' : 'border-border/40',
(stale || !isValidPercentage) && 'opacity-50'
)}
>
<div className="flex items-start justify-between mb-3">
<div>
<h4 className={cn("font-semibold", isPrimary ? "text-sm" : "text-xs")}>
{title}
</h4>
<h4 className={cn('font-semibold', isPrimary ? 'text-sm' : 'text-xs')}>{title}</h4>
<p className="text-[10px] text-muted-foreground">{subtitle}</p>
</div>
{isValidPercentage ? (
<div className="flex items-center gap-1.5">
<StatusIcon className={cn("w-3.5 h-3.5", status.color)} />
<StatusIcon className={cn('w-3.5 h-3.5', status.color)} />
<span
className={cn(
"font-mono font-bold",
'font-mono font-bold',
status.color,
isPrimary ? "text-base" : "text-sm"
isPrimary ? 'text-base' : 'text-sm'
)}
>
{Math.round(safePercentage)}%
@@ -185,11 +167,14 @@ export function ClaudeUsagePopover() {
<span className="text-xs text-muted-foreground">N/A</span>
)}
</div>
<ProgressBar percentage={safePercentage} colorClass={isValidPercentage ? status.bg : "bg-muted-foreground/30"} />
<ProgressBar
percentage={safePercentage}
colorClass={isValidPercentage ? status.bg : 'bg-muted-foreground/30'}
/>
{resetText && (
<div className="mt-2 flex justify-end">
<p className="text-xs text-muted-foreground flex items-center gap-1">
{title === "Session Usage" && <Clock className="w-3 h-3" />}
{title === 'Session Usage' && <Clock className="w-3 h-3" />}
{resetText}
</p>
</div>
@@ -206,26 +191,21 @@ export function ClaudeUsagePopover() {
const getProgressBarColor = (percentage: number) => {
if (percentage >= 80) return 'bg-red-500';
if (percentage >= 50) return 'bg-yellow-500';
return "bg-green-500";
return 'bg-green-500';
};
const trigger = (
<Button
variant="ghost"
size="sm"
className="h-9 gap-3 bg-secondary border border-border px-3"
>
<Button variant="ghost" size="sm" className="h-9 gap-3 bg-secondary border border-border px-3">
<span className="text-sm font-medium">Usage</span>
{claudeUsage && (
<div className={cn(
"h-1.5 w-16 bg-muted-foreground/20 rounded-full overflow-hidden transition-opacity",
isStale && "opacity-60"
)}>
<div
className={cn(
'h-1.5 w-16 bg-muted-foreground/20 rounded-full overflow-hidden transition-opacity',
isStale && 'opacity-60'
)}
>
<div
className={cn(
"h-full transition-all duration-500",
getProgressBarColor(maxPercentage)
)}
className={cn('h-full transition-all duration-500', getProgressBarColor(maxPercentage))}
style={{ width: `${Math.min(maxPercentage, 100)}%` }}
/>
</div>

View File

@@ -40,9 +40,7 @@ export function SidebarNavigation({
</span>
</div>
)}
{section.label && !sidebarOpen && (
<div className="h-px bg-border/30 mx-2 my-1.5"></div>
)}
{section.label && !sidebarOpen && <div className="h-px bg-border/30 mx-2 my-1.5"></div>}
{/* Nav Items */}
<div className="space-y-1.5">

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from "react";
import { useEffect, useState, useMemo } from 'react';
const TOTAL_DURATION = 2300; // Total animation duration in ms (tightened from 4000)
const LOGO_ENTER_DURATION = 500; // Tightened from 1200
@@ -36,9 +36,7 @@ function generateParticles(count: number): Particle[] {
}
export function SplashScreen({ onComplete }: { onComplete: () => void }) {
const [phase, setPhase] = useState<"enter" | "hold" | "exit" | "done">(
"enter"
);
const [phase, setPhase] = useState<'enter' | 'hold' | 'exit' | 'done'>('enter');
const particles = useMemo(() => generateParticles(50), []);
@@ -46,11 +44,11 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
const timers: NodeJS.Timeout[] = [];
// Phase transitions
timers.push(setTimeout(() => setPhase("hold"), LOGO_ENTER_DURATION));
timers.push(setTimeout(() => setPhase("exit"), EXIT_START));
timers.push(setTimeout(() => setPhase('hold'), LOGO_ENTER_DURATION));
timers.push(setTimeout(() => setPhase('exit'), EXIT_START));
timers.push(
setTimeout(() => {
setPhase("done");
setPhase('done');
onComplete();
}, TOTAL_DURATION)
);
@@ -58,7 +56,7 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
return () => timers.forEach(clearTimeout);
}, [onComplete]);
if (phase === "done") return null;
if (phase === 'done') return null;
return (
<div
@@ -66,10 +64,10 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
fixed inset-0 z-[9999] flex items-center justify-center
bg-background
transition-opacity duration-500 ease-out
${phase === "exit" ? "opacity-0" : "opacity-100"}
${phase === 'exit' ? 'opacity-0' : 'opacity-100'}
`}
style={{
pointerEvents: phase === "exit" ? "none" : "auto",
pointerEvents: phase === 'exit' ? 'none' : 'auto',
}}
>
<style>{`
@@ -91,15 +89,14 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
<div
className="absolute inset-0 opacity-30"
style={{
background:
"radial-gradient(circle at center, var(--brand-500) 0%, transparent 70%)",
background: 'radial-gradient(circle at center, var(--brand-500) 0%, transparent 70%)',
}}
/>
{/* Particle container 1 - Clockwise */}
<div
className="absolute inset-0 flex items-center justify-center overflow-hidden"
style={{ animation: "spin-slow 60s linear infinite" }}
style={{ animation: 'spin-slow 60s linear infinite' }}
>
{particles.slice(0, 25).map((particle) => (
<div
@@ -107,15 +104,15 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
className="absolute"
style={{
transform:
phase === "exit"
phase === 'exit'
? `translate(${Math.cos((particle.angle * Math.PI) / 180) * particle.distance}px, ${Math.sin((particle.angle * Math.PI) / 180) * particle.distance}px)`
: `translate(${particle.x}px, ${particle.y}px)`,
transition:
phase === "enter"
phase === 'enter'
? `all 600ms ease-out ${PARTICLES_ENTER_DELAY + particle.delay}ms`
: phase === "exit"
: phase === 'exit'
? `all 800ms cubic-bezier(0.4, 0, 1, 1) ${particle.delay * 0.3}ms`
: "all 300ms ease-out",
: 'all 300ms ease-out',
}}
>
<div
@@ -125,15 +122,10 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
height: particle.size,
background: `linear-gradient(135deg, var(--brand-400), var(--brand-600))`,
boxShadow: `0 0 ${particle.size * 2}px var(--brand-500)`,
opacity:
phase === "enter"
? 0
: phase === "hold"
? particle.opacity
: 0,
transform: phase === "exit" ? "scale(0)" : "scale(1)",
opacity: phase === 'enter' ? 0 : phase === 'hold' ? particle.opacity : 0,
transform: phase === 'exit' ? 'scale(0)' : 'scale(1)',
animation: `float ${particle.floatDuration}ms ease-in-out infinite`,
transition: "opacity 300ms ease-out, transform 300ms ease-out",
transition: 'opacity 300ms ease-out, transform 300ms ease-out',
}}
/>
</div>
@@ -143,7 +135,7 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
{/* Particle container 2 - Counter-Clockwise */}
<div
className="absolute inset-0 flex items-center justify-center overflow-hidden"
style={{ animation: "spin-slow-reverse 75s linear infinite" }}
style={{ animation: 'spin-slow-reverse 75s linear infinite' }}
>
{particles.slice(25).map((particle) => (
<div
@@ -151,15 +143,15 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
className="absolute"
style={{
transform:
phase === "exit"
phase === 'exit'
? `translate(${Math.cos((particle.angle * Math.PI) / 180) * particle.distance}px, ${Math.sin((particle.angle * Math.PI) / 180) * particle.distance}px)`
: `translate(${particle.x}px, ${particle.y}px)`,
transition:
phase === "enter"
phase === 'enter'
? `all 600ms ease-out ${PARTICLES_ENTER_DELAY + particle.delay}ms`
: phase === "exit"
: phase === 'exit'
? `all 800ms cubic-bezier(0.4, 0, 1, 1) ${particle.delay * 0.3}ms`
: "all 300ms ease-out",
: 'all 300ms ease-out',
}}
>
<div
@@ -169,16 +161,11 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
height: particle.size,
background: `linear-gradient(135deg, var(--brand-400), var(--brand-600))`,
boxShadow: `0 0 ${particle.size * 2}px var(--brand-500)`,
opacity:
phase === "enter"
? 0
: phase === "hold"
? particle.opacity
: 0,
transform: phase === "exit" ? "scale(0)" : "scale(1)",
opacity: phase === 'enter' ? 0 : phase === 'hold' ? particle.opacity : 0,
transform: phase === 'exit' ? 'scale(0)' : 'scale(1)',
animation: `float ${particle.floatDuration}ms ease-in-out infinite`,
animationDelay: `${particle.delay}ms`,
transition: "opacity 300ms ease-out, transform 300ms ease-out",
transition: 'opacity 300ms ease-out, transform 300ms ease-out',
}}
/>
</div>
@@ -189,30 +176,29 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
<div
className="relative z-10"
style={{
opacity: phase === "enter" ? 0 : phase === "exit" ? 0 : 1,
opacity: phase === 'enter' ? 0 : phase === 'exit' ? 0 : 1,
transform:
phase === "enter"
? "scale(0.3) rotate(-20deg)"
: phase === "exit"
? "scale(2.5) translateY(-100px)"
: "scale(1) rotate(0deg)",
phase === 'enter'
? 'scale(0.3) rotate(-20deg)'
: phase === 'exit'
? 'scale(2.5) translateY(-100px)'
: 'scale(1) rotate(0deg)',
transition:
phase === "enter"
phase === 'enter'
? `all ${LOGO_ENTER_DURATION}ms cubic-bezier(0.34, 1.56, 0.64, 1)`
: phase === "exit"
? "all 600ms cubic-bezier(0.4, 0, 1, 1)"
: "all 300ms ease-out",
: phase === 'exit'
? 'all 600ms cubic-bezier(0.4, 0, 1, 1)'
: 'all 300ms ease-out',
}}
>
{/* Glow effect behind logo */}
<div
className="absolute inset-0 blur-3xl"
style={{
background:
"radial-gradient(circle, var(--brand-500) 0%, transparent 70%)",
transform: "scale(2.5)",
opacity: phase === "hold" ? 0.6 : 0,
transition: "opacity 500ms ease-out",
background: 'radial-gradient(circle, var(--brand-500) 0%, transparent 70%)',
transform: 'scale(2.5)',
opacity: phase === 'hold' ? 0.6 : 0,
transition: 'opacity 500ms ease-out',
}}
/>
@@ -226,7 +212,7 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
style={{
width: 120,
height: 120,
filter: "drop-shadow(0 0 30px var(--brand-500))",
filter: 'drop-shadow(0 0 30px var(--brand-500))',
}}
>
<defs>
@@ -238,16 +224,10 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
y2="256"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" style={{ stopColor: "var(--brand-400)" }} />
<stop offset="100%" style={{ stopColor: "var(--brand-600)" }} />
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
</linearGradient>
<filter
id="splash-shadow"
x="-20%"
y="-20%"
width="140%"
height="140%"
>
<filter id="splash-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow
dx="0"
dy="4"
@@ -257,14 +237,7 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
/>
</filter>
</defs>
<rect
x="16"
y="16"
width="224"
height="224"
rx="56"
fill="url(#splash-bg)"
/>
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#splash-bg)" />
<g
fill="none"
stroke="#FFFFFF"
@@ -284,20 +257,20 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
<div
className="absolute flex items-center gap-1"
style={{
top: "calc(50% + 80px)",
opacity: phase === "enter" ? 0 : phase === "exit" ? 0 : 1,
top: 'calc(50% + 80px)',
opacity: phase === 'enter' ? 0 : phase === 'exit' ? 0 : 1,
transform:
phase === "enter"
? "translateY(20px)"
: phase === "exit"
? "translateY(-30px) scale(1.2)"
: "translateY(0)",
phase === 'enter'
? 'translateY(20px)'
: phase === 'exit'
? 'translateY(-30px) scale(1.2)'
: 'translateY(0)',
transition:
phase === "enter"
phase === 'enter'
? `all 600ms ease-out ${LOGO_ENTER_DURATION - 200}ms`
: phase === "exit"
? "all 500ms cubic-bezier(0.4, 0, 1, 1)"
: "all 300ms ease-out",
: phase === 'exit'
? 'all 500ms cubic-bezier(0.4, 0, 1, 1)'
: 'all 300ms ease-out',
}}
>
<span className="font-bold text-foreground text-4xl tracking-tight leading-none">

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { Loader2 } from "lucide-react";
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { Loader2 } from 'lucide-react';
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none 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 active:scale-[0.98]",
@@ -11,43 +11,35 @@ const buttonVariants = cva(
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md hover:shadow-primary/25",
'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md hover:shadow-primary/25',
destructive:
"bg-destructive text-white shadow-sm hover:bg-destructive/90 hover:shadow-md hover:shadow-destructive/25 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
'bg-destructive text-white shadow-sm hover:bg-destructive/90 hover:shadow-md hover:shadow-destructive/25 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline active:scale-100",
"animated-outline":
"relative overflow-hidden rounded-xl hover:bg-transparent shadow-none",
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline active:scale-100',
'animated-outline': 'relative overflow-hidden rounded-xl hover:bg-transparent shadow-none',
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: "default",
size: "default",
variant: 'default',
size: 'default',
},
}
);
// Loading spinner component
function ButtonSpinner({ className }: { className?: string }) {
return (
<Loader2
className={cn("size-4 animate-spin", className)}
aria-hidden="true"
/>
);
return <Loader2 className={cn('size-4 animate-spin', className)} aria-hidden="true" />;
}
function Button({
@@ -59,7 +51,7 @@ function Button({
disabled,
children,
...props
}: React.ComponentProps<"button"> &
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
loading?: boolean;
@@ -67,12 +59,12 @@ function Button({
const isDisabled = disabled || loading;
// Special handling for animated-outline variant
if (variant === "animated-outline" && !asChild) {
if (variant === 'animated-outline' && !asChild) {
return (
<button
className={cn(
buttonVariants({ variant, size }),
"group p-[1px]", // Force 1px padding for the gradient border, group for hover animation
'group p-[1px]', // Force 1px padding for the gradient border, group for hover animation
className
)}
data-slot="button"
@@ -85,10 +77,10 @@ function Button({
{/* Inner content container */}
<span
className={cn(
"animated-outline-inner inline-flex h-full w-full cursor-pointer items-center justify-center gap-2 rounded-[10px] px-4 py-1 text-sm font-medium backdrop-blur-3xl transition-all duration-200",
size === "sm" && "px-3 text-xs gap-1.5",
size === "lg" && "px-8",
size === "icon" && "p-0 gap-0"
'animated-outline-inner inline-flex h-full w-full cursor-pointer items-center justify-center gap-2 rounded-[10px] px-4 py-1 text-sm font-medium backdrop-blur-3xl transition-all duration-200',
size === 'sm' && 'px-3 text-xs gap-1.5',
size === 'lg' && 'px-8',
size === 'icon' && 'p-0 gap-0'
)}
>
{loading && <ButtonSpinner />}
@@ -98,7 +90,7 @@ function Button({
);
}
const Comp = asChild ? Slot : "button";
const Comp = asChild ? Slot : 'button';
return (
<Comp

View File

@@ -1,8 +1,8 @@
import * as React from "react";
import * as React from 'react';
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
interface CardProps extends React.ComponentProps<"div"> {
interface CardProps extends React.ComponentProps<'div'> {
gradient?: boolean;
}
@@ -11,12 +11,12 @@ function Card({ className, gradient = false, ...props }: CardProps) {
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-1 rounded-xl border border-white/10 backdrop-blur-md py-6",
'bg-card text-card-foreground flex flex-col gap-1 rounded-xl border border-white/10 backdrop-blur-md py-6',
// Premium layered shadow
"shadow-[0_1px_2px_rgba(0,0,0,0.05),0_4px_6px_rgba(0,0,0,0.05),0_10px_20px_rgba(0,0,0,0.04)]",
'shadow-[0_1px_2px_rgba(0,0,0,0.05),0_4px_6px_rgba(0,0,0,0.05),0_10px_20px_rgba(0,0,0,0.04)]',
// Gradient border option
gradient &&
"relative before:absolute before:inset-0 before:rounded-xl before:p-[1px] before:bg-gradient-to-br before:from-white/20 before:to-transparent before:pointer-events-none before:-z-10",
'relative before:absolute before:inset-0 before:rounded-xl before:p-[1px] before:bg-gradient-to-br before:from-white/20 before:to-transparent before:pointer-events-none before:-z-10',
className
)}
{...props}
@@ -24,12 +24,12 @@ function Card({ className, gradient = false, ...props }: CardProps) {
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className
)}
{...props}
@@ -37,65 +37,48 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold tracking-tight", className)}
className={cn('leading-none font-semibold tracking-tight', className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm leading-relaxed", className)}
className={cn('text-muted-foreground text-sm leading-relaxed', className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center gap-3 px-6 [.border-t]:pt-6", className)}
className={cn('flex items-center gap-3 px-6 [.border-t]:pt-6', className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
import React, { useEffect, useCallback, useRef } from "react";
import { Button, buttonVariants } from "./button";
import { cn } from "@/lib/utils";
import type { VariantProps } from "class-variance-authority";
import React, { useEffect, useCallback, useRef } from 'react';
import { Button, buttonVariants } from './button';
import { cn } from '@/lib/utils';
import type { VariantProps } from 'class-variance-authority';
export interface HotkeyConfig {
/** The key to trigger the hotkey (e.g., "Enter", "s", "n") */
@@ -18,8 +17,7 @@ export interface HotkeyConfig {
}
export interface HotkeyButtonProps
extends React.ComponentProps<"button">,
VariantProps<typeof buttonVariants> {
extends React.ComponentProps<'button'>, VariantProps<typeof buttonVariants> {
/** Hotkey configuration - can be a simple key string or a full config object */
hotkey?: string | HotkeyConfig;
/** Whether to show the hotkey indicator badge */
@@ -38,14 +36,14 @@ export interface HotkeyButtonProps
* Get the modifier key symbol based on platform
*/
function getModifierSymbol(isMac: boolean): string {
return isMac ? "⌘" : "Ctrl";
return isMac ? '⌘' : 'Ctrl';
}
/**
* Parse hotkey config into a normalized format
*/
function parseHotkeyConfig(hotkey: string | HotkeyConfig): HotkeyConfig {
if (typeof hotkey === "string") {
if (typeof hotkey === 'string') {
return { key: hotkey };
}
return hotkey;
@@ -54,10 +52,7 @@ function parseHotkeyConfig(hotkey: string | HotkeyConfig): HotkeyConfig {
/**
* Generate the display label for the hotkey
*/
function getHotkeyDisplayLabel(
config: HotkeyConfig,
isMac: boolean
): React.ReactNode {
function getHotkeyDisplayLabel(config: HotkeyConfig, isMac: boolean): React.ReactNode {
if (config.label) {
return config.label;
}
@@ -74,10 +69,7 @@ function getHotkeyDisplayLabel(
if (config.shift) {
parts.push(
<span
key="shift"
className="leading-none flex items-center justify-center"
>
<span key="shift" className="leading-none flex items-center justify-center">
</span>
);
@@ -86,7 +78,7 @@ function getHotkeyDisplayLabel(
if (config.alt) {
parts.push(
<span key="alt" className="leading-none flex items-center justify-center">
{isMac ? "⌥" : "Alt"}
{isMac ? '⌥' : 'Alt'}
</span>
);
}
@@ -94,36 +86,36 @@ function getHotkeyDisplayLabel(
// Convert key to display format
let keyDisplay = config.key;
switch (config.key.toLowerCase()) {
case "enter":
keyDisplay = "↵";
case 'enter':
keyDisplay = '↵';
break;
case "escape":
case "esc":
keyDisplay = "Esc";
case 'escape':
case 'esc':
keyDisplay = 'Esc';
break;
case "arrowup":
keyDisplay = "↑";
case 'arrowup':
keyDisplay = '↑';
break;
case "arrowdown":
keyDisplay = "↓";
case 'arrowdown':
keyDisplay = '↓';
break;
case "arrowleft":
keyDisplay = "←";
case 'arrowleft':
keyDisplay = '←';
break;
case "arrowright":
keyDisplay = "→";
case 'arrowright':
keyDisplay = '→';
break;
case "backspace":
keyDisplay = "⌫";
case 'backspace':
keyDisplay = '⌫';
break;
case "delete":
keyDisplay = "⌦";
case 'delete':
keyDisplay = '⌦';
break;
case "tab":
keyDisplay = "⇥";
case 'tab':
keyDisplay = '⇥';
break;
case " ":
keyDisplay = "Space";
case ' ':
keyDisplay = 'Space';
break;
default:
// Capitalize single letters
@@ -148,16 +140,16 @@ function isInputElement(element: Element | null): boolean {
if (!element) return false;
const tagName = element.tagName.toLowerCase();
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
return true;
}
if (element.getAttribute("contenteditable") === "true") {
if (element.getAttribute('contenteditable') === 'true') {
return true;
}
const role = element.getAttribute("role");
if (role === "textbox" || role === "searchbox" || role === "combobox") {
const role = element.getAttribute('role');
if (role === 'textbox' || role === 'searchbox' || role === 'combobox') {
return true;
}
@@ -194,7 +186,7 @@ export function HotkeyButton({
// Detect platform on mount
useEffect(() => {
setIsMac(navigator.platform.toLowerCase().includes("mac"));
setIsMac(navigator.platform.toLowerCase().includes('mac'));
}, []);
const config = hotkey ? parseHotkeyConfig(hotkey) : null;
@@ -205,11 +197,7 @@ export function HotkeyButton({
// Don't trigger when typing in inputs (unless explicitly scoped or using cmdCtrl modifier)
// cmdCtrl shortcuts like Cmd+Enter should work even in inputs as they're intentional submit actions
if (
!scopeRef &&
!config.cmdCtrl &&
isInputElement(document.activeElement)
) {
if (!scopeRef && !config.cmdCtrl && isInputElement(document.activeElement)) {
return;
}
@@ -233,8 +221,7 @@ export function HotkeyButton({
if (scopeRef && scopeRef.current) {
const scopeEl = scopeRef.current;
const isVisible =
scopeEl.offsetParent !== null ||
getComputedStyle(scopeEl).display !== "none";
scopeEl.offsetParent !== null || getComputedStyle(scopeEl).display !== 'none';
if (!isVisible) return;
}
@@ -257,9 +244,9 @@ export function HotkeyButton({
useEffect(() => {
if (!config || !hotkeyActive) return;
window.addEventListener("keydown", handleKeyDown);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener('keydown', handleKeyDown);
};
}, [config, hotkeyActive, handleKeyDown]);
@@ -285,7 +272,7 @@ export function HotkeyButton({
asChild={asChild}
{...props}
>
{typeof children === "string" ? (
{typeof children === 'string' ? (
<>
{children}
{hotkeyIndicator}

View File

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

View File

@@ -1,153 +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",
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",
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',
},
};
@@ -161,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(() => {
@@ -187,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;
@@ -203,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}
@@ -229,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 && (
@@ -262,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>
@@ -289,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>
))}
@@ -326,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>
@@ -354,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: [],
@@ -388,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];
@@ -421,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);
};
@@ -437,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);
@@ -449,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 }
@@ -470,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();
}
};
@@ -484,176 +500,194 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
return (
<TooltipProvider>
<div className="space-y-4" data-testid="shortcut-reference-panel">
{editable && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => resetKeyboardShortcuts()}
className="gap-2 text-xs"
data-testid="reset-all-shortcuts-button"
>
<RotateCcw className="w-3 h-3" />
Reset All to Defaults
</Button>
</div>
)}
{Object.entries(groupedShortcuts).map(([category, shortcuts]) => {
const colors = CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS];
return (
<div key={category} className="space-y-2">
<h4 className={cn("text-sm font-semibold", colors.text)}>
{colors.label}
</h4>
<div className="grid grid-cols-2 gap-2">
{shortcuts.map(({ key, label, value }) => {
const isModified = mergedShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key];
const isEditing = editingShortcut === key;
return (
<div
key={key}
className={cn(
"flex items-center justify-between p-2 rounded-lg bg-sidebar-accent/10 border transition-colors",
isEditing ? "border-brand-500" : "border-sidebar-border",
editable && !isEditing && "hover:bg-sidebar-accent/20 cursor-pointer"
)}
onClick={() => editable && !isEditing && handleStartEdit(key)}
data-testid={`shortcut-row-${key}`}
>
<span className="text-sm text-foreground">{label}</span>
<div className="flex items-center gap-2">
{isEditing ? (
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
{/* Modifier checkboxes */}
<div className="flex items-center gap-1.5 text-xs">
<div className="flex items-center gap-1">
<Checkbox
id={`mod-cmd-${key}`}
checked={modifiers.cmdCtrl}
onCheckedChange={(checked) => handleModifierChange("cmdCtrl", !!checked, key)}
className="h-3.5 w-3.5"
/>
<Label htmlFor={`mod-cmd-${key}`} className="text-xs text-muted-foreground cursor-pointer">
{isMac ? "⌘" : "Ctrl"}
</Label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id={`mod-alt-${key}`}
checked={modifiers.alt}
onCheckedChange={(checked) => handleModifierChange("alt", !!checked, key)}
className="h-3.5 w-3.5"
/>
<Label htmlFor={`mod-alt-${key}`} className="text-xs text-muted-foreground cursor-pointer">
{isMac ? "⌥" : "Alt"}
</Label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id={`mod-shift-${key}`}
checked={modifiers.shift}
onCheckedChange={(checked) => handleModifierChange("shift", !!checked, key)}
className="h-3.5 w-3.5"
/>
<Label htmlFor={`mod-shift-${key}`} className="text-xs text-muted-foreground cursor-pointer">
</Label>
</div>
</div>
<span className="text-muted-foreground">+</span>
<Input
value={keyValue}
onChange={(e) => handleKeyChange(e.target.value, key)}
onKeyDown={handleKeyDown}
className={cn(
"w-12 h-7 text-center font-mono text-xs uppercase",
shortcutError && "border-red-500 focus-visible:ring-red-500"
)}
placeholder="Key"
maxLength={1}
autoFocus
data-testid={`edit-shortcut-input-${key}`}
/>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 hover:bg-green-500/20 hover:text-green-400"
onClick={(e) => {
e.stopPropagation();
handleSaveShortcut();
}}
disabled={!!shortcutError || !keyValue}
data-testid={`save-shortcut-${key}`}
>
<CheckCircle2 className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 hover:bg-red-500/20 hover:text-red-400"
onClick={(e) => {
e.stopPropagation();
handleCancelEdit();
}}
data-testid={`cancel-shortcut-${key}`}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<>
<kbd
className={cn(
"px-2 py-1 text-xs font-mono rounded border",
colors.bg,
colors.border,
colors.text
)}
>
{formatShortcut(value, true)}
</kbd>
{isModified && editable && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 hover:bg-yellow-500/20 hover:text-yellow-400"
onClick={(e) => {
e.stopPropagation();
handleResetShortcut(key);
}}
data-testid={`reset-shortcut-${key}`}
>
<RotateCcw className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Reset to default ({DEFAULT_KEYBOARD_SHORTCUTS[key]})
</TooltipContent>
</Tooltip>
)}
{isModified && !editable && (
<span className="w-2 h-2 rounded-full bg-yellow-500" />
)}
{editable && !isModified && (
<Edit2 className="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100" />
)}
</>
)}
</div>
</div>
);
})}
</div>
{editingShortcut && shortcutError && SHORTCUT_CATEGORIES[editingShortcut] === category && (
<p className="text-xs text-red-400 mt-1">{shortcutError}</p>
)}
{editable && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => resetKeyboardShortcuts()}
className="gap-2 text-xs"
data-testid="reset-all-shortcuts-button"
>
<RotateCcw className="w-3 h-3" />
Reset All to Defaults
</Button>
</div>
);
})}
</div>
)}
{Object.entries(groupedShortcuts).map(([category, shortcuts]) => {
const colors = CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS];
return (
<div key={category} className="space-y-2">
<h4 className={cn('text-sm font-semibold', colors.text)}>{colors.label}</h4>
<div className="grid grid-cols-2 gap-2">
{shortcuts.map(({ key, label, value }) => {
const isModified = mergedShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key];
const isEditing = editingShortcut === key;
return (
<div
key={key}
className={cn(
'flex items-center justify-between p-2 rounded-lg bg-sidebar-accent/10 border transition-colors',
isEditing ? 'border-brand-500' : 'border-sidebar-border',
editable && !isEditing && 'hover:bg-sidebar-accent/20 cursor-pointer'
)}
onClick={() => editable && !isEditing && handleStartEdit(key)}
data-testid={`shortcut-row-${key}`}
>
<span className="text-sm text-foreground">{label}</span>
<div className="flex items-center gap-2">
{isEditing ? (
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
{/* Modifier checkboxes */}
<div className="flex items-center gap-1.5 text-xs">
<div className="flex items-center gap-1">
<Checkbox
id={`mod-cmd-${key}`}
checked={modifiers.cmdCtrl}
onCheckedChange={(checked) =>
handleModifierChange('cmdCtrl', !!checked, key)
}
className="h-3.5 w-3.5"
/>
<Label
htmlFor={`mod-cmd-${key}`}
className="text-xs text-muted-foreground cursor-pointer"
>
{isMac ? '⌘' : 'Ctrl'}
</Label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id={`mod-alt-${key}`}
checked={modifiers.alt}
onCheckedChange={(checked) =>
handleModifierChange('alt', !!checked, key)
}
className="h-3.5 w-3.5"
/>
<Label
htmlFor={`mod-alt-${key}`}
className="text-xs text-muted-foreground cursor-pointer"
>
{isMac ? '⌥' : 'Alt'}
</Label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id={`mod-shift-${key}`}
checked={modifiers.shift}
onCheckedChange={(checked) =>
handleModifierChange('shift', !!checked, key)
}
className="h-3.5 w-3.5"
/>
<Label
htmlFor={`mod-shift-${key}`}
className="text-xs text-muted-foreground cursor-pointer"
>
</Label>
</div>
</div>
<span className="text-muted-foreground">+</span>
<Input
value={keyValue}
onChange={(e) => handleKeyChange(e.target.value, key)}
onKeyDown={handleKeyDown}
className={cn(
'w-12 h-7 text-center font-mono text-xs uppercase',
shortcutError && 'border-red-500 focus-visible:ring-red-500'
)}
placeholder="Key"
maxLength={1}
autoFocus
data-testid={`edit-shortcut-input-${key}`}
/>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 hover:bg-green-500/20 hover:text-green-400"
onClick={(e) => {
e.stopPropagation();
handleSaveShortcut();
}}
disabled={!!shortcutError || !keyValue}
data-testid={`save-shortcut-${key}`}
>
<CheckCircle2 className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 hover:bg-red-500/20 hover:text-red-400"
onClick={(e) => {
e.stopPropagation();
handleCancelEdit();
}}
data-testid={`cancel-shortcut-${key}`}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<>
<kbd
className={cn(
'px-2 py-1 text-xs font-mono rounded border',
colors.bg,
colors.border,
colors.text
)}
>
{formatShortcut(value, true)}
</kbd>
{isModified && editable && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 hover:bg-yellow-500/20 hover:text-yellow-400"
onClick={(e) => {
e.stopPropagation();
handleResetShortcut(key);
}}
data-testid={`reset-shortcut-${key}`}
>
<RotateCcw className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Reset to default ({DEFAULT_KEYBOARD_SHORTCUTS[key]})
</TooltipContent>
</Tooltip>
)}
{isModified && !editable && (
<span className="w-2 h-2 rounded-full bg-yellow-500" />
)}
{editable && !isModified && (
<Edit2 className="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100" />
)}
</>
)}
</div>
</div>
);
})}
</div>
{editingShortcut &&
shortcutError &&
SHORTCUT_CATEGORIES[editingShortcut] === category && (
<p className="text-xs text-red-400 mt-1">{shortcutError}</p>
)}
</div>
);
})}
</div>
</TooltipProvider>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,26 +1,26 @@
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import {
PointerSensor,
useSensor,
useSensors,
rectIntersection,
pointerWithin,
} from "@dnd-kit/core";
import { useAppStore, Feature } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import type { AutoModeEvent } from "@/types/electron";
import { pathsEqual } from "@/lib/utils";
import { getBlockingDependencies } from "@automaker/dependency-resolver";
import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal";
import { RefreshCw } from "lucide-react";
import { useAutoMode } from "@/hooks/use-auto-mode";
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
import { useWindowState } from "@/hooks/use-window-state";
} from '@dnd-kit/core';
import { useAppStore, Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import type { AutoModeEvent } from '@/types/electron';
import { pathsEqual } from '@/lib/utils';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
import { RefreshCw } from 'lucide-react';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useWindowState } from '@/hooks/use-window-state';
// Board-view specific imports
import { BoardHeader } from "./board-view/board-header";
import { BoardSearchBar } from "./board-view/board-search-bar";
import { BoardControls } from "./board-view/board-controls";
import { KanbanBoard } from "./board-view/kanban-board";
import { BoardHeader } from './board-view/board-header';
import { BoardSearchBar } from './board-view/board-search-bar';
import { BoardControls } from './board-view/board-controls';
import { KanbanBoard } from './board-view/kanban-board';
import {
AddFeatureDialog,
AgentOutputModal,
@@ -31,15 +31,15 @@ import {
FeatureSuggestionsDialog,
FollowUpDialog,
PlanApprovalDialog,
} from "./board-view/dialogs";
import { CreateWorktreeDialog } from "./board-view/dialogs/create-worktree-dialog";
import { DeleteWorktreeDialog } from "./board-view/dialogs/delete-worktree-dialog";
import { CommitWorktreeDialog } from "./board-view/dialogs/commit-worktree-dialog";
import { CreatePRDialog } from "./board-view/dialogs/create-pr-dialog";
import { CreateBranchDialog } from "./board-view/dialogs/create-branch-dialog";
import { WorktreePanel } from "./board-view/worktree-panel";
import type { PRInfo, WorktreeInfo } from "./board-view/worktree-panel/types";
import { COLUMNS } from "./board-view/constants";
} from './board-view/dialogs';
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialog';
import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog';
import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog';
import { WorktreePanel } from './board-view/worktree-panel';
import type { PRInfo, WorktreeInfo } from './board-view/worktree-panel/types';
import { COLUMNS } from './board-view/constants';
import {
useBoardFeatures,
useBoardDragDrop,
@@ -51,12 +51,10 @@ import {
useBoardPersistence,
useFollowUpState,
useSuggestionsState,
} from "./board-view/hooks";
} from './board-view/hooks';
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType<
ReturnType<typeof useAppStore.getState>["getWorktrees"]
> = [];
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
/** Delay before starting a newly created feature to allow state to settle */
const FEATURE_CREATION_SETTLE_DELAY_MS = 500;
@@ -98,26 +96,18 @@ export function BoardView() {
const [isMounted, setIsMounted] = useState(false);
const [showOutputModal, setShowOutputModal] = useState(false);
const [outputFeature, setOutputFeature] = useState<Feature | null>(null);
const [featuresWithContext, setFeaturesWithContext] = useState<Set<string>>(
new Set()
);
const [showArchiveAllVerifiedDialog, setShowArchiveAllVerifiedDialog] =
useState(false);
const [showBoardBackgroundModal, setShowBoardBackgroundModal] =
useState(false);
const [featuresWithContext, setFeaturesWithContext] = useState<Set<string>>(new Set());
const [showArchiveAllVerifiedDialog, setShowArchiveAllVerifiedDialog] = useState(false);
const [showBoardBackgroundModal, setShowBoardBackgroundModal] = useState(false);
const [showCompletedModal, setShowCompletedModal] = useState(false);
const [deleteCompletedFeature, setDeleteCompletedFeature] =
useState<Feature | null>(null);
const [deleteCompletedFeature, setDeleteCompletedFeature] = useState<Feature | null>(null);
// State for viewing plan in read-only mode
const [viewPlanFeature, setViewPlanFeature] = useState<Feature | null>(null);
// Worktree dialog states
const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] =
useState(false);
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] =
useState(false);
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] =
useState(false);
const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] = useState(false);
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] = useState(false);
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false);
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{
@@ -158,7 +148,7 @@ export function BoardView() {
closeSuggestionsDialog,
} = useSuggestionsState();
// Search filter for Kanban cards
const [searchQuery, setSearchQuery] = useState("");
const [searchQuery, setSearchQuery] = useState('');
// Plan approval loading state
const [isPlanApprovalLoading, setIsPlanApprovalLoading] = useState(false);
// Derive spec creation state from store - check if current project is the one being created
@@ -175,14 +165,11 @@ export function BoardView() {
return false;
}
const result = await api.autoMode.contextExists(
currentProject.path,
featureId
);
const result = await api.autoMode.contextExists(currentProject.path, featureId);
return result.success && result.exists === true;
} catch (error) {
console.error("[Board] Error checking context:", error);
console.error('[Board] Error checking context:', error);
return false;
}
},
@@ -228,9 +215,7 @@ export function BoardView() {
// Get unique categories from existing features AND persisted categories for autocomplete suggestions
const categorySuggestions = useMemo(() => {
const featureCategories = hookFeatures
.map((f) => f.category)
.filter(Boolean);
const featureCategories = hookFeatures.map((f) => f.category).filter(Boolean);
// Merge feature categories with persisted categories
const allCategories = [...featureCategories, ...persistedCategories];
return [...new Set(allCategories)].sort();
@@ -264,7 +249,7 @@ export function BoardView() {
setBranchSuggestions(localBranches);
}
} catch (error) {
console.error("[BoardView] Error fetching branches:", error);
console.error('[BoardView] Error fetching branches:', error);
setBranchSuggestions([]);
}
};
@@ -276,8 +261,8 @@ export function BoardView() {
const branchCardCounts = useMemo(() => {
return hookFeatures.reduce(
(counts, feature) => {
if (feature.status !== "completed") {
const branch = feature.branchName ?? "main";
if (feature.status !== 'completed') {
const branch = feature.branchName ?? 'main';
counts[branch] = (counts[branch] || 0) + 1;
}
return counts;
@@ -304,8 +289,9 @@ export function BoardView() {
}, []);
// Use persistence hook
const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } =
useBoardPersistence({ currentProject });
const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } = useBoardPersistence({
currentProject,
});
// Memoize the removed worktrees handler to prevent infinite loops
const handleRemovedWorktrees = useCallback(
@@ -332,15 +318,13 @@ export function BoardView() {
const inProgressFeaturesForShortcuts = useMemo(() => {
return hookFeatures.filter((f) => {
const isRunning = runningAutoTasks.includes(f.id);
return isRunning || f.status === "in_progress";
return isRunning || f.status === 'in_progress';
});
}, [hookFeatures, runningAutoTasks]);
// Get current worktree info (path) for filtering features
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
const currentWorktreeInfo = currentProject
? getCurrentWorktree(currentProject.path)
: null;
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
const worktrees = useMemo(
@@ -359,9 +343,7 @@ export function BoardView() {
return worktrees.find((w) => w.isMain);
} else {
// Specific worktree selected - find it by path
return worktrees.find(
(w) => !w.isMain && pathsEqual(w.path, currentWorktreePath)
);
return worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath));
}
}, [worktrees, currentWorktreePath]);
@@ -371,7 +353,7 @@ export function BoardView() {
// Get the branch for the currently selected worktree (for defaulting new features)
// Use the branch from selectedWorktree, or fall back to main worktree's branch
const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || "main";
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
// Extract all action handlers into a hook
const {
@@ -422,9 +404,7 @@ export function BoardView() {
if (!currentProject) return;
// Check if worktree already exists in the store (by branch name)
const currentWorktrees = getWorktrees(currentProject.path);
const existingWorktree = currentWorktrees.find(
(w) => w.branch === newWorktree.branch
);
const existingWorktree = currentWorktrees.find((w) => w.branch === newWorktree.branch);
// Only add if it doesn't already exist (to avoid duplicates)
if (!existingWorktree) {
@@ -435,17 +415,10 @@ export function BoardView() {
isCurrent: false,
hasWorktree: true,
};
setWorktrees(currentProject.path, [
...currentWorktrees,
newWorktreeInfo,
]);
setWorktrees(currentProject.path, [...currentWorktrees, newWorktreeInfo]);
}
// Select the worktree (whether it existed or was just added)
setCurrentWorktree(
currentProject.path,
newWorktree.path,
newWorktree.branch
);
setCurrentWorktree(currentProject.path, newWorktree.path, newWorktree.branch);
},
currentWorktreeBranch,
});
@@ -460,17 +433,17 @@ export function BoardView() {
// Create the feature
const featureData = {
category: "PR Review",
category: 'PR Review',
description,
steps: [],
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: "opus" as const,
thinkingLevel: "none" as const,
model: 'opus' as const,
thinkingLevel: 'none' as const,
branchName: worktree.branch,
priority: 1, // High priority for PR feedback
planningMode: "skip" as const,
planningMode: 'skip' as const,
requirePlanApproval: false,
};
@@ -483,7 +456,7 @@ export function BoardView() {
const newFeature = latestFeatures.find(
(f) =>
f.branchName === worktree.branch &&
f.status === "backlog" &&
f.status === 'backlog' &&
f.description.includes(`PR #${prNumber}`)
);
@@ -502,17 +475,17 @@ export function BoardView() {
// Create the feature
const featureData = {
category: "Maintenance",
category: 'Maintenance',
description,
steps: [],
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: "opus" as const,
thinkingLevel: "none" as const,
model: 'opus' as const,
thinkingLevel: 'none' as const,
branchName: worktree.branch,
priority: 1, // High priority for conflict resolution
planningMode: "skip" as const,
planningMode: 'skip' as const,
requirePlanApproval: false,
};
@@ -524,8 +497,8 @@ export function BoardView() {
const newFeature = latestFeatures.find(
(f) =>
f.branchName === worktree.branch &&
f.status === "backlog" &&
f.description.includes("Pull latest from origin/main")
f.status === 'backlog' &&
f.description.includes('Pull latest from origin/main')
);
if (newFeature) {
@@ -573,22 +546,21 @@ export function BoardView() {
if (!currentProject) return;
// Only process events for the current project
const eventProjectPath =
"projectPath" in event ? event.projectPath : undefined;
const eventProjectPath = 'projectPath' in event ? event.projectPath : undefined;
if (eventProjectPath && eventProjectPath !== currentProject.path) {
return;
}
switch (event.type) {
case "auto_mode_feature_start":
case 'auto_mode_feature_start':
// Feature is now confirmed running - remove from pending
if (event.featureId) {
pendingFeaturesRef.current.delete(event.featureId);
}
break;
case "auto_mode_feature_complete":
case "auto_mode_error":
case 'auto_mode_feature_complete':
case 'auto_mode_error':
// Feature completed or errored - remove from pending if still there
if (event.featureId) {
pendingFeaturesRef.current.delete(event.featureId);
@@ -629,8 +601,7 @@ export function BoardView() {
// Count currently running tasks + pending features
// Use ref to get the latest running tasks without causing effect re-runs
const currentRunning =
runningAutoTasksRef.current.length + pendingFeaturesRef.current.size;
const currentRunning = runningAutoTasksRef.current.length + pendingFeaturesRef.current.size;
const availableSlots = maxConcurrency - currentRunning;
// No available slots, skip check
@@ -643,7 +614,7 @@ export function BoardView() {
// Use ref to get the latest features without causing effect re-runs
const currentFeatures = hookFeaturesRef.current;
const backlogFeatures = currentFeatures.filter((f) => {
if (f.status !== "backlog") return false;
if (f.status !== 'backlog') return false;
const featureBranch = f.branchName;
@@ -700,9 +671,8 @@ export function BoardView() {
// If feature has no branchName and primary worktree is selected, assign primary branch
if (currentWorktreePath === null && !feature.branchName) {
const primaryBranch =
(currentProject.path
? getPrimaryWorktreeBranch(currentProject.path)
: null) || "main";
(currentProject.path ? getPrimaryWorktreeBranch(currentProject.path) : null) ||
'main';
await persistFeatureUpdate(feature.id, {
branchName: primaryBranch,
});
@@ -788,9 +758,7 @@ export function BoardView() {
// Find feature for pending plan approval
const pendingApprovalFeature = useMemo(() => {
if (!pendingPlanApproval) return null;
return (
hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null
);
return hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null;
}, [pendingPlanApproval, hookFeatures]);
// Handle plan approval
@@ -803,7 +771,7 @@ export function BoardView() {
try {
const api = getElectronAPI();
if (!api?.autoMode?.approvePlan) {
throw new Error("Plan approval API not available");
throw new Error('Plan approval API not available');
}
const result = await api.autoMode.approvePlan(
@@ -819,7 +787,7 @@ export function BoardView() {
const currentFeature = hookFeatures.find((f) => f.id === featureId);
updateFeature(featureId, {
planSpec: {
status: "approved",
status: 'approved',
content: editedPlan || pendingPlanApproval.planContent,
version: currentFeature?.planSpec?.version || 1,
approvedAt: new Date().toISOString(),
@@ -829,10 +797,10 @@ export function BoardView() {
// Reload features from server to ensure sync
loadFeatures();
} else {
console.error("[Board] Failed to approve plan:", result.error);
console.error('[Board] Failed to approve plan:', result.error);
}
} catch (error) {
console.error("[Board] Error approving plan:", error);
console.error('[Board] Error approving plan:', error);
} finally {
setIsPlanApprovalLoading(false);
setPendingPlanApproval(null);
@@ -858,7 +826,7 @@ export function BoardView() {
try {
const api = getElectronAPI();
if (!api?.autoMode?.approvePlan) {
throw new Error("Plan approval API not available");
throw new Error('Plan approval API not available');
}
const result = await api.autoMode.approvePlan(
@@ -874,9 +842,9 @@ export function BoardView() {
// Get current feature to preserve version
const currentFeature = hookFeatures.find((f) => f.id === featureId);
updateFeature(featureId, {
status: "backlog",
status: 'backlog',
planSpec: {
status: "rejected",
status: 'rejected',
content: pendingPlanApproval.planContent,
version: currentFeature?.planSpec?.version || 1,
reviewedByUser: true,
@@ -885,10 +853,10 @@ export function BoardView() {
// Reload features from server to ensure sync
loadFeatures();
} else {
console.error("[Board] Failed to reject plan:", result.error);
console.error('[Board] Failed to reject plan:', result.error);
}
} catch (error) {
console.error("[Board] Error rejecting plan:", error);
console.error('[Board] Error rejecting plan:', error);
} finally {
setIsPlanApprovalLoading(false);
setPendingPlanApproval(null);
@@ -911,8 +879,8 @@ export function BoardView() {
// Determine the planning mode for approval (skip should never have a plan requiring approval)
const mode = feature.planningMode;
const approvalMode: "lite" | "spec" | "full" =
mode === "lite" || mode === "spec" || mode === "full" ? mode : "spec";
const approvalMode: 'lite' | 'spec' | 'full' =
mode === 'lite' || mode === 'spec' || mode === 'full' ? mode : 'spec';
// Re-open the approval dialog with the feature's plan data
setPendingPlanApproval({
@@ -927,10 +895,7 @@ export function BoardView() {
if (!currentProject) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="board-view-no-project"
>
<div className="flex-1 flex items-center justify-center" data-testid="board-view-no-project">
<p className="text-muted-foreground">No project selected</p>
</div>
);
@@ -938,10 +903,7 @@ export function BoardView() {
if (isLoading) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="board-view-loading"
>
<div className="flex-1 flex items-center justify-center" data-testid="board-view-loading">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
@@ -970,7 +932,7 @@ export function BoardView() {
addFeatureShortcut={{
key: shortcuts.addFeature,
action: () => setShowAddDialog(true),
description: "Add new feature",
description: 'Add new feature',
}}
isMounted={isMounted}
/>
@@ -1125,8 +1087,8 @@ export function BoardView() {
<AgentOutputModal
open={showOutputModal}
onClose={() => setShowOutputModal(false)}
featureDescription={outputFeature?.description || ""}
featureId={outputFeature?.id || ""}
featureDescription={outputFeature?.description || ''}
featureId={outputFeature?.id || ''}
featureStatus={outputFeature?.status}
onNumberKeyPress={handleOutputModalNumberKeyPress}
/>
@@ -1135,7 +1097,7 @@ export function BoardView() {
<ArchiveAllVerifiedDialog
open={showArchiveAllVerifiedDialog}
onOpenChange={setShowArchiveAllVerifiedDialog}
verifiedCount={getColumnFeatures("verified").length}
verifiedCount={getColumnFeatures('verified').length}
onConfirm={async () => {
await handleArchiveAllVerified();
setShowArchiveAllVerifiedDialog(false);
@@ -1177,7 +1139,7 @@ export function BoardView() {
}
}}
feature={pendingApprovalFeature}
planContent={pendingPlanApproval?.planContent || ""}
planContent={pendingPlanApproval?.planContent || ''}
onApprove={handlePlanApprove}
onReject={handlePlanReject}
isLoading={isPlanApprovalLoading}
@@ -1212,17 +1174,10 @@ export function BoardView() {
isCurrent: false,
hasWorktree: true,
};
setWorktrees(currentProject.path, [
...currentWorktrees,
newWorktreeInfo,
]);
setWorktrees(currentProject.path, [...currentWorktrees, newWorktreeInfo]);
// Now set the current worktree with both path and branch
setCurrentWorktree(
currentProject.path,
newWorktree.path,
newWorktree.branch
);
setCurrentWorktree(currentProject.path, newWorktree.path, newWorktree.branch);
// Trigger refresh to get full worktree details (hasChanges, etc.)
setWorktreeRefreshKey((k) => k + 1);
@@ -1237,9 +1192,7 @@ export function BoardView() {
worktree={selectedWorktreeForAction}
affectedFeatureCount={
selectedWorktreeForAction
? hookFeatures.filter(
(f) => f.branchName === selectedWorktreeForAction.branch
).length
? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length
: 0
}
onDeleted={(deletedWorktree, _deletedBranch) => {
@@ -1291,9 +1244,7 @@ export function BoardView() {
// Persist changes asynchronously and in parallel
Promise.all(
featuresToUpdate.map((feature) =>
persistFeatureUpdate(feature.id, { prUrl })
)
featuresToUpdate.map((feature) => persistFeatureUpdate(feature.id, { prUrl }))
).catch(console.error);
}
setWorktreeRefreshKey((k) => k + 1);

View File

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

View File

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

View File

@@ -1,2 +1,2 @@
export { KanbanCard } from "./kanban-card/kanban-card";
export { KanbanColumn } from "./kanban-column";
export { KanbanCard } from './kanban-card/kanban-card';
export { KanbanColumn } from './kanban-column';

View File

@@ -1,5 +1,5 @@
import { Feature } from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { Feature } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import {
Edit,
PlayCircle,
@@ -10,7 +10,7 @@ import {
Eye,
Wand2,
Archive,
} from "lucide-react";
} from 'lucide-react';
interface CardActionsProps {
feature: Feature;
@@ -52,7 +52,7 @@ export function CardActions({
{isCurrentAutoTask && (
<>
{/* Approve Plan button - PRIORITY: shows even when agent is "running" (paused for approval) */}
{feature.planSpec?.status === "generated" && onApprovePlan && (
{feature.planSpec?.status === 'generated' && onApprovePlan && (
<Button
variant="default"
size="sm"
@@ -109,10 +109,10 @@ export function CardActions({
)}
</>
)}
{!isCurrentAutoTask && feature.status === "in_progress" && (
{!isCurrentAutoTask && feature.status === 'in_progress' && (
<>
{/* Approve Plan button - shows when plan is generated and waiting for approval */}
{feature.planSpec?.status === "generated" && onApprovePlan && (
{feature.planSpec?.status === 'generated' && onApprovePlan && (
<Button
variant="default"
size="sm"
@@ -191,7 +191,7 @@ export function CardActions({
)}
</>
)}
{!isCurrentAutoTask && feature.status === "verified" && (
{!isCurrentAutoTask && feature.status === 'verified' && (
<>
{/* Logs button */}
{onViewOutput && (
@@ -229,7 +229,7 @@ export function CardActions({
)}
</>
)}
{!isCurrentAutoTask && feature.status === "waiting_approval" && (
{!isCurrentAutoTask && feature.status === 'waiting_approval' && (
<>
{/* Refine prompt button */}
{onFollowUp && (
@@ -282,7 +282,7 @@ export function CardActions({
) : null}
</>
)}
{!isCurrentAutoTask && feature.status === "backlog" && (
{!isCurrentAutoTask && feature.status === 'backlog' && (
<>
<Button
variant="secondary"

View File

@@ -1,19 +1,14 @@
import { useEffect, useMemo, useState } from "react";
import { Feature, useAppStore } from "@/store/app-store";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { AlertCircle, Lock, Hand, Sparkles } from "lucide-react";
import { getBlockingDependencies } from "@automaker/dependency-resolver";
import { useEffect, useMemo, useState } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
interface CardBadgeProps {
children: React.ReactNode;
className?: string;
"data-testid"?: string;
'data-testid'?: string;
title?: string;
}
@@ -21,16 +16,11 @@ interface CardBadgeProps {
* Shared badge component matching the "Just Finished" badge style
* Used for priority badges and other card badges
*/
function CardBadge({
children,
className,
"data-testid": dataTestId,
title,
}: CardBadgeProps) {
function CardBadge({ children, className, 'data-testid': dataTestId, title }: CardBadgeProps) {
return (
<div
className={cn(
"inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium",
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium',
className
)}
data-testid={dataTestId}
@@ -50,7 +40,7 @@ export function CardBadges({ feature }: CardBadgesProps) {
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
const blockingDependencies = useMemo(() => {
if (!enableDependencyBlocking || feature.status !== "backlog") {
if (!enableDependencyBlocking || feature.status !== 'backlog') {
return [];
}
return getBlockingDependencies(feature, features);
@@ -62,7 +52,7 @@ export function CardBadges({ feature }: CardBadgesProps) {
(blockingDependencies.length > 0 &&
!feature.error &&
!feature.skipTests &&
feature.status === "backlog");
feature.status === 'backlog');
if (!showStatusBadges) {
return null;
@@ -77,8 +67,8 @@ export function CardBadges({ feature }: CardBadgesProps) {
<TooltipTrigger asChild>
<div
className={cn(
"inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium",
"bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]"
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium',
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]'
)}
data-testid={`error-badge-${feature.id}`}
>
@@ -96,14 +86,14 @@ export function CardBadges({ feature }: CardBadgesProps) {
{blockingDependencies.length > 0 &&
!feature.error &&
!feature.skipTests &&
feature.status === "backlog" && (
feature.status === 'backlog' && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"inline-flex items-center gap-1 rounded-full border-2 px-1.5 py-0.5 text-[10px] font-bold",
"bg-orange-500/20 border-orange-500/50 text-orange-500"
'inline-flex items-center gap-1 rounded-full border-2 px-1.5 py-0.5 text-[10px] font-bold',
'bg-orange-500/20 border-orange-500/50 text-orange-500'
)}
data-testid={`blocked-badge-${feature.id}`}
>
@@ -112,10 +102,8 @@ export function CardBadges({ feature }: CardBadgesProps) {
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
<p className="font-medium mb-1">
Blocked by {blockingDependencies.length} incomplete{" "}
{blockingDependencies.length === 1
? "dependency"
: "dependencies"}
Blocked by {blockingDependencies.length} incomplete{' '}
{blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}
</p>
<p className="text-muted-foreground">
{blockingDependencies
@@ -123,7 +111,7 @@ export function CardBadges({ feature }: CardBadgesProps) {
const dep = features.find((f) => f.id === depId);
return dep?.description || depId;
})
.join(", ")}
.join(', ')}
</p>
</TooltipContent>
</Tooltip>
@@ -141,11 +129,7 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
const [currentTime, setCurrentTime] = useState(() => Date.now());
const isJustFinished = useMemo(() => {
if (
!feature.justFinishedAt ||
feature.status !== "waiting_approval" ||
feature.error
) {
if (!feature.justFinishedAt || feature.status !== 'waiting_approval' || feature.error) {
return false;
}
const finishedTime = new Date(feature.justFinishedAt).getTime();
@@ -154,7 +138,7 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
}, [feature.justFinishedAt, feature.status, feature.error, currentTime]);
useEffect(() => {
if (!feature.justFinishedAt || feature.status !== "waiting_approval") {
if (!feature.justFinishedAt || feature.status !== 'waiting_approval') {
return;
}
@@ -179,7 +163,7 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
const showPriorityBadges =
feature.priority ||
(feature.skipTests && !feature.error && feature.status === "backlog") ||
(feature.skipTests && !feature.error && feature.status === 'backlog') ||
isJustFinished;
if (!showPriorityBadges) {
@@ -195,45 +179,39 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
<TooltipTrigger asChild>
<CardBadge
className={cn(
"bg-opacity-90 border rounded-[6px] px-1.5 py-0.5 flex items-center justify-center border-[1.5px] w-5 h-5", // badge style from example
'bg-opacity-90 border rounded-[6px] px-1.5 py-0.5 flex items-center justify-center border-[1.5px] w-5 h-5', // badge style from example
feature.priority === 1 &&
"bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]",
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]',
feature.priority === 2 &&
"bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]",
'bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]',
feature.priority === 3 &&
"bg-[var(--status-info-bg)] border-[var(--status-info)]/40 text-[var(--status-info)]"
'bg-[var(--status-info-bg)] border-[var(--status-info)]/40 text-[var(--status-info)]'
)}
data-testid={`priority-badge-${feature.id}`}
>
{feature.priority === 1 ? (
<span className="font-bold text-xs flex items-center gap-0.5">
H
</span>
<span className="font-bold text-xs flex items-center gap-0.5">H</span>
) : feature.priority === 2 ? (
<span className="font-bold text-xs flex items-center gap-0.5">
M
</span>
<span className="font-bold text-xs flex items-center gap-0.5">M</span>
) : (
<span className="font-bold text-xs flex items-center gap-0.5">
L
</span>
<span className="font-bold text-xs flex items-center gap-0.5">L</span>
)}
</CardBadge>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<p>
{feature.priority === 1
? "High Priority"
? 'High Priority'
: feature.priority === 2
? "Medium Priority"
: "Low Priority"}
? 'Medium Priority'
: 'Low Priority'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Manual verification badge */}
{feature.skipTests && !feature.error && feature.status === "backlog" && (
{feature.skipTests && !feature.error && feature.status === 'backlog' && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -1,5 +1,5 @@
import { Feature } from "@/store/app-store";
import { GitBranch, GitPullRequest, ExternalLink, CheckCircle2, Circle } from "lucide-react";
import { Feature } from '@/store/app-store';
import { GitBranch, GitPullRequest, ExternalLink, CheckCircle2, Circle } from 'lucide-react';
interface CardContentSectionsProps {
feature: Feature;
@@ -25,10 +25,10 @@ export function CardContentSections({
)}
{/* PR URL Display */}
{typeof feature.prUrl === "string" &&
{typeof feature.prUrl === 'string' &&
/^https?:\/\//i.test(feature.prUrl) &&
(() => {
const prNumber = feature.prUrl.split("/").pop();
const prNumber = feature.prUrl.split('/').pop();
return (
<div className="mb-2">
<a
@@ -43,7 +43,7 @@ export function CardContentSections({
>
<GitPullRequest className="w-3 h-3 shrink-0" />
<span className="truncate max-w-[150px]">
{prNumber ? `Pull Request #${prNumber}` : "Pull Request"}
{prNumber ? `Pull Request #${prNumber}` : 'Pull Request'}
</span>
<ExternalLink className="w-2.5 h-2.5 shrink-0" />
</a>
@@ -59,14 +59,12 @@ export function CardContentSections({
key={index}
className="flex items-start gap-2 text-[11px] text-muted-foreground/80"
>
{feature.status === "verified" ? (
{feature.status === 'verified' ? (
<CheckCircle2 className="w-3 h-3 mt-0.5 text-[var(--status-success)] shrink-0" />
) : (
<Circle className="w-3 h-3 mt-0.5 shrink-0 text-muted-foreground/50" />
)}
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">
{step}
</span>
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">{step}</span>
</div>
))}
{feature.steps.length > 3 && (
@@ -79,4 +77,3 @@ export function CardContentSections({
</>
);
}

View File

@@ -1,18 +1,14 @@
import { useState } from "react";
import { Feature } from "@/store/app-store";
import { cn } from "@/lib/utils";
import {
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useState } from 'react';
import { Feature } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
} from '@/components/ui/dropdown-menu';
import {
GripVertical,
Edit,
@@ -23,10 +19,10 @@ import {
ChevronDown,
ChevronUp,
Cpu,
} from "lucide-react";
import { CountUpTimer } from "@/components/ui/count-up-timer";
import { formatModelName, DEFAULT_MODEL } from "@/lib/agent-context-parser";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
} from 'lucide-react';
import { CountUpTimer } from '@/components/ui/count-up-timer';
import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
interface CardHeaderProps {
feature: Feature;
@@ -100,9 +96,7 @@ export function CardHeaderSection({
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
<div className="flex items-center gap-1">
<Cpu className="w-3 h-3" />
<span>
{formatModelName(feature.model ?? DEFAULT_MODEL)}
</span>
<span>{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
</div>
</DropdownMenuContent>
@@ -111,7 +105,7 @@ export function CardHeaderSection({
)}
{/* Backlog header */}
{!isCurrentAutoTask && feature.status === "backlog" && (
{!isCurrentAutoTask && feature.status === 'backlog' && (
<div className="absolute top-2 right-2">
<Button
variant="ghost"
@@ -128,8 +122,7 @@ export function CardHeaderSection({
{/* Waiting approval / Verified header */}
{!isCurrentAutoTask &&
(feature.status === "waiting_approval" ||
feature.status === "verified") && (
(feature.status === 'waiting_approval' || feature.status === 'verified') && (
<>
<div className="absolute top-2 right-2 flex items-center gap-1">
<Button
@@ -142,9 +135,7 @@ export function CardHeaderSection({
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`edit-${
feature.status === "waiting_approval"
? "waiting"
: "verified"
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
title="Edit"
>
@@ -161,9 +152,7 @@ export function CardHeaderSection({
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`logs-${
feature.status === "waiting_approval"
? "waiting"
: "verified"
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
title="Logs"
>
@@ -177,9 +166,7 @@ export function CardHeaderSection({
onClick={handleDeleteClick}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`delete-${
feature.status === "waiting_approval"
? "waiting"
: "verified"
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
title="Delete"
>
@@ -190,7 +177,7 @@ export function CardHeaderSection({
)}
{/* In progress header */}
{!isCurrentAutoTask && feature.status === "in_progress" && (
{!isCurrentAutoTask && feature.status === 'in_progress' && (
<>
<div className="absolute top-2 right-2 flex items-center gap-1">
<Button
@@ -246,9 +233,7 @@ export function CardHeaderSection({
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
<div className="flex items-center gap-1">
<Cpu className="w-3 h-3" />
<span>
{formatModelName(feature.model ?? DEFAULT_MODEL)}
</span>
<span>{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
</div>
</DropdownMenuContent>
@@ -271,9 +256,7 @@ export function CardHeaderSection({
{feature.titleGenerating ? (
<div className="flex items-center gap-1.5 mb-1">
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground italic">
Generating title...
</span>
<span className="text-xs text-muted-foreground italic">Generating title...</span>
</div>
) : feature.title ? (
<CardTitle className="text-sm font-semibold text-foreground mb-1 line-clamp-2">
@@ -282,13 +265,13 @@ export function CardHeaderSection({
) : null}
<CardDescription
className={cn(
"text-xs leading-snug break-words hyphens-auto overflow-hidden text-muted-foreground",
!isDescriptionExpanded && "line-clamp-3"
'text-xs leading-snug break-words hyphens-auto overflow-hidden text-muted-foreground',
!isDescriptionExpanded && 'line-clamp-3'
)}
>
{feature.description || feature.summary || feature.id}
</CardDescription>
{(feature.description || feature.summary || "").length > 100 && (
{(feature.description || feature.summary || '').length > 100 && (
<button
onClick={(e) => {
e.stopPropagation();
@@ -327,4 +310,3 @@ export function CardHeaderSection({
</CardHeader>
);
}

View File

@@ -1,14 +1,14 @@
import React, { memo } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import { Feature, useAppStore } from "@/store/app-store";
import { CardBadges, PriorityBadges } from "./card-badges";
import { CardHeaderSection } from "./card-header";
import { CardContentSections } from "./card-content-sections";
import { AgentInfoPanel } from "./agent-info-panel";
import { CardActions } from "./card-actions";
import React, { memo } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card';
import { Feature, useAppStore } from '@/store/app-store';
import { CardBadges, PriorityBadges } from './card-badges';
import { CardHeaderSection } from './card-header';
import { CardContentSections } from './card-content-sections';
import { AgentInfoPanel } from './agent-info-panel';
import { CardActions } from './card-actions';
interface KanbanCardProps {
feature: Feature;
@@ -63,23 +63,14 @@ export const KanbanCard = memo(function KanbanCard({
}: KanbanCardProps) {
const { kanbanCardDetailLevel, useWorktrees } = useAppStore();
const showSteps =
kanbanCardDetailLevel === "standard" ||
kanbanCardDetailLevel === "detailed";
const showSteps = kanbanCardDetailLevel === 'standard' || kanbanCardDetailLevel === 'detailed';
const isDraggable =
feature.status === "backlog" ||
feature.status === "waiting_approval" ||
feature.status === "verified" ||
(feature.status === "in_progress" && !isCurrentAutoTask);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
feature.status === 'backlog' ||
feature.status === 'waiting_approval' ||
feature.status === 'verified' ||
(feature.status === 'in_progress' && !isCurrentAutoTask);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: feature.id,
disabled: !isDraggable,
});
@@ -92,10 +83,10 @@ export const KanbanCard = memo(function KanbanCard({
const borderStyle: React.CSSProperties = { ...style };
if (!cardBorderEnabled) {
(borderStyle as Record<string, string>).borderWidth = "0px";
(borderStyle as Record<string, string>).borderColor = "transparent";
(borderStyle as Record<string, string>).borderWidth = '0px';
(borderStyle as Record<string, string>).borderColor = 'transparent';
} else if (cardBorderOpacity !== 100) {
(borderStyle as Record<string, string>).borderWidth = "1px";
(borderStyle as Record<string, string>).borderWidth = '1px';
(borderStyle as Record<string, string>).borderColor =
`color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
}
@@ -105,28 +96,22 @@ export const KanbanCard = memo(function KanbanCard({
ref={setNodeRef}
style={isCurrentAutoTask ? style : borderStyle}
className={cn(
"cursor-grab active:cursor-grabbing relative kanban-card-content select-none",
"transition-all duration-200 ease-out",
'cursor-grab active:cursor-grabbing relative kanban-card-content select-none',
'transition-all duration-200 ease-out',
// Premium shadow system
"shadow-sm hover:shadow-md hover:shadow-black/10",
'shadow-sm hover:shadow-md hover:shadow-black/10',
// Subtle lift on hover
"hover:-translate-y-0.5",
!isCurrentAutoTask &&
cardBorderEnabled &&
cardBorderOpacity === 100 &&
"border-border/50",
!isCurrentAutoTask &&
cardBorderEnabled &&
cardBorderOpacity !== 100 &&
"border",
!isDragging && "bg-transparent",
!glassmorphism && "backdrop-blur-[0px]!",
isDragging && "scale-105 shadow-xl shadow-black/20 rotate-1",
'hover:-translate-y-0.5',
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity === 100 && 'border-border/50',
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity !== 100 && 'border',
!isDragging && 'bg-transparent',
!glassmorphism && 'backdrop-blur-[0px]!',
isDragging && 'scale-105 shadow-xl shadow-black/20 rotate-1',
// Error state - using CSS variable
feature.error &&
!isCurrentAutoTask &&
"border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg",
!isDraggable && "cursor-default"
'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
!isDraggable && 'cursor-default'
)}
data-testid={`kanban-card-${feature.id}`}
onDoubleClick={onEdit}
@@ -137,8 +122,8 @@ export const KanbanCard = memo(function KanbanCard({
{!isDragging && (
<div
className={cn(
"absolute inset-0 rounded-xl bg-card -z-10",
glassmorphism && "backdrop-blur-sm"
'absolute inset-0 rounded-xl bg-card -z-10',
glassmorphism && 'backdrop-blur-sm'
)}
style={{ opacity: opacity / 100 }}
/>
@@ -149,9 +134,7 @@ export const KanbanCard = memo(function KanbanCard({
{/* Category row */}
<div className="px-3 pt-4">
<span className="text-[11px] text-muted-foreground/70 font-medium">
{feature.category}
</span>
<span className="text-[11px] text-muted-foreground/70 font-medium">{feature.category}</span>
</div>
{/* Priority and Manual Verification badges */}
@@ -169,11 +152,7 @@ export const KanbanCard = memo(function KanbanCard({
<CardContent className="px-3 pt-0 pb-0">
{/* Content Sections */}
<CardContentSections
feature={feature}
useWorktrees={useWorktrees}
showSteps={showSteps}
/>
<CardContentSections feature={feature} useWorktrees={useWorktrees} showSteps={showSteps} />
{/* Agent Info Panel */}
<AgentInfoPanel

View File

@@ -1,5 +1,5 @@
import { Feature } from "@/store/app-store";
import { AgentTaskInfo } from "@/lib/agent-context-parser";
import { Feature } from '@/store/app-store';
import { AgentTaskInfo } from '@/lib/agent-context-parser';
import {
Dialog,
DialogContent,
@@ -7,10 +7,10 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Markdown } from "@/components/ui/markdown";
import { Sparkles } from "lucide-react";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Markdown } from '@/components/ui/markdown';
import { Sparkles } from 'lucide-react';
interface SummaryDialogProps {
feature: Feature;
@@ -40,23 +40,17 @@ export function SummaryDialog({
</DialogTitle>
<DialogDescription
className="text-sm"
title={feature.description || feature.summary || ""}
title={feature.description || feature.summary || ''}
>
{(() => {
const displayText =
feature.description || feature.summary || "No description";
return displayText.length > 100
? `${displayText.slice(0, 100)}...`
: displayText;
const displayText = feature.description || feature.summary || 'No description';
return displayText.length > 100 ? `${displayText.slice(0, 100)}...` : displayText;
})()}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-4 bg-card rounded-lg border border-border/50">
<Markdown>
{feature.summary ||
summary ||
agentInfo?.summary ||
"No summary available"}
{feature.summary || summary || agentInfo?.summary || 'No summary available'}
</Markdown>
</div>
<DialogFooter>
@@ -72,4 +66,3 @@ export function SummaryDialog({
</Dialog>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback } from 'react';
import {
Feature,
FeatureImage,
@@ -6,13 +6,13 @@ import {
ThinkingLevel,
PlanningMode,
useAppStore,
} from "@/store/app-store";
import { FeatureImagePath as DescriptionImagePath } from "@/components/ui/description-image-dropzone";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
import { useAutoMode } from "@/hooks/use-auto-mode";
import { truncateDescription } from "@/lib/utils";
import { getBlockingDependencies } from "@automaker/dependency-resolver";
} from '@/store/app-store';
import { FeatureImagePath as DescriptionImagePath } from '@/components/ui/description-image-dropzone';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { truncateDescription } from '@/lib/utils';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
interface UseBoardActionsProps {
currentProject: { path: string; id: string } | null;
@@ -20,10 +20,7 @@ interface UseBoardActionsProps {
runningAutoTasks: string[];
loadFeatures: () => Promise<void>;
persistFeatureCreate: (feature: Feature) => Promise<void>;
persistFeatureUpdate: (
featureId: string,
updates: Partial<Feature>
) => Promise<void>;
persistFeatureUpdate: (featureId: string, updates: Partial<Feature>) => Promise<void>;
persistFeatureDelete: (featureId: string) => Promise<void>;
saveCategory: (category: string) => Promise<void>;
setEditingFeature: (feature: Feature | null) => void;
@@ -113,14 +110,11 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (api?.worktree?.create) {
const result = await api.worktree.create(
currentProject.path,
finalBranchName
);
const result = await api.worktree.create(currentProject.path, finalBranchName);
if (result.success && result.worktree) {
console.log(
`[Board] Worktree for branch "${finalBranchName}" ${
result.worktree?.isNew ? "created" : "already exists"
result.worktree?.isNew ? 'created' : 'already exists'
}`
);
// Auto-select the worktree when creating a feature for it
@@ -135,29 +129,27 @@ export function useBoardActions({
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
result.error
);
toast.error("Failed to create worktree", {
description: result.error || "An error occurred",
toast.error('Failed to create worktree', {
description: result.error || 'An error occurred',
});
}
}
} catch (error) {
console.error("[Board] Error creating worktree:", error);
toast.error("Failed to create worktree", {
description:
error instanceof Error ? error.message : "An error occurred",
console.error('[Board] Error creating worktree:', error);
toast.error('Failed to create worktree', {
description: error instanceof Error ? error.message : 'An error occurred',
});
}
}
// Check if we need to generate a title
const needsTitleGeneration =
!featureData.title.trim() && featureData.description.trim();
const needsTitleGeneration = !featureData.title.trim() && featureData.description.trim();
const newFeatureData = {
...featureData,
title: featureData.title,
titleGenerating: needsTitleGeneration,
status: "backlog" as const,
status: 'backlog' as const,
branchName: finalBranchName,
};
const createdFeature = addFeature(newFeatureData);
@@ -187,7 +179,7 @@ export function useBoardActions({
}
})
.catch((error) => {
console.error("[Board] Error generating title:", error);
console.error('[Board] Error generating title:', error);
// Clear generating flag on error
const titleUpdates = { titleGenerating: false };
updateFeature(createdFeature.id, titleUpdates);
@@ -235,14 +227,11 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (api?.worktree?.create) {
const result = await api.worktree.create(
currentProject.path,
finalBranchName
);
const result = await api.worktree.create(currentProject.path, finalBranchName);
if (result.success) {
console.log(
`[Board] Worktree for branch "${finalBranchName}" ${
result.worktree?.isNew ? "created" : "already exists"
result.worktree?.isNew ? 'created' : 'already exists'
}`
);
// Refresh worktree list in UI
@@ -252,16 +241,15 @@ export function useBoardActions({
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
result.error
);
toast.error("Failed to create worktree", {
description: result.error || "An error occurred",
toast.error('Failed to create worktree', {
description: result.error || 'An error occurred',
});
}
}
} catch (error) {
console.error("[Board] Error creating worktree:", error);
toast.error("Failed to create worktree", {
description:
error instanceof Error ? error.message : "An error occurred",
console.error('[Board] Error creating worktree:', error);
toast.error('Failed to create worktree', {
description: error instanceof Error ? error.message : 'An error occurred',
});
}
}
@@ -300,15 +288,13 @@ export function useBoardActions({
if (isRunning) {
try {
await autoMode.stopFeature(featureId);
toast.success("Agent stopped", {
description: `Stopped and deleted: ${truncateDescription(
feature.description
)}`,
toast.success('Agent stopped', {
description: `Stopped and deleted: ${truncateDescription(feature.description)}`,
});
} catch (error) {
console.error("[Board] Error stopping feature before delete:", error);
toast.error("Failed to stop agent", {
description: "The feature will still be deleted.",
console.error('[Board] Error stopping feature before delete:', error);
toast.error('Failed to stop agent', {
description: 'The feature will still be deleted.',
});
}
}
@@ -321,17 +307,11 @@ export function useBoardActions({
await api.deleteFile(imagePathObj.path);
console.log(`[Board] Deleted image: ${imagePathObj.path}`);
} catch (error) {
console.error(
`[Board] Failed to delete image ${imagePathObj.path}:`,
error
);
console.error(`[Board] Failed to delete image ${imagePathObj.path}:`, error);
}
}
} catch (error) {
console.error(
`[Board] Error deleting images for feature ${featureId}:`,
error
);
console.error(`[Board] Error deleting images for feature ${featureId}:`, error);
}
}
@@ -348,7 +328,7 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (!api?.autoMode) {
console.error("Auto mode API not available");
console.error('Auto mode API not available');
return;
}
@@ -362,15 +342,15 @@ export function useBoardActions({
if (result.success) {
console.log(
"[Board] Feature run started successfully, branch:",
feature.branchName || "default"
'[Board] Feature run started successfully, branch:',
feature.branchName || 'default'
);
} else {
console.error("[Board] Failed to run feature:", result.error);
console.error('[Board] Failed to run feature:', result.error);
await loadFeatures();
}
} catch (error) {
console.error("[Board] Error running feature:", error);
console.error('[Board] Error running feature:', error);
await loadFeatures();
}
},
@@ -380,9 +360,9 @@ export function useBoardActions({
const handleStartImplementation = useCallback(
async (feature: Feature) => {
if (!autoMode.canStartNewTask) {
toast.error("Concurrency limit reached", {
toast.error('Concurrency limit reached', {
description: `You can only have ${autoMode.maxConcurrency} task${
autoMode.maxConcurrency > 1 ? "s" : ""
autoMode.maxConcurrency > 1 ? 's' : ''
} running at a time. Wait for a task to complete or increase the limit.`,
});
return false;
@@ -397,22 +377,22 @@ export function useBoardActions({
const dep = features.find((f) => f.id === depId);
return dep ? truncateDescription(dep.description, 40) : depId;
})
.join(", ");
.join(', ');
toast.warning("Starting feature with incomplete dependencies", {
toast.warning('Starting feature with incomplete dependencies', {
description: `This feature depends on: ${depDescriptions}`,
});
}
}
const updates = {
status: "in_progress" as const,
status: 'in_progress' as const,
startedAt: new Date().toISOString(),
};
updateFeature(feature.id, updates);
// Must await to ensure feature status is persisted before starting agent
await persistFeatureUpdate(feature.id, updates);
console.log("[Board] Feature moved to in_progress, starting agent...");
console.log('[Board] Feature moved to in_progress, starting agent...');
await handleRunFeature(feature);
return true;
},
@@ -433,23 +413,20 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (!api?.autoMode) {
console.error("Auto mode API not available");
console.error('Auto mode API not available');
return;
}
const result = await api.autoMode.verifyFeature(
currentProject.path,
feature.id
);
const result = await api.autoMode.verifyFeature(currentProject.path, feature.id);
if (result.success) {
console.log("[Board] Feature verification started successfully");
console.log('[Board] Feature verification started successfully');
} else {
console.error("[Board] Failed to verify feature:", result.error);
console.error('[Board] Failed to verify feature:', result.error);
await loadFeatures();
}
} catch (error) {
console.error("[Board] Error verifying feature:", error);
console.error('[Board] Error verifying feature:', error);
await loadFeatures();
}
},
@@ -463,7 +440,7 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (!api?.autoMode) {
console.error("Auto mode API not available");
console.error('Auto mode API not available');
return;
}
@@ -474,13 +451,13 @@ export function useBoardActions({
);
if (result.success) {
console.log("[Board] Feature resume started successfully");
console.log('[Board] Feature resume started successfully');
} else {
console.error("[Board] Failed to resume feature:", result.error);
console.error('[Board] Failed to resume feature:', result.error);
await loadFeatures();
}
} catch (error) {
console.error("[Board] Error resuming feature:", error);
console.error('[Board] Error resuming feature:', error);
await loadFeatures();
}
},
@@ -489,15 +466,13 @@ export function useBoardActions({
const handleManualVerify = useCallback(
(feature: Feature) => {
moveFeature(feature.id, "verified");
moveFeature(feature.id, 'verified');
persistFeatureUpdate(feature.id, {
status: "verified",
status: 'verified',
justFinishedAt: undefined,
});
toast.success("Feature verified", {
description: `Marked as verified: ${truncateDescription(
feature.description
)}`,
toast.success('Feature verified', {
description: `Marked as verified: ${truncateDescription(feature.description)}`,
});
},
[moveFeature, persistFeatureUpdate]
@@ -506,15 +481,13 @@ export function useBoardActions({
const handleMoveBackToInProgress = useCallback(
(feature: Feature) => {
const updates = {
status: "in_progress" as const,
status: 'in_progress' as const,
startedAt: new Date().toISOString(),
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.info("Feature moved back", {
description: `Moved back to In Progress: ${truncateDescription(
feature.description
)}`,
toast.info('Feature moved back', {
description: `Moved back to In Progress: ${truncateDescription(feature.description)}`,
});
},
[updateFeature, persistFeatureUpdate]
@@ -523,16 +496,11 @@ export function useBoardActions({
const handleOpenFollowUp = useCallback(
(feature: Feature) => {
setFollowUpFeature(feature);
setFollowUpPrompt("");
setFollowUpPrompt('');
setFollowUpImagePaths([]);
setShowFollowUpDialog(true);
},
[
setFollowUpFeature,
setFollowUpPrompt,
setFollowUpImagePaths,
setShowFollowUpDialog,
]
[setFollowUpFeature, setFollowUpPrompt, setFollowUpImagePaths, setShowFollowUpDialog]
);
const handleSendFollowUp = useCallback(async () => {
@@ -543,15 +511,15 @@ export function useBoardActions({
const api = getElectronAPI();
if (!api?.autoMode?.followUpFeature) {
console.error("Follow-up feature API not available");
toast.error("Follow-up not available", {
description: "This feature is not available in the current version.",
console.error('Follow-up feature API not available');
toast.error('Follow-up not available', {
description: 'This feature is not available in the current version.',
});
return;
}
const updates = {
status: "in_progress" as const,
status: 'in_progress' as const,
startedAt: new Date().toISOString(),
justFinishedAt: undefined,
};
@@ -560,14 +528,12 @@ export function useBoardActions({
setShowFollowUpDialog(false);
setFollowUpFeature(null);
setFollowUpPrompt("");
setFollowUpPrompt('');
setFollowUpImagePaths([]);
setFollowUpPreviewMap(new Map());
toast.success("Follow-up started", {
description: `Continuing work on: ${truncateDescription(
featureDescription
)}`,
toast.success('Follow-up started', {
description: `Continuing work on: ${truncateDescription(featureDescription)}`,
});
const imagePaths = followUpImagePaths.map((img) => img.path);
@@ -581,10 +547,9 @@ export function useBoardActions({
// No worktreePath - server derives from feature.branchName
)
.catch((error) => {
console.error("[Board] Error sending follow-up:", error);
toast.error("Failed to send follow-up", {
description:
error instanceof Error ? error.message : "An error occurred",
console.error('[Board] Error sending follow-up:', error);
toast.error('Failed to send follow-up', {
description: error instanceof Error ? error.message : 'An error occurred',
});
loadFeatures();
});
@@ -610,10 +575,9 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (!api?.autoMode?.commitFeature) {
console.error("Commit feature API not available");
toast.error("Commit not available", {
description:
"This feature is not available in the current version.",
console.error('Commit feature API not available');
toast.error('Commit not available', {
description: 'This feature is not available in the current version.',
});
return;
}
@@ -626,38 +590,29 @@ export function useBoardActions({
);
if (result.success) {
moveFeature(feature.id, "verified");
persistFeatureUpdate(feature.id, { status: "verified" });
toast.success("Feature committed", {
description: `Committed and verified: ${truncateDescription(
feature.description
)}`,
moveFeature(feature.id, 'verified');
persistFeatureUpdate(feature.id, { status: 'verified' });
toast.success('Feature committed', {
description: `Committed and verified: ${truncateDescription(feature.description)}`,
});
// Refresh worktree selector to update commit counts
onWorktreeCreated?.();
} else {
console.error("[Board] Failed to commit feature:", result.error);
toast.error("Failed to commit feature", {
description: result.error || "An error occurred",
console.error('[Board] Failed to commit feature:', result.error);
toast.error('Failed to commit feature', {
description: result.error || 'An error occurred',
});
await loadFeatures();
}
} catch (error) {
console.error("[Board] Error committing feature:", error);
toast.error("Failed to commit feature", {
description:
error instanceof Error ? error.message : "An error occurred",
console.error('[Board] Error committing feature:', error);
toast.error('Failed to commit feature', {
description: error instanceof Error ? error.message : 'An error occurred',
});
await loadFeatures();
}
},
[
currentProject,
moveFeature,
persistFeatureUpdate,
loadFeatures,
onWorktreeCreated,
]
[currentProject, moveFeature, persistFeatureUpdate, loadFeatures, onWorktreeCreated]
);
const handleMergeFeature = useCallback(
@@ -667,37 +622,32 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (!api?.worktree?.mergeFeature) {
console.error("Worktree API not available");
toast.error("Merge not available", {
description:
"This feature is not available in the current version.",
console.error('Worktree API not available');
toast.error('Merge not available', {
description: 'This feature is not available in the current version.',
});
return;
}
const result = await api.worktree.mergeFeature(
currentProject.path,
feature.id
);
const result = await api.worktree.mergeFeature(currentProject.path, feature.id);
if (result.success) {
await loadFeatures();
toast.success("Feature merged", {
toast.success('Feature merged', {
description: `Changes merged to main branch: ${truncateDescription(
feature.description
)}`,
});
} else {
console.error("[Board] Failed to merge feature:", result.error);
toast.error("Failed to merge feature", {
description: result.error || "An error occurred",
console.error('[Board] Failed to merge feature:', result.error);
toast.error('Failed to merge feature', {
description: result.error || 'An error occurred',
});
}
} catch (error) {
console.error("[Board] Error merging feature:", error);
toast.error("Failed to merge feature", {
description:
error instanceof Error ? error.message : "An error occurred",
console.error('[Board] Error merging feature:', error);
toast.error('Failed to merge feature', {
description: error instanceof Error ? error.message : 'An error occurred',
});
}
},
@@ -707,12 +657,12 @@ export function useBoardActions({
const handleCompleteFeature = useCallback(
(feature: Feature) => {
const updates = {
status: "completed" as const,
status: 'completed' as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.success("Feature completed", {
toast.success('Feature completed', {
description: `Archived: ${truncateDescription(feature.description)}`,
});
},
@@ -722,15 +672,13 @@ export function useBoardActions({
const handleUnarchiveFeature = useCallback(
(feature: Feature) => {
const updates = {
status: "verified" as const,
status: 'verified' as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.success("Feature restored", {
description: `Moved back to verified: ${truncateDescription(
feature.description
)}`,
toast.success('Feature restored', {
description: `Moved back to verified: ${truncateDescription(feature.description)}`,
});
},
[updateFeature, persistFeatureUpdate]
@@ -746,7 +694,7 @@ export function useBoardActions({
const handleOutputModalNumberKeyPress = useCallback(
(key: string) => {
const index = key === "0" ? 9 : parseInt(key, 10) - 1;
const index = key === '0' ? 9 : parseInt(key, 10) - 1;
const targetFeature = inProgressFeaturesForShortcuts[index];
if (!targetFeature) {
@@ -759,12 +707,7 @@ export function useBoardActions({
setOutputFeature(targetFeature);
}
},
[
inProgressFeaturesForShortcuts,
outputFeature?.id,
setShowOutputModal,
setOutputFeature,
]
[inProgressFeaturesForShortcuts, outputFeature?.id, setShowOutputModal, setOutputFeature]
);
const handleForceStopFeature = useCallback(
@@ -773,9 +716,9 @@ export function useBoardActions({
await autoMode.stopFeature(feature.id);
const targetStatus =
feature.skipTests && feature.status === "waiting_approval"
? "waiting_approval"
: "backlog";
feature.skipTests && feature.status === 'waiting_approval'
? 'waiting_approval'
: 'backlog';
if (targetStatus !== feature.status) {
moveFeature(feature.id, targetStatus);
@@ -783,21 +726,18 @@ export function useBoardActions({
await persistFeatureUpdate(feature.id, { status: targetStatus });
}
toast.success("Agent stopped", {
toast.success('Agent stopped', {
description:
targetStatus === "waiting_approval"
targetStatus === 'waiting_approval'
? `Stopped commit - returned to waiting approval: ${truncateDescription(
feature.description
)}`
: `Stopped working on: ${truncateDescription(
feature.description
)}`,
: `Stopped working on: ${truncateDescription(feature.description)}`,
});
} catch (error) {
console.error("[Board] Error stopping feature:", error);
toast.error("Failed to stop agent", {
description:
error instanceof Error ? error.message : "An error occurred",
console.error('[Board] Error stopping feature:', error);
toast.error('Failed to stop agent', {
description: error instanceof Error ? error.message : 'An error occurred',
});
}
},
@@ -807,25 +747,21 @@ export function useBoardActions({
const handleStartNextFeatures = useCallback(async () => {
// Filter backlog features by the currently selected worktree branch
// This ensures "G" only starts features from the filtered list
const primaryBranch = projectPath
? getPrimaryWorktreeBranch(projectPath)
: null;
const primaryBranch = projectPath ? getPrimaryWorktreeBranch(projectPath) : null;
const backlogFeatures = features.filter((f) => {
if (f.status !== "backlog") return false;
if (f.status !== 'backlog') return false;
// Determine the feature's branch (default to primary branch if not set)
const featureBranch = f.branchName || primaryBranch || "main";
const featureBranch = f.branchName || primaryBranch || 'main';
// If no worktree is selected (currentWorktreeBranch is null or matches primary),
// show features with no branch or primary branch
if (
!currentWorktreeBranch ||
(projectPath &&
isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch))
(projectPath && isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch))
) {
return (
!f.branchName ||
(projectPath && isPrimaryWorktreeBranch(projectPath, featureBranch))
!f.branchName || (projectPath && isPrimaryWorktreeBranch(projectPath, featureBranch))
);
}
@@ -833,13 +769,11 @@ export function useBoardActions({
return featureBranch === currentWorktreeBranch;
});
const availableSlots =
useAppStore.getState().maxConcurrency - runningAutoTasks.length;
const availableSlots = useAppStore.getState().maxConcurrency - runningAutoTasks.length;
if (availableSlots <= 0) {
toast.error("Concurrency limit reached", {
description:
"Wait for a task to complete or increase the concurrency limit.",
toast.error('Concurrency limit reached', {
description: 'Wait for a task to complete or increase the concurrency limit.',
});
return;
}
@@ -847,12 +781,11 @@ export function useBoardActions({
if (backlogFeatures.length === 0) {
const isOnPrimaryBranch =
!currentWorktreeBranch ||
(projectPath &&
isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch));
toast.info("Backlog empty", {
(projectPath && isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch));
toast.info('Backlog empty', {
description: !isOnPrimaryBranch
? `No features in backlog for branch "${currentWorktreeBranch}".`
: "No features in backlog to start.",
: 'No features in backlog to start.',
});
return;
}
@@ -882,9 +815,9 @@ export function useBoardActions({
});
if (!featureToStart) {
toast.info("No eligible features", {
toast.info('No eligible features', {
description:
"All backlog features have unmet dependencies. Complete their dependencies first.",
'All backlog features have unmet dependencies. Complete their dependencies first.',
});
return;
}
@@ -904,7 +837,7 @@ export function useBoardActions({
]);
const handleArchiveAllVerified = useCallback(async () => {
const verifiedFeatures = features.filter((f) => f.status === "verified");
const verifiedFeatures = features.filter((f) => f.status === 'verified');
for (const feature of verifiedFeatures) {
const isRunning = runningAutoTasks.includes(feature.id);
@@ -912,30 +845,21 @@ export function useBoardActions({
try {
await autoMode.stopFeature(feature.id);
} catch (error) {
console.error(
"[Board] Error stopping feature before archive:",
error
);
console.error('[Board] Error stopping feature before archive:', error);
}
}
// Archive the feature by setting status to completed
const updates = {
status: "completed" as const,
status: 'completed' as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
}
toast.success("All verified features archived", {
toast.success('All verified features archived', {
description: `Archived ${verifiedFeatures.length} feature(s).`,
});
}, [
features,
runningAutoTasks,
autoMode,
updateFeature,
persistFeatureUpdate,
]);
}, [features, runningAutoTasks, autoMode, updateFeature, persistFeatureUpdate]);
return {
handleAddFeature,

View File

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

View File

@@ -1,8 +1,8 @@
import { useMemo, useCallback } from "react";
import { Feature, useAppStore } from "@/store/app-store";
import { resolveDependencies, getBlockingDependencies } from "@automaker/dependency-resolver";
import { useMemo, useCallback } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import { resolveDependencies, getBlockingDependencies } from '@automaker/dependency-resolver';
type ColumnId = Feature["status"];
type ColumnId = Feature['status'];
interface UseBoardColumnFeaturesProps {
features: Feature[];
@@ -87,7 +87,7 @@ export function useBoardColumnFeatures({
// Filter all items by worktree, including backlog
// This ensures backlog items with a branch assigned only show in that branch
if (status === "backlog") {
if (status === 'backlog') {
if (matchesWorktree) {
map.backlog.push(f);
}
@@ -136,7 +136,14 @@ export function useBoardColumnFeatures({
}
return map;
}, [features, runningAutoTasks, searchQuery, currentWorktreePath, currentWorktreeBranch, projectPath]);
}, [
features,
runningAutoTasks,
searchQuery,
currentWorktreePath,
currentWorktreeBranch,
projectPath,
]);
const getColumnFeatures = useCallback(
(columnId: ColumnId) => {
@@ -147,7 +154,7 @@ export function useBoardColumnFeatures({
// Memoize completed features for the archive modal
const completedFeatures = useMemo(() => {
return features.filter((f) => f.status === "completed");
return features.filter((f) => f.status === 'completed');
}, [features]);
return {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,66 +1,50 @@
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';
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
export type ModelOption = {
id: AgentModel;
label: string;
description: string;
badge?: string;
provider: "claude";
provider: 'claude';
};
export const CLAUDE_MODELS: ModelOption[] = [
{
id: "haiku",
label: "Claude Haiku",
description: "Fast and efficient for simple tasks.",
badge: "Speed",
provider: "claude",
id: 'haiku',
label: 'Claude Haiku',
description: 'Fast and efficient for simple tasks.',
badge: 'Speed',
provider: 'claude',
},
{
id: "sonnet",
label: "Claude Sonnet",
description: "Balanced performance with strong reasoning.",
badge: "Balanced",
provider: "claude",
id: 'sonnet',
label: 'Claude Sonnet',
description: 'Balanced performance with strong reasoning.',
badge: 'Balanced',
provider: 'claude',
},
{
id: "opus",
label: "Claude Opus",
description: "Most capable model for complex work.",
badge: "Premium",
provider: "claude",
id: 'opus',
label: 'Claude Opus',
description: 'Most capable model for complex work.',
badge: 'Premium',
provider: 'claude',
},
];
export const THINKING_LEVELS: ThinkingLevel[] = [
"none",
"low",
"medium",
"high",
"ultrathink",
];
export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
none: "None",
low: "Low",
medium: "Med",
high: "High",
ultrathink: "Ultra",
none: 'None',
low: 'Low',
medium: 'Med',
high: 'High',
ultrathink: 'Ultra',
};
// Profile icon mapping
export const PROFILE_ICONS: Record<
string,
React.ComponentType<{ className?: string }>
> = {
export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
Brain,
Zap,
Scale,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,10 @@
import { Button } from "@/components/ui/button";
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo } from "../types";
import { BranchSwitchDropdown } from "./branch-switch-dropdown";
import { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
import { Button } from '@/components/ui/button';
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo } from '../types';
import { BranchSwitchDropdown } from './branch-switch-dropdown';
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
interface WorktreeTabProps {
worktree: WorktreeInfo;
@@ -91,49 +85,48 @@ export function WorktreeTab({
onStopDevServer,
onOpenDevServerUrl,
}: WorktreeTabProps) {
let prBadge: JSX.Element | null = null;
if (worktree.pr) {
const prState = worktree.pr.state?.toLowerCase() ?? "open";
const prState = worktree.pr.state?.toLowerCase() ?? 'open';
const prStateClasses = (() => {
// When selected (active tab), use high contrast solid background (paper-like)
if (isSelected) {
return "bg-background text-foreground border-transparent shadow-sm";
return 'bg-background text-foreground border-transparent shadow-sm';
}
// When not selected, use the colored variants
switch (prState) {
case "open":
case "reopened":
return "bg-emerald-500/15 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 border-emerald-500/30 dark:border-emerald-500/40 hover:bg-emerald-500/25";
case "draft":
return "bg-amber-500/15 dark:bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30 dark:border-amber-500/40 hover:bg-amber-500/25";
case "merged":
return "bg-purple-500/15 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400 border-purple-500/30 dark:border-purple-500/40 hover:bg-purple-500/25";
case "closed":
return "bg-rose-500/15 dark:bg-rose-500/20 text-rose-600 dark:text-rose-400 border-rose-500/30 dark:border-rose-500/40 hover:bg-rose-500/25";
case 'open':
case 'reopened':
return 'bg-emerald-500/15 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 border-emerald-500/30 dark:border-emerald-500/40 hover:bg-emerald-500/25';
case 'draft':
return 'bg-amber-500/15 dark:bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30 dark:border-amber-500/40 hover:bg-amber-500/25';
case 'merged':
return 'bg-purple-500/15 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400 border-purple-500/30 dark:border-purple-500/40 hover:bg-purple-500/25';
case 'closed':
return 'bg-rose-500/15 dark:bg-rose-500/20 text-rose-600 dark:text-rose-400 border-rose-500/30 dark:border-rose-500/40 hover:bg-rose-500/25';
default:
return "bg-muted text-muted-foreground border-border/60 hover:bg-muted/80";
return 'bg-muted text-muted-foreground border-border/60 hover:bg-muted/80';
}
})();
const prLabel = `Pull Request #${worktree.pr.number}, ${prState}${worktree.pr.title ? `: ${worktree.pr.title}` : ""}`;
const prLabel = `Pull Request #${worktree.pr.number}, ${prState}${worktree.pr.title ? `: ${worktree.pr.title}` : ''}`;
// Helper to get status icon color for the selected state
const getStatusColorClass = () => {
if (!isSelected) return "";
if (!isSelected) return '';
switch (prState) {
case "open":
case "reopened":
return "text-emerald-600 dark:text-emerald-500";
case "draft":
return "text-amber-600 dark:text-amber-500";
case "merged":
return "text-purple-600 dark:text-purple-500";
case "closed":
return "text-rose-600 dark:text-rose-500";
case 'open':
case 'reopened':
return 'text-emerald-600 dark:text-emerald-500';
case 'draft':
return 'text-amber-600 dark:text-amber-500';
case 'merged':
return 'text-purple-600 dark:text-purple-500';
case 'closed':
return 'text-rose-600 dark:text-rose-500';
default:
return "text-muted-foreground";
return 'text-muted-foreground';
}
};
@@ -142,9 +135,9 @@ export function WorktreeTab({
role="button"
tabIndex={0}
className={cn(
"ml-1.5 inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium transition-colors",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 focus:ring-offset-background",
"cursor-pointer hover:opacity-80 active:opacity-70",
'ml-1.5 inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium transition-colors',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 focus:ring-offset-background',
'cursor-pointer hover:opacity-80 active:opacity-70',
prStateClasses
)}
title={`${prLabel} - Click to open`}
@@ -152,25 +145,25 @@ export function WorktreeTab({
onClick={(e) => {
e.stopPropagation(); // Prevent triggering worktree selection
if (worktree.pr?.url) {
window.open(worktree.pr.url, "_blank", "noopener,noreferrer");
window.open(worktree.pr.url, '_blank', 'noopener,noreferrer');
}
}}
onKeyDown={(e) => {
// Prevent event from bubbling to parent button
e.stopPropagation();
if (e.key === "Enter" || e.key === " ") {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (worktree.pr?.url) {
window.open(worktree.pr.url, "_blank", "noopener,noreferrer");
window.open(worktree.pr.url, '_blank', 'noopener,noreferrer');
}
}
}}
>
<GitPullRequest className={cn("w-3 h-3", getStatusColorClass())} aria-hidden="true" />
<span aria-hidden="true" className={isSelected ? "text-foreground font-semibold" : ""}>
<GitPullRequest className={cn('w-3 h-3', getStatusColorClass())} aria-hidden="true" />
<span aria-hidden="true" className={isSelected ? 'text-foreground font-semibold' : ''}>
PR #{worktree.pr.number}
</span>
<span className={cn("capitalize", getStatusColorClass())} aria-hidden="true">
<span className={cn('capitalize', getStatusColorClass())} aria-hidden="true">
{prState}
</span>
</span>
@@ -182,12 +175,12 @@ export function WorktreeTab({
{worktree.isMain ? (
<>
<Button
variant={isSelected ? "default" : "outline"}
variant={isSelected ? 'default' : 'outline'}
size="sm"
className={cn(
"h-7 px-3 text-xs font-mono gap-1.5 border-r-0 rounded-l-md rounded-r-none",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary"
'h-7 px-3 text-xs font-mono gap-1.5 border-r-0 rounded-l-md rounded-r-none',
isSelected && 'bg-primary text-primary-foreground',
!isSelected && 'bg-secondary/50 hover:bg-secondary'
)}
onClick={() => onSelectWorktree(worktree)}
disabled={isActivating}
@@ -196,9 +189,7 @@ export function WorktreeTab({
data-testid={`worktree-branch-${worktree.branch}`}
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && (
<RefreshCw className="w-3 h-3 animate-spin" />
)}
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
{worktree.branch}
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
@@ -209,18 +200,23 @@ export function WorktreeTab({
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border",
isSelected
? "bg-amber-500 text-amber-950 border-amber-400"
: "bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30"
)}>
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
isSelected
? 'bg-amber-500 text-amber-950 border-amber-400'
: 'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30'
)}
>
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
{changedFilesCount ?? "!"}
{changedFilesCount ?? '!'}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{changedFilesCount ?? "Some"} uncommitted file{changedFilesCount !== 1 ? "s" : ""}</p>
<p>
{changedFilesCount ?? 'Some'} uncommitted file
{changedFilesCount !== 1 ? 's' : ''}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -243,26 +239,24 @@ export function WorktreeTab({
</>
) : (
<Button
variant={isSelected ? "default" : "outline"}
variant={isSelected ? 'default' : 'outline'}
size="sm"
className={cn(
"h-7 px-3 text-xs font-mono gap-1.5 rounded-l-md rounded-r-none border-r-0",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary",
!worktree.hasWorktree && !isSelected && "opacity-70"
'h-7 px-3 text-xs font-mono gap-1.5 rounded-l-md rounded-r-none border-r-0',
isSelected && 'bg-primary text-primary-foreground',
!isSelected && 'bg-secondary/50 hover:bg-secondary',
!worktree.hasWorktree && !isSelected && 'opacity-70'
)}
onClick={() => onSelectWorktree(worktree)}
disabled={isActivating}
title={
worktree.hasWorktree
? "Click to switch to this worktree's branch"
: "Click to switch to this branch"
: 'Click to switch to this branch'
}
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && (
<RefreshCw className="w-3 h-3 animate-spin" />
)}
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
{worktree.branch}
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
@@ -273,18 +267,23 @@ export function WorktreeTab({
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border",
isSelected
? "bg-amber-500 text-amber-950 border-amber-400"
: "bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30"
)}>
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
isSelected
? 'bg-amber-500 text-amber-950 border-amber-400'
: 'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30'
)}
>
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
{changedFilesCount ?? "!"}
{changedFilesCount ?? '!'}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{changedFilesCount ?? "Some"} uncommitted file{changedFilesCount !== 1 ? "s" : ""}</p>
<p>
{changedFilesCount ?? 'Some'} uncommitted file
{changedFilesCount !== 1 ? 's' : ''}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -295,13 +294,13 @@ export function WorktreeTab({
{isDevServerRunning && (
<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",
"text-green-500"
'h-7 w-7 p-0 rounded-none border-r-0',
isSelected && 'bg-primary text-primary-foreground',
!isSelected && 'bg-secondary/50 hover:bg-secondary',
'text-green-500'
)}
onClick={() => onOpenDevServerUrl(worktree)}
title={`Open dev server (port ${devServerInfo?.port})`}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,8 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { pathsEqual } from "@/lib/utils";
import type { WorktreeInfo } from "../types";
import { useState, useEffect, useCallback, useRef } from 'react';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { pathsEqual } from '@/lib/utils';
import type { WorktreeInfo } from '../types';
interface UseWorktreesOptions {
projectPath: string;
@@ -11,7 +10,11 @@ interface UseWorktreesOptions {
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
}
export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktrees }: UseWorktreesOptions) {
export function useWorktrees({
projectPath,
refreshTrigger = 0,
onRemovedWorktrees,
}: UseWorktreesOptions) {
const [isLoading, setIsLoading] = useState(false);
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
@@ -20,34 +23,37 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
const fetchWorktrees = useCallback(async (options?: { silent?: boolean }) => {
if (!projectPath) return;
const silent = options?.silent ?? false;
if (!silent) {
setIsLoading(true);
}
try {
const api = getElectronAPI();
if (!api?.worktree?.listAll) {
console.warn("Worktree API not available");
return;
}
const result = await api.worktree.listAll(projectPath, true);
if (result.success && result.worktrees) {
setWorktrees(result.worktrees);
setWorktreesInStore(projectPath, result.worktrees);
}
// Return removed worktrees so they can be handled by the caller
return result.removedWorktrees;
} catch (error) {
console.error("Failed to fetch worktrees:", error);
return undefined;
} finally {
const fetchWorktrees = useCallback(
async (options?: { silent?: boolean }) => {
if (!projectPath) return;
const silent = options?.silent ?? false;
if (!silent) {
setIsLoading(false);
setIsLoading(true);
}
}
}, [projectPath, setWorktreesInStore]);
try {
const api = getElectronAPI();
if (!api?.worktree?.listAll) {
console.warn('Worktree API not available');
return;
}
const result = await api.worktree.listAll(projectPath, true);
if (result.success && result.worktrees) {
setWorktrees(result.worktrees);
setWorktreesInStore(projectPath, result.worktrees);
}
// Return removed worktrees so they can be handled by the caller
return result.removedWorktrees;
} catch (error) {
console.error('Failed to fetch worktrees:', error);
return undefined;
} finally {
if (!silent) {
setIsLoading(false);
}
}
},
[projectPath, setWorktreesInStore]
);
useEffect(() => {
fetchWorktrees();
@@ -77,15 +83,16 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
if (worktrees.length > 0) {
const current = currentWorktreeRef.current;
const currentPath = current?.path;
const currentWorktreeExists = currentPath === null
? true
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
const currentWorktreeExists =
currentPath === null
? true
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
if (current == null || (currentPath !== null && !currentWorktreeExists)) {
// Find the primary worktree and get its branch name
// Fallback to "main" only if worktrees haven't loaded yet
const mainWorktree = worktrees.find((w) => w.isMain);
const mainBranch = mainWorktree?.branch || "main";
const mainBranch = mainWorktree?.branch || 'main';
setCurrentWorktree(projectPath, null, mainBranch);
}
}
@@ -93,11 +100,7 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
const handleSelectWorktree = useCallback(
(worktree: WorktreeInfo) => {
setCurrentWorktree(
projectPath,
worktree.isMain ? null : worktree.path,
worktree.branch
);
setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch);
},
[projectPath, setCurrentWorktree]
);

View File

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

View File

@@ -1,15 +1,9 @@
import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import {
GitBranch,
Plus,
RefreshCw,
PanelLeftOpen,
PanelLeftClose,
} from "lucide-react";
import { cn, pathsEqual } from "@/lib/utils";
import { getItem, setItem } from "@/lib/storage";
import type { WorktreePanelProps, WorktreeInfo } from "./types";
import { useState, useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { GitBranch, Plus, RefreshCw, PanelLeftOpen, PanelLeftClose } from 'lucide-react';
import { cn, pathsEqual } from '@/lib/utils';
import { getItem, setItem } from '@/lib/storage';
import type { WorktreePanelProps, WorktreeInfo } from './types';
import {
useWorktrees,
useDevServers,
@@ -17,10 +11,10 @@ import {
useWorktreeActions,
useDefaultEditor,
useRunningFeatures,
} from "./hooks";
import { WorktreeTab } from "./components";
} from './hooks';
import { WorktreeTab } from './components';
const WORKTREE_PANEL_COLLAPSED_KEY = "worktree-panel-collapsed";
const WORKTREE_PANEL_COLLAPSED_KEY = 'worktree-panel-collapsed';
export function WorktreePanel({
projectPath,
@@ -93,7 +87,7 @@ export function WorktreePanel({
// Collapse state with localStorage persistence
const [isCollapsed, setIsCollapsed] = useState(() => {
const saved = getItem(WORKTREE_PANEL_COLLAPSED_KEY);
return saved === "true";
return saved === 'true';
});
useEffect(() => {
@@ -131,26 +125,22 @@ export function WorktreePanel({
const isWorktreeSelected = (worktree: WorktreeInfo) => {
return worktree.isMain
? currentWorktree === null ||
currentWorktree === undefined ||
currentWorktree.path === null
? currentWorktree === null || currentWorktree === undefined || currentWorktree.path === null
: pathsEqual(worktree.path, currentWorktreePath);
};
const handleBranchDropdownOpenChange =
(worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
resetBranchFilter();
}
};
const handleBranchDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
resetBranchFilter();
}
};
const handleActionsDropdownOpenChange =
(worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
}
};
const handleActionsDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
}
};
const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
@@ -170,12 +160,10 @@ export function WorktreePanel({
</Button>
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Branch:</span>
<span className="text-sm font-mono font-medium">
{selectedWorktree?.branch ?? "main"}
</span>
<span className="text-sm font-mono font-medium">{selectedWorktree?.branch ?? 'main'}</span>
{selectedWorktree?.hasChanges && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30">
{selectedWorktree.changedFilesCount ?? "!"}
{selectedWorktree.changedFilesCount ?? '!'}
</span>
)}
</div>
@@ -223,12 +211,8 @@ export function WorktreePanel({
aheadCount={aheadCount}
behindCount={behindCount}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(
mainWorktree
)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(
mainWorktree
)}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
@@ -281,12 +265,8 @@ export function WorktreePanel({
aheadCount={aheadCount}
behindCount={behindCount}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(
worktree
)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(
worktree
)}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
@@ -321,20 +301,14 @@ export function WorktreePanel({
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (
removedWorktrees &&
removedWorktrees.length > 0 &&
onRemovedWorktrees
) {
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
<RefreshCw
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
/>
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
</Button>
</div>
</>

View File

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

View File

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

View File

@@ -1,32 +1,20 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useAppStore, Feature } from "@/store/app-store";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Bot,
Send,
User,
Loader2,
Sparkles,
FileText,
ArrowLeft,
CheckCircle,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getElectronAPI } from "@/lib/electron";
import { Markdown } from "@/components/ui/markdown";
import { useFileBrowser } from "@/contexts/file-browser-context";
import { toast } from "sonner";
import { useNavigate } from "@tanstack/react-router";
import {
getDefaultWorkspaceDirectory,
saveLastProjectDirectory,
} from "@/lib/workspace-config";
import { useState, useCallback, useRef, useEffect } from 'react';
import { useAppStore, Feature } from '@/store/app-store';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Bot, Send, User, Loader2, Sparkles, FileText, ArrowLeft, CheckCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { Markdown } from '@/components/ui/markdown';
import { useFileBrowser } from '@/contexts/file-browser-context';
import { toast } from 'sonner';
import { useNavigate } from '@tanstack/react-router';
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
interface InterviewMessage {
id: string;
role: "user" | "assistant";
role: 'user' | 'assistant';
content: string;
timestamp: Date;
}
@@ -42,28 +30,28 @@ interface InterviewState {
// Interview questions flow
const INTERVIEW_QUESTIONS = [
{
id: "project-description",
question: "What do you want to build?",
hint: "Describe your project idea in a few sentences",
field: "projectDescription" as const,
id: 'project-description',
question: 'What do you want to build?',
hint: 'Describe your project idea in a few sentences',
field: 'projectDescription' as const,
},
{
id: "tech-stack",
question: "What tech stack would you like to use?",
hint: "e.g., React, Next.js, Node.js, Python, etc.",
field: "techStack" as const,
id: 'tech-stack',
question: 'What tech stack would you like to use?',
hint: 'e.g., React, Next.js, Node.js, Python, etc.',
field: 'techStack' as const,
},
{
id: "core-features",
question: "What are the core features you want to include?",
hint: "List the main functionalities your app should have",
field: "features" as const,
id: 'core-features',
question: 'What are the core features you want to include?',
hint: 'List the main functionalities your app should have',
field: 'features' as const,
},
{
id: "additional",
question: "Any additional requirements or preferences?",
hint: "Design preferences, integrations, deployment needs, etc.",
field: "additionalNotes" as const,
id: 'additional',
question: 'Any additional requirements or preferences?',
hint: 'Design preferences, integrations, deployment needs, etc.',
field: 'additionalNotes' as const,
},
];
@@ -71,21 +59,21 @@ export function InterviewView() {
const { addProject, setCurrentProject, setAppSpec } = useAppStore();
const { openFileBrowser } = useFileBrowser();
const navigate = useNavigate();
const [input, setInput] = useState("");
const [input, setInput] = useState('');
const [messages, setMessages] = useState<InterviewMessage[]>([]);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [interviewData, setInterviewData] = useState<InterviewState>({
projectName: "",
projectDescription: "",
projectName: '',
projectDescription: '',
techStack: [],
features: [],
additionalNotes: "",
additionalNotes: '',
});
const [isGenerating, setIsGenerating] = useState(false);
const [isComplete, setIsComplete] = useState(false);
const [generatedSpec, setGeneratedSpec] = useState<string | null>(null);
const [projectPath, setProjectPath] = useState("");
const [projectName, setProjectName] = useState("");
const [projectPath, setProjectPath] = useState('');
const [projectName, setProjectName] = useState('');
const [showProjectSetup, setShowProjectSetup] = useState(false);
const messagesContainerRef = useRef<HTMLDivElement>(null);
@@ -109,7 +97,7 @@ export function InterviewView() {
setProjectPath(defaultDir);
}
} catch (error) {
console.error("Failed to load default workspace directory:", error);
console.error('Failed to load default workspace directory:', error);
}
};
@@ -124,8 +112,8 @@ export function InterviewView() {
useEffect(() => {
if (messages.length === 0) {
const welcomeMessage: InterviewMessage = {
id: "welcome",
role: "assistant",
id: 'welcome',
role: 'assistant',
content: `Hello! I'm here to help you plan your new project. Let's go through a few questions to understand what you want to build.\n\n**${INTERVIEW_QUESTIONS[0].question}**\n\n_${INTERVIEW_QUESTIONS[0].hint}_`,
timestamp: new Date(),
};
@@ -142,7 +130,7 @@ export function InterviewView() {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTo({
top: messagesContainerRef.current.scrollHeight,
behavior: "smooth",
behavior: 'smooth',
});
}
}, 100);
@@ -166,7 +154,7 @@ export function InterviewView() {
const userMessage: InterviewMessage = {
id: `user-${Date.now()}`,
role: "user",
role: 'user',
content: input,
timestamp: new Date(),
};
@@ -178,25 +166,20 @@ export function InterviewView() {
if (currentQuestion) {
setInterviewData((prev) => {
const newData = { ...prev };
if (
currentQuestion.field === "techStack" ||
currentQuestion.field === "features"
) {
if (currentQuestion.field === 'techStack' || currentQuestion.field === 'features') {
// Parse comma-separated values into array
newData[currentQuestion.field] = input
.split(",")
.split(',')
.map((s) => s.trim())
.filter(Boolean);
} else {
(newData as Record<string, string | string[]>)[
currentQuestion.field
] = input;
(newData as Record<string, string | string[]>)[currentQuestion.field] = input;
}
return newData;
});
}
setInput("");
setInput('');
// Move to next question or complete
const nextIndex = currentQuestionIndex + 1;
@@ -206,7 +189,7 @@ export function InterviewView() {
const nextQuestion = INTERVIEW_QUESTIONS[nextIndex];
const assistantMessage: InterviewMessage = {
id: `assistant-${Date.now()}`,
role: "assistant",
role: 'assistant',
content: `Great! **${nextQuestion.question}**\n\n_${nextQuestion.hint}_`,
timestamp: new Date(),
};
@@ -216,34 +199,30 @@ export function InterviewView() {
// All questions answered - generate spec
const summaryMessage: InterviewMessage = {
id: `assistant-summary-${Date.now()}`,
role: "assistant",
role: 'assistant',
content:
"Perfect! I have all the information I need. Now let me generate your project specification...",
'Perfect! I have all the information I need. Now let me generate your project specification...',
timestamp: new Date(),
};
setMessages((prev) => [...prev, summaryMessage]);
generateSpec({
...interviewData,
projectDescription:
currentQuestionIndex === 0
? input
: interviewData.projectDescription,
projectDescription: currentQuestionIndex === 0 ? input : interviewData.projectDescription,
techStack:
currentQuestionIndex === 1
? input
.split(",")
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: interviewData.techStack,
features:
currentQuestionIndex === 2
? input
.split(",")
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: interviewData.features,
additionalNotes:
currentQuestionIndex === 3 ? input : interviewData.additionalNotes,
additionalNotes: currentQuestionIndex === 3 ? input : interviewData.additionalNotes,
});
}
}, 500);
@@ -265,7 +244,7 @@ export function InterviewView() {
const completionMessage: InterviewMessage = {
id: `assistant-complete-${Date.now()}`,
role: "assistant",
role: 'assistant',
content: `I've generated a draft project specification based on our conversation!\n\nPlease provide a project name and choose where to save your project, then click "Create Project" to get started.`,
timestamp: new Date(),
};
@@ -274,15 +253,15 @@ export function InterviewView() {
const generateAppSpec = (data: InterviewState): string => {
const projectName = data.projectDescription
.split(" ")
.split(' ')
.slice(0, 3)
.join("-")
.join('-')
.toLowerCase()
.replace(/[^a-z0-9-]/g, "");
.replace(/[^a-z0-9-]/g, '');
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
return `<project_specification>
<project_name>${projectName || "my-project"}</project_name>
<project_name>${projectName || 'my-project'}</project_name>
<overview>
${data.projectDescription}
@@ -291,25 +270,21 @@ export function InterviewView() {
<technology_stack>
${
data.techStack.length > 0
? data.techStack
.map((tech) => `<technology>${tech}</technology>`)
.join("\n ")
: "<!-- Define your tech stack -->"
? data.techStack.map((tech) => `<technology>${tech}</technology>`).join('\n ')
: '<!-- Define your tech stack -->'
}
</technology_stack>
<core_capabilities>
${
data.features.length > 0
? data.features
.map((feature) => `<capability>${feature}</capability>`)
.join("\n ")
: "<!-- List core features -->"
? data.features.map((feature) => `<capability>${feature}</capability>`).join('\n ')
: '<!-- List core features -->'
}
</core_capabilities>
<additional_requirements>
${data.additionalNotes || "None specified"}
${data.additionalNotes || 'None specified'}
</additional_requirements>
<development_guidelines>
@@ -323,9 +298,8 @@ export function InterviewView() {
const handleSelectDirectory = async () => {
const selectedPath = await openFileBrowser({
title: "Select Base Directory",
description:
"Choose the parent directory where your new project will be created",
title: 'Select Base Directory',
description: 'Choose the parent directory where your new project will be created',
initialPath: projectPath || undefined,
});
@@ -345,45 +319,42 @@ export function InterviewView() {
const api = getElectronAPI();
// Use platform-specific path separator
const pathSep =
typeof window !== "undefined" && (window as any).electronAPI
? navigator.platform.indexOf("Win") !== -1
? "\\"
: "/"
: "/";
typeof window !== 'undefined' && (window as any).electronAPI
? navigator.platform.indexOf('Win') !== -1
? '\\'
: '/'
: '/';
const fullProjectPath = `${projectPath}${pathSep}${projectName}`;
// Create project directory
const mkdirResult = await api.mkdir(fullProjectPath);
if (!mkdirResult.success) {
toast.error("Failed to create project directory", {
description: mkdirResult.error || "Unknown error occurred",
toast.error('Failed to create project directory', {
description: mkdirResult.error || 'Unknown error occurred',
});
setIsGenerating(false);
return;
}
// Write app_spec.txt with generated content
await api.writeFile(
`${fullProjectPath}/.automaker/app_spec.txt`,
generatedSpec
);
await api.writeFile(`${fullProjectPath}/.automaker/app_spec.txt`, generatedSpec);
// Create initial feature in the features folder
const initialFeature: Feature = {
id: crypto.randomUUID(),
category: "Core",
description: "Initial project setup",
status: "backlog" as const,
category: 'Core',
description: 'Initial project setup',
status: 'backlog' as const,
steps: [
"Step 1: Review app_spec.txt",
"Step 2: Set up development environment",
"Step 3: Start implementing features",
'Step 1: Review app_spec.txt',
'Step 2: Set up development environment',
'Step 3: Start implementing features',
],
skipTests: true,
};
if (!api.features) {
throw new Error("Features API not available");
throw new Error('Features API not available');
}
await api.features.create(fullProjectPath, initialFeature);
@@ -401,27 +372,24 @@ export function InterviewView() {
addProject(project);
setCurrentProject(project);
} catch (error) {
console.error("Failed to create project:", error);
console.error('Failed to create project:', error);
setIsGenerating(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleGoBack = () => {
navigate({ to: "/" });
navigate({ to: '/' });
};
return (
<div
className="flex-1 flex flex-col content-bg min-h-0"
data-testid="interview-view"
>
<div className="flex-1 flex flex-col content-bg min-h-0" data-testid="interview-view">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-3">
@@ -439,10 +407,8 @@ export function InterviewView() {
<h1 className="text-xl font-bold">New Project Interview</h1>
<p className="text-sm text-muted-foreground">
{isComplete
? "Specification generated!"
: `Question ${currentQuestionIndex + 1} of ${
INTERVIEW_QUESTIONS.length
}`}
? 'Specification generated!'
: `Question ${currentQuestionIndex + 1} of ${INTERVIEW_QUESTIONS.length}`}
</p>
</div>
</div>
@@ -453,18 +419,16 @@ export function InterviewView() {
<div
key={index}
className={cn(
"w-2 h-2 rounded-full transition-colors",
'w-2 h-2 rounded-full transition-colors',
index < currentQuestionIndex
? "bg-green-500"
? 'bg-green-500'
: index === currentQuestionIndex
? "bg-primary"
: "bg-zinc-700"
? 'bg-primary'
: 'bg-zinc-700'
)}
/>
))}
{isComplete && (
<CheckCircle className="w-4 h-4 text-green-500 ml-2" />
)}
{isComplete && <CheckCircle className="w-4 h-4 text-green-500 ml-2" />}
</div>
</div>
@@ -477,18 +441,15 @@ export function InterviewView() {
{messages.map((message) => (
<div
key={message.id}
className={cn(
"flex gap-3",
message.role === "user" && "flex-row-reverse"
)}
className={cn('flex gap-3', message.role === 'user' && 'flex-row-reverse')}
>
<div
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center shrink-0",
message.role === "assistant" ? "bg-primary/10" : "bg-muted"
'w-8 h-8 rounded-full flex items-center justify-center shrink-0',
message.role === 'assistant' ? 'bg-primary/10' : 'bg-muted'
)}
>
{message.role === "assistant" ? (
{message.role === 'assistant' ? (
<Bot className="w-4 h-4 text-primary" />
) : (
<User className="w-4 h-4" />
@@ -496,28 +457,24 @@ export function InterviewView() {
</div>
<Card
className={cn(
"max-w-[80%]",
message.role === "user"
? "bg-transparent border border-primary text-foreground"
: "border border-primary/30 bg-card"
'max-w-[80%]',
message.role === 'user'
? 'bg-transparent border border-primary text-foreground'
: 'border border-primary/30 bg-card'
)}
>
<CardContent className="px-3 py-2">
{message.role === "assistant" ? (
{message.role === 'assistant' ? (
<Markdown className="text-sm text-primary prose-headings:text-primary prose-strong:text-primary prose-code:text-primary">
{message.content}
</Markdown>
) : (
<p className="text-sm whitespace-pre-wrap">
{message.content}
</p>
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
)}
<p
className={cn(
"text-xs mt-1",
message.role === "user"
? "text-muted-foreground"
: "text-primary/70"
'text-xs mt-1',
message.role === 'user' ? 'text-muted-foreground' : 'text-primary/70'
)}
>
{message.timestamp.toLocaleTimeString()}
@@ -536,9 +493,7 @@ export function InterviewView() {
<CardContent className="p-3">
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<span className="text-sm text-primary">
Generating specification...
</span>
<span className="text-sm text-primary">Generating specification...</span>
</div>
</CardContent>
</Card>
@@ -548,10 +503,7 @@ export function InterviewView() {
{/* Project Setup Form */}
{showProjectSetup && (
<div className="mt-6">
<Card
className="bg-zinc-900/50 border-white/10"
data-testid="project-setup-form"
>
<Card className="bg-zinc-900/50 border-white/10" data-testid="project-setup-form">
<CardContent className="p-6 space-y-4">
<div className="flex items-center gap-2 mb-4">
<FileText className="w-5 h-5 text-primary" />
@@ -560,10 +512,7 @@ export function InterviewView() {
<div className="space-y-4">
<div className="space-y-2">
<label
htmlFor="project-name"
className="text-sm font-medium text-zinc-300"
>
<label htmlFor="project-name" className="text-sm font-medium text-zinc-300">
Project Name
</label>
<Input
@@ -577,10 +526,7 @@ export function InterviewView() {
</div>
<div className="space-y-2">
<label
htmlFor="project-path"
className="text-sm font-medium text-zinc-300"
>
<label htmlFor="project-path" className="text-sm font-medium text-zinc-300">
Parent Directory
</label>
<div className="flex gap-2">

View File

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

View File

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

View File

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

View File

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

View File

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

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