mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Merge branch 'v0.11.0rc' into fix/pipeline-resume-edge-cases
This commit is contained in:
405
apps/ui/src/components/codex-usage-popover.tsx
Normal file
405
apps/ui/src/components/codex-usage-popover.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
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 { useSetupStore } from '@/store/setup-store';
|
||||
|
||||
// Error codes for distinguishing failure modes
|
||||
const ERROR_CODES = {
|
||||
API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE',
|
||||
AUTH_ERROR: 'AUTH_ERROR',
|
||||
NOT_AVAILABLE: 'NOT_AVAILABLE',
|
||||
UNKNOWN: 'UNKNOWN',
|
||||
} as const;
|
||||
|
||||
type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
|
||||
|
||||
type UsageError = {
|
||||
code: ErrorCode;
|
||||
message: string;
|
||||
};
|
||||
|
||||
// Fixed refresh interval (45 seconds)
|
||||
const REFRESH_INTERVAL_SECONDS = 45;
|
||||
|
||||
// Helper to format reset time
|
||||
function formatResetTime(unixTimestamp: number): string {
|
||||
const date = new Date(unixTimestamp * 1000);
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
|
||||
// If less than 1 hour, show minutes
|
||||
if (diff < 3600000) {
|
||||
const mins = Math.ceil(diff / 60000);
|
||||
return `Resets in ${mins}m`;
|
||||
}
|
||||
|
||||
// If less than 24 hours, show hours and minutes
|
||||
if (diff < 86400000) {
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const mins = Math.ceil((diff % 3600000) / 60000);
|
||||
return `Resets in ${hours}h ${mins > 0 ? `${mins}m` : ''}`;
|
||||
}
|
||||
|
||||
// Otherwise show date
|
||||
return `Resets ${date.toLocaleDateString()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||||
}
|
||||
|
||||
// Helper to format window duration
|
||||
function getWindowLabel(durationMins: number): { title: string; subtitle: string } {
|
||||
if (durationMins < 60) {
|
||||
return { title: `${durationMins}min Window`, subtitle: 'Rate limit' };
|
||||
}
|
||||
if (durationMins < 1440) {
|
||||
const hours = Math.round(durationMins / 60);
|
||||
return { title: `${hours}h Window`, subtitle: 'Rate limit' };
|
||||
}
|
||||
const days = Math.round(durationMins / 1440);
|
||||
return { title: `${days}d Window`, subtitle: 'Rate limit' };
|
||||
}
|
||||
|
||||
export function CodexUsagePopover() {
|
||||
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
|
||||
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<UsageError | null>(null);
|
||||
|
||||
// Check if Codex is authenticated
|
||||
const isCodexAuthenticated = codexAuthStatus?.authenticated;
|
||||
|
||||
// Check if data is stale (older than 2 minutes)
|
||||
const isStale = useMemo(() => {
|
||||
return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
|
||||
}, [codexUsageLastUpdated]);
|
||||
|
||||
const fetchUsage = useCallback(
|
||||
async (isAutoRefresh = false) => {
|
||||
if (!isAutoRefresh) setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.codex) {
|
||||
setError({
|
||||
code: ERROR_CODES.API_BRIDGE_UNAVAILABLE,
|
||||
message: 'Codex API bridge not available',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const data = await api.codex.getUsage();
|
||||
if ('error' in data) {
|
||||
// Check if it's the "not available" error
|
||||
if (
|
||||
data.message?.includes('not available') ||
|
||||
data.message?.includes('does not provide')
|
||||
) {
|
||||
setError({
|
||||
code: ERROR_CODES.NOT_AVAILABLE,
|
||||
message: data.message || data.error,
|
||||
});
|
||||
} else {
|
||||
setError({
|
||||
code: ERROR_CODES.AUTH_ERROR,
|
||||
message: data.message || data.error,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
setCodexUsage(data);
|
||||
} catch (err) {
|
||||
setError({
|
||||
code: ERROR_CODES.UNKNOWN,
|
||||
message: err instanceof Error ? err.message : 'Failed to fetch usage',
|
||||
});
|
||||
} finally {
|
||||
if (!isAutoRefresh) setLoading(false);
|
||||
}
|
||||
},
|
||||
[setCodexUsage]
|
||||
);
|
||||
|
||||
// Auto-fetch on mount if data is stale (only if authenticated)
|
||||
useEffect(() => {
|
||||
if (isStale && isCodexAuthenticated) {
|
||||
fetchUsage(true);
|
||||
}
|
||||
}, [isStale, isCodexAuthenticated, fetchUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip if not authenticated
|
||||
if (!isCodexAuthenticated) return;
|
||||
|
||||
// Initial fetch when opened
|
||||
if (open) {
|
||||
if (!codexUsage || isStale) {
|
||||
fetchUsage();
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-refresh interval (only when open)
|
||||
let intervalId: NodeJS.Timeout | null = null;
|
||||
if (open) {
|
||||
intervalId = setInterval(() => {
|
||||
fetchUsage(true);
|
||||
}, REFRESH_INTERVAL_SECONDS * 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
};
|
||||
}, [open, codexUsage, isStale, isCodexAuthenticated, fetchUsage]);
|
||||
|
||||
// Derived status color/icon helper
|
||||
const getStatusInfo = (percentage: number) => {
|
||||
if (percentage >= 75) return { color: 'text-red-500', icon: XCircle, bg: 'bg-red-500' };
|
||||
if (percentage >= 50)
|
||||
return { color: 'text-orange-500', icon: AlertTriangle, bg: 'bg-orange-500' };
|
||||
return { color: 'text-green-500', icon: CheckCircle, bg: 'bg-green-500' };
|
||||
};
|
||||
|
||||
// Helper component for the progress bar
|
||||
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)}
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const UsageCard = ({
|
||||
title,
|
||||
subtitle,
|
||||
percentage,
|
||||
resetText,
|
||||
isPrimary = false,
|
||||
stale = false,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
percentage: number;
|
||||
resetText?: string;
|
||||
isPrimary?: boolean;
|
||||
stale?: boolean;
|
||||
}) => {
|
||||
const isValidPercentage =
|
||||
typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage);
|
||||
const safePercentage = isValidPercentage ? percentage : 0;
|
||||
|
||||
const status = getStatusInfo(safePercentage);
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
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'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<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)} />
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono font-bold',
|
||||
status.color,
|
||||
isPrimary ? 'text-base' : 'text-sm'
|
||||
)}
|
||||
>
|
||||
{Math.round(safePercentage)}%
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">N/A</span>
|
||||
)}
|
||||
</div>
|
||||
<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">
|
||||
<Clock className="w-3 h-3" />
|
||||
{resetText}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Header Button
|
||||
const maxPercentage = codexUsage?.rateLimits
|
||||
? Math.max(
|
||||
codexUsage.rateLimits.primary?.usedPercent || 0,
|
||||
codexUsage.rateLimits.secondary?.usedPercent || 0
|
||||
)
|
||||
: 0;
|
||||
|
||||
const getProgressBarColor = (percentage: number) => {
|
||||
if (percentage >= 80) return 'bg-red-500';
|
||||
if (percentage >= 50) return 'bg-yellow-500';
|
||||
return 'bg-green-500';
|
||||
};
|
||||
|
||||
const trigger = (
|
||||
<Button variant="ghost" size="sm" className="h-9 gap-3 bg-secondary border border-border px-3">
|
||||
<span className="text-sm font-medium">Codex</span>
|
||||
{codexUsage && codexUsage.rateLimits && (
|
||||
<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))}
|
||||
style={{ width: `${Math.min(maxPercentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-80 p-0 overflow-hidden bg-background/95 backdrop-blur-xl border-border shadow-2xl"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-secondary/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">Codex Usage</span>
|
||||
</div>
|
||||
{error && error.code !== ERROR_CODES.NOT_AVAILABLE && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-6 w-6', loading && 'opacity-80')}
|
||||
onClick={() => !loading && fetchUsage(false)}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3">
|
||||
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
|
||||
<div className="space-y-1 flex flex-col items-center">
|
||||
<p className="text-sm font-medium">
|
||||
{error.code === ERROR_CODES.NOT_AVAILABLE ? 'Usage not available' : error.message}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{error.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
|
||||
'Ensure the Electron bridge is running or restart the app'
|
||||
) : error.code === ERROR_CODES.NOT_AVAILABLE ? (
|
||||
<>
|
||||
Codex CLI doesn't provide usage statistics. Check{' '}
|
||||
<a
|
||||
href="https://platform.openai.com/usage"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
OpenAI dashboard
|
||||
</a>{' '}
|
||||
for usage details.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Make sure Codex CLI is installed and authenticated via{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">codex login</code>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : !codexUsage ? (
|
||||
// Loading state
|
||||
<div className="flex flex-col items-center justify-center py-8 space-y-2">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
|
||||
<p className="text-xs text-muted-foreground">Loading usage data...</p>
|
||||
</div>
|
||||
) : codexUsage.rateLimits ? (
|
||||
<>
|
||||
{/* Primary Window Card */}
|
||||
{codexUsage.rateLimits.primary && (
|
||||
<UsageCard
|
||||
title={getWindowLabel(codexUsage.rateLimits.primary.windowDurationMins).title}
|
||||
subtitle={
|
||||
getWindowLabel(codexUsage.rateLimits.primary.windowDurationMins).subtitle
|
||||
}
|
||||
percentage={codexUsage.rateLimits.primary.usedPercent}
|
||||
resetText={formatResetTime(codexUsage.rateLimits.primary.resetsAt)}
|
||||
isPrimary={true}
|
||||
stale={isStale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Secondary Window Card */}
|
||||
{codexUsage.rateLimits.secondary && (
|
||||
<UsageCard
|
||||
title={getWindowLabel(codexUsage.rateLimits.secondary.windowDurationMins).title}
|
||||
subtitle={
|
||||
getWindowLabel(codexUsage.rateLimits.secondary.windowDurationMins).subtitle
|
||||
}
|
||||
percentage={codexUsage.rateLimits.secondary.usedPercent}
|
||||
resetText={formatResetTime(codexUsage.rateLimits.secondary.resetsAt)}
|
||||
stale={isStale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Plan Type */}
|
||||
{codexUsage.rateLimits.planType && (
|
||||
<div className="rounded-xl border border-border/40 bg-secondary/20 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Plan:{' '}
|
||||
<span className="text-foreground font-medium">
|
||||
{codexUsage.rateLimits.planType.charAt(0).toUpperCase() +
|
||||
codexUsage.rateLimits.planType.slice(1)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
|
||||
<p className="text-sm font-medium mt-3">No usage data available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-secondary/10 border-t border-border/50">
|
||||
<a
|
||||
href="https://platform.openai.com/usage"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
|
||||
>
|
||||
OpenAI Dashboard <ExternalLink className="w-2.5 h-2.5" />
|
||||
</a>
|
||||
|
||||
<span className="text-[10px] text-muted-foreground">Updates every minute</span>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { ImageIcon, Upload, Loader2, Trash2 } from 'lucide-react';
|
||||
|
||||
const logger = createLogger('BoardBackgroundModal');
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -13,7 +16,8 @@ import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore, defaultBackgroundSettings } from '@/store/app-store';
|
||||
import { getHttpApiClient, getServerUrlSync } from '@/lib/http-api-client';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||
import { useBoardBackgroundSettings } from '@/hooks/use-board-background-settings';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
@@ -62,12 +66,13 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
// Update preview image when background settings change
|
||||
useEffect(() => {
|
||||
if (currentProject && backgroundSettings.imagePath) {
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync();
|
||||
// Add cache-busting query parameter to force browser to reload image
|
||||
const cacheBuster = imageVersion ? `&v=${imageVersion}` : `&v=${Date.now()}`;
|
||||
const imagePath = `${serverUrl}/api/fs/image?path=${encodeURIComponent(
|
||||
backgroundSettings.imagePath
|
||||
)}&projectPath=${encodeURIComponent(currentProject.path)}${cacheBuster}`;
|
||||
const cacheBuster = imageVersion ?? Date.now().toString();
|
||||
const imagePath = getAuthenticatedImageUrl(
|
||||
backgroundSettings.imagePath,
|
||||
currentProject.path,
|
||||
cacheBuster
|
||||
);
|
||||
setPreviewImage(imagePath);
|
||||
} else {
|
||||
setPreviewImage(null);
|
||||
@@ -113,7 +118,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
setPreviewImage(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to process image:', error);
|
||||
logger.error('Failed to process image:', error);
|
||||
toast.error('Failed to process image');
|
||||
setPreviewImage(null);
|
||||
} finally {
|
||||
@@ -185,7 +190,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
toast.error(result.error || 'Failed to clear background image');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to clear background:', error);
|
||||
logger.error('Failed to clear background:', error);
|
||||
toast.error('Failed to clear background');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
|
||||
@@ -11,10 +11,10 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PathInput } from '@/components/ui/path-input';
|
||||
import { Kbd, KbdGroup } from '@/components/ui/kbd';
|
||||
import { getJSON, setJSON } from '@/lib/storage';
|
||||
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
|
||||
import { useOSDetection } from '@/hooks';
|
||||
import { apiPost } from '@/lib/api-fetch';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
interface DirectoryEntry {
|
||||
name: string;
|
||||
@@ -40,28 +40,8 @@ interface FileBrowserDialogProps {
|
||||
initialPath?: string;
|
||||
}
|
||||
|
||||
const RECENT_FOLDERS_KEY = 'file-browser-recent-folders';
|
||||
const MAX_RECENT_FOLDERS = 5;
|
||||
|
||||
function getRecentFolders(): string[] {
|
||||
return getJSON<string[]>(RECENT_FOLDERS_KEY) ?? [];
|
||||
}
|
||||
|
||||
function addRecentFolder(path: string): void {
|
||||
const recent = getRecentFolders();
|
||||
// Remove if already exists, then add to front
|
||||
const filtered = recent.filter((p) => p !== path);
|
||||
const updated = [path, ...filtered].slice(0, MAX_RECENT_FOLDERS);
|
||||
setJSON(RECENT_FOLDERS_KEY, updated);
|
||||
}
|
||||
|
||||
function removeRecentFolder(path: string): string[] {
|
||||
const recent = getRecentFolders();
|
||||
const updated = recent.filter((p) => p !== path);
|
||||
setJSON(RECENT_FOLDERS_KEY, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function FileBrowserDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -78,20 +58,20 @@ export function FileBrowserDialog({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [warning, setWarning] = useState('');
|
||||
const [recentFolders, setRecentFolders] = useState<string[]>([]);
|
||||
|
||||
// Load recent folders when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRecentFolders(getRecentFolders());
|
||||
}
|
||||
}, [open]);
|
||||
// Use recent folders from app store (synced via API)
|
||||
const recentFolders = useAppStore((s) => s.recentFolders);
|
||||
const setRecentFolders = useAppStore((s) => s.setRecentFolders);
|
||||
const addRecentFolder = useAppStore((s) => s.addRecentFolder);
|
||||
|
||||
const handleRemoveRecent = useCallback((e: React.MouseEvent, path: string) => {
|
||||
e.stopPropagation();
|
||||
const updated = removeRecentFolder(path);
|
||||
setRecentFolders(updated);
|
||||
}, []);
|
||||
const handleRemoveRecent = useCallback(
|
||||
(e: React.MouseEvent, path: string) => {
|
||||
e.stopPropagation();
|
||||
const updated = recentFolders.filter((p) => p !== path);
|
||||
setRecentFolders(updated);
|
||||
},
|
||||
[recentFolders, setRecentFolders]
|
||||
);
|
||||
|
||||
const browseDirectory = useCallback(async (dirPath?: string) => {
|
||||
setLoading(true);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -29,6 +30,8 @@ import { cn } from '@/lib/utils';
|
||||
import { useFileBrowser } from '@/contexts/file-browser-context';
|
||||
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
|
||||
|
||||
const logger = createLogger('NewProjectModal');
|
||||
|
||||
interface ValidationErrors {
|
||||
projectName?: boolean;
|
||||
workspaceDir?: boolean;
|
||||
@@ -78,7 +81,7 @@ export function NewProjectModal({
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to get default workspace directory:', error);
|
||||
logger.error('Failed to get default workspace directory:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingWorkspace(false);
|
||||
|
||||
@@ -5,31 +5,16 @@
|
||||
* Prompts them to either restart the app in a container or reload to try again.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ShieldX, RefreshCw, Container, Copy, Check } from 'lucide-react';
|
||||
import { ShieldX, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const DOCKER_COMMAND = 'npm run dev:docker';
|
||||
|
||||
export function SandboxRejectionScreen() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleReload = () => {
|
||||
// Clear the rejection state and reload
|
||||
sessionStorage.removeItem('automaker-sandbox-denied');
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(DOCKER_COMMAND);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full text-center space-y-6">
|
||||
@@ -46,32 +31,10 @@ export function SandboxRejectionScreen() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 border border-border rounded-lg p-4 text-left space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Container className="w-5 h-5 mt-0.5 text-primary flex-shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="font-medium text-sm">Run in Docker (Recommended)</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Run Automaker in a containerized sandbox environment:
|
||||
</p>
|
||||
<div className="flex items-center gap-2 bg-background border border-border rounded-lg p-2">
|
||||
<code className="flex-1 text-sm font-mono px-2">{DOCKER_COMMAND}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="h-8 px-2 hover:bg-muted"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
For safer operation, consider running Automaker in Docker. See the README for
|
||||
instructions.
|
||||
</p>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ShieldAlert, Copy, Check } from 'lucide-react';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -25,10 +25,7 @@ interface SandboxRiskDialogProps {
|
||||
onDeny: () => void;
|
||||
}
|
||||
|
||||
const DOCKER_COMMAND = 'npm run dev:docker';
|
||||
|
||||
export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialogProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [skipInFuture, setSkipInFuture] = useState(false);
|
||||
|
||||
const handleConfirm = () => {
|
||||
@@ -37,16 +34,6 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog
|
||||
setSkipInFuture(false);
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(DOCKER_COMMAND);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent
|
||||
@@ -78,26 +65,10 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
For safer operation, consider running Automaker in Docker:
|
||||
</p>
|
||||
<div className="flex items-center gap-2 bg-muted/50 border border-border rounded-lg p-2">
|
||||
<code className="flex-1 text-sm font-mono px-2">{DOCKER_COMMAND}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="h-8 px-2 hover:bg-muted"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
For safer operation, consider running Automaker in Docker. See the README for
|
||||
instructions.
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
220
apps/ui/src/components/icons/editor-icons.tsx
Normal file
220
apps/ui/src/components/icons/editor-icons.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import type { ComponentType, ComponentProps } from 'react';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
|
||||
type IconProps = ComponentProps<'svg'>;
|
||||
type IconComponent = ComponentType<IconProps>;
|
||||
|
||||
const ANTIGRAVITY_COMMANDS = ['antigravity', 'agy'] as const;
|
||||
const [PRIMARY_ANTIGRAVITY_COMMAND, LEGACY_ANTIGRAVITY_COMMAND] = ANTIGRAVITY_COMMANDS;
|
||||
|
||||
/**
|
||||
* Cursor editor logo icon - from LobeHub icons
|
||||
*/
|
||||
export function CursorIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* VS Code editor logo icon
|
||||
*/
|
||||
export function VSCodeIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* VS Code Insiders editor logo icon (same as VS Code)
|
||||
*/
|
||||
export function VSCodeInsidersIcon(props: IconProps) {
|
||||
return <VSCodeIcon {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kiro editor logo icon (VS Code fork)
|
||||
*/
|
||||
export function KiroIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 32 32" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M6.594.016A7.006 7.006 0 0 0 .742 3.875a6.996 6.996 0 0 0-.726 2.793C.004 6.878 0 9.93.004 16.227c.004 8.699.008 9.265.031 9.476.113.93.324 1.652.707 2.422a6.918 6.918 0 0 0 3.172 3.148c.75.372 1.508.59 2.398.692.227.027.77.027 9.688.027 8.945 0 9.457 0 9.688-.027.917-.106 1.66-.32 2.437-.707a6.918 6.918 0 0 0 3.148-3.172c.372-.75.59-1.508.692-2.398.027-.227.027-.77.027-9.665 0-9.976.004-9.53-.07-10.03a6.993 6.993 0 0 0-3.024-4.798 6.427 6.427 0 0 0-.757-.445 7.06 7.06 0 0 0-2.774-.734c-.328-.02-18.437-.02-18.773 0Zm10.789 5.406a7.556 7.556 0 0 1 6.008 3.805c.148.257.406.796.52 1.085.394 1 .632 2.157.769 3.75.035.38.05 1.965.023 2.407-.125 2.168-.625 4.183-1.515 6.078a9.77 9.77 0 0 1-.801 1.437c-.93 1.305-2.32 2.332-3.48 2.57-.895.184-1.602-.1-2.048-.827a3.42 3.42 0 0 1-.25-.528c-.035-.097-.062-.129-.086-.09-.003.008-.09.075-.191.153-.95.722-2.02 1.175-3.059 1.293-.273.03-.859.023-1.085-.016-.715-.121-1.286-.441-1.649-.93a2.563 2.563 0 0 1-.328-.632c-.117-.36-.156-.813-.117-1.227.054-.55.226-1.184.484-1.766a.48.48 0 0 0 .043-.117 2.11 2.11 0 0 0-.137.055c-.363.16-.898.305-1.308.351-.844.098-1.426-.14-1.715-.699-.106-.203-.149-.39-.16-.676-.008-.261.008-.43.066-.656.059-.23.121-.367.403-.89.382-.72.492-.946.636-1.348.328-.899.48-1.723.688-3.754.148-1.469.254-2.14.433-2.766.028-.09.078-.277.114-.414.796-3.074 3.113-5.183 6.148-5.601.129-.016.309-.04.399-.047.238-.016.96-.02 1.195 0Zm0 0" />
|
||||
<path d="M16.754 11.336a.815.815 0 0 0-.375.219c-.176.18-.293.441-.356.804-.039.235-.058.602-.039.868.028.406.082.64.204.894.128.262.304.426.546.496.106.031.383.031.5 0 .422-.113.703-.531.801-1.191a4.822 4.822 0 0 0-.012-.95c-.062-.378-.183-.675-.359-.863a.808.808 0 0 0-.648-.293.804.804 0 0 0-.262.016ZM20.375 11.328a1.01 1.01 0 0 0-.363.188c-.164.144-.293.402-.364.718-.05.23-.07.426-.07.743 0 .32.02.511.07.742.11.496.352.808.688.898.121.031.379.031.5 0 .402-.105.68-.5.781-1.11.035-.198.047-.648.024-.87-.063-.63-.293-1.059-.649-1.23a1.513 1.513 0 0 0-.219-.079 1.362 1.362 0 0 0-.398 0Zm0 0" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zed editor logo icon (from Simple Icons)
|
||||
*/
|
||||
export function ZedIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M2.25 1.5a.75.75 0 0 0-.75.75v16.5H0V2.25A2.25 2.25 0 0 1 2.25 0h20.095c1.002 0 1.504 1.212.795 1.92L10.764 14.298h3.486V12.75h1.5v1.922a1.125 1.125 0 0 1-1.125 1.125H9.264l-2.578 2.578h11.689V9h1.5v9.375a1.5 1.5 0 0 1-1.5 1.5H5.185L2.562 22.5H21.75a.75.75 0 0 0 .75-.75V5.25H24v16.5A2.25 2.25 0 0 1 21.75 24H1.655C.653 24 .151 22.788.86 22.08L13.19 9.75H9.75v1.5h-1.5V9.375A1.125 1.125 0 0 1 9.375 8.25h5.314l2.625-2.625H5.625V15h-1.5V5.625a1.5 1.5 0 0 1 1.5-1.5h13.19L21.438 1.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sublime Text editor logo icon
|
||||
*/
|
||||
export function SublimeTextIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M20.953.004a.397.397 0 0 0-.18.045L3.473 8.63a.397.397 0 0 0-.033.69l4.873 3.33-5.26 2.882a.397.397 0 0 0-.006.692l17.3 9.73a.397.397 0 0 0 .593-.344V15.094a.397.397 0 0 0-.203-.346l-4.917-2.763 5.233-2.725a.397.397 0 0 0 .207-.348V.397a.397.397 0 0 0-.307-.393z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS Finder icon
|
||||
*/
|
||||
export function FinderIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M2.5 3A2.5 2.5 0 0 0 0 5.5v13A2.5 2.5 0 0 0 2.5 21h19a2.5 2.5 0 0 0 2.5-2.5v-13A2.5 2.5 0 0 0 21.5 3h-19zM7 8.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm10 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm-9 6c0-.276.336-.5.75-.5h6.5c.414 0 .75.224.75.5v1c0 .828-1.343 2.5-4 2.5s-4-1.672-4-2.5v-1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Windsurf editor logo icon (by Codeium) - from LobeHub icons
|
||||
*/
|
||||
export function WindsurfIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M23.78 5.004h-.228a2.187 2.187 0 00-2.18 2.196v4.912c0 .98-.804 1.775-1.76 1.775a1.818 1.818 0 01-1.472-.773L13.168 5.95a2.197 2.197 0 00-1.81-.95c-1.134 0-2.154.972-2.154 2.173v4.94c0 .98-.797 1.775-1.76 1.775-.57 0-1.136-.289-1.472-.773L.408 5.098C.282 4.918 0 5.007 0 5.228v4.284c0 .216.066.426.188.604l5.475 7.889c.324.466.8.812 1.351.938 1.377.316 2.645-.754 2.645-2.117V11.89c0-.98.787-1.775 1.76-1.775h.002c.586 0 1.135.288 1.472.773l4.972 7.163a2.15 2.15 0 001.81.95c1.158 0 2.151-.973 2.151-2.173v-4.939c0-.98.787-1.775 1.76-1.775h.194c.122 0 .22-.1.22-.222V5.225a.221.221 0 00-.22-.222z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trae editor logo icon (by ByteDance) - from LobeHub icons
|
||||
*/
|
||||
export function TraeIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M24 20.541H3.428v-3.426H0V3.4h24V20.54zM3.428 17.115h17.144V6.827H3.428v10.288zm8.573-5.196l-2.425 2.424-2.424-2.424 2.424-2.424 2.425 2.424zm6.857-.001l-2.424 2.423-2.425-2.423 2.425-2.425 2.424 2.425z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* JetBrains Rider logo icon
|
||||
*/
|
||||
export function RiderIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M0 0v24h24V0zm7.031 3.113A4.063 4.063 0 0 1 9.72 4.14a3.23 3.23 0 0 1 .84 2.28A3.16 3.16 0 0 1 8.4 9.54l2.46 3.6H8.28L6.12 9.9H4.38v3.24H2.16V3.12c1.61-.004 3.281.009 4.871-.007zm5.509.007h3.96c3.18 0 5.34 2.16 5.34 5.04 0 2.82-2.16 5.04-5.34 5.04h-3.96zm4.069 1.976c-.607.01-1.235.004-1.849.004v6.06h1.74a2.882 2.882 0 0 0 3.06-3 2.897 2.897 0 0 0-2.951-3.064zM4.319 5.1v2.88H6.6c1.08 0 1.68-.6 1.68-1.44 0-.96-.66-1.44-1.74-1.44zM2.16 19.5h9V21h-9Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* JetBrains WebStorm logo icon
|
||||
*/
|
||||
export function WebStormIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M0 0v24h24V0H0zm17.889 2.889c1.444 0 2.667.444 3.667 1.278l-1.111 1.667c-.889-.611-1.722-1-2.556-1s-1.278.389-1.278.889v.056c0 .667.444.889 2.111 1.333 2 .556 3.111 1.278 3.111 3v.056c0 2-1.5 3.111-3.611 3.111-1.5-.056-3-.611-4.167-1.667l1.278-1.556c.889.722 1.833 1.222 2.944 1.222.889 0 1.389-.333 1.389-.944v-.056c0-.556-.333-.833-2-1.278-2-.5-3.222-1.056-3.222-3.056v-.056c0-1.833 1.444-3 3.444-3zm-16.111.222h2.278l1.5 5.778 1.722-5.778h1.667l1.667 5.778 1.5-5.778h2.333l-2.833 9.944H9.723L8.112 7.277l-1.667 5.778H4.612L1.779 3.111zm.5 16.389h9V21h-9v-1.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Xcode logo icon
|
||||
*/
|
||||
export function XcodeIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M19.06 5.3327c.4517-.1936.7744-.2581 1.097-.1936.5163.1291.7744.5163.968.7098.1936.3872.9034.7744 1.2261.8389.2581.0645.7098-.6453 1.0325-1.2906.3227-.5808.5163-1.3552.4517-1.5488-.0645-.1936-.968-.5808-1.1616-.5808-.1291 0-.3872.1291-.8389.0645-.4517-.0645-.9034-.5808-1.1616-.968-.4517-.6453-1.097-1.0325-1.6778-1.3552-.6453-.3227-1.3552-.5163-2.065-.6453-1.0325-.2581-2.065-.4517-3.0975-.3227-.5808.0645-1.2906.1291-1.8069.3227-.0645 0-.1936.1936-.0645.1936s.5808.0645.5808.0645-.5807.1292-.5807.2583c0 .1291.0645.1291.1291.1291.0645 0 1.4842-.0645 2.065 0 .6453.1291 1.3552.4517 1.8069 1.2261.7744 1.4197.4517 2.7749.2581 3.2266-.968 2.1295-8.6472 15.2294-9.0344 16.1328-.3873.9034-.5163 1.4842.5807 2.065s1.6778.3227 2.0005-.0645c.3872-.5163 7.0339-17.1654 9.2925-18.2624zm-3.6138 8.7117h1.5488c1.0325 0 1.2261.5163 1.2261.7098.0645.5163-.1936 1.1616-1.2261 1.1616h-.968l.7744 1.2906c.4517.7744.2581 1.1616 0 1.4197-.3872.3872-1.2261.3872-1.6778-.4517l-.9034-1.5488c-.6453 1.4197-1.2906 2.9684-2.065 4.7753h4.0009c1.9359 0 3.5492-1.6133 3.5492-3.5492V6.5588c-.0645-.1291-.1936-.0645-.2581 0-.3872.4517-1.4842 2.0004-4.001 7.4856zm-9.8087 8.0019h-.3227c-2.3231 0-4.1945-1.8714-4.1945-4.1945V7.0105c0-2.3231 1.8714-4.1945 4.1945-4.1945h9.3571c-.1936-.1936-.968-.5163-1.7423-.4517-.3227 0-.968.1291-1.3552-.1291-.3872-.3227-.3227-.5163-.9034-.5163H4.9277c-2.6458 0-4.7753 2.1295-4.7753 4.7753v11.7447c0 2.6458 2.1295 4.7753 4.4527 4.7108.6452 0 .8388-.5162 1.0324-.9034zM20.4152 6.9459v10.9058c0 2.3231-1.8714 4.1945-4.1945 4.1945H11.897s-.3872 1.0325.8389 1.0325h3.8719c2.6458 0 4.7753-2.1295 4.7753-4.7753V8.8173c.0646-.9034-.7098-1.4842-.9679-1.8714zm-18.5851.0646v10.8413c0 1.9359 1.6133 3.5492 3.5492 3.5492h.5808c0-.0645.7744-1.4197 2.4522-4.2591.1936-.3872.4517-.7744.7098-1.2261H4.4114c-.5808 0-.9034-.3872-.968-.7098-.1291-.5163.1936-1.1616.9034-1.1616h2.3877l3.033-5.2916s-.7098-1.2906-.9034-1.6133c-.2582-.4517-.1291-.9034.129-1.1615.3872-.3872 1.0325-.5808 1.6778.4517l.2581.3872.2581-.3872c.5808-.8389.968-.7744 1.2906-.7098.5163.1291.8389.7098.3872 1.6133L8.864 14.0444h1.3552c.4517-.7744.9034-1.5488 1.3552-2.3877-.0645-.3227-.1291-.7098-.0645-1.0325.0645-.5163.3227-.968.6453-1.3552l.3872.6453c1.2261-2.1295 2.1295-3.9364 2.3877-4.6463.1291-.3872.3227-1.1616.1291-1.8069H5.3794c-2.0005.0001-3.5493 1.6134-3.5493 3.5494zM4.605 17.7872c0-.0645.7744-1.4197.7744-1.4197 1.2261-.3227 1.8069.4517 1.8714.5163 0 0-.8389 1.4842-1.097 1.7423s-.5808.3227-.9034.2581c-.5164-.129-.839-.6453-.6454-1.097z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Android Studio logo icon
|
||||
*/
|
||||
export function AndroidStudioIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M19.2693 10.3368c-.3321 0-.6026.2705-.6026.6031v9.8324h-1.7379l-3.3355-6.9396c.476-.5387.6797-1.286.5243-2.0009a2.2862 2.2862 0 0 0-1.2893-1.6248v-.8124c.0121-.2871-.1426-.5787-.4043-.7407-.1391-.0825-.2884-.1234-.4402-.1234a.8478.8478 0 0 0-.4318.1182c-.2701.1671-.4248.4587-.4123.7662l-.0003.721c-1.0149.3668-1.6619 1.4153-1.4867 2.5197a2.282 2.282 0 0 0 .5916 1.2103l-3.2096 6.9064H4.0928c-1.0949-.007-1.9797-.8948-1.9832-1.9896V5.016c-.0055 1.1024.8836 2.0006 1.9859 2.0062a2.024 2.024 0 0 0 .1326-.0037h14.7453s2.5343-.2189 2.8619 1.5392c-.2491.0287-.4449.2321-.4449.4889 0 .7115-.5791 1.2901-1.3028 1.2901h-.8183zM17.222 22.5366c.2347.4837.0329 1.066-.4507 1.3007-.1296.0629-.2666.0895-.4018.0927a.9738.9738 0 0 1-.3194-.0455c-.024-.0078-.046-.0209-.0694-.0305a.9701.9701 0 0 1-.2277-.1321c-.0247-.0192-.0495-.038-.0724-.0598-.0825-.0783-.1574-.1672-.21-.2757l-1.2554-2.6143-1.5585-3.2452a.7725.7725 0 0 0-.6995-.4443h-.0024a.792.792 0 0 0-.7083.4443l-1.5109 3.2452-1.2321 2.6464a.9722.9722 0 0 1-.7985.5795c-.0626.0053-.1238-.0024-.185-.0087-.0344-.0036-.069-.0053-.1025-.0124-.0489-.0103-.0954-.0278-.142-.0452-.0301-.0113-.0613-.0197-.0901-.0339-.0496-.0244-.0948-.0565-.1397-.0889-.0217-.0156-.0457-.0275-.0662-.045a.9862.9862 0 0 1-.1695-.1844.9788.9788 0 0 1-.0708-.9852l.8469-1.8223 3.2676-7.0314a1.7964 1.7964 0 0 1-.7072-1.1637c-.1555-.9799.5129-1.9003 1.4928-2.0559V9.3946a.3542.3542 0 0 1 .1674-.3155.3468.3468 0 0 1 .3541 0 .354.354 0 0 1 .1674.3155v1.159l.0129.0064a1.8028 1.8028 0 0 1 1.2878 1.378 1.7835 1.7835 0 0 1-.6439 1.7836l3.3889 7.0507.8481 1.7643zM12.9841 12.306c.0042-.6081-.4854-1.1044-1.0935-1.1085a1.1204 1.1204 0 0 0-.7856.3219 1.101 1.101 0 0 0-.323.7716c-.0042.6081.4854 1.1044 1.0935 1.1085h.0077c.6046 0 1.0967-.488 1.1009-1.0935zm-1.027 5.2768c-.1119.0005-.2121.0632-.2571.1553l-1.4127 3.0342h3.3733l-1.4564-3.0328a.274.274 0 0 0-.2471-.1567zm8.1432-6.7459l-.0129-.0001h-.8177a.103.103 0 0 0-.103.103v12.9103a.103.103 0 0 0 .0966.103h.8435c.9861-.0035 1.7836-.804 1.7836-1.79V9.0468c0 .9887-.8014 1.7901-1.7901 1.7901zM2.6098 5.0161v.019c.0039.816.6719 1.483 1.4874 1.4869a12.061 12.061 0 0 1 .1309-.0034h1.1286c.1972-1.315.7607-2.525 1.638-3.4859H4.0993c-.9266.0031-1.6971.6401-1.9191 1.4975.2417.0355.4296.235.4296.4859zm6.3381-2.8977L7.9112.3284a.219.219 0 0 1 0-.2189A.2384.2384 0 0 1 8.098 0a.219.219 0 0 1 .1867.1094l1.0496 1.8158a6.4907 6.4907 0 0 1 5.3186 0L15.696.1094a.2189.2189 0 0 1 .3734.2189l-1.0302 1.79c1.6671.9125 2.7974 2.5439 3.0975 4.4018l-12.286-.0014c.3004-1.8572 1.4305-3.488 3.0972-4.4003zm5.3774 2.6202a.515.515 0 0 0 .5271.5028.515.515 0 0 0 .5151-.5151.5213.5213 0 0 0-.8885-.367.5151.5151 0 0 0-.1537.3793zm-5.7178-.0067a.5151.5151 0 0 0 .5207.5095.5086.5086 0 0 0 .367-.1481.5215.5215 0 1 0-.734-.7341.515.515 0 0 0-.1537.3727z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Antigravity IDE logo icon - stylized "A" arch shape
|
||||
*/
|
||||
export function AntigravityIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12 1C11 1 9.5 3 8 7c-1.5 4-3 8.5-4 11.5-.5 1.5-.3 2.8.5 3.3.8.5 2 .2 3-.8.8-.8 1.3-2 1.8-3.2.3-.8.8-1.3 1.5-1.3h2.4c.7 0 1.2.5 1.5 1.3.5 1.2 1 2.4 1.8 3.2 1 1 2.2 1.3 3 .8.8-.5 1-1.8.5-3.3-1-3-2.5-7.5-4-11.5C14.5 3 13 1 12 1zm0 5c.8 2 2 5.5 3 8.5H9c1-3 2.2-6.5 3-8.5z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate icon component for an editor command
|
||||
*/
|
||||
export function getEditorIcon(command: string): IconComponent {
|
||||
// Handle direct CLI commands
|
||||
const cliIcons: Record<string, IconComponent> = {
|
||||
cursor: CursorIcon,
|
||||
code: VSCodeIcon,
|
||||
'code-insiders': VSCodeInsidersIcon,
|
||||
kido: KiroIcon,
|
||||
zed: ZedIcon,
|
||||
subl: SublimeTextIcon,
|
||||
windsurf: WindsurfIcon,
|
||||
trae: TraeIcon,
|
||||
rider: RiderIcon,
|
||||
webstorm: WebStormIcon,
|
||||
xed: XcodeIcon,
|
||||
studio: AndroidStudioIcon,
|
||||
[PRIMARY_ANTIGRAVITY_COMMAND]: AntigravityIcon,
|
||||
[LEGACY_ANTIGRAVITY_COMMAND]: AntigravityIcon,
|
||||
open: FinderIcon,
|
||||
explorer: FolderOpen,
|
||||
'xdg-open': FolderOpen,
|
||||
};
|
||||
|
||||
// Check direct match first
|
||||
if (cliIcons[command]) {
|
||||
return cliIcons[command];
|
||||
}
|
||||
|
||||
// Handle 'open' commands (macOS) - both 'open -a AppName' and 'open "/path/to/App.app"'
|
||||
if (command.startsWith('open')) {
|
||||
const cmdLower = command.toLowerCase();
|
||||
if (cmdLower.includes('cursor')) return CursorIcon;
|
||||
if (cmdLower.includes('visual studio code - insiders')) return VSCodeInsidersIcon;
|
||||
if (cmdLower.includes('visual studio code')) return VSCodeIcon;
|
||||
if (cmdLower.includes('kiro')) return KiroIcon;
|
||||
if (cmdLower.includes('zed')) return ZedIcon;
|
||||
if (cmdLower.includes('sublime')) return SublimeTextIcon;
|
||||
if (cmdLower.includes('windsurf')) return WindsurfIcon;
|
||||
if (cmdLower.includes('trae')) return TraeIcon;
|
||||
if (cmdLower.includes('rider')) return RiderIcon;
|
||||
if (cmdLower.includes('webstorm')) return WebStormIcon;
|
||||
if (cmdLower.includes('xcode')) return XcodeIcon;
|
||||
if (cmdLower.includes('android studio')) return AndroidStudioIcon;
|
||||
if (cmdLower.includes('antigravity')) return AntigravityIcon;
|
||||
// If just 'open' without app name, it's Finder
|
||||
if (command === 'open') return FinderIcon;
|
||||
}
|
||||
|
||||
return FolderOpen;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { useNavigate, useLocation } from '@tanstack/react-router';
|
||||
|
||||
const logger = createLogger('Sidebar');
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore, type ThemeMode } from '@/store/app-store';
|
||||
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||
@@ -14,7 +17,6 @@ import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
|
||||
import {
|
||||
CollapseToggleButton,
|
||||
SidebarHeader,
|
||||
ProjectActions,
|
||||
SidebarNavigation,
|
||||
ProjectSelectorWithOptions,
|
||||
SidebarFooter,
|
||||
@@ -56,7 +58,7 @@ export function Sidebar() {
|
||||
} = useAppStore();
|
||||
|
||||
// Environment variable flags for hiding sidebar items
|
||||
const { hideTerminal, hideWiki, hideRunningAgents, hideContext, hideSpecEditor, hideAiProfiles } =
|
||||
const { hideTerminal, hideWiki, hideRunningAgents, hideContext, hideSpecEditor } =
|
||||
SIDEBAR_FEATURE_FLAGS;
|
||||
|
||||
// Get customizable keyboard shortcuts
|
||||
@@ -124,6 +126,9 @@ export function Sidebar() {
|
||||
// Derive isCreatingSpec from store state
|
||||
const isCreatingSpec = specCreatingForProject !== null;
|
||||
const creatingSpecProjectPath = specCreatingForProject;
|
||||
// Check if the current project is specifically the one generating spec
|
||||
const isCurrentProjectGeneratingSpec =
|
||||
specCreatingForProject !== null && specCreatingForProject === currentProject?.path;
|
||||
|
||||
// Auto-collapse sidebar on small screens and update Electron window minWidth
|
||||
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
|
||||
@@ -215,7 +220,7 @@ export function Sidebar() {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Sidebar] Failed to open project:', error);
|
||||
logger.error('Failed to open project:', error);
|
||||
toast.error('Failed to open project', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
@@ -229,7 +234,6 @@ export function Sidebar() {
|
||||
hideSpecEditor,
|
||||
hideContext,
|
||||
hideTerminal,
|
||||
hideAiProfiles,
|
||||
currentProject,
|
||||
projects,
|
||||
projectHistory,
|
||||
@@ -240,6 +244,7 @@ export function Sidebar() {
|
||||
cyclePrevProject,
|
||||
cycleNextProject,
|
||||
unviewedValidationsCount,
|
||||
isSpecGenerating: isCurrentProjectGeneratingSpec,
|
||||
});
|
||||
|
||||
// Register keyboard shortcuts
|
||||
@@ -252,121 +257,122 @@ export function Sidebar() {
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'flex-shrink-0 flex flex-col z-30 relative',
|
||||
// Glass morphism background with gradient
|
||||
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
|
||||
// Premium border with subtle glow
|
||||
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
|
||||
// Smooth width transition
|
||||
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
||||
sidebarOpen ? 'w-16 lg:w-72' : 'w-16'
|
||||
<>
|
||||
{/* Mobile overlay backdrop */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||
onClick={toggleSidebar}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
data-testid="sidebar"
|
||||
>
|
||||
<CollapseToggleButton
|
||||
sidebarOpen={sidebarOpen}
|
||||
toggleSidebar={toggleSidebar}
|
||||
shortcut={shortcuts.toggleSidebar}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
|
||||
|
||||
{/* Project Actions - Moved above project selector */}
|
||||
{sidebarOpen && (
|
||||
<ProjectActions
|
||||
setShowNewProjectModal={setShowNewProjectModal}
|
||||
handleOpenFolder={handleOpenFolder}
|
||||
setShowTrashDialog={setShowTrashDialog}
|
||||
trashedProjects={trashedProjects}
|
||||
shortcuts={{ openProject: shortcuts.openProject }}
|
||||
/>
|
||||
<aside
|
||||
className={cn(
|
||||
'flex-shrink-0 flex flex-col z-50 relative',
|
||||
// Glass morphism background with gradient
|
||||
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
|
||||
// Premium border with subtle glow
|
||||
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
|
||||
// Smooth width transition
|
||||
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
||||
// Mobile: hidden when closed, full width overlay when open
|
||||
// Desktop: always visible, toggle between narrow and wide
|
||||
sidebarOpen ? 'fixed lg:relative left-0 top-0 h-full w-72' : 'hidden lg:flex w-16'
|
||||
)}
|
||||
|
||||
<ProjectSelectorWithOptions
|
||||
data-testid="sidebar"
|
||||
>
|
||||
<CollapseToggleButton
|
||||
sidebarOpen={sidebarOpen}
|
||||
isProjectPickerOpen={isProjectPickerOpen}
|
||||
setIsProjectPickerOpen={setIsProjectPickerOpen}
|
||||
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
|
||||
toggleSidebar={toggleSidebar}
|
||||
shortcut={shortcuts.toggleSidebar}
|
||||
/>
|
||||
|
||||
<SidebarNavigation
|
||||
currentProject={currentProject}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
|
||||
|
||||
<ProjectSelectorWithOptions
|
||||
sidebarOpen={sidebarOpen}
|
||||
isProjectPickerOpen={isProjectPickerOpen}
|
||||
setIsProjectPickerOpen={setIsProjectPickerOpen}
|
||||
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
|
||||
/>
|
||||
|
||||
<SidebarNavigation
|
||||
currentProject={currentProject}
|
||||
sidebarOpen={sidebarOpen}
|
||||
navSections={navSections}
|
||||
isActiveRoute={isActiveRoute}
|
||||
navigate={navigate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SidebarFooter
|
||||
sidebarOpen={sidebarOpen}
|
||||
navSections={navSections}
|
||||
isActiveRoute={isActiveRoute}
|
||||
navigate={navigate}
|
||||
hideWiki={hideWiki}
|
||||
hideRunningAgents={hideRunningAgents}
|
||||
runningAgentsCount={runningAgentsCount}
|
||||
shortcuts={{ settings: shortcuts.settings }}
|
||||
/>
|
||||
<TrashDialog
|
||||
open={showTrashDialog}
|
||||
onOpenChange={setShowTrashDialog}
|
||||
trashedProjects={trashedProjects}
|
||||
activeTrashId={activeTrashId}
|
||||
handleRestoreProject={handleRestoreProject}
|
||||
handleDeleteProjectFromDisk={handleDeleteProjectFromDisk}
|
||||
deleteTrashedProject={deleteTrashedProject}
|
||||
handleEmptyTrash={handleEmptyTrash}
|
||||
isEmptyingTrash={isEmptyingTrash}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SidebarFooter
|
||||
sidebarOpen={sidebarOpen}
|
||||
isActiveRoute={isActiveRoute}
|
||||
navigate={navigate}
|
||||
hideWiki={hideWiki}
|
||||
hideRunningAgents={hideRunningAgents}
|
||||
runningAgentsCount={runningAgentsCount}
|
||||
shortcuts={{ settings: shortcuts.settings }}
|
||||
/>
|
||||
<TrashDialog
|
||||
open={showTrashDialog}
|
||||
onOpenChange={setShowTrashDialog}
|
||||
trashedProjects={trashedProjects}
|
||||
activeTrashId={activeTrashId}
|
||||
handleRestoreProject={handleRestoreProject}
|
||||
handleDeleteProjectFromDisk={handleDeleteProjectFromDisk}
|
||||
deleteTrashedProject={deleteTrashedProject}
|
||||
handleEmptyTrash={handleEmptyTrash}
|
||||
isEmptyingTrash={isEmptyingTrash}
|
||||
/>
|
||||
{/* New Project Setup Dialog */}
|
||||
<CreateSpecDialog
|
||||
open={showSetupDialog}
|
||||
onOpenChange={setShowSetupDialog}
|
||||
projectOverview={projectOverview}
|
||||
onProjectOverviewChange={setProjectOverview}
|
||||
generateFeatures={generateFeatures}
|
||||
onGenerateFeaturesChange={setGenerateFeatures}
|
||||
analyzeProject={analyzeProject}
|
||||
onAnalyzeProjectChange={setAnalyzeProject}
|
||||
featureCount={featureCount}
|
||||
onFeatureCountChange={setFeatureCount}
|
||||
onCreateSpec={handleCreateInitialSpec}
|
||||
onSkip={handleSkipSetup}
|
||||
isCreatingSpec={isCreatingSpec}
|
||||
showSkipButton={true}
|
||||
title="Set Up Your Project"
|
||||
description="We didn't find an app_spec.txt file. Let us help you generate your app_spec.txt to help describe your project for our system. We'll analyze your project's tech stack and create a comprehensive specification."
|
||||
/>
|
||||
|
||||
{/* New Project Setup Dialog */}
|
||||
<CreateSpecDialog
|
||||
open={showSetupDialog}
|
||||
onOpenChange={setShowSetupDialog}
|
||||
projectOverview={projectOverview}
|
||||
onProjectOverviewChange={setProjectOverview}
|
||||
generateFeatures={generateFeatures}
|
||||
onGenerateFeaturesChange={setGenerateFeatures}
|
||||
analyzeProject={analyzeProject}
|
||||
onAnalyzeProjectChange={setAnalyzeProject}
|
||||
featureCount={featureCount}
|
||||
onFeatureCountChange={setFeatureCount}
|
||||
onCreateSpec={handleCreateInitialSpec}
|
||||
onSkip={handleSkipSetup}
|
||||
isCreatingSpec={isCreatingSpec}
|
||||
showSkipButton={true}
|
||||
title="Set Up Your Project"
|
||||
description="We didn't find an app_spec.txt file. Let us help you generate your app_spec.txt to help describe your project for our system. We'll analyze your project's tech stack and create a comprehensive specification."
|
||||
/>
|
||||
<OnboardingDialog
|
||||
open={showOnboardingDialog}
|
||||
onOpenChange={setShowOnboardingDialog}
|
||||
newProjectName={newProjectName}
|
||||
onSkip={handleOnboardingSkip}
|
||||
onGenerateSpec={handleOnboardingGenerateSpec}
|
||||
/>
|
||||
|
||||
<OnboardingDialog
|
||||
open={showOnboardingDialog}
|
||||
onOpenChange={setShowOnboardingDialog}
|
||||
newProjectName={newProjectName}
|
||||
onSkip={handleOnboardingSkip}
|
||||
onGenerateSpec={handleOnboardingGenerateSpec}
|
||||
/>
|
||||
{/* Delete Project Confirmation Dialog */}
|
||||
<DeleteProjectDialog
|
||||
open={showDeleteProjectDialog}
|
||||
onOpenChange={setShowDeleteProjectDialog}
|
||||
project={currentProject}
|
||||
onConfirm={moveProjectToTrash}
|
||||
/>
|
||||
|
||||
{/* Delete Project Confirmation Dialog */}
|
||||
<DeleteProjectDialog
|
||||
open={showDeleteProjectDialog}
|
||||
onOpenChange={setShowDeleteProjectDialog}
|
||||
project={currentProject}
|
||||
onConfirm={moveProjectToTrash}
|
||||
/>
|
||||
|
||||
{/* New Project Modal */}
|
||||
<NewProjectModal
|
||||
open={showNewProjectModal}
|
||||
onOpenChange={setShowNewProjectModal}
|
||||
onCreateBlankProject={handleCreateBlankProject}
|
||||
onCreateFromTemplate={handleCreateFromTemplate}
|
||||
onCreateFromCustomUrl={handleCreateFromCustomUrl}
|
||||
isCreating={isCreatingProject}
|
||||
/>
|
||||
</aside>
|
||||
{/* New Project Modal */}
|
||||
<NewProjectModal
|
||||
open={showNewProjectModal}
|
||||
onOpenChange={setShowNewProjectModal}
|
||||
onCreateBlankProject={handleCreateBlankProject}
|
||||
onCreateFromTemplate={handleCreateFromTemplate}
|
||||
onCreateFromCustomUrl={handleCreateFromCustomUrl}
|
||||
isCreating={isCreatingProject}
|
||||
/>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
import type { NavigateOptions } from '@tanstack/react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useOSDetection } from '@/hooks/use-os-detection';
|
||||
|
||||
interface AutomakerLogoProps {
|
||||
sidebarOpen: boolean;
|
||||
navigate: (opts: NavigateOptions) => void;
|
||||
}
|
||||
|
||||
function getOSAbbreviation(os: string): string {
|
||||
switch (os) {
|
||||
case 'mac':
|
||||
return 'M';
|
||||
case 'windows':
|
||||
return 'W';
|
||||
case 'linux':
|
||||
return 'L';
|
||||
default:
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
|
||||
export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
|
||||
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0';
|
||||
const { os } = useOSDetection();
|
||||
const appMode = import.meta.env.VITE_APP_MODE || '?';
|
||||
const versionSuffix = `${getOSAbbreviation(os)}${appMode}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -15,67 +32,74 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
|
||||
'flex items-center gap-3 titlebar-no-drag cursor-pointer group',
|
||||
!sidebarOpen && 'flex-col gap-1'
|
||||
)}
|
||||
onClick={() => navigate({ to: '/' })}
|
||||
onClick={() => navigate({ to: '/dashboard' })}
|
||||
data-testid="logo-button"
|
||||
>
|
||||
{!sidebarOpen ? (
|
||||
<div className="relative flex flex-col items-center justify-center rounded-lg gap-0.5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
role="img"
|
||||
aria-label="Automaker Logo"
|
||||
className="size-8 group-hover:rotate-12 transition-transform duration-300 ease-out"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="bg-collapsed"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="256"
|
||||
y2="256"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
|
||||
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
|
||||
</linearGradient>
|
||||
<filter id="iconShadow-collapsed" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow
|
||||
dx="0"
|
||||
dy="4"
|
||||
stdDeviation="4"
|
||||
floodColor="#000000"
|
||||
floodOpacity="0.25"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-collapsed)" />
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="20"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
filter="url(#iconShadow-collapsed)"
|
||||
{/* Collapsed logo - only shown when sidebar is closed */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center rounded-lg gap-0.5',
|
||||
sidebarOpen ? 'hidden' : 'flex'
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
role="img"
|
||||
aria-label="Automaker Logo"
|
||||
className="size-8 group-hover:rotate-12 transition-transform duration-300 ease-out"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="bg-collapsed"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="256"
|
||||
y2="256"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M92 92 L52 128 L92 164" />
|
||||
<path d="M144 72 L116 184" />
|
||||
<path d="M164 92 L204 128 L164 164" />
|
||||
</g>
|
||||
</svg>
|
||||
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium">
|
||||
v{appVersion}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn('flex flex-col', 'hidden lg:flex')}>
|
||||
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
|
||||
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
|
||||
</linearGradient>
|
||||
<filter id="iconShadow-collapsed" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow
|
||||
dx="0"
|
||||
dy="4"
|
||||
stdDeviation="4"
|
||||
floodColor="#000000"
|
||||
floodOpacity="0.25"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-collapsed)" />
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="20"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
filter="url(#iconShadow-collapsed)"
|
||||
>
|
||||
<path d="M92 92 L52 128 L92 164" />
|
||||
<path d="M144 72 L116 184" />
|
||||
<path d="M164 92 L204 128 L164 164" />
|
||||
</g>
|
||||
</svg>
|
||||
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium">
|
||||
v{appVersion} {versionSuffix}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Expanded logo - shown when sidebar is open */}
|
||||
{sidebarOpen && (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
role="img"
|
||||
aria-label="automaker"
|
||||
className="h-[36.8px] w-[36.8px] group-hover:rotate-12 transition-transform duration-300 ease-out"
|
||||
className="h-8 w-8 lg:h-[36.8px] lg:w-[36.8px] shrink-0 group-hover:rotate-12 transition-transform duration-300 ease-out"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
@@ -113,12 +137,12 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
|
||||
<path d="M164 92 L204 128 L164 164" />
|
||||
</g>
|
||||
</svg>
|
||||
<span className="font-bold text-foreground text-[1.7rem] tracking-tight leading-none translate-y-[-2px]">
|
||||
<span className="font-bold text-foreground text-xl lg:text-[1.7rem] tracking-tight leading-none translate-y-[-2px]">
|
||||
automaker<span className="text-brand-500">.</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium ml-[38.8px]">
|
||||
v{appVersion}
|
||||
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium ml-9 lg:ml-[38.8px]">
|
||||
v{appVersion} {versionSuffix}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,9 @@ export function CollapseToggleButton({
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
'hidden lg:flex absolute top-[68px] -right-3 z-9999',
|
||||
// Show on desktop always, show on mobile only when sidebar is open
|
||||
sidebarOpen ? 'flex' : 'hidden lg:flex',
|
||||
'absolute top-[68px] -right-3 z-9999',
|
||||
'group/toggle items-center justify-center w-7 h-7 rounded-full',
|
||||
// Glass morphism button
|
||||
'bg-card/95 backdrop-blur-sm border border-border/80',
|
||||
|
||||
@@ -117,7 +117,7 @@ export function ProjectSelectorWithOptions({
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md bg-muted text-muted-foreground"
|
||||
className="hidden sm:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md bg-muted text-muted-foreground"
|
||||
data-testid="project-picker-shortcut"
|
||||
>
|
||||
{formatShortcut(shortcuts.projectPicker, true)}
|
||||
@@ -219,7 +219,7 @@ export function ProjectSelectorWithOptions({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'hidden lg:flex items-center justify-center w-[42px] h-[42px] rounded-lg',
|
||||
'flex items-center justify-center w-[42px] h-[42px] rounded-lg',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'bg-transparent hover:bg-accent/60',
|
||||
'border border-border/50 hover:border-border',
|
||||
|
||||
@@ -72,7 +72,7 @@ export function SidebarFooter({
|
||||
<span
|
||||
className={cn(
|
||||
'ml-3 font-medium text-sm flex-1 text-left',
|
||||
sidebarOpen ? 'hidden lg:block' : 'hidden'
|
||||
sidebarOpen ? 'block' : 'hidden'
|
||||
)}
|
||||
>
|
||||
Wiki
|
||||
@@ -148,7 +148,7 @@ export function SidebarFooter({
|
||||
<span
|
||||
className={cn(
|
||||
'ml-3 font-medium text-sm flex-1 text-left',
|
||||
sidebarOpen ? 'hidden lg:block' : 'hidden'
|
||||
sidebarOpen ? 'block' : 'hidden'
|
||||
)}
|
||||
>
|
||||
Running Agents
|
||||
@@ -157,7 +157,7 @@ export function SidebarFooter({
|
||||
{sidebarOpen && runningAgentsCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'hidden lg:flex items-center justify-center',
|
||||
'flex items-center justify-center',
|
||||
'min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full',
|
||||
'bg-brand-500 text-white shadow-sm',
|
||||
'animate-in fade-in zoom-in duration-200',
|
||||
@@ -227,7 +227,7 @@ export function SidebarFooter({
|
||||
<span
|
||||
className={cn(
|
||||
'ml-3 font-medium text-sm flex-1 text-left',
|
||||
sidebarOpen ? 'hidden lg:block' : 'hidden'
|
||||
sidebarOpen ? 'block' : 'hidden'
|
||||
)}
|
||||
>
|
||||
Settings
|
||||
@@ -235,7 +235,7 @@ export function SidebarFooter({
|
||||
{sidebarOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
'hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
|
||||
'hidden sm:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
|
||||
isActiveRoute('settings')
|
||||
? 'bg-brand-500/20 text-brand-400'
|
||||
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
||||
|
||||
@@ -20,9 +20,11 @@ export function SidebarHeader({ sidebarOpen, navigate }: SidebarHeaderProps) {
|
||||
// Background gradient for depth
|
||||
'bg-gradient-to-b from-transparent to-background/5',
|
||||
'flex items-center',
|
||||
sidebarOpen ? 'px-3 lg:px-5 justify-start' : 'px-3 justify-center',
|
||||
// Add left padding on macOS to avoid overlapping with traffic light buttons
|
||||
isMac && 'pt-4 pl-20'
|
||||
sidebarOpen ? 'px-4 lg:px-5 justify-start' : 'px-3 justify-center',
|
||||
// Add padding on macOS to avoid overlapping with traffic light buttons
|
||||
isMac && sidebarOpen && 'pt-4',
|
||||
// Smaller top padding on macOS when collapsed
|
||||
isMac && !sidebarOpen && 'pt-4'
|
||||
)}
|
||||
>
|
||||
<AutomakerLogo sidebarOpen={sidebarOpen} navigate={navigate} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NavigateOptions } from '@tanstack/react-router';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatShortcut } from '@/store/app-store';
|
||||
import type { NavSection } from '../types';
|
||||
@@ -34,7 +35,7 @@ export function SidebarNavigation({
|
||||
<div key={sectionIdx} className={sectionIdx > 0 && sidebarOpen ? 'mt-6' : ''}>
|
||||
{/* Section Label */}
|
||||
{section.label && sidebarOpen && (
|
||||
<div className="hidden lg:block px-3 mb-2">
|
||||
<div className="px-3 mb-2">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-widest">
|
||||
{section.label}
|
||||
</span>
|
||||
@@ -52,7 +53,8 @@ export function SidebarNavigation({
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
navigate({ to: `/${item.id}` as const });
|
||||
// Cast to the router's path type; item.id is constrained to known routes
|
||||
navigate({ to: `/${item.id}` as unknown as '/' });
|
||||
}}
|
||||
className={cn(
|
||||
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
|
||||
@@ -79,14 +81,23 @@ export function SidebarNavigation({
|
||||
data-testid={`nav-${item.id}`}
|
||||
>
|
||||
<div className="relative">
|
||||
<Icon
|
||||
className={cn(
|
||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||
isActive
|
||||
? 'text-brand-500 drop-shadow-sm'
|
||||
: 'group-hover:text-brand-400 group-hover:scale-110'
|
||||
)}
|
||||
/>
|
||||
{item.isLoading ? (
|
||||
<Loader2
|
||||
className={cn(
|
||||
'w-[18px] h-[18px] shrink-0 animate-spin',
|
||||
isActive ? 'text-brand-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
className={cn(
|
||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||
isActive
|
||||
? 'text-brand-500 drop-shadow-sm'
|
||||
: 'group-hover:text-brand-400 group-hover:scale-110'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{/* Count badge for collapsed state */}
|
||||
{!sidebarOpen && item.count !== undefined && item.count > 0 && (
|
||||
<span
|
||||
@@ -104,7 +115,7 @@ export function SidebarNavigation({
|
||||
<span
|
||||
className={cn(
|
||||
'ml-3 font-medium text-sm flex-1 text-left',
|
||||
sidebarOpen ? 'hidden lg:block' : 'hidden'
|
||||
sidebarOpen ? 'block' : 'hidden'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
@@ -113,7 +124,7 @@ export function SidebarNavigation({
|
||||
{item.count !== undefined && item.count > 0 && sidebarOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
'hidden lg:flex items-center justify-center',
|
||||
'flex items-center justify-center',
|
||||
'min-w-5 h-5 px-1.5 text-[10px] font-bold rounded-full',
|
||||
'bg-primary text-primary-foreground shadow-sm',
|
||||
'animate-in fade-in zoom-in duration-200'
|
||||
@@ -126,7 +137,7 @@ export function SidebarNavigation({
|
||||
{item.shortcut && sidebarOpen && !item.count && (
|
||||
<span
|
||||
className={cn(
|
||||
'hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
|
||||
'hidden sm:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-brand-500/20 text-brand-400'
|
||||
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
||||
|
||||
@@ -20,5 +20,4 @@ export const SIDEBAR_FEATURE_FLAGS = {
|
||||
hideRunningAgents: import.meta.env.VITE_HIDE_RUNNING_AGENTS === 'true',
|
||||
hideContext: import.meta.env.VITE_HIDE_CONTEXT === 'true',
|
||||
hideSpecEditor: import.meta.env.VITE_HIDE_SPEC_EDITOR === 'true',
|
||||
hideAiProfiles: import.meta.env.VITE_HIDE_AI_PROFILES === 'true',
|
||||
} as const;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRef } from 'react';
|
||||
import { Rocket, CheckCircle2, Zap, FileText, Sparkles, ArrowRight } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -24,13 +25,25 @@ export function OnboardingDialog({
|
||||
onSkip,
|
||||
onGenerateSpec,
|
||||
}: OnboardingDialogProps) {
|
||||
// Track if we're closing because user clicked "Generate App Spec"
|
||||
// to avoid incorrectly calling onSkip
|
||||
const isGeneratingRef = useRef(false);
|
||||
|
||||
const handleGenerateSpec = () => {
|
||||
isGeneratingRef.current = true;
|
||||
onGenerateSpec();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
if (!isOpen && !isGeneratingRef.current) {
|
||||
// Only call onSkip when user dismisses dialog (escape, click outside, or skip button)
|
||||
// NOT when they click "Generate App Spec"
|
||||
onSkip();
|
||||
}
|
||||
isGeneratingRef.current = false;
|
||||
onOpenChange(isOpen);
|
||||
}}
|
||||
>
|
||||
@@ -108,7 +121,7 @@ export function OnboardingDialog({
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onGenerateSpec}
|
||||
onClick={handleGenerateSpec}
|
||||
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
|
||||
@@ -5,11 +5,12 @@ import {
|
||||
LayoutGrid,
|
||||
Bot,
|
||||
BookOpen,
|
||||
UserCircle,
|
||||
Terminal,
|
||||
CircleDot,
|
||||
GitPullRequest,
|
||||
Zap,
|
||||
Lightbulb,
|
||||
Brain,
|
||||
Network,
|
||||
} from 'lucide-react';
|
||||
import type { NavSection, NavItem } from '../types';
|
||||
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||
@@ -25,16 +26,19 @@ interface UseNavigationProps {
|
||||
cycleNextProject: string;
|
||||
spec: string;
|
||||
context: string;
|
||||
profiles: string;
|
||||
memory: string;
|
||||
board: string;
|
||||
graph: string;
|
||||
agent: string;
|
||||
terminal: string;
|
||||
settings: string;
|
||||
ideation: string;
|
||||
githubIssues: string;
|
||||
githubPrs: string;
|
||||
};
|
||||
hideSpecEditor: boolean;
|
||||
hideContext: boolean;
|
||||
hideTerminal: boolean;
|
||||
hideAiProfiles: boolean;
|
||||
currentProject: Project | null;
|
||||
projects: Project[];
|
||||
projectHistory: string[];
|
||||
@@ -46,6 +50,8 @@ interface UseNavigationProps {
|
||||
cycleNextProject: () => void;
|
||||
/** Count of unviewed validations to show on GitHub Issues nav item */
|
||||
unviewedValidationsCount?: number;
|
||||
/** Whether spec generation is currently running for the current project */
|
||||
isSpecGenerating?: boolean;
|
||||
}
|
||||
|
||||
export function useNavigation({
|
||||
@@ -53,7 +59,6 @@ export function useNavigation({
|
||||
hideSpecEditor,
|
||||
hideContext,
|
||||
hideTerminal,
|
||||
hideAiProfiles,
|
||||
currentProject,
|
||||
projects,
|
||||
projectHistory,
|
||||
@@ -64,6 +69,7 @@ export function useNavigation({
|
||||
cyclePrevProject,
|
||||
cycleNextProject,
|
||||
unviewedValidationsCount,
|
||||
isSpecGenerating,
|
||||
}: UseNavigationProps) {
|
||||
// Track if current project has a GitHub remote
|
||||
const [hasGitHubRemote, setHasGitHubRemote] = useState(false);
|
||||
@@ -92,11 +98,18 @@ export function useNavigation({
|
||||
// Build navigation sections
|
||||
const navSections: NavSection[] = useMemo(() => {
|
||||
const allToolsItems: NavItem[] = [
|
||||
{
|
||||
id: 'ideation',
|
||||
label: 'Ideation',
|
||||
icon: Lightbulb,
|
||||
shortcut: shortcuts.ideation,
|
||||
},
|
||||
{
|
||||
id: 'spec',
|
||||
label: 'Spec Editor',
|
||||
icon: FileText,
|
||||
shortcut: shortcuts.spec,
|
||||
isLoading: isSpecGenerating,
|
||||
},
|
||||
{
|
||||
id: 'context',
|
||||
@@ -105,10 +118,10 @@ export function useNavigation({
|
||||
shortcut: shortcuts.context,
|
||||
},
|
||||
{
|
||||
id: 'profiles',
|
||||
label: 'AI Profiles',
|
||||
icon: UserCircle,
|
||||
shortcut: shortcuts.profiles,
|
||||
id: 'memory',
|
||||
label: 'Memory',
|
||||
icon: Brain,
|
||||
shortcut: shortcuts.memory,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -120,9 +133,6 @@ export function useNavigation({
|
||||
if (item.id === 'context' && hideContext) {
|
||||
return false;
|
||||
}
|
||||
if (item.id === 'profiles' && hideAiProfiles) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -134,6 +144,12 @@ export function useNavigation({
|
||||
icon: LayoutGrid,
|
||||
shortcut: shortcuts.board,
|
||||
},
|
||||
{
|
||||
id: 'graph',
|
||||
label: 'Graph View',
|
||||
icon: Network,
|
||||
shortcut: shortcuts.graph,
|
||||
},
|
||||
{
|
||||
id: 'agent',
|
||||
label: 'Agent Runner',
|
||||
@@ -172,12 +188,14 @@ export function useNavigation({
|
||||
id: 'github-issues',
|
||||
label: 'Issues',
|
||||
icon: CircleDot,
|
||||
shortcut: shortcuts.githubIssues,
|
||||
count: unviewedValidationsCount,
|
||||
},
|
||||
{
|
||||
id: 'github-prs',
|
||||
label: 'Pull Requests',
|
||||
icon: GitPullRequest,
|
||||
shortcut: shortcuts.githubPrs,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -189,9 +207,9 @@ export function useNavigation({
|
||||
hideSpecEditor,
|
||||
hideContext,
|
||||
hideTerminal,
|
||||
hideAiProfiles,
|
||||
hasGitHubRemote,
|
||||
unviewedValidationsCount,
|
||||
isSpecGenerating,
|
||||
]);
|
||||
|
||||
// Build keyboard shortcuts for navigation
|
||||
@@ -242,7 +260,8 @@ export function useNavigation({
|
||||
if (item.shortcut) {
|
||||
shortcutsList.push({
|
||||
key: item.shortcut,
|
||||
action: () => navigate({ to: `/${item.id}` as const }),
|
||||
// Cast to router path type; ids are constrained to known routes
|
||||
action: () => navigate({ to: `/${item.id}` as unknown as '/' }),
|
||||
description: `Navigate to ${item.label}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
const logger = createLogger('ProjectCreation');
|
||||
import { initializeProject } from '@/lib/project-init';
|
||||
import { toast } from 'sonner';
|
||||
import type { StarterTemplate } from '@/lib/templates';
|
||||
@@ -82,7 +85,7 @@ export function useProjectCreation({
|
||||
|
||||
toast.success('Project created successfully');
|
||||
} catch (error) {
|
||||
console.error('[ProjectCreation] Failed to finalize project:', error);
|
||||
logger.error('Failed to finalize project:', error);
|
||||
toast.error('Failed to initialize project', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
@@ -108,7 +111,7 @@ export function useProjectCreation({
|
||||
// Finalize project setup
|
||||
await finalizeProjectCreation(projectPath, projectName);
|
||||
} catch (error) {
|
||||
console.error('[ProjectCreation] Failed to create blank project:', error);
|
||||
logger.error('Failed to create blank project:', error);
|
||||
toast.error('Failed to create project', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
@@ -129,6 +132,9 @@ export function useProjectCreation({
|
||||
const api = getElectronAPI();
|
||||
|
||||
// Clone template repository
|
||||
if (!api.templates) {
|
||||
throw new Error('Templates API is not available');
|
||||
}
|
||||
const cloneResult = await api.templates.clone(template.repoUrl, projectName, parentDir);
|
||||
if (!cloneResult.success) {
|
||||
throw new Error(cloneResult.error || 'Failed to clone template');
|
||||
@@ -180,7 +186,7 @@ export function useProjectCreation({
|
||||
description: `Created ${projectName} from ${template.name}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ProjectCreation] Failed to create from template:', error);
|
||||
logger.error('Failed to create from template:', error);
|
||||
toast.error('Failed to create project from template', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
@@ -201,6 +207,9 @@ export function useProjectCreation({
|
||||
const api = getElectronAPI();
|
||||
|
||||
// Clone custom repository
|
||||
if (!api.templates) {
|
||||
throw new Error('Templates API is not available');
|
||||
}
|
||||
const cloneResult = await api.templates.clone(repoUrl, projectName, parentDir);
|
||||
if (!cloneResult.success) {
|
||||
throw new Error(cloneResult.error || 'Failed to clone repository');
|
||||
@@ -252,7 +261,7 @@ export function useProjectCreation({
|
||||
description: `Created ${projectName} from ${repoUrl}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ProjectCreation] Failed to create from custom URL:', error);
|
||||
logger.error('Failed to create from custom URL:', error);
|
||||
toast.error('Failed to create project from URL', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
const logger = createLogger('RunningAgents');
|
||||
|
||||
export function useRunningAgents() {
|
||||
const [runningAgentsCount, setRunningAgentsCount] = useState(0);
|
||||
|
||||
@@ -15,7 +18,7 @@ export function useRunningAgents() {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Sidebar] Error fetching running agents count:', error);
|
||||
logger.error('Error fetching running agents count:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
const logger = createLogger('SetupDialog');
|
||||
import { toast } from 'sonner';
|
||||
import type { FeatureCount } from '@/components/views/spec-view/types';
|
||||
|
||||
@@ -53,7 +56,7 @@ export function useSetupDialog({
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
console.error('[SetupDialog] Failed to start spec creation:', result.error);
|
||||
logger.error('Failed to start spec creation:', result.error);
|
||||
setSpecCreatingForProject(null);
|
||||
toast.error('Failed to create specification', {
|
||||
description: result.error,
|
||||
@@ -66,7 +69,7 @@ export function useSetupDialog({
|
||||
}
|
||||
// If successful, we'll wait for the events to update the state
|
||||
} catch (error) {
|
||||
console.error('[SetupDialog] Failed to create spec:', error);
|
||||
logger.error('Failed to create spec:', error);
|
||||
setSpecCreatingForProject(null);
|
||||
toast.error('Failed to create specification', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import type { SpecRegenerationEvent } from '@/types/electron';
|
||||
|
||||
@@ -30,20 +33,18 @@ export function useSpecRegeneration({
|
||||
if (!api.specRegeneration) return;
|
||||
|
||||
const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => {
|
||||
console.log(
|
||||
'[Sidebar] Spec regeneration event:',
|
||||
event.type,
|
||||
'for project:',
|
||||
event.projectPath
|
||||
);
|
||||
logger.debug('Spec regeneration event:', event.type, 'for project:', event.projectPath);
|
||||
|
||||
// Only handle events for the project we're currently setting up
|
||||
if (event.projectPath !== creatingSpecProjectPath && event.projectPath !== setupProjectPath) {
|
||||
console.log('[Sidebar] Ignoring event - not for project being set up');
|
||||
logger.debug('Ignoring event - not for project being set up');
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'spec_regeneration_complete') {
|
||||
// Only show toast if we're in active creation flow (not regular regeneration)
|
||||
const isCreationFlow = creatingSpecProjectPath !== null;
|
||||
|
||||
setSpecCreatingForProject(null);
|
||||
setShowSetupDialog(false);
|
||||
setProjectOverview('');
|
||||
@@ -51,9 +52,12 @@ export function useSpecRegeneration({
|
||||
// Clear onboarding state if we came from onboarding
|
||||
setNewProjectName('');
|
||||
setNewProjectPath('');
|
||||
toast.success('App specification created', {
|
||||
description: 'Your project is now set up and ready to go!',
|
||||
});
|
||||
|
||||
if (isCreationFlow) {
|
||||
toast.success('App specification created', {
|
||||
description: 'Your project is now set up and ready to go!',
|
||||
});
|
||||
}
|
||||
} else if (event.type === 'spec_regeneration_error') {
|
||||
setSpecCreatingForProject(null);
|
||||
toast.error('Failed to create specification', {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const logger = createLogger('TrashOperations');
|
||||
import { getElectronAPI, type TrashedProject } from '@/lib/electron';
|
||||
|
||||
interface UseTrashOperationsProps {
|
||||
@@ -24,7 +27,7 @@ export function useTrashOperations({
|
||||
description: 'Added back to your project list.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Sidebar] Failed to restore project:', error);
|
||||
logger.error('Failed to restore project:', error);
|
||||
toast.error('Failed to restore project', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
@@ -52,7 +55,7 @@ export function useTrashOperations({
|
||||
description: trashedProject.path,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Sidebar] Failed to delete project from disk:', error);
|
||||
logger.error('Failed to delete project from disk:', error);
|
||||
toast.error('Failed to delete project folder', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
@@ -69,7 +72,7 @@ export function useTrashOperations({
|
||||
emptyTrash();
|
||||
toast.success('Recycle bin cleared');
|
||||
} catch (error) {
|
||||
console.error('[Sidebar] Failed to empty trash:', error);
|
||||
logger.error('Failed to empty trash:', error);
|
||||
toast.error('Failed to clear recycle bin', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
const logger = createLogger('UnviewedValidations');
|
||||
import type { Project, StoredValidation } from '@/lib/electron';
|
||||
|
||||
/**
|
||||
@@ -38,7 +41,7 @@ export function useUnviewedValidations(currentProject: Project | null) {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[useUnviewedValidations] Failed to load count:', err);
|
||||
logger.error('Failed to load count:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface NavItem {
|
||||
shortcut?: string;
|
||||
/** Optional count badge to display next to the nav item */
|
||||
count?: number;
|
||||
/** Whether this nav item is in a loading state (shows spinner) */
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface SortableProjectItemProps {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
const logger = createLogger('SessionManager');
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -126,7 +129,7 @@ export function SessionManager({
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore errors for individual session checks
|
||||
console.warn(`[SessionManager] Failed to check running state for ${session.id}:`, err);
|
||||
logger.warn(`Failed to check running state for ${session.id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +230,7 @@ export function SessionManager({
|
||||
const handleArchiveSession = async (sessionId: string) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.sessions) {
|
||||
console.error('[SessionManager] Sessions API not available');
|
||||
logger.error('[SessionManager] Sessions API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -240,10 +243,10 @@ export function SessionManager({
|
||||
}
|
||||
await loadSessions();
|
||||
} else {
|
||||
console.error('[SessionManager] Archive failed:', result.error);
|
||||
logger.error('[SessionManager] Archive failed:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SessionManager] Archive error:', error);
|
||||
logger.error('[SessionManager] Archive error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -251,7 +254,7 @@ export function SessionManager({
|
||||
const handleUnarchiveSession = async (sessionId: string) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.sessions) {
|
||||
console.error('[SessionManager] Sessions API not available');
|
||||
logger.error('[SessionManager] Sessions API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -260,10 +263,10 @@ export function SessionManager({
|
||||
if (result.success) {
|
||||
await loadSessions();
|
||||
} else {
|
||||
console.error('[SessionManager] Unarchive failed:', result.error);
|
||||
logger.error('[SessionManager] Unarchive failed:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SessionManager] Unarchive error:', error);
|
||||
logger.error('[SessionManager] Unarchive error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
7
apps/ui/src/components/shared/index.ts
Normal file
7
apps/ui/src/components/shared/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Model Override Components
|
||||
export { ModelOverrideTrigger, type ModelOverrideTriggerProps } from './model-override-trigger';
|
||||
export {
|
||||
useModelOverride,
|
||||
type UseModelOverrideOptions,
|
||||
type UseModelOverrideResult,
|
||||
} from './use-model-override';
|
||||
126
apps/ui/src/components/shared/model-override-trigger.tsx
Normal file
126
apps/ui/src/components/shared/model-override-trigger.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import * as React from 'react';
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { ModelAlias, CursorModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types';
|
||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||
|
||||
/**
|
||||
* Normalize PhaseModelEntry or string to PhaseModelEntry
|
||||
*/
|
||||
function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
|
||||
if (typeof entry === 'string') {
|
||||
return { model: entry as ModelAlias | CursorModelId };
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
export interface ModelOverrideTriggerProps {
|
||||
/** Current effective model entry (from global settings or explicit override) */
|
||||
currentModelEntry: PhaseModelEntry;
|
||||
/** Callback when user selects override */
|
||||
onModelChange: (entry: PhaseModelEntry | null) => void;
|
||||
/** Optional: which phase this is for (shows global default) */
|
||||
phase?: PhaseModelKey;
|
||||
/** Size variants for different contexts */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** Show as icon-only or with label */
|
||||
variant?: 'icon' | 'button' | 'inline';
|
||||
/** Whether the model is currently overridden from global */
|
||||
isOverridden?: boolean;
|
||||
/** Optional class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ModelOverrideTrigger({
|
||||
currentModelEntry,
|
||||
onModelChange,
|
||||
phase,
|
||||
size = 'sm',
|
||||
variant = 'icon',
|
||||
isOverridden = false,
|
||||
className,
|
||||
}: ModelOverrideTriggerProps) {
|
||||
const { phaseModels } = useAppStore();
|
||||
|
||||
const handleChange = (entry: PhaseModelEntry) => {
|
||||
// If the new entry matches the global default, clear the override
|
||||
// Otherwise, set it as override
|
||||
if (phase) {
|
||||
const globalDefault = phaseModels[phase];
|
||||
const normalizedGlobal = normalizeEntry(globalDefault);
|
||||
|
||||
// Compare models (and thinking levels if both have them)
|
||||
const modelsMatch = entry.model === normalizedGlobal.model;
|
||||
const thinkingMatch =
|
||||
(entry.thinkingLevel || 'none') === (normalizedGlobal.thinkingLevel || 'none');
|
||||
|
||||
if (modelsMatch && thinkingMatch) {
|
||||
onModelChange(null); // Clear override
|
||||
} else {
|
||||
onModelChange(entry); // Set override
|
||||
}
|
||||
} else {
|
||||
onModelChange(entry);
|
||||
}
|
||||
};
|
||||
|
||||
// Size classes for icon variant
|
||||
const sizeClasses = {
|
||||
sm: 'h-6 w-6',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-10 w-10',
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 'w-3.5 h-3.5',
|
||||
md: 'w-4 h-4',
|
||||
lg: 'w-5 h-5',
|
||||
};
|
||||
|
||||
// For icon variant, wrap PhaseModelSelector and hide text/chevron with CSS
|
||||
if (variant === 'icon') {
|
||||
return (
|
||||
<div className={cn('relative inline-block', className)}>
|
||||
<div className="relative [&_button>span]:hidden [&_button>svg:last-child]:hidden [&_button]:p-0 [&_button]:min-w-0 [&_button]:w-auto [&_button]:h-auto [&_button]:border-0 [&_button]:bg-transparent">
|
||||
<PhaseModelSelector
|
||||
value={currentModelEntry}
|
||||
onChange={handleChange}
|
||||
compact
|
||||
triggerClassName={cn(
|
||||
'relative rounded-md',
|
||||
'transition-colors duration-150',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
disabled={false}
|
||||
align="end"
|
||||
/>
|
||||
</div>
|
||||
{isOverridden && (
|
||||
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-brand-500 rounded-full z-10 pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For button and inline variants, use PhaseModelSelector in compact mode
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<PhaseModelSelector
|
||||
value={currentModelEntry}
|
||||
onChange={handleChange}
|
||||
compact
|
||||
triggerClassName={variant === 'button' ? className : undefined}
|
||||
disabled={false}
|
||||
/>
|
||||
{isOverridden && (
|
||||
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-brand-500 rounded-full z-10" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
apps/ui/src/components/shared/use-model-override.ts
Normal file
114
apps/ui/src/components/shared/use-model-override.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { ModelAlias, CursorModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
|
||||
export interface UseModelOverrideOptions {
|
||||
/** Which phase this override is for */
|
||||
phase: PhaseModelKey;
|
||||
/** Initial override value (optional) */
|
||||
initialOverride?: PhaseModelEntry | null;
|
||||
}
|
||||
|
||||
export interface UseModelOverrideResult {
|
||||
/** The effective model entry (override or global default) */
|
||||
effectiveModelEntry: PhaseModelEntry;
|
||||
/** The effective model string (for backward compatibility with APIs that only accept strings) */
|
||||
effectiveModel: ModelAlias | CursorModelId;
|
||||
/** Whether the model is currently overridden */
|
||||
isOverridden: boolean;
|
||||
/** Set a model override */
|
||||
setOverride: (entry: PhaseModelEntry | null) => void;
|
||||
/** Clear the override and use global default */
|
||||
clearOverride: () => void;
|
||||
/** The global default for this phase */
|
||||
globalDefault: PhaseModelEntry;
|
||||
/** The current override value (null if not overridden) */
|
||||
override: PhaseModelEntry | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize PhaseModelEntry or string to PhaseModelEntry
|
||||
*/
|
||||
function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
|
||||
if (typeof entry === 'string') {
|
||||
return { model: entry as ModelAlias | CursorModelId };
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract model string from PhaseModelEntry or string
|
||||
*/
|
||||
function extractModel(entry: PhaseModelEntry | string): ModelAlias | CursorModelId {
|
||||
if (typeof entry === 'string') {
|
||||
return entry as ModelAlias | CursorModelId;
|
||||
}
|
||||
return entry.model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing model overrides per phase
|
||||
*
|
||||
* Provides a simple way to allow users to override the global phase model
|
||||
* for a specific run or context. Now supports PhaseModelEntry with thinking levels.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function EnhanceDialog() {
|
||||
* const { effectiveModelEntry, isOverridden, setOverride, clearOverride } = useModelOverride({
|
||||
* phase: 'enhancementModel',
|
||||
* });
|
||||
*
|
||||
* return (
|
||||
* <ModelOverrideTrigger
|
||||
* currentModelEntry={effectiveModelEntry}
|
||||
* onModelChange={setOverride}
|
||||
* phase="enhancementModel"
|
||||
* isOverridden={isOverridden}
|
||||
* />
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useModelOverride({
|
||||
phase,
|
||||
initialOverride = null,
|
||||
}: UseModelOverrideOptions): UseModelOverrideResult {
|
||||
const { phaseModels } = useAppStore();
|
||||
const [override, setOverrideState] = useState<PhaseModelEntry | null>(
|
||||
initialOverride ? normalizeEntry(initialOverride) : null
|
||||
);
|
||||
|
||||
// Normalize global default to PhaseModelEntry, with fallback to DEFAULT_PHASE_MODELS
|
||||
// This handles cases where settings haven't been migrated to include new phase models
|
||||
const globalDefault = normalizeEntry(phaseModels[phase] ?? DEFAULT_PHASE_MODELS[phase]);
|
||||
|
||||
const effectiveModelEntry = useMemo(() => {
|
||||
return override ?? globalDefault;
|
||||
}, [override, globalDefault]);
|
||||
|
||||
const effectiveModel = useMemo(() => {
|
||||
return effectiveModelEntry.model;
|
||||
}, [effectiveModelEntry]);
|
||||
|
||||
const isOverridden = override !== null;
|
||||
|
||||
const setOverride = useCallback((entry: PhaseModelEntry | null) => {
|
||||
setOverrideState(entry ? normalizeEntry(entry) : null);
|
||||
}, []);
|
||||
|
||||
const clearOverride = useCallback(() => {
|
||||
setOverrideState(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
effectiveModelEntry,
|
||||
effectiveModel,
|
||||
isOverridden,
|
||||
setOverride,
|
||||
clearOverride,
|
||||
globalDefault,
|
||||
override,
|
||||
};
|
||||
}
|
||||
276
apps/ui/src/components/ui/ansi-output.tsx
Normal file
276
apps/ui/src/components/ui/ansi-output.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useMemo } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AnsiOutputProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ANSI color codes to CSS color mappings
|
||||
const ANSI_COLORS: Record<number, string> = {
|
||||
// Standard colors
|
||||
30: '#6b7280', // Black (use gray for visibility on dark bg)
|
||||
31: '#ef4444', // Red
|
||||
32: '#22c55e', // Green
|
||||
33: '#eab308', // Yellow
|
||||
34: '#3b82f6', // Blue
|
||||
35: '#a855f7', // Magenta
|
||||
36: '#06b6d4', // Cyan
|
||||
37: '#d1d5db', // White
|
||||
// Bright colors
|
||||
90: '#9ca3af', // Bright Black (Gray)
|
||||
91: '#f87171', // Bright Red
|
||||
92: '#4ade80', // Bright Green
|
||||
93: '#facc15', // Bright Yellow
|
||||
94: '#60a5fa', // Bright Blue
|
||||
95: '#c084fc', // Bright Magenta
|
||||
96: '#22d3ee', // Bright Cyan
|
||||
97: '#ffffff', // Bright White
|
||||
};
|
||||
|
||||
const ANSI_BG_COLORS: Record<number, string> = {
|
||||
40: 'transparent',
|
||||
41: '#ef4444',
|
||||
42: '#22c55e',
|
||||
43: '#eab308',
|
||||
44: '#3b82f6',
|
||||
45: '#a855f7',
|
||||
46: '#06b6d4',
|
||||
47: '#f3f4f6',
|
||||
// Bright backgrounds
|
||||
100: '#374151',
|
||||
101: '#f87171',
|
||||
102: '#4ade80',
|
||||
103: '#facc15',
|
||||
104: '#60a5fa',
|
||||
105: '#c084fc',
|
||||
106: '#22d3ee',
|
||||
107: '#ffffff',
|
||||
};
|
||||
|
||||
interface TextSegment {
|
||||
text: string;
|
||||
style: {
|
||||
color?: string;
|
||||
backgroundColor?: string;
|
||||
fontWeight?: string;
|
||||
fontStyle?: string;
|
||||
textDecoration?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip hyperlink escape sequences (OSC 8)
|
||||
* Format: ESC]8;;url ESC\ text ESC]8;; ESC\
|
||||
*/
|
||||
function stripHyperlinks(text: string): string {
|
||||
// Remove OSC 8 hyperlink sequences
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return text.replace(/\x1b\]8;;[^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip other OSC sequences (title, etc.)
|
||||
*/
|
||||
function stripOtherOSC(text: string): string {
|
||||
// Remove OSC sequences (ESC ] ... BEL or ESC ] ... ST)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return text.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
|
||||
}
|
||||
|
||||
function parseAnsi(text: string): TextSegment[] {
|
||||
// Pre-process: strip hyperlinks and other OSC sequences
|
||||
let processedText = stripHyperlinks(text);
|
||||
processedText = stripOtherOSC(processedText);
|
||||
|
||||
const segments: TextSegment[] = [];
|
||||
|
||||
// Match ANSI escape sequences: ESC[...m (SGR - Select Graphic Rendition)
|
||||
// Also handle ESC[K (erase line) and other CSI sequences by stripping them
|
||||
// The ESC character can be \x1b, \033, \u001b
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ansiRegex = /\x1b\[([0-9;]*)([a-zA-Z])/g;
|
||||
|
||||
let currentStyle: TextSegment['style'] = {};
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = ansiRegex.exec(processedText)) !== null) {
|
||||
// Add text before this escape sequence
|
||||
if (match.index > lastIndex) {
|
||||
const content = processedText.slice(lastIndex, match.index);
|
||||
if (content) {
|
||||
segments.push({ text: content, style: { ...currentStyle } });
|
||||
}
|
||||
}
|
||||
|
||||
const params = match[1];
|
||||
const command = match[2];
|
||||
|
||||
// Only process 'm' command (SGR - graphics/color)
|
||||
// Ignore other commands like K (erase), H (cursor), J (clear), etc.
|
||||
if (command === 'm') {
|
||||
// Parse the escape sequence codes
|
||||
const codes = params ? params.split(';').map((c) => parseInt(c, 10) || 0) : [0];
|
||||
|
||||
for (let i = 0; i < codes.length; i++) {
|
||||
const code = codes[i];
|
||||
|
||||
if (code === 0) {
|
||||
// Reset all attributes
|
||||
currentStyle = {};
|
||||
} else if (code === 1) {
|
||||
// Bold
|
||||
currentStyle.fontWeight = 'bold';
|
||||
} else if (code === 2) {
|
||||
// Dim/faint
|
||||
currentStyle.color = 'var(--muted-foreground)';
|
||||
} else if (code === 3) {
|
||||
// Italic
|
||||
currentStyle.fontStyle = 'italic';
|
||||
} else if (code === 4) {
|
||||
// Underline
|
||||
currentStyle.textDecoration = 'underline';
|
||||
} else if (code === 22) {
|
||||
// Normal intensity (not bold, not dim)
|
||||
currentStyle.fontWeight = undefined;
|
||||
} else if (code === 23) {
|
||||
// Not italic
|
||||
currentStyle.fontStyle = undefined;
|
||||
} else if (code === 24) {
|
||||
// Not underlined
|
||||
currentStyle.textDecoration = undefined;
|
||||
} else if (code === 38) {
|
||||
// Extended foreground color
|
||||
if (codes[i + 1] === 5 && codes[i + 2] !== undefined) {
|
||||
// 256 color mode: 38;5;n
|
||||
const colorIndex = codes[i + 2];
|
||||
currentStyle.color = get256Color(colorIndex);
|
||||
i += 2;
|
||||
} else if (codes[i + 1] === 2 && codes[i + 4] !== undefined) {
|
||||
// RGB mode: 38;2;r;g;b
|
||||
const r = codes[i + 2];
|
||||
const g = codes[i + 3];
|
||||
const b = codes[i + 4];
|
||||
currentStyle.color = `rgb(${r}, ${g}, ${b})`;
|
||||
i += 4;
|
||||
}
|
||||
} else if (code === 48) {
|
||||
// Extended background color
|
||||
if (codes[i + 1] === 5 && codes[i + 2] !== undefined) {
|
||||
// 256 color mode: 48;5;n
|
||||
const colorIndex = codes[i + 2];
|
||||
currentStyle.backgroundColor = get256Color(colorIndex);
|
||||
i += 2;
|
||||
} else if (codes[i + 1] === 2 && codes[i + 4] !== undefined) {
|
||||
// RGB mode: 48;2;r;g;b
|
||||
const r = codes[i + 2];
|
||||
const g = codes[i + 3];
|
||||
const b = codes[i + 4];
|
||||
currentStyle.backgroundColor = `rgb(${r}, ${g}, ${b})`;
|
||||
i += 4;
|
||||
}
|
||||
} else if (ANSI_COLORS[code]) {
|
||||
// Standard foreground color (30-37, 90-97)
|
||||
currentStyle.color = ANSI_COLORS[code];
|
||||
} else if (ANSI_BG_COLORS[code]) {
|
||||
// Standard background color (40-47, 100-107)
|
||||
currentStyle.backgroundColor = ANSI_BG_COLORS[code];
|
||||
} else if (code === 39) {
|
||||
// Default foreground
|
||||
currentStyle.color = undefined;
|
||||
} else if (code === 49) {
|
||||
// Default background
|
||||
currentStyle.backgroundColor = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Add remaining text after last escape sequence
|
||||
if (lastIndex < processedText.length) {
|
||||
const content = processedText.slice(lastIndex);
|
||||
if (content) {
|
||||
segments.push({ text: content, style: { ...currentStyle } });
|
||||
}
|
||||
}
|
||||
|
||||
// If no segments were created (no ANSI codes), return the whole text
|
||||
if (segments.length === 0 && processedText) {
|
||||
segments.push({ text: processedText, style: {} });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert 256-color palette index to CSS color
|
||||
*/
|
||||
function get256Color(index: number): string {
|
||||
// 0-15: Standard colors
|
||||
if (index < 16) {
|
||||
const standardColors = [
|
||||
'#000000',
|
||||
'#cd0000',
|
||||
'#00cd00',
|
||||
'#cdcd00',
|
||||
'#0000ee',
|
||||
'#cd00cd',
|
||||
'#00cdcd',
|
||||
'#e5e5e5',
|
||||
'#7f7f7f',
|
||||
'#ff0000',
|
||||
'#00ff00',
|
||||
'#ffff00',
|
||||
'#5c5cff',
|
||||
'#ff00ff',
|
||||
'#00ffff',
|
||||
'#ffffff',
|
||||
];
|
||||
return standardColors[index];
|
||||
}
|
||||
|
||||
// 16-231: 6x6x6 color cube
|
||||
if (index < 232) {
|
||||
const n = index - 16;
|
||||
const b = n % 6;
|
||||
const g = Math.floor(n / 6) % 6;
|
||||
const r = Math.floor(n / 36);
|
||||
const toHex = (v: number) => (v === 0 ? 0 : 55 + v * 40);
|
||||
return `rgb(${toHex(r)}, ${toHex(g)}, ${toHex(b)})`;
|
||||
}
|
||||
|
||||
// 232-255: Grayscale
|
||||
const gray = 8 + (index - 232) * 10;
|
||||
return `rgb(${gray}, ${gray}, ${gray})`;
|
||||
}
|
||||
|
||||
export function AnsiOutput({ text, className }: AnsiOutputProps) {
|
||||
const segments = useMemo(() => parseAnsi(text), [text]);
|
||||
|
||||
return (
|
||||
<pre
|
||||
className={cn(
|
||||
'font-mono text-xs whitespace-pre-wrap break-words text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{segments.map((segment, index) => (
|
||||
<span
|
||||
key={index}
|
||||
style={{
|
||||
color: segment.style.color,
|
||||
backgroundColor: segment.style.backgroundColor,
|
||||
fontWeight: segment.style.fontWeight,
|
||||
fontStyle: segment.style.fontStyle,
|
||||
textDecoration: segment.style.textDecoration,
|
||||
}}
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const logger = createLogger('DescriptionImageDropZone');
|
||||
import { ImageIcon, X, Loader2, FileText } from 'lucide-react';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getServerUrlSync } from '@/lib/http-api-client';
|
||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||
import { useAppStore, type FeatureImagePath, type FeatureTextFilePath } from '@/store/app-store';
|
||||
import {
|
||||
sanitizeFilename,
|
||||
@@ -94,9 +97,8 @@ export function DescriptionImageDropZone({
|
||||
// Construct server URL for loading saved images
|
||||
const getImageServerUrl = useCallback(
|
||||
(imagePath: string): string => {
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync();
|
||||
const projectPath = currentProject?.path || '';
|
||||
return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`;
|
||||
return getAuthenticatedImageUrl(imagePath, projectPath);
|
||||
},
|
||||
[currentProject?.path]
|
||||
);
|
||||
@@ -108,7 +110,7 @@ export function DescriptionImageDropZone({
|
||||
// Check if saveImageToTemp method exists
|
||||
if (!api.saveImageToTemp) {
|
||||
// Fallback path when saveImageToTemp is not available
|
||||
console.log('[DescriptionImageDropZone] Using fallback path for image');
|
||||
logger.info('Using fallback path for image');
|
||||
return `.automaker/images/${Date.now()}_${filename}`;
|
||||
}
|
||||
|
||||
@@ -118,10 +120,10 @@ export function DescriptionImageDropZone({
|
||||
if (result.success && result.path) {
|
||||
return result.path;
|
||||
}
|
||||
console.error('[DescriptionImageDropZone] Failed to save image:', result.error);
|
||||
logger.error('Failed to save image:', result.error);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[DescriptionImageDropZone] Error saving image:', error);
|
||||
logger.error('Error saving image:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
@@ -216,7 +218,7 @@ export function DescriptionImageDropZone({
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.warn('File upload errors:', errors);
|
||||
logger.warn('File upload errors:', errors);
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const logger = createLogger('FeatureImageUpload');
|
||||
import { ImageIcon, X, Upload } from 'lucide-react';
|
||||
import {
|
||||
fileToBase64,
|
||||
@@ -77,7 +80,7 @@ export function FeatureImageUpload({
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.warn('Image upload errors:', errors);
|
||||
logger.warn('Image upload errors:', errors);
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const logger = createLogger('ImageDropZone');
|
||||
import { ImageIcon, X, Upload } from 'lucide-react';
|
||||
import type { ImageAttachment } from '@/store/app-store';
|
||||
import {
|
||||
@@ -88,7 +91,7 @@ export function ImageDropZone({
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.warn('Image upload errors:', errors);
|
||||
logger.warn('Image upload errors:', errors);
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
|
||||
@@ -84,12 +84,16 @@ const KEYBOARD_ROWS = [
|
||||
// Map shortcut names to human-readable labels
|
||||
const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
|
||||
board: 'Kanban Board',
|
||||
graph: 'Graph View',
|
||||
agent: 'Agent Runner',
|
||||
spec: 'Spec Editor',
|
||||
context: 'Context',
|
||||
memory: 'Memory',
|
||||
settings: 'Settings',
|
||||
profiles: 'AI Profiles',
|
||||
terminal: 'Terminal',
|
||||
ideation: 'Ideation',
|
||||
githubIssues: 'GitHub Issues',
|
||||
githubPrs: 'Pull Requests',
|
||||
toggleSidebar: 'Toggle Sidebar',
|
||||
addFeature: 'Add Feature',
|
||||
addContextFile: 'Add Context File',
|
||||
@@ -99,7 +103,6 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
|
||||
projectPicker: 'Project Picker',
|
||||
cyclePrevProject: 'Prev Project',
|
||||
cycleNextProject: 'Next Project',
|
||||
addProfile: 'Add Profile',
|
||||
splitTerminalRight: 'Split Right',
|
||||
splitTerminalDown: 'Split Down',
|
||||
closeTerminal: 'Close Terminal',
|
||||
@@ -109,12 +112,16 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
|
||||
// Categorize shortcuts for color coding
|
||||
const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, 'navigation' | 'ui' | 'action'> = {
|
||||
board: 'navigation',
|
||||
graph: 'navigation',
|
||||
agent: 'navigation',
|
||||
spec: 'navigation',
|
||||
context: 'navigation',
|
||||
memory: 'navigation',
|
||||
settings: 'navigation',
|
||||
profiles: 'navigation',
|
||||
terminal: 'navigation',
|
||||
ideation: 'navigation',
|
||||
githubIssues: 'navigation',
|
||||
githubPrs: 'navigation',
|
||||
toggleSidebar: 'ui',
|
||||
addFeature: 'action',
|
||||
addContextFile: 'action',
|
||||
@@ -124,7 +131,6 @@ const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, 'navigation' | 'ui' |
|
||||
projectPicker: 'action',
|
||||
cyclePrevProject: 'action',
|
||||
cycleNextProject: 'action',
|
||||
addProfile: 'action',
|
||||
splitTerminalRight: 'action',
|
||||
splitTerminalDown: 'action',
|
||||
closeTerminal: 'action',
|
||||
|
||||
575
apps/ui/src/components/ui/provider-icon.tsx
Normal file
575
apps/ui/src/components/ui/provider-icon.tsx
Normal file
@@ -0,0 +1,575 @@
|
||||
import type { ComponentType, SVGProps } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AgentModel, ModelProvider } from '@automaker/types';
|
||||
import { getProviderFromModel } from '@/lib/utils';
|
||||
|
||||
const PROVIDER_ICON_KEYS = {
|
||||
anthropic: 'anthropic',
|
||||
openai: 'openai',
|
||||
openrouter: 'openrouter',
|
||||
cursor: 'cursor',
|
||||
gemini: 'gemini',
|
||||
grok: 'grok',
|
||||
opencode: 'opencode',
|
||||
deepseek: 'deepseek',
|
||||
qwen: 'qwen',
|
||||
nova: 'nova',
|
||||
meta: 'meta',
|
||||
mistral: 'mistral',
|
||||
minimax: 'minimax',
|
||||
glm: 'glm',
|
||||
bigpickle: 'bigpickle',
|
||||
} as const;
|
||||
|
||||
type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS;
|
||||
|
||||
interface ProviderIconDefinition {
|
||||
viewBox: string;
|
||||
path: string;
|
||||
fillRule?: 'nonzero' | 'evenodd';
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition> = {
|
||||
anthropic: {
|
||||
viewBox: '0 0 248 248',
|
||||
// Official Claude logo from claude.ai favicon
|
||||
path: 'M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z',
|
||||
fill: '#d97757',
|
||||
},
|
||||
openai: {
|
||||
viewBox: '0 0 158.7128 157.296',
|
||||
path: 'M60.8734,57.2556v-14.9432c0-1.2586.4722-2.2029,1.5728-2.8314l30.0443-17.3023c4.0899-2.3593,8.9662-3.4599,13.9988-3.4599,18.8759,0,30.8307,14.6289,30.8307,30.2006,0,1.1007,0,2.3593-.158,3.6178l-31.1446-18.2467c-1.8872-1.1006-3.7754-1.1006-5.6629,0l-39.4812,22.9651ZM131.0276,115.4561v-35.7074c0-2.2028-.9446-3.7756-2.8318-4.8763l-39.481-22.9651,12.8982-7.3934c1.1007-.6285,2.0453-.6285,3.1458,0l30.0441,17.3024c8.6523,5.0341,14.4708,15.7296,14.4708,26.1107,0,11.9539-7.0769,22.965-18.2461,27.527v.0021ZM51.593,83.9964l-12.8982-7.5497c-1.1007-.6285-1.5728-1.5728-1.5728-2.8314v-34.6048c0-16.8303,12.8982-29.5722,30.3585-29.5722,6.607,0,12.7403,2.2029,17.9324,6.1349l-30.987,17.9324c-1.8871,1.1007-2.8314,2.6735-2.8314,4.8764v45.6159l-.0014-.0015ZM79.3562,100.0403l-18.4829-10.3811v-22.0209l18.4829-10.3811,18.4812,10.3811v22.0209l-18.4812,10.3811ZM91.2319,147.8591c-6.607,0-12.7403-2.2031-17.9324-6.1344l30.9866-17.9333c1.8872-1.1005,2.8318-2.6728,2.8318-4.8759v-45.616l13.0564,7.5498c1.1005.6285,1.5723,1.5728,1.5723,2.8314v34.6051c0,16.8297-13.0564,29.5723-30.5147,29.5723v.001ZM53.9522,112.7822l-30.0443-17.3024c-8.652-5.0343-14.471-15.7296-14.471-26.1107,0-12.1119,7.2356-22.9652,18.403-27.5272v35.8634c0,2.2028.9443,3.7756,2.8314,4.8763l39.3248,22.8068-12.8982,7.3938c-1.1007.6287-2.045.6287-3.1456,0ZM52.2229,138.5791c-17.7745,0-30.8306-13.3713-30.8306-29.8871,0-1.2585.1578-2.5169.3143-3.7754l30.987,17.9323c1.8871,1.1005,3.7757,1.1005,5.6628,0l39.4811-22.807v14.9435c0,1.2585-.4721,2.2021-1.5728,2.8308l-30.0443,17.3025c-4.0898,2.359-8.9662,3.4605-13.9989,3.4605h.0014ZM91.2319,157.296c19.0327,0,34.9188-13.5272,38.5383-31.4594,17.6164-4.562,28.9425-21.0779,28.9425-37.908,0-11.0112-4.719-21.7066-13.2133-29.4143.7867-3.3035,1.2595-6.607,1.2595-9.909,0-22.4929-18.2471-39.3247-39.3251-39.3247-4.2461,0-8.3363.6285-12.4262,2.045-7.0792-6.9213-16.8318-11.3254-27.5271-11.3254-19.0331,0-34.9191,13.5268-38.5384,31.4591C11.3255,36.0212,0,52.5373,0,69.3675c0,11.0112,4.7184,21.7065,13.2125,29.4142-.7865,3.3035-1.2586,6.6067-1.2586,9.9092,0,22.4923,18.2466,39.3241,39.3248,39.3241,4.2462,0,8.3362-.6277,12.426-2.0441,7.0776,6.921,16.8302,11.3251,27.5271,11.3251Z',
|
||||
fill: '#74aa9c',
|
||||
},
|
||||
openrouter: {
|
||||
viewBox: '0 0 24 24',
|
||||
// OpenRouter logo from Simple Icons
|
||||
path: 'M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z',
|
||||
fill: '#94A3B8',
|
||||
},
|
||||
cursor: {
|
||||
viewBox: '0 0 512 512',
|
||||
// Official Cursor logo - hexagonal shape with triangular wedge
|
||||
path: 'M415.035 156.35l-151.503-87.4695c-4.865-2.8094-10.868-2.8094-15.733 0l-151.4969 87.4695c-4.0897 2.362-6.6146 6.729-6.6146 11.459v176.383c0 4.73 2.5249 9.097 6.6146 11.458l151.5039 87.47c4.865 2.809 10.868 2.809 15.733 0l151.504-87.47c4.089-2.361 6.614-6.728 6.614-11.458v-176.383c0-4.73-2.525-9.097-6.614-11.459zm-9.516 18.528l-146.255 253.32c-.988 1.707-3.599 1.01-3.599-.967v-165.872c0-3.314-1.771-6.379-4.644-8.044l-143.645-82.932c-1.707-.988-1.01-3.599.968-3.599h292.509c4.154 0 6.75 4.503 4.673 8.101h-.007z',
|
||||
fill: '#5E9EFF',
|
||||
},
|
||||
gemini: {
|
||||
viewBox: '0 0 192 192',
|
||||
// Official Google Gemini sparkle logo from gemini.google.com
|
||||
path: 'M164.93 86.68c-13.56-5.84-25.42-13.84-35.6-24.01-10.17-10.17-18.18-22.04-24.01-35.6-2.23-5.19-4.04-10.54-5.42-16.02C99.45 9.26 97.85 8 96 8s-3.45 1.26-3.9 3.05c-1.38 5.48-3.18 10.81-5.42 16.02-5.84 13.56-13.84 25.43-24.01 35.6-10.17 10.16-22.04 18.17-35.6 24.01-5.19 2.23-10.54 4.04-16.02 5.42C9.26 92.55 8 94.15 8 96s1.26 3.45 3.05 3.9c5.48 1.38 10.81 3.18 16.02 5.42 13.56 5.84 25.42 13.84 35.6 24.01 10.17 10.17 18.18 22.04 24.01 35.6 2.24 5.2 4.04 10.54 5.42 16.02A4.03 4.03 0 0 0 96 184c1.85 0 3.45-1.26 3.9-3.05 1.38-5.48 3.18-10.81 5.42-16.02 5.84-13.56 13.84-25.42 24.01-35.6 10.17-10.17 22.04-18.18 35.6-24.01 5.2-2.24 10.54-4.04 16.02-5.42A4.03 4.03 0 0 0 184 96c0-1.85-1.26-3.45-3.05-3.9-5.48-1.38-10.81-3.18-16.02-5.42',
|
||||
},
|
||||
grok: {
|
||||
viewBox: '0 0 512 509.641',
|
||||
// Official Grok/xAI logo - stylized symbol from grok.com
|
||||
path: 'M213.235 306.019l178.976-180.002v.169l51.695-51.763c-.924 1.32-1.86 2.605-2.785 3.89-39.281 54.164-58.46 80.649-43.07 146.922l-.09-.101c10.61 45.11-.744 95.137-37.398 131.836-46.216 46.306-120.167 56.611-181.063 14.928l42.462-19.675c38.863 15.278 81.392 8.57 111.947-22.03 30.566-30.6 37.432-75.159 22.065-112.252-2.92-7.025-11.67-8.795-17.792-4.263l-124.947 92.341zm-25.786 22.437l-.033.034L68.094 435.217c7.565-10.429 16.957-20.294 26.327-30.149 26.428-27.803 52.653-55.359 36.654-94.302-21.422-52.112-8.952-113.177 30.724-152.898 41.243-41.254 101.98-51.661 152.706-30.758 11.23 4.172 21.016 10.114 28.638 15.639l-42.359 19.584c-39.44-16.563-84.629-5.299-112.207 22.313-37.298 37.308-44.84 102.003-1.128 143.81z',
|
||||
},
|
||||
opencode: {
|
||||
viewBox: '0 0 512 512',
|
||||
// Official OpenCode favicon - geometric icon from opencode.ai
|
||||
path: 'M384 416H128V96H384V416ZM320 160H192V352H320V160Z',
|
||||
fillRule: 'evenodd',
|
||||
fill: '#6366F1',
|
||||
},
|
||||
deepseek: {
|
||||
viewBox: '0 0 24 24',
|
||||
// Official DeepSeek logo - whale icon from lobehub/lobe-icons
|
||||
path: 'M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z',
|
||||
},
|
||||
qwen: {
|
||||
viewBox: '0 0 24 24',
|
||||
// Official Qwen logo - geometric star from lobehub/lobe-icons
|
||||
path: 'M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z',
|
||||
},
|
||||
nova: {
|
||||
viewBox: '0 0 33 32',
|
||||
// Official Amazon Nova logo from lobehub/lobe-icons
|
||||
path: 'm17.865 23.28 1.533 1.543c.07.07.092.175.055.267l-2.398 6.118A1.24 1.24 0 0 1 15.9 32c-.51 0-.969-.315-1.155-.793l-3.451-8.804-5.582 5.617a.246.246 0 0 1-.35 0l-1.407-1.415a.25.25 0 0 1 0-.352l6.89-6.932a1.3 1.3 0 0 1 .834-.398 1.25 1.25 0 0 1 1.232.79l2.992 7.63 1.557-3.977a.248.248 0 0 1 .408-.085zm8.224-19.3-5.583 5.617-3.45-8.805a1.24 1.24 0 0 0-1.43-.762c-.414.092-.744.407-.899.805l-2.38 6.072a.25.25 0 0 0 .055.267l1.533 1.543c.127.127.34.082.407-.085L15.9 4.655l2.991 7.629a1.24 1.24 0 0 0 2.035.425l6.922-6.965a.25.25 0 0 0 0-.352L26.44 3.977a.246.246 0 0 0-.35 0zM8.578 17.566l-3.953-1.567 7.582-3.01c.49-.195.815-.685.785-1.24a1.3 1.3 0 0 0-.395-.84l-6.886-6.93a.246.246 0 0 0-.35 0L3.954 5.395a.25.25 0 0 0 0 .353l5.583 5.617-8.75 3.472a1.25 1.25 0 0 0 0 2.325l6.079 2.412a.24.24 0 0 0 .266-.055l1.533-1.542a.25.25 0 0 0-.085-.41zm22.434-2.73-6.08-2.412a.24.24 0 0 0-.265.055l-1.533 1.542a.25.25 0 0 0 .084.41L27.172 16l-7.583 3.01a1.255 1.255 0 0 0-.785 1.24c.018.317.172.614.395.84l6.89 6.931a.246.246 0 0 0 .35 0l1.406-1.415a.25.25 0 0 0 0-.352l-5.582-5.617 8.75-3.472a1.25 1.25 0 0 0 0-2.325z',
|
||||
fill: '#FF9900',
|
||||
},
|
||||
// Meta and Mistral use custom standalone SVG components
|
||||
// These placeholder entries prevent TypeScript errors
|
||||
meta: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: '',
|
||||
},
|
||||
mistral: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: '',
|
||||
},
|
||||
minimax: {
|
||||
viewBox: '0 0 24 24',
|
||||
// Official MiniMax logo from lobehub/lobe-icons
|
||||
path: 'M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z',
|
||||
},
|
||||
glm: {
|
||||
viewBox: '0 0 24 24',
|
||||
// Official Z.ai logo from lobehub/lobe-icons (GLM provider)
|
||||
path: 'M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z',
|
||||
},
|
||||
bigpickle: {
|
||||
viewBox: '0 0 24 24',
|
||||
// Big Pickle logo - stylized shape with dots
|
||||
path: 'M8 4c-2.21 0-4 1.79-4 4v8c0 2.21 1.79 4 4 4h8c2.21 0 4-1.79 4-4V8c0-2.21-1.79-4-4-4H8zm0 2h8c1.103 0 2 .897 2 2v8c0 1.103-.897 2-2 2H8c-1.103 0-2-.897-2-2V8c0-1.103.897-2 2-2zm2 3a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2zm-4 4a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2z',
|
||||
fill: '#4ADE80',
|
||||
},
|
||||
};
|
||||
|
||||
export interface ProviderIconProps extends Omit<SVGProps<SVGSVGElement>, 'viewBox'> {
|
||||
provider: ProviderIconKey;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function ProviderIcon({ provider, title, className, ...props }: ProviderIconProps) {
|
||||
const definition = PROVIDER_ICON_DEFINITIONS[provider];
|
||||
const {
|
||||
role,
|
||||
'aria-label': ariaLabel,
|
||||
'aria-labelledby': ariaLabelledby,
|
||||
'aria-hidden': ariaHidden,
|
||||
...rest
|
||||
} = props;
|
||||
const hasAccessibleLabel = Boolean(title || ariaLabel || ariaLabelledby);
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={definition.viewBox}
|
||||
className={cn('inline-block', className)}
|
||||
role={role ?? (hasAccessibleLabel ? 'img' : 'presentation')}
|
||||
aria-hidden={ariaHidden ?? !hasAccessibleLabel}
|
||||
focusable="false"
|
||||
{...rest}
|
||||
>
|
||||
{title && <title>{title}</title>}
|
||||
<path
|
||||
d={definition.path}
|
||||
fill={definition.fill || 'currentColor'}
|
||||
fillRule={definition.fillRule}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnthropicIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.anthropic} {...props} />;
|
||||
}
|
||||
|
||||
export function OpenAIIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.openai} {...props} />;
|
||||
}
|
||||
|
||||
export function OpenRouterIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.openrouter} {...props} />;
|
||||
}
|
||||
|
||||
export function CursorIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.cursor} {...props} />;
|
||||
}
|
||||
|
||||
export function GeminiIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.gemini} {...props} />;
|
||||
}
|
||||
|
||||
export function GrokIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.grok} {...props} />;
|
||||
}
|
||||
|
||||
export function OpenCodeIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.opencode} {...props} />;
|
||||
}
|
||||
|
||||
export function DeepSeekIcon({
|
||||
className,
|
||||
title,
|
||||
...props
|
||||
}: {
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const hasAccessibleLabel = Boolean(title);
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={cn('inline-block', className)}
|
||||
role={hasAccessibleLabel ? 'img' : 'presentation'}
|
||||
aria-hidden={!hasAccessibleLabel}
|
||||
focusable="false"
|
||||
{...props}
|
||||
>
|
||||
{title && <title>{title}</title>}
|
||||
<path
|
||||
d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z"
|
||||
fill="#4D6BFE"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function QwenIcon({ className, title, ...props }: { className?: string; title?: string }) {
|
||||
const hasAccessibleLabel = Boolean(title);
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={cn('inline-block', className)}
|
||||
role={hasAccessibleLabel ? 'img' : 'presentation'}
|
||||
aria-hidden={!hasAccessibleLabel}
|
||||
focusable="false"
|
||||
{...props}
|
||||
>
|
||||
{title && <title>{title}</title>}
|
||||
<defs>
|
||||
<linearGradient id="qwen-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style={{ stopColor: '#6336E7', stopOpacity: 0.84 }} />
|
||||
<stop offset="100%" style={{ stopColor: '#6F69F7', stopOpacity: 0.84 }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"
|
||||
fill="url(#qwen-gradient)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function NovaIcon({ className, title, ...props }: { className?: string; title?: string }) {
|
||||
const hasAccessibleLabel = Boolean(title);
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 33 32"
|
||||
className={cn('inline-block', className)}
|
||||
role={hasAccessibleLabel ? 'img' : 'presentation'}
|
||||
aria-hidden={!hasAccessibleLabel}
|
||||
focusable="false"
|
||||
{...props}
|
||||
>
|
||||
{title && <title>{title}</title>}
|
||||
<path
|
||||
d="m17.865 23.28 1.533 1.543c.07.07.092.175.055.267l-2.398 6.118A1.24 1.24 0 0 1 15.9 32c-.51 0-.969-.315-1.155-.793l-3.451-8.804-5.582 5.617a.246.246 0 0 1-.35 0l-1.407-1.415a.25.25 0 0 1 0-.352l6.89-6.932a1.3 1.3 0 0 1 .834-.398 1.25 1.25 0 0 1 1.232.79l2.992 7.63 1.557-3.977a.248.248 0 0 1 .408-.085zm8.224-19.3-5.583 5.617-3.45-8.805a1.24 1.24 0 0 0-1.43-.762c-.414.092-.744.407-.899.805l-2.38 6.072a.25.25 0 0 0 .055.267l1.533 1.543c.127.127.34.082.407-.085L15.9 4.655l2.991 7.629a1.24 1.24 0 0 0 2.035.425l6.922-6.965a.25.25 0 0 0 0-.352L26.44 3.977a.246.246 0 0 0-.35 0zM8.578 17.566l-3.953-1.567 7.582-3.01c.49-.195.815-.685.785-1.24a1.3 1.3 0 0 0-.395-.84l-6.886-6.93a.246.246 0 0 0-.35 0L3.954 5.395a.25.25 0 0 0 0 .353l5.583 5.617-8.75 3.472a1.25 1.25 0 0 0 0 2.325l6.079 2.412a.24.24 0 0 0 .266-.055l1.533-1.542a.25.25 0 0 0-.085-.41zm22.434-2.73-6.08-2.412a.24.24 0 0 0-.265.055l-1.533 1.542a.25.25 0 0 0 .084.41L27.172 16l-7.583 3.01a1.255 1.255 0 0 0-.785 1.24c.018.317.172.614.395.84l6.89 6.931a.246.246 0 0 0 .35 0l1.406-1.415a.25.25 0 0 0 0-.352l-5.582-5.617 8.75-3.472a1.25 1.25 0 0 0 0-2.325z"
|
||||
fill="#FF9900"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MistralIcon({
|
||||
className,
|
||||
title,
|
||||
...props
|
||||
}: {
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const hasAccessibleLabel = Boolean(title);
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={cn('inline-block', className)}
|
||||
role={hasAccessibleLabel ? 'img' : 'presentation'}
|
||||
aria-hidden={!hasAccessibleLabel}
|
||||
focusable="false"
|
||||
{...props}
|
||||
>
|
||||
{title && <title>{title}</title>}
|
||||
<path d="M3.428 3.4h3.429v3.428H3.428V3.4zm13.714 0h3.43v3.428h-3.43V3.4z" fill="gold" />
|
||||
<path
|
||||
d="M3.428 6.828h6.857v3.429H3.429V6.828zm10.286 0h6.857v3.429h-6.857V6.828z"
|
||||
fill="#FFAF00"
|
||||
/>
|
||||
<path d="M3.428 10.258h17.144v3.428H3.428v-3.428z" fill="#FF8205" />
|
||||
<path
|
||||
d="M3.428 13.686h3.429v3.428H3.428v-3.428zm6.858 0h3.429v3.428h-3.429v-3.428zm6.856 0h3.43v3.428h-3.43v-3.428z"
|
||||
fill="#FA500F"
|
||||
/>
|
||||
<path d="M0 17.114h10.286v3.429H0v-3.429zm13.714 0H24v3.429H13.714v-3.429z" fill="#E10500" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetaIcon({ className, title, ...props }: { className?: string; title?: string }) {
|
||||
const hasAccessibleLabel = Boolean(title);
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={cn('inline-block', className)}
|
||||
role={hasAccessibleLabel ? 'img' : 'presentation'}
|
||||
aria-hidden={!hasAccessibleLabel}
|
||||
focusable="false"
|
||||
{...props}
|
||||
>
|
||||
{title && <title>{title}</title>}
|
||||
<path
|
||||
d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"
|
||||
fill="#1877F2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MiniMaxIcon({
|
||||
className,
|
||||
title,
|
||||
...props
|
||||
}: {
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const hasAccessibleLabel = Boolean(title);
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={cn('inline-block', className)}
|
||||
role={hasAccessibleLabel ? 'img' : 'presentation'}
|
||||
aria-hidden={!hasAccessibleLabel}
|
||||
focusable="false"
|
||||
{...props}
|
||||
>
|
||||
{title && <title>{title}</title>}
|
||||
<path
|
||||
d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GlmIcon({ className, title, ...props }: { className?: string; title?: string }) {
|
||||
const hasAccessibleLabel = Boolean(title);
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={cn('inline-block', className)}
|
||||
role={hasAccessibleLabel ? 'img' : 'presentation'}
|
||||
aria-hidden={!hasAccessibleLabel}
|
||||
focusable="false"
|
||||
{...props}
|
||||
>
|
||||
{title && <title>{title}</title>}
|
||||
<path
|
||||
d="M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BigPickleIcon({
|
||||
className,
|
||||
title,
|
||||
...props
|
||||
}: {
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const hasAccessibleLabel = Boolean(title);
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={cn('inline-block', className)}
|
||||
role={hasAccessibleLabel ? 'img' : 'presentation'}
|
||||
aria-hidden={!hasAccessibleLabel}
|
||||
focusable="false"
|
||||
{...props}
|
||||
>
|
||||
{title && <title>{title}</title>}
|
||||
<path
|
||||
d="M8 4c-2.21 0-4 1.79-4 4v8c0 2.21 1.79 4 4 4h8c2.21 0 4-1.79 4-4V8c0-2.21-1.79-4-4-4H8zm0 2h8c1.103 0 2 .897 2 2v8c0 1.103-.897 2-2 2H8c-1.103 0-2-.897-2-2V8c0-1.103.897-2 2-2zm2 3a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2zm-4 4a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2z"
|
||||
fill="#4ADE80"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const PROVIDER_ICON_COMPONENTS: Record<
|
||||
ModelProvider,
|
||||
ComponentType<{ className?: string }>
|
||||
> = {
|
||||
claude: AnthropicIcon,
|
||||
cursor: CursorIcon,
|
||||
codex: OpenAIIcon,
|
||||
opencode: OpenCodeIcon,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the underlying model icon based on the model string
|
||||
* For Cursor models, detects whether it's Claude, GPT, Gemini, Grok, or Cursor-specific
|
||||
*/
|
||||
function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
|
||||
if (!model) return 'anthropic';
|
||||
|
||||
const modelStr = typeof model === 'string' ? model.toLowerCase() : model;
|
||||
|
||||
// Check for Amazon Bedrock models first (amazon-bedrock/...)
|
||||
if (modelStr.startsWith('openrouter/')) {
|
||||
return 'openrouter';
|
||||
}
|
||||
|
||||
// Check for Amazon Bedrock models first (amazon-bedrock/...)
|
||||
if (modelStr.startsWith('amazon-bedrock/')) {
|
||||
// Bedrock-hosted models - detect the specific provider
|
||||
if (modelStr.includes('anthropic') || modelStr.includes('claude')) {
|
||||
return 'anthropic';
|
||||
}
|
||||
if (modelStr.includes('deepseek')) {
|
||||
return 'deepseek';
|
||||
}
|
||||
if (modelStr.includes('nova')) {
|
||||
return 'nova';
|
||||
}
|
||||
if (modelStr.includes('meta') || modelStr.includes('llama')) {
|
||||
return 'meta';
|
||||
}
|
||||
if (modelStr.includes('mistral')) {
|
||||
return 'mistral';
|
||||
}
|
||||
if (modelStr.includes('qwen')) {
|
||||
return 'qwen';
|
||||
}
|
||||
// Default for unknown Bedrock models
|
||||
return 'opencode';
|
||||
}
|
||||
|
||||
// Check for native OpenCode models (opencode/...)
|
||||
if (modelStr.startsWith('opencode/')) {
|
||||
// Native OpenCode models - check specific model types
|
||||
if (modelStr.includes('big-pickle')) {
|
||||
return 'bigpickle';
|
||||
}
|
||||
if (modelStr.includes('grok')) {
|
||||
return 'grok';
|
||||
}
|
||||
if (modelStr.includes('glm')) {
|
||||
return 'glm';
|
||||
}
|
||||
if (modelStr.includes('gpt-5-nano') || modelStr.includes('nano')) {
|
||||
return 'openai'; // GPT-5 Nano uses OpenAI icon
|
||||
}
|
||||
if (modelStr.includes('minimax')) {
|
||||
return 'minimax';
|
||||
}
|
||||
// Default for other OpenCode models
|
||||
return 'opencode';
|
||||
}
|
||||
|
||||
// Check for dynamic OpenCode provider models (provider/model format)
|
||||
// e.g., zai-coding-plan/glm-4.5, github-copilot/gpt-4o, google/gemini-2.5-pro
|
||||
// Only handle strings with exactly one slash (not URLs or paths)
|
||||
if (!modelStr.includes('://')) {
|
||||
const slashIndex = modelStr.indexOf('/');
|
||||
if (slashIndex !== -1 && slashIndex === modelStr.lastIndexOf('/')) {
|
||||
const providerName = modelStr.slice(0, slashIndex);
|
||||
const modelName = modelStr.slice(slashIndex + 1);
|
||||
|
||||
// Skip if either part is empty
|
||||
if (providerName && modelName) {
|
||||
// Check model name for known patterns
|
||||
if (modelName.includes('glm')) {
|
||||
return 'glm';
|
||||
}
|
||||
if (
|
||||
modelName.includes('claude') ||
|
||||
modelName.includes('sonnet') ||
|
||||
modelName.includes('opus')
|
||||
) {
|
||||
return 'anthropic';
|
||||
}
|
||||
if (modelName.includes('gpt') || modelName.includes('o1') || modelName.includes('o3')) {
|
||||
return 'openai';
|
||||
}
|
||||
if (modelName.includes('gemini')) {
|
||||
return 'gemini';
|
||||
}
|
||||
if (modelName.includes('grok')) {
|
||||
return 'grok';
|
||||
}
|
||||
if (modelName.includes('deepseek')) {
|
||||
return 'deepseek';
|
||||
}
|
||||
if (modelName.includes('llama')) {
|
||||
return 'meta';
|
||||
}
|
||||
if (modelName.includes('qwen')) {
|
||||
return 'qwen';
|
||||
}
|
||||
if (modelName.includes('mistral')) {
|
||||
return 'mistral';
|
||||
}
|
||||
// Check provider name for hints
|
||||
if (providerName.includes('google')) {
|
||||
return 'gemini';
|
||||
}
|
||||
if (providerName.includes('anthropic')) {
|
||||
return 'anthropic';
|
||||
}
|
||||
if (providerName.includes('openai')) {
|
||||
return 'openai';
|
||||
}
|
||||
if (providerName.includes('openrouter')) {
|
||||
return 'openrouter';
|
||||
}
|
||||
if (providerName.includes('xai')) {
|
||||
return 'grok';
|
||||
}
|
||||
// Default for unknown dynamic models
|
||||
return 'opencode';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Cursor-specific models with underlying providers
|
||||
if (modelStr.includes('sonnet') || modelStr.includes('opus') || modelStr.includes('claude')) {
|
||||
return 'anthropic';
|
||||
}
|
||||
if (modelStr.includes('gpt-') || modelStr.includes('codex')) {
|
||||
return 'openai';
|
||||
}
|
||||
if (modelStr.includes('gemini')) {
|
||||
return 'gemini';
|
||||
}
|
||||
if (modelStr.includes('grok')) {
|
||||
return 'grok';
|
||||
}
|
||||
if (modelStr.includes('cursor') || modelStr === 'auto' || modelStr === 'composer-1') {
|
||||
return 'cursor';
|
||||
}
|
||||
|
||||
// Default based on provider
|
||||
const provider = getProviderFromModel(model);
|
||||
if (provider === 'codex') return 'openai';
|
||||
if (provider === 'cursor') return 'cursor';
|
||||
if (provider === 'opencode') return 'opencode';
|
||||
return 'anthropic';
|
||||
}
|
||||
|
||||
export function getProviderIconForModel(
|
||||
model?: AgentModel | string
|
||||
): ComponentType<{ className?: string }> {
|
||||
const iconKey = getUnderlyingModelIcon(model);
|
||||
|
||||
const iconMap: Record<ProviderIconKey, ComponentType<{ className?: string }>> = {
|
||||
anthropic: AnthropicIcon,
|
||||
openai: OpenAIIcon,
|
||||
openrouter: OpenRouterIcon,
|
||||
cursor: CursorIcon,
|
||||
gemini: GeminiIcon,
|
||||
grok: GrokIcon,
|
||||
opencode: OpenCodeIcon,
|
||||
deepseek: DeepSeekIcon,
|
||||
qwen: QwenIcon,
|
||||
nova: NovaIcon,
|
||||
meta: MetaIcon,
|
||||
mistral: MistralIcon,
|
||||
minimax: MiniMaxIcon,
|
||||
glm: GlmIcon,
|
||||
bigpickle: BigPickleIcon,
|
||||
};
|
||||
|
||||
return iconMap[iconKey] || AnthropicIcon;
|
||||
}
|
||||
142
apps/ui/src/components/ui/shell-syntax-editor.tsx
Normal file
142
apps/ui/src/components/ui/shell-syntax-editor.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { StreamLanguage } from '@codemirror/language';
|
||||
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||
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 ShellSyntaxEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
minHeight?: string;
|
||||
maxHeight?: string;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
// Syntax highlighting using CSS variables for theme compatibility
|
||||
const syntaxColors = HighlightStyle.define([
|
||||
// Keywords (if, then, else, fi, for, while, do, done, case, esac, etc.)
|
||||
{ tag: t.keyword, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
|
||||
|
||||
// Strings (single and double quoted)
|
||||
{ tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
|
||||
|
||||
// Comments
|
||||
{ tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' },
|
||||
|
||||
// Variables ($VAR, ${VAR})
|
||||
{ tag: t.variableName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
|
||||
|
||||
// Operators
|
||||
{ tag: t.operator, color: 'var(--muted-foreground)' },
|
||||
|
||||
// Numbers
|
||||
{ tag: t.number, color: 'var(--chart-3, oklch(0.7 0.15 150))' },
|
||||
|
||||
// Function names / commands
|
||||
{ tag: t.function(t.variableName), color: 'var(--primary)' },
|
||||
{ tag: t.attributeName, color: 'var(--chart-5, oklch(0.65 0.2 30))' },
|
||||
|
||||
// Default text
|
||||
{ tag: t.content, color: 'var(--foreground)' },
|
||||
]);
|
||||
|
||||
// Editor theme using CSS variables
|
||||
const editorTheme = EditorView.theme({
|
||||
'&': {
|
||||
height: '100%',
|
||||
fontSize: '0.875rem',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--foreground)',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||
},
|
||||
'.cm-content': {
|
||||
padding: '0.75rem',
|
||||
minHeight: '100%',
|
||||
caretColor: '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: 'var(--accent)',
|
||||
opacity: '0.3',
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0 0.25rem',
|
||||
},
|
||||
'&.cm-focused': {
|
||||
outline: 'none',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--muted-foreground)',
|
||||
border: 'none',
|
||||
paddingRight: '0.5rem',
|
||||
},
|
||||
'.cm-lineNumbers .cm-gutterElement': {
|
||||
minWidth: '2rem',
|
||||
textAlign: 'right',
|
||||
paddingRight: '0.5rem',
|
||||
},
|
||||
'.cm-placeholder': {
|
||||
color: 'var(--muted-foreground)',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
});
|
||||
|
||||
// Combine all extensions
|
||||
const extensions: Extension[] = [
|
||||
StreamLanguage.define(shell),
|
||||
syntaxHighlighting(syntaxColors),
|
||||
editorTheme,
|
||||
];
|
||||
|
||||
export function ShellSyntaxEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
minHeight = '200px',
|
||||
maxHeight,
|
||||
'data-testid': testId,
|
||||
}: ShellSyntaxEditorProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('w-full rounded-lg border border-border bg-muted/30', className)}
|
||||
style={{ minHeight }}
|
||||
data-testid={testId}
|
||||
>
|
||||
<CodeMirror
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
extensions={extensions}
|
||||
theme="none"
|
||||
placeholder={placeholder}
|
||||
height={maxHeight}
|
||||
minHeight={minHeight}
|
||||
className="[&_.cm-editor]:min-h-[inherit]"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: false,
|
||||
highlightActiveLine: true,
|
||||
highlightSelectionMatches: true,
|
||||
autocompletion: false,
|
||||
bracketMatching: true,
|
||||
indentOnInput: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const logger = createLogger('TaskProgressPanel');
|
||||
import { Check, Loader2, Circle, ChevronDown, ChevronRight, FileCode } from 'lucide-react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import type { AutoModeEvent } from '@/types/electron';
|
||||
@@ -19,11 +22,18 @@ interface TaskProgressPanelProps {
|
||||
featureId: string;
|
||||
projectPath?: string;
|
||||
className?: string;
|
||||
/** Whether the panel starts expanded (default: true) */
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
export function TaskProgressPanel({ featureId, projectPath, className }: TaskProgressPanelProps) {
|
||||
export function TaskProgressPanel({
|
||||
featureId,
|
||||
projectPath,
|
||||
className,
|
||||
defaultExpanded = true,
|
||||
}: TaskProgressPanelProps) {
|
||||
const [tasks, setTasks] = useState<TaskInfo[]>([]);
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||
|
||||
@@ -42,10 +52,12 @@ export function TaskProgressPanel({ featureId, projectPath, className }: TaskPro
|
||||
}
|
||||
|
||||
const result = await api.features.get(projectPath, featureId);
|
||||
if (result.success && result.feature?.planSpec?.tasks) {
|
||||
const planTasks = result.feature.planSpec.tasks;
|
||||
const currentId = result.feature.planSpec.currentTaskId;
|
||||
const completedCount = result.feature.planSpec.tasksCompleted || 0;
|
||||
const feature: any = (result as any).feature;
|
||||
if (result.success && feature?.planSpec?.tasks) {
|
||||
const planSpec = feature.planSpec as any;
|
||||
const planTasks = planSpec.tasks;
|
||||
const currentId = planSpec.currentTaskId;
|
||||
const completedCount = planSpec.tasksCompleted || 0;
|
||||
|
||||
// Convert planSpec tasks to TaskInfo with proper status
|
||||
const initialTasks: TaskInfo[] = planTasks.map((t: any, index: number) => ({
|
||||
@@ -65,7 +77,7 @@ export function TaskProgressPanel({ featureId, projectPath, className }: TaskPro
|
||||
setCurrentTaskId(currentId || null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load initial tasks:', error);
|
||||
logger.error('Failed to load initial tasks:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -151,13 +163,13 @@ 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',
|
||||
'group rounded-lg 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"
|
||||
className="w-full flex items-center justify-between px-3 py-2.5 bg-muted/10 hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
@@ -218,9 +230,9 @@ export function TaskProgressPanel({ featureId, projectPath, className }: TaskPro
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="p-5 pt-2 relative max-h-[300px] overflow-y-auto scrollbar-visible">
|
||||
<div className="p-4 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="absolute left-[2.35rem] top-4 bottom-8 w-px bg-linear-to-b from-border/80 via-border/40 to-transparent" />
|
||||
|
||||
<div className="space-y-5">
|
||||
{tasks.map((task, index) => {
|
||||
|
||||
612
apps/ui/src/components/usage-popover.tsx
Normal file
612
apps/ui/src/components/usage-popover.tsx
Normal file
@@ -0,0 +1,612 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
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 { useSetupStore } from '@/store/setup-store';
|
||||
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||
|
||||
// Error codes for distinguishing failure modes
|
||||
const ERROR_CODES = {
|
||||
API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE',
|
||||
AUTH_ERROR: 'AUTH_ERROR',
|
||||
NOT_AVAILABLE: 'NOT_AVAILABLE',
|
||||
UNKNOWN: 'UNKNOWN',
|
||||
} as const;
|
||||
|
||||
type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
|
||||
|
||||
type UsageError = {
|
||||
code: ErrorCode;
|
||||
message: string;
|
||||
};
|
||||
|
||||
// Fixed refresh interval (45 seconds)
|
||||
const REFRESH_INTERVAL_SECONDS = 45;
|
||||
|
||||
// Helper to format reset time for Codex
|
||||
function formatCodexResetTime(unixTimestamp: number): string {
|
||||
const date = new Date(unixTimestamp * 1000);
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
|
||||
if (diff < 3600000) {
|
||||
const mins = Math.ceil(diff / 60000);
|
||||
return `Resets in ${mins}m`;
|
||||
}
|
||||
if (diff < 86400000) {
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const mins = Math.ceil((diff % 3600000) / 60000);
|
||||
return `Resets in ${hours}h ${mins > 0 ? `${mins}m` : ''}`;
|
||||
}
|
||||
return `Resets ${date.toLocaleDateString()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||||
}
|
||||
|
||||
// Helper to format window duration for Codex
|
||||
function getCodexWindowLabel(durationMins: number): { title: string; subtitle: string } {
|
||||
if (durationMins < 60) {
|
||||
return { title: `${durationMins}min Window`, subtitle: 'Rate limit' };
|
||||
}
|
||||
if (durationMins < 1440) {
|
||||
const hours = Math.round(durationMins / 60);
|
||||
return { title: `${hours}h Window`, subtitle: 'Rate limit' };
|
||||
}
|
||||
const days = Math.round(durationMins / 1440);
|
||||
return { title: `${days}d Window`, subtitle: 'Rate limit' };
|
||||
}
|
||||
|
||||
export function UsagePopover() {
|
||||
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
|
||||
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
|
||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'claude' | 'codex'>('claude');
|
||||
const [claudeLoading, setClaudeLoading] = useState(false);
|
||||
const [codexLoading, setCodexLoading] = useState(false);
|
||||
const [claudeError, setClaudeError] = useState<UsageError | null>(null);
|
||||
const [codexError, setCodexError] = useState<UsageError | null>(null);
|
||||
|
||||
// Check authentication status
|
||||
const isClaudeCliVerified =
|
||||
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
|
||||
const isCodexAuthenticated = codexAuthStatus?.authenticated;
|
||||
|
||||
// Determine which tab to show by default
|
||||
useEffect(() => {
|
||||
if (isClaudeCliVerified) {
|
||||
setActiveTab('claude');
|
||||
} else if (isCodexAuthenticated) {
|
||||
setActiveTab('codex');
|
||||
}
|
||||
}, [isClaudeCliVerified, isCodexAuthenticated]);
|
||||
|
||||
// Check if data is stale (older than 2 minutes)
|
||||
const isClaudeStale = useMemo(() => {
|
||||
return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000;
|
||||
}, [claudeUsageLastUpdated]);
|
||||
|
||||
const isCodexStale = useMemo(() => {
|
||||
return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
|
||||
}, [codexUsageLastUpdated]);
|
||||
|
||||
const fetchClaudeUsage = useCallback(
|
||||
async (isAutoRefresh = false) => {
|
||||
if (!isAutoRefresh) setClaudeLoading(true);
|
||||
setClaudeError(null);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.claude) {
|
||||
setClaudeError({
|
||||
code: ERROR_CODES.API_BRIDGE_UNAVAILABLE,
|
||||
message: 'Claude API bridge not available',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const data = await api.claude.getUsage();
|
||||
if ('error' in data) {
|
||||
setClaudeError({
|
||||
code: ERROR_CODES.AUTH_ERROR,
|
||||
message: data.message || data.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setClaudeUsage(data);
|
||||
} catch (err) {
|
||||
setClaudeError({
|
||||
code: ERROR_CODES.UNKNOWN,
|
||||
message: err instanceof Error ? err.message : 'Failed to fetch usage',
|
||||
});
|
||||
} finally {
|
||||
if (!isAutoRefresh) setClaudeLoading(false);
|
||||
}
|
||||
},
|
||||
[setClaudeUsage]
|
||||
);
|
||||
|
||||
const fetchCodexUsage = useCallback(
|
||||
async (isAutoRefresh = false) => {
|
||||
if (!isAutoRefresh) setCodexLoading(true);
|
||||
setCodexError(null);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.codex) {
|
||||
setCodexError({
|
||||
code: ERROR_CODES.API_BRIDGE_UNAVAILABLE,
|
||||
message: 'Codex API bridge not available',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const data = await api.codex.getUsage();
|
||||
if ('error' in data) {
|
||||
if (
|
||||
data.message?.includes('not available') ||
|
||||
data.message?.includes('does not provide')
|
||||
) {
|
||||
setCodexError({
|
||||
code: ERROR_CODES.NOT_AVAILABLE,
|
||||
message: data.message || data.error,
|
||||
});
|
||||
} else {
|
||||
setCodexError({
|
||||
code: ERROR_CODES.AUTH_ERROR,
|
||||
message: data.message || data.error,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
setCodexUsage(data);
|
||||
} catch (err) {
|
||||
setCodexError({
|
||||
code: ERROR_CODES.UNKNOWN,
|
||||
message: err instanceof Error ? err.message : 'Failed to fetch usage',
|
||||
});
|
||||
} finally {
|
||||
if (!isAutoRefresh) setCodexLoading(false);
|
||||
}
|
||||
},
|
||||
[setCodexUsage]
|
||||
);
|
||||
|
||||
// Auto-fetch on mount if data is stale
|
||||
useEffect(() => {
|
||||
if (isClaudeStale && isClaudeCliVerified) {
|
||||
fetchClaudeUsage(true);
|
||||
}
|
||||
}, [isClaudeStale, isClaudeCliVerified, fetchClaudeUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCodexStale && isCodexAuthenticated) {
|
||||
fetchCodexUsage(true);
|
||||
}
|
||||
}, [isCodexStale, isCodexAuthenticated, fetchCodexUsage]);
|
||||
|
||||
// Auto-refresh when popover is open
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
// Fetch based on active tab
|
||||
if (activeTab === 'claude' && isClaudeCliVerified) {
|
||||
if (!claudeUsage || isClaudeStale) {
|
||||
fetchClaudeUsage();
|
||||
}
|
||||
const intervalId = setInterval(() => {
|
||||
fetchClaudeUsage(true);
|
||||
}, REFRESH_INTERVAL_SECONDS * 1000);
|
||||
return () => clearInterval(intervalId);
|
||||
}
|
||||
|
||||
if (activeTab === 'codex' && isCodexAuthenticated) {
|
||||
if (!codexUsage || isCodexStale) {
|
||||
fetchCodexUsage();
|
||||
}
|
||||
const intervalId = setInterval(() => {
|
||||
fetchCodexUsage(true);
|
||||
}, REFRESH_INTERVAL_SECONDS * 1000);
|
||||
return () => clearInterval(intervalId);
|
||||
}
|
||||
}, [
|
||||
open,
|
||||
activeTab,
|
||||
claudeUsage,
|
||||
isClaudeStale,
|
||||
isClaudeCliVerified,
|
||||
codexUsage,
|
||||
isCodexStale,
|
||||
isCodexAuthenticated,
|
||||
fetchClaudeUsage,
|
||||
fetchCodexUsage,
|
||||
]);
|
||||
|
||||
// Derived status color/icon helper
|
||||
const getStatusInfo = (percentage: number) => {
|
||||
if (percentage >= 75) return { color: 'text-red-500', icon: XCircle, bg: 'bg-red-500' };
|
||||
if (percentage >= 50)
|
||||
return { color: 'text-orange-500', icon: AlertTriangle, bg: 'bg-orange-500' };
|
||||
return { color: 'text-green-500', icon: CheckCircle, bg: 'bg-green-500' };
|
||||
};
|
||||
|
||||
// Helper component for the progress bar
|
||||
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)}
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const UsageCard = ({
|
||||
title,
|
||||
subtitle,
|
||||
percentage,
|
||||
resetText,
|
||||
isPrimary = false,
|
||||
stale = false,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
percentage: number;
|
||||
resetText?: string;
|
||||
isPrimary?: boolean;
|
||||
stale?: boolean;
|
||||
}) => {
|
||||
const isValidPercentage =
|
||||
typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage);
|
||||
const safePercentage = isValidPercentage ? percentage : 0;
|
||||
|
||||
const status = getStatusInfo(safePercentage);
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
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'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<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)} />
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono font-bold',
|
||||
status.color,
|
||||
isPrimary ? 'text-base' : 'text-sm'
|
||||
)}
|
||||
>
|
||||
{Math.round(safePercentage)}%
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">N/A</span>
|
||||
)}
|
||||
</div>
|
||||
<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">
|
||||
<Clock className="w-3 h-3" />
|
||||
{resetText}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Calculate max percentage for header button
|
||||
const claudeMaxPercentage = claudeUsage
|
||||
? Math.max(claudeUsage.sessionPercentage || 0, claudeUsage.weeklyPercentage || 0)
|
||||
: 0;
|
||||
|
||||
const codexMaxPercentage = codexUsage?.rateLimits
|
||||
? Math.max(
|
||||
codexUsage.rateLimits.primary?.usedPercent || 0,
|
||||
codexUsage.rateLimits.secondary?.usedPercent || 0
|
||||
)
|
||||
: 0;
|
||||
|
||||
const maxPercentage = Math.max(claudeMaxPercentage, codexMaxPercentage);
|
||||
const isStale = activeTab === 'claude' ? isClaudeStale : isCodexStale;
|
||||
|
||||
const getProgressBarColor = (percentage: number) => {
|
||||
if (percentage >= 80) return 'bg-red-500';
|
||||
if (percentage >= 50) return 'bg-yellow-500';
|
||||
return 'bg-green-500';
|
||||
};
|
||||
|
||||
const trigger = (
|
||||
<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 || codexUsage) && (
|
||||
<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))}
|
||||
style={{ width: `${Math.min(maxPercentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
// Determine which tabs to show
|
||||
const showClaudeTab = isClaudeCliVerified;
|
||||
const showCodexTab = isCodexAuthenticated;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-80 p-0 overflow-hidden bg-background/95 backdrop-blur-xl border-border shadow-2xl"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
>
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'claude' | 'codex')}>
|
||||
{/* Tabs Header */}
|
||||
{showClaudeTab && showCodexTab && (
|
||||
<TabsList className="grid w-full grid-cols-2 rounded-none border-b border-border/50">
|
||||
<TabsTrigger value="claude" className="gap-2">
|
||||
<AnthropicIcon className="w-3.5 h-3.5" />
|
||||
Claude
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="codex" className="gap-2">
|
||||
<OpenAIIcon className="w-3.5 h-3.5" />
|
||||
Codex
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
)}
|
||||
|
||||
{/* Claude Tab Content */}
|
||||
<TabsContent value="claude" className="m-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-secondary/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<AnthropicIcon className="w-4 h-4" />
|
||||
<span className="text-sm font-semibold">Claude Usage</span>
|
||||
</div>
|
||||
{claudeError && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-6 w-6', claudeLoading && 'opacity-80')}
|
||||
onClick={() => !claudeLoading && fetchClaudeUsage(false)}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{claudeError ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3">
|
||||
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
|
||||
<div className="space-y-1 flex flex-col items-center">
|
||||
<p className="text-sm font-medium">{claudeError.message}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{claudeError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
|
||||
'Ensure the Electron bridge is running or restart the app'
|
||||
) : (
|
||||
<>
|
||||
Make sure Claude CLI is installed and authenticated via{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">claude login</code>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : !claudeUsage ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 space-y-2">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
|
||||
<p className="text-xs text-muted-foreground">Loading usage data...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<UsageCard
|
||||
title="Session Usage"
|
||||
subtitle="5-hour rolling window"
|
||||
percentage={claudeUsage.sessionPercentage}
|
||||
resetText={claudeUsage.sessionResetText}
|
||||
isPrimary={true}
|
||||
stale={isClaudeStale}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<UsageCard
|
||||
title="Weekly"
|
||||
subtitle="All models"
|
||||
percentage={claudeUsage.weeklyPercentage}
|
||||
resetText={claudeUsage.weeklyResetText}
|
||||
stale={isClaudeStale}
|
||||
/>
|
||||
<UsageCard
|
||||
title="Sonnet"
|
||||
subtitle="Weekly"
|
||||
percentage={claudeUsage.sonnetWeeklyPercentage}
|
||||
resetText={claudeUsage.sonnetResetText}
|
||||
stale={isClaudeStale}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{claudeUsage.costLimit && claudeUsage.costLimit > 0 && (
|
||||
<UsageCard
|
||||
title="Extra Usage"
|
||||
subtitle={`${claudeUsage.costUsed ?? 0} / ${claudeUsage.costLimit} ${claudeUsage.costCurrency ?? ''}`}
|
||||
percentage={
|
||||
claudeUsage.costLimit > 0
|
||||
? ((claudeUsage.costUsed ?? 0) / claudeUsage.costLimit) * 100
|
||||
: 0
|
||||
}
|
||||
stale={isClaudeStale}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-secondary/10 border-t border-border/50">
|
||||
<a
|
||||
href="https://status.claude.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
|
||||
>
|
||||
Claude Status <ExternalLink className="w-2.5 h-2.5" />
|
||||
</a>
|
||||
<span className="text-[10px] text-muted-foreground">Updates every minute</span>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Codex Tab Content */}
|
||||
<TabsContent value="codex" className="m-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-secondary/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<OpenAIIcon className="w-4 h-4" />
|
||||
<span className="text-sm font-semibold">Codex Usage</span>
|
||||
</div>
|
||||
{codexError && codexError.code !== ERROR_CODES.NOT_AVAILABLE && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-6 w-6', codexLoading && 'opacity-80')}
|
||||
onClick={() => !codexLoading && fetchCodexUsage(false)}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{codexError ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3">
|
||||
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
|
||||
<div className="space-y-1 flex flex-col items-center">
|
||||
<p className="text-sm font-medium">
|
||||
{codexError.code === ERROR_CODES.NOT_AVAILABLE
|
||||
? 'Usage not available'
|
||||
: codexError.message}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{codexError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
|
||||
'Ensure the Electron bridge is running or restart the app'
|
||||
) : codexError.code === ERROR_CODES.NOT_AVAILABLE ? (
|
||||
<>
|
||||
Codex CLI doesn't provide usage statistics. Check{' '}
|
||||
<a
|
||||
href="https://platform.openai.com/usage"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
OpenAI dashboard
|
||||
</a>{' '}
|
||||
for usage details.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Make sure Codex CLI is installed and authenticated via{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">codex login</code>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : !codexUsage ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 space-y-2">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
|
||||
<p className="text-xs text-muted-foreground">Loading usage data...</p>
|
||||
</div>
|
||||
) : codexUsage.rateLimits ? (
|
||||
<>
|
||||
{codexUsage.rateLimits.primary && (
|
||||
<UsageCard
|
||||
title={
|
||||
getCodexWindowLabel(codexUsage.rateLimits.primary.windowDurationMins).title
|
||||
}
|
||||
subtitle={
|
||||
getCodexWindowLabel(codexUsage.rateLimits.primary.windowDurationMins)
|
||||
.subtitle
|
||||
}
|
||||
percentage={codexUsage.rateLimits.primary.usedPercent}
|
||||
resetText={formatCodexResetTime(codexUsage.rateLimits.primary.resetsAt)}
|
||||
isPrimary={true}
|
||||
stale={isCodexStale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{codexUsage.rateLimits.secondary && (
|
||||
<UsageCard
|
||||
title={
|
||||
getCodexWindowLabel(codexUsage.rateLimits.secondary.windowDurationMins)
|
||||
.title
|
||||
}
|
||||
subtitle={
|
||||
getCodexWindowLabel(codexUsage.rateLimits.secondary.windowDurationMins)
|
||||
.subtitle
|
||||
}
|
||||
percentage={codexUsage.rateLimits.secondary.usedPercent}
|
||||
resetText={formatCodexResetTime(codexUsage.rateLimits.secondary.resetsAt)}
|
||||
stale={isCodexStale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{codexUsage.rateLimits.planType && (
|
||||
<div className="rounded-xl border border-border/40 bg-secondary/20 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Plan:{' '}
|
||||
<span className="text-foreground font-medium">
|
||||
{codexUsage.rateLimits.planType.charAt(0).toUpperCase() +
|
||||
codexUsage.rateLimits.planType.slice(1)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
|
||||
<p className="text-sm font-medium mt-3">No usage data available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-secondary/10 border-t border-border/50">
|
||||
<a
|
||||
href="https://platform.openai.com/usage"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
|
||||
>
|
||||
OpenAI Dashboard <ExternalLink className="w-2.5 h-2.5" />
|
||||
</a>
|
||||
<span className="text-[10px] text-muted-foreground">Updates every minute</span>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -19,6 +20,8 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
const logger = createLogger('AgentToolsView');
|
||||
|
||||
interface ToolResult {
|
||||
success: boolean;
|
||||
output?: string;
|
||||
@@ -62,7 +65,7 @@ export function AgentToolsView() {
|
||||
|
||||
try {
|
||||
// Simulate agent requesting file read
|
||||
console.log(`[Agent Tool] Requesting to read file: ${readFilePath}`);
|
||||
logger.info(`[Agent Tool] Requesting to read file: ${readFilePath}`);
|
||||
|
||||
const result = await api.readFile(readFilePath);
|
||||
|
||||
@@ -72,14 +75,14 @@ export function AgentToolsView() {
|
||||
output: result.content,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(`[Agent Tool] File read successful: ${readFilePath}`);
|
||||
logger.info(`[Agent Tool] File read successful: ${readFilePath}`);
|
||||
} else {
|
||||
setReadFileResult({
|
||||
success: false,
|
||||
error: result.error || 'Failed to read file',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(`[Agent Tool] File read failed: ${result.error}`);
|
||||
logger.info(`[Agent Tool] File read failed: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
setReadFileResult({
|
||||
@@ -101,7 +104,7 @@ export function AgentToolsView() {
|
||||
|
||||
try {
|
||||
// Simulate agent requesting file write
|
||||
console.log(`[Agent Tool] Requesting to write file: ${writeFilePath}`);
|
||||
logger.info(`[Agent Tool] Requesting to write file: ${writeFilePath}`);
|
||||
|
||||
const result = await api.writeFile(writeFilePath, writeFileContent);
|
||||
|
||||
@@ -111,14 +114,14 @@ export function AgentToolsView() {
|
||||
output: `File written successfully: ${writeFilePath}`,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(`[Agent Tool] File write successful: ${writeFilePath}`);
|
||||
logger.info(`[Agent Tool] File write successful: ${writeFilePath}`);
|
||||
} else {
|
||||
setWriteFileResult({
|
||||
success: false,
|
||||
error: result.error || 'Failed to write file',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(`[Agent Tool] File write failed: ${result.error}`);
|
||||
logger.info(`[Agent Tool] File write failed: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
setWriteFileResult({
|
||||
@@ -140,7 +143,7 @@ export function AgentToolsView() {
|
||||
|
||||
try {
|
||||
// Terminal command simulation for demonstration purposes
|
||||
console.log(`[Agent Tool] Simulating command: ${terminalCommand}`);
|
||||
logger.info(`[Agent Tool] Simulating command: ${terminalCommand}`);
|
||||
|
||||
// Simulated outputs for common commands (preview mode)
|
||||
// In production, the agent executes commands via Claude SDK
|
||||
@@ -165,7 +168,7 @@ export function AgentToolsView() {
|
||||
output: output,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(`[Agent Tool] Command executed successfully: ${terminalCommand}`);
|
||||
logger.info(`[Agent Tool] Command executed successfully: ${terminalCommand}`);
|
||||
} catch (error) {
|
||||
setTerminalResult({
|
||||
success: false,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
import { Bot, PanelLeftClose, PanelLeft, Wrench, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface AgentHeaderProps {
|
||||
projectName: string;
|
||||
currentSessionId: string | null;
|
||||
isConnected: boolean;
|
||||
isProcessing: boolean;
|
||||
currentTool: string | null;
|
||||
messagesCount: number;
|
||||
showSessionManager: boolean;
|
||||
onToggleSessionManager: () => void;
|
||||
onClearChat: () => void;
|
||||
}
|
||||
|
||||
export function AgentHeader({
|
||||
projectName,
|
||||
currentSessionId,
|
||||
isConnected,
|
||||
isProcessing,
|
||||
currentTool,
|
||||
messagesCount,
|
||||
showSessionManager,
|
||||
onToggleSessionManager,
|
||||
onClearChat,
|
||||
}: AgentHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggleSessionManager}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showSessionManager ? (
|
||||
<PanelLeftClose className="w-4 h-4" />
|
||||
) : (
|
||||
<PanelLeft className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<Bot className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{projectName}
|
||||
{currentSessionId && !isConnected && ' - Connecting...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status indicators & actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{currentTool && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-full border border-border">
|
||||
<Wrench className="w-3 h-3 text-primary" />
|
||||
<span className="font-medium">{currentTool}</span>
|
||||
</div>
|
||||
)}
|
||||
{currentSessionId && messagesCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearChat}
|
||||
disabled={isProcessing}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ImageAttachment } from '@/store/app-store';
|
||||
import { MessageList } from './message-list';
|
||||
import { NoSessionState } from './empty-states';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: string;
|
||||
isError?: boolean;
|
||||
images?: ImageAttachment[];
|
||||
}
|
||||
|
||||
interface ChatAreaProps {
|
||||
currentSessionId: string | null;
|
||||
messages: Message[];
|
||||
isProcessing: boolean;
|
||||
showSessionManager: boolean;
|
||||
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
onScroll: () => void;
|
||||
onShowSessionManager: () => void;
|
||||
}
|
||||
|
||||
export function ChatArea({
|
||||
currentSessionId,
|
||||
messages,
|
||||
isProcessing,
|
||||
showSessionManager,
|
||||
messagesContainerRef,
|
||||
onScroll,
|
||||
onShowSessionManager,
|
||||
}: ChatAreaProps) {
|
||||
if (!currentSessionId) {
|
||||
return (
|
||||
<NoSessionState
|
||||
showSessionManager={showSessionManager}
|
||||
onShowSessionManager={onShowSessionManager}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageList
|
||||
messages={messages}
|
||||
isProcessing={isProcessing}
|
||||
messagesContainerRef={messagesContainerRef}
|
||||
onScroll={onScroll}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Sparkles, Bot, PanelLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function NoProjectState() {
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center bg-background"
|
||||
data-testid="agent-view-no-project"
|
||||
>
|
||||
<div className="text-center max-w-md">
|
||||
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-6">
|
||||
<Sparkles className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-3 text-foreground">No Project Selected</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Open or create a project to start working with the AI agent.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NoSessionStateProps {
|
||||
showSessionManager: boolean;
|
||||
onShowSessionManager: () => void;
|
||||
}
|
||||
|
||||
export function NoSessionState({ showSessionManager, onShowSessionManager }: NoSessionStateProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center bg-background"
|
||||
data-testid="no-session-placeholder"
|
||||
>
|
||||
<div className="text-center max-w-md">
|
||||
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mx-auto mb-6">
|
||||
<Bot className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold mb-3 text-foreground">No Session Selected</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">
|
||||
Create or select a session to start chatting with the AI agent
|
||||
</p>
|
||||
<Button onClick={onShowSessionManager} variant="outline" className="gap-2">
|
||||
<PanelLeft className="w-4 h-4" />
|
||||
{showSessionManager ? 'View' : 'Show'} Sessions
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { ThinkingIndicator } from './thinking-indicator';
|
||||
export { NoProjectState, NoSessionState } from './empty-states';
|
||||
export { MessageBubble } from './message-bubble';
|
||||
export { MessageList } from './message-list';
|
||||
export { AgentHeader } from './agent-header';
|
||||
export { ChatArea } from './chat-area';
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Bot, User, ImageIcon, AlertCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import type { ImageAttachment } from '@/store/app-store';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: string;
|
||||
images?: ImageAttachment[];
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
const isError = message.isError && message.role === 'assistant';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-4 max-w-4xl',
|
||||
message.role === 'user' ? 'flex-row-reverse ml-auto' : ''
|
||||
)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm',
|
||||
isError
|
||||
? 'bg-red-500/10 ring-1 ring-red-500/20'
|
||||
: message.role === 'assistant'
|
||||
? 'bg-primary/10 ring-1 ring-primary/20'
|
||||
: 'bg-muted ring-1 ring-border'
|
||||
)}
|
||||
>
|
||||
{isError ? (
|
||||
<AlertCircle className="w-4 h-4 text-red-500" />
|
||||
) : message.role === 'assistant' ? (
|
||||
<Bot className="w-4 h-4 text-primary" />
|
||||
) : (
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message Bubble */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 max-w-[85%] rounded-2xl px-4 py-3 shadow-sm',
|
||||
isError
|
||||
? 'bg-red-500/10 border border-red-500/30'
|
||||
: message.role === 'user'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-card border border-border'
|
||||
)}
|
||||
>
|
||||
{message.role === 'assistant' ? (
|
||||
<Markdown
|
||||
className={cn(
|
||||
'text-sm prose-p:leading-relaxed prose-headings:text-foreground prose-strong:text-foreground prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded',
|
||||
isError
|
||||
? 'text-red-600 dark:text-red-400 prose-code:text-red-600 dark:prose-code:text-red-400 prose-code:bg-red-500/10'
|
||||
: 'text-foreground prose-code:text-primary prose-code:bg-muted'
|
||||
)}
|
||||
>
|
||||
{message.content}
|
||||
</Markdown>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">{message.content}</p>
|
||||
)}
|
||||
|
||||
{/* Display attached images for user messages */}
|
||||
{message.role === 'user' && message.images && message.images.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-primary-foreground/80">
|
||||
<ImageIcon className="w-3 h-3" />
|
||||
<span>
|
||||
{message.images.length} image
|
||||
{message.images.length > 1 ? 's' : ''} attached
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{message.images.map((image, index) => {
|
||||
// Construct proper data URL from base64 data and mime type
|
||||
const dataUrl = image.data.startsWith('data:')
|
||||
? image.data
|
||||
: `data:${image.mimeType || 'image/png'};base64,${image.data}`;
|
||||
return (
|
||||
<div
|
||||
key={image.id || `img-${index}`}
|
||||
className="relative group rounded-lg overflow-hidden border border-primary-foreground/20 bg-primary-foreground/10"
|
||||
>
|
||||
<img
|
||||
src={dataUrl}
|
||||
alt={image.filename || `Attached image ${index + 1}`}
|
||||
className="w-20 h-20 object-cover hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/50 px-1.5 py-0.5 text-[9px] text-white truncate">
|
||||
{image.filename || `Image ${index + 1}`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
'text-[11px] mt-2 font-medium',
|
||||
isError
|
||||
? 'text-red-500/70'
|
||||
: message.role === 'user'
|
||||
? 'text-primary-foreground/70'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{new Date(message.timestamp).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ImageAttachment } from '@/store/app-store';
|
||||
import { MessageBubble } from './message-bubble';
|
||||
import { ThinkingIndicator } from './thinking-indicator';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: string;
|
||||
images?: ImageAttachment[];
|
||||
}
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
isProcessing: boolean;
|
||||
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
onScroll: () => void;
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
isProcessing,
|
||||
messagesContainerRef,
|
||||
onScroll,
|
||||
}: MessageListProps) {
|
||||
return (
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="flex-1 overflow-y-auto px-6 py-6 space-y-6 scroll-smooth"
|
||||
data-testid="message-list"
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
|
||||
{/* Thinking Indicator */}
|
||||
{isProcessing && <ThinkingIndicator />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Bot } from 'lucide-react';
|
||||
|
||||
export function ThinkingIndicator() {
|
||||
return (
|
||||
<div className="flex gap-4 max-w-4xl">
|
||||
<div className="w-9 h-9 rounded-xl bg-primary/10 ring-1 ring-primary/20 flex items-center justify-center shrink-0 shadow-sm">
|
||||
<Bot className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-2xl px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: '0ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: '150ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: '300ms' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">Thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
apps/ui/src/components/views/agent-view/hooks/index.ts
Normal file
4
apps/ui/src/components/views/agent-view/hooks/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { useAgentScroll } from './use-agent-scroll';
|
||||
export { useFileAttachments } from './use-file-attachments';
|
||||
export { useAgentShortcuts } from './use-agent-shortcuts';
|
||||
export { useAgentSession } from './use-agent-session';
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useRef, useState, useCallback, useEffect } from 'react';
|
||||
|
||||
interface UseAgentScrollOptions {
|
||||
messagesLength: number;
|
||||
currentSessionId: string | null;
|
||||
}
|
||||
|
||||
interface UseAgentScrollResult {
|
||||
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
isUserAtBottom: boolean;
|
||||
handleScroll: () => void;
|
||||
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
||||
}
|
||||
|
||||
export function useAgentScroll({
|
||||
messagesLength,
|
||||
currentSessionId,
|
||||
}: UseAgentScrollOptions): UseAgentScrollResult {
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isUserAtBottom, setIsUserAtBottom] = useState(true);
|
||||
|
||||
// Scroll position detection
|
||||
const checkIfUserIsAtBottom = useCallback(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const threshold = 50; // 50px threshold for "near bottom"
|
||||
const isAtBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight <= threshold;
|
||||
|
||||
setIsUserAtBottom(isAtBottom);
|
||||
}, []);
|
||||
|
||||
// Scroll to bottom function
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: behavior,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle scroll events
|
||||
const handleScroll = useCallback(() => {
|
||||
checkIfUserIsAtBottom();
|
||||
}, [checkIfUserIsAtBottom]);
|
||||
|
||||
// Auto-scroll effect when messages change
|
||||
useEffect(() => {
|
||||
// Only auto-scroll if user was already at bottom
|
||||
if (isUserAtBottom && messagesLength > 0) {
|
||||
// Use a small delay to ensure DOM is updated
|
||||
setTimeout(() => {
|
||||
scrollToBottom('smooth');
|
||||
}, 100);
|
||||
}
|
||||
}, [messagesLength, isUserAtBottom, scrollToBottom]);
|
||||
|
||||
// Initial scroll to bottom when session changes
|
||||
useEffect(() => {
|
||||
if (currentSessionId && messagesLength > 0) {
|
||||
// Scroll immediately without animation when switching sessions
|
||||
setTimeout(() => {
|
||||
scrollToBottom('auto');
|
||||
setIsUserAtBottom(true);
|
||||
}, 100);
|
||||
}
|
||||
}, [currentSessionId, scrollToBottom, messagesLength]);
|
||||
|
||||
return {
|
||||
messagesContainerRef,
|
||||
isUserAtBottom,
|
||||
handleScroll,
|
||||
scrollToBottom,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
const logger = createLogger('AgentSession');
|
||||
|
||||
interface UseAgentSessionOptions {
|
||||
projectPath: string | undefined;
|
||||
}
|
||||
|
||||
interface UseAgentSessionResult {
|
||||
currentSessionId: string | null;
|
||||
handleSelectSession: (sessionId: string | null) => void;
|
||||
}
|
||||
|
||||
export function useAgentSession({ projectPath }: UseAgentSessionOptions): UseAgentSessionResult {
|
||||
const { setLastSelectedSession, getLastSelectedSession } = useAppStore();
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||
|
||||
// Track if initial session has been loaded
|
||||
const initialSessionLoadedRef = useRef(false);
|
||||
|
||||
// Handle session selection with persistence
|
||||
const handleSelectSession = useCallback(
|
||||
(sessionId: string | null) => {
|
||||
setCurrentSessionId(sessionId);
|
||||
// Persist the selection for this project
|
||||
if (projectPath) {
|
||||
setLastSelectedSession(projectPath, sessionId);
|
||||
}
|
||||
},
|
||||
[projectPath, setLastSelectedSession]
|
||||
);
|
||||
|
||||
// Restore last selected session when switching to Agent view or when project changes
|
||||
useEffect(() => {
|
||||
if (!projectPath) {
|
||||
// No project, reset
|
||||
setCurrentSessionId(null);
|
||||
initialSessionLoadedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Only restore once per project
|
||||
if (initialSessionLoadedRef.current) return;
|
||||
initialSessionLoadedRef.current = true;
|
||||
|
||||
const lastSessionId = getLastSelectedSession(projectPath);
|
||||
if (lastSessionId) {
|
||||
logger.info('Restoring last selected session:', lastSessionId);
|
||||
setCurrentSessionId(lastSessionId);
|
||||
}
|
||||
}, [projectPath, getLastSelectedSession]);
|
||||
|
||||
// Reset initialSessionLoadedRef when project changes
|
||||
useEffect(() => {
|
||||
initialSessionLoadedRef.current = false;
|
||||
}, [projectPath]);
|
||||
|
||||
return {
|
||||
currentSessionId,
|
||||
handleSelectSession,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
useKeyboardShortcuts,
|
||||
useKeyboardShortcutsConfig,
|
||||
type KeyboardShortcut,
|
||||
} from '@/hooks/use-keyboard-shortcuts';
|
||||
|
||||
interface UseAgentShortcutsOptions {
|
||||
currentProject: { path: string; name: string } | null;
|
||||
quickCreateSessionRef: React.MutableRefObject<(() => Promise<void>) | null>;
|
||||
}
|
||||
|
||||
export function useAgentShortcuts({
|
||||
currentProject,
|
||||
quickCreateSessionRef,
|
||||
}: UseAgentShortcutsOptions): void {
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
|
||||
// Keyboard shortcuts for agent view
|
||||
const agentShortcuts: KeyboardShortcut[] = useMemo(() => {
|
||||
const shortcutsList: KeyboardShortcut[] = [];
|
||||
|
||||
// New session shortcut - only when in agent view with a project
|
||||
if (currentProject) {
|
||||
shortcutsList.push({
|
||||
key: shortcuts.newSession,
|
||||
action: () => {
|
||||
if (quickCreateSessionRef.current) {
|
||||
quickCreateSessionRef.current();
|
||||
}
|
||||
},
|
||||
description: 'Create new session',
|
||||
});
|
||||
}
|
||||
|
||||
return shortcutsList;
|
||||
}, [currentProject, shortcuts, quickCreateSessionRef]);
|
||||
|
||||
// Register keyboard shortcuts
|
||||
useKeyboardShortcuts(agentShortcuts);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
|
||||
|
||||
const logger = createLogger('FileAttachments');
|
||||
import {
|
||||
fileToBase64,
|
||||
generateImageId,
|
||||
generateFileId,
|
||||
validateImageFile,
|
||||
validateTextFile,
|
||||
isTextFile,
|
||||
isImageFile,
|
||||
fileToText,
|
||||
getTextFileMimeType,
|
||||
DEFAULT_MAX_FILE_SIZE,
|
||||
DEFAULT_MAX_FILES,
|
||||
} from '@/lib/image-utils';
|
||||
|
||||
interface UseFileAttachmentsOptions {
|
||||
isProcessing: boolean;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
interface UseFileAttachmentsResult {
|
||||
selectedImages: ImageAttachment[];
|
||||
selectedTextFiles: TextFileAttachment[];
|
||||
showImageDropZone: boolean;
|
||||
isDragOver: boolean;
|
||||
handleImagesSelected: (images: ImageAttachment[]) => void;
|
||||
toggleImageDropZone: () => void;
|
||||
processDroppedFiles: (files: FileList) => Promise<void>;
|
||||
removeImage: (imageId: string) => void;
|
||||
removeTextFile: (fileId: string) => void;
|
||||
handleDragEnter: (e: React.DragEvent) => void;
|
||||
handleDragLeave: (e: React.DragEvent) => void;
|
||||
handleDragOver: (e: React.DragEvent) => void;
|
||||
handleDrop: (e: React.DragEvent) => Promise<void>;
|
||||
handlePaste: (e: React.ClipboardEvent) => Promise<void>;
|
||||
clearAllFiles: () => void;
|
||||
setSelectedImages: React.Dispatch<React.SetStateAction<ImageAttachment[]>>;
|
||||
setSelectedTextFiles: React.Dispatch<React.SetStateAction<TextFileAttachment[]>>;
|
||||
setShowImageDropZone: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export function useFileAttachments({
|
||||
isProcessing,
|
||||
isConnected,
|
||||
}: UseFileAttachmentsOptions): UseFileAttachmentsResult {
|
||||
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
|
||||
const [selectedTextFiles, setSelectedTextFiles] = useState<TextFileAttachment[]>([]);
|
||||
const [showImageDropZone, setShowImageDropZone] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const handleImagesSelected = useCallback((images: ImageAttachment[]) => {
|
||||
setSelectedImages(images);
|
||||
}, []);
|
||||
|
||||
const toggleImageDropZone = useCallback(() => {
|
||||
setShowImageDropZone((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Process dropped files (images and text files)
|
||||
const processDroppedFiles = useCallback(
|
||||
async (files: FileList) => {
|
||||
if (isProcessing) return;
|
||||
|
||||
const newImages: ImageAttachment[] = [];
|
||||
const newTextFiles: TextFileAttachment[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
// Check if it's a text file
|
||||
if (isTextFile(file)) {
|
||||
const validation = validateTextFile(file);
|
||||
if (!validation.isValid) {
|
||||
errors.push(validation.error!);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we've reached max files
|
||||
const totalFiles =
|
||||
newImages.length +
|
||||
selectedImages.length +
|
||||
newTextFiles.length +
|
||||
selectedTextFiles.length;
|
||||
if (totalFiles >= DEFAULT_MAX_FILES) {
|
||||
errors.push(`Maximum ${DEFAULT_MAX_FILES} files allowed.`);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fileToText(file);
|
||||
const textFileAttachment: TextFileAttachment = {
|
||||
id: generateFileId(),
|
||||
content,
|
||||
mimeType: getTextFileMimeType(file.name),
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
};
|
||||
newTextFiles.push(textFileAttachment);
|
||||
} catch {
|
||||
errors.push(`${file.name}: Failed to read text file.`);
|
||||
}
|
||||
}
|
||||
// Check if it's an image file
|
||||
else if (isImageFile(file)) {
|
||||
const validation = validateImageFile(file, DEFAULT_MAX_FILE_SIZE);
|
||||
if (!validation.isValid) {
|
||||
errors.push(validation.error!);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we've reached max files
|
||||
const totalFiles =
|
||||
newImages.length +
|
||||
selectedImages.length +
|
||||
newTextFiles.length +
|
||||
selectedTextFiles.length;
|
||||
if (totalFiles >= DEFAULT_MAX_FILES) {
|
||||
errors.push(`Maximum ${DEFAULT_MAX_FILES} files allowed.`);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
const imageAttachment: ImageAttachment = {
|
||||
id: generateImageId(),
|
||||
data: base64,
|
||||
mimeType: file.type,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
};
|
||||
newImages.push(imageAttachment);
|
||||
} catch {
|
||||
errors.push(`${file.name}: Failed to process image.`);
|
||||
}
|
||||
} else {
|
||||
errors.push(`${file.name}: Unsupported file type. Use images, .txt, or .md files.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
logger.warn('File upload errors:', errors);
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
setSelectedImages((prev) => [...prev, ...newImages]);
|
||||
}
|
||||
|
||||
if (newTextFiles.length > 0) {
|
||||
setSelectedTextFiles((prev) => [...prev, ...newTextFiles]);
|
||||
}
|
||||
},
|
||||
[isProcessing, selectedImages, selectedTextFiles]
|
||||
);
|
||||
|
||||
// Remove individual image
|
||||
const removeImage = useCallback((imageId: string) => {
|
||||
setSelectedImages((prev) => prev.filter((img) => img.id !== imageId));
|
||||
}, []);
|
||||
|
||||
// Remove individual text file
|
||||
const removeTextFile = useCallback((fileId: string) => {
|
||||
setSelectedTextFiles((prev) => prev.filter((file) => file.id !== fileId));
|
||||
}, []);
|
||||
|
||||
// Clear all files
|
||||
const clearAllFiles = useCallback(() => {
|
||||
setSelectedImages([]);
|
||||
setSelectedTextFiles([]);
|
||||
}, []);
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragEnter = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isProcessing || !isConnected) return;
|
||||
|
||||
// Check if dragged items contain files
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
},
|
||||
[isProcessing, isConnected]
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Only set dragOver to false if we're leaving the input container
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
if (isProcessing || !isConnected) return;
|
||||
|
||||
// Check if we have files
|
||||
const files = e.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
processDroppedFiles(files);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle file paths (from screenshots or other sources)
|
||||
const items = e.dataTransfer.items;
|
||||
if (items && items.length > 0) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
processDroppedFiles(dataTransfer.files);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[isProcessing, isConnected, processDroppedFiles]
|
||||
);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (e: React.ClipboardEvent) => {
|
||||
// Check if clipboard contains files
|
||||
const items = e.clipboardData?.items;
|
||||
if (items) {
|
||||
const files: File[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
e.preventDefault(); // Prevent default paste of file path
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
files.forEach((file) => dataTransfer.items.add(file));
|
||||
await processDroppedFiles(dataTransfer.files);
|
||||
}
|
||||
}
|
||||
},
|
||||
[processDroppedFiles]
|
||||
);
|
||||
|
||||
return {
|
||||
selectedImages,
|
||||
selectedTextFiles,
|
||||
showImageDropZone,
|
||||
isDragOver,
|
||||
handleImagesSelected,
|
||||
toggleImageDropZone,
|
||||
processDroppedFiles,
|
||||
removeImage,
|
||||
removeTextFile,
|
||||
handleDragEnter,
|
||||
handleDragLeave,
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
handlePaste,
|
||||
clearAllFiles,
|
||||
setSelectedImages,
|
||||
setSelectedTextFiles,
|
||||
setShowImageDropZone,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { ImageDropZone } from '@/components/ui/image-drop-zone';
|
||||
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
|
||||
import type { PhaseModelEntry } from '@automaker/types';
|
||||
import { FilePreview } from './file-preview';
|
||||
import { QueueDisplay } from './queue-display';
|
||||
import { InputControls } from './input-controls';
|
||||
|
||||
interface QueueItem {
|
||||
id: string;
|
||||
message: string;
|
||||
imagePaths?: string[];
|
||||
}
|
||||
|
||||
interface AgentInputAreaProps {
|
||||
input: string;
|
||||
onInputChange: (value: string) => void;
|
||||
onSend: () => void;
|
||||
onStop: () => void;
|
||||
/** Current model selection (model + optional thinking level) */
|
||||
modelSelection: PhaseModelEntry;
|
||||
/** Callback when model is selected */
|
||||
onModelSelect: (entry: PhaseModelEntry) => void;
|
||||
isProcessing: boolean;
|
||||
isConnected: boolean;
|
||||
// File attachments
|
||||
selectedImages: ImageAttachment[];
|
||||
selectedTextFiles: TextFileAttachment[];
|
||||
showImageDropZone: boolean;
|
||||
isDragOver: boolean;
|
||||
onImagesSelected: (images: ImageAttachment[]) => void;
|
||||
onToggleImageDropZone: () => void;
|
||||
onRemoveImage: (imageId: string) => void;
|
||||
onRemoveTextFile: (fileId: string) => void;
|
||||
onClearAllFiles: () => void;
|
||||
onDragEnter: (e: React.DragEvent) => void;
|
||||
onDragLeave: (e: React.DragEvent) => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDrop: (e: React.DragEvent) => Promise<void>;
|
||||
onPaste: (e: React.ClipboardEvent) => Promise<void>;
|
||||
// Queue
|
||||
serverQueue: QueueItem[];
|
||||
onRemoveFromQueue: (id: string) => void;
|
||||
onClearQueue: () => void;
|
||||
// Refs
|
||||
inputRef?: React.RefObject<HTMLTextAreaElement | null>;
|
||||
}
|
||||
|
||||
export function AgentInputArea({
|
||||
input,
|
||||
onInputChange,
|
||||
onSend,
|
||||
onStop,
|
||||
modelSelection,
|
||||
onModelSelect,
|
||||
isProcessing,
|
||||
isConnected,
|
||||
selectedImages,
|
||||
selectedTextFiles,
|
||||
showImageDropZone,
|
||||
isDragOver,
|
||||
onImagesSelected,
|
||||
onToggleImageDropZone,
|
||||
onRemoveImage,
|
||||
onRemoveTextFile,
|
||||
onClearAllFiles,
|
||||
onDragEnter,
|
||||
onDragLeave,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onPaste,
|
||||
serverQueue,
|
||||
onRemoveFromQueue,
|
||||
onClearQueue,
|
||||
inputRef,
|
||||
}: AgentInputAreaProps) {
|
||||
const hasFiles = selectedImages.length > 0 || selectedTextFiles.length > 0;
|
||||
|
||||
return (
|
||||
<div className="border-t border-border p-4 bg-card/50 backdrop-blur-sm">
|
||||
{/* Image Drop Zone (when visible) */}
|
||||
{showImageDropZone && (
|
||||
<ImageDropZone
|
||||
onImagesSelected={onImagesSelected}
|
||||
images={selectedImages}
|
||||
maxFiles={5}
|
||||
className="mb-4"
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Queued Prompts List */}
|
||||
<QueueDisplay
|
||||
serverQueue={serverQueue}
|
||||
onRemoveFromQueue={onRemoveFromQueue}
|
||||
onClearQueue={onClearQueue}
|
||||
/>
|
||||
|
||||
{/* Selected Files Preview - only show when ImageDropZone is hidden */}
|
||||
{!showImageDropZone && (
|
||||
<FilePreview
|
||||
selectedImages={selectedImages}
|
||||
selectedTextFiles={selectedTextFiles}
|
||||
isProcessing={isProcessing}
|
||||
onRemoveImage={onRemoveImage}
|
||||
onRemoveTextFile={onRemoveTextFile}
|
||||
onClearAll={onClearAllFiles}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Input Controls */}
|
||||
<InputControls
|
||||
input={input}
|
||||
onInputChange={onInputChange}
|
||||
onSend={onSend}
|
||||
onStop={onStop}
|
||||
onToggleImageDropZone={onToggleImageDropZone}
|
||||
onPaste={onPaste}
|
||||
modelSelection={modelSelection}
|
||||
onModelSelect={onModelSelect}
|
||||
isProcessing={isProcessing}
|
||||
isConnected={isConnected}
|
||||
hasFiles={hasFiles}
|
||||
isDragOver={isDragOver}
|
||||
showImageDropZone={showImageDropZone}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { X, FileText } from 'lucide-react';
|
||||
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
|
||||
import { formatFileSize } from '@/lib/image-utils';
|
||||
|
||||
interface FilePreviewProps {
|
||||
selectedImages: ImageAttachment[];
|
||||
selectedTextFiles: TextFileAttachment[];
|
||||
isProcessing: boolean;
|
||||
onRemoveImage: (imageId: string) => void;
|
||||
onRemoveTextFile: (fileId: string) => void;
|
||||
onClearAll: () => void;
|
||||
}
|
||||
|
||||
export function FilePreview({
|
||||
selectedImages,
|
||||
selectedTextFiles,
|
||||
isProcessing,
|
||||
onRemoveImage,
|
||||
onRemoveTextFile,
|
||||
onClearAll,
|
||||
}: FilePreviewProps) {
|
||||
const totalFiles = selectedImages.length + selectedTextFiles.length;
|
||||
|
||||
if (totalFiles === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{totalFiles} file{totalFiles > 1 ? 's' : ''} attached
|
||||
</p>
|
||||
<button
|
||||
onClick={onClearAll}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Image attachments */}
|
||||
{selectedImages.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="group relative rounded-lg border border-border bg-muted/30 p-2 flex items-center gap-2 hover:border-primary/30 transition-colors"
|
||||
>
|
||||
{/* Image thumbnail */}
|
||||
<div className="w-8 h-8 rounded-md overflow-hidden bg-muted flex-shrink-0">
|
||||
<img src={image.data} alt={image.filename} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
{/* Image info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-foreground truncate max-w-24">
|
||||
{image.filename}
|
||||
</p>
|
||||
{image.size !== undefined && (
|
||||
<p className="text-[10px] text-muted-foreground">{formatFileSize(image.size)}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Remove button */}
|
||||
{image.id && (
|
||||
<button
|
||||
onClick={() => onRemoveImage(image.id!)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* Text file attachments */}
|
||||
{selectedTextFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="group relative rounded-lg border border-border bg-muted/30 p-2 flex items-center gap-2 hover:border-primary/30 transition-colors"
|
||||
>
|
||||
{/* File icon */}
|
||||
<div className="w-8 h-8 rounded-md bg-muted flex-shrink-0 flex items-center justify-center">
|
||||
<FileText className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
{/* File info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-foreground truncate max-w-24">
|
||||
{file.filename}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">{formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
{/* Remove button */}
|
||||
<button
|
||||
onClick={() => onRemoveTextFile(file.id)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { AgentInputArea } from './agent-input-area';
|
||||
export { FilePreview } from './file-preview';
|
||||
export { QueueDisplay } from './queue-display';
|
||||
export { InputControls } from './input-controls';
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useRef, useCallback, useEffect } from 'react';
|
||||
import { Send, Paperclip, Square, ListOrdered } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AgentModelSelector } from '../shared/agent-model-selector';
|
||||
import type { PhaseModelEntry } from '@automaker/types';
|
||||
|
||||
interface InputControlsProps {
|
||||
input: string;
|
||||
onInputChange: (value: string) => void;
|
||||
onSend: () => void;
|
||||
onStop: () => void;
|
||||
onToggleImageDropZone: () => void;
|
||||
onPaste: (e: React.ClipboardEvent) => Promise<void>;
|
||||
/** Current model selection (model + optional thinking level) */
|
||||
modelSelection: PhaseModelEntry;
|
||||
/** Callback when model is selected */
|
||||
onModelSelect: (entry: PhaseModelEntry) => void;
|
||||
isProcessing: boolean;
|
||||
isConnected: boolean;
|
||||
hasFiles: boolean;
|
||||
isDragOver: boolean;
|
||||
showImageDropZone: boolean;
|
||||
// Drag handlers
|
||||
onDragEnter: (e: React.DragEvent) => void;
|
||||
onDragLeave: (e: React.DragEvent) => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDrop: (e: React.DragEvent) => Promise<void>;
|
||||
// Refs
|
||||
inputRef?: React.RefObject<HTMLTextAreaElement | null>;
|
||||
}
|
||||
|
||||
export function InputControls({
|
||||
input,
|
||||
onInputChange,
|
||||
onSend,
|
||||
onStop,
|
||||
onToggleImageDropZone,
|
||||
onPaste,
|
||||
modelSelection,
|
||||
onModelSelect,
|
||||
isProcessing,
|
||||
isConnected,
|
||||
hasFiles,
|
||||
isDragOver,
|
||||
showImageDropZone,
|
||||
onDragEnter,
|
||||
onDragLeave,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
inputRef: externalInputRef,
|
||||
}: InputControlsProps) {
|
||||
const internalInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputRef = externalInputRef || internalInputRef;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSend();
|
||||
}
|
||||
};
|
||||
|
||||
const adjustTextareaHeight = useCallback(() => {
|
||||
const textarea = inputRef.current;
|
||||
if (!textarea) return;
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
}, [inputRef]);
|
||||
|
||||
useEffect(() => {
|
||||
adjustTextareaHeight();
|
||||
}, [input, adjustTextareaHeight]);
|
||||
|
||||
const canSend = (input.trim() || hasFiles) && isConnected;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Text Input and Controls */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-2 transition-all duration-200 rounded-xl p-1',
|
||||
isDragOver && 'bg-primary/5 ring-2 ring-primary/30'
|
||||
)}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<div className="flex-1 relative">
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
placeholder={
|
||||
isDragOver
|
||||
? 'Drop your files here...'
|
||||
: isProcessing
|
||||
? 'Type to queue another prompt...'
|
||||
: 'Describe what you want to build...'
|
||||
}
|
||||
value={input}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={onPaste}
|
||||
disabled={!isConnected}
|
||||
data-testid="agent-input"
|
||||
rows={1}
|
||||
className={cn(
|
||||
'min-h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all resize-none max-h-36 overflow-y-auto py-2.5',
|
||||
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
|
||||
hasFiles && 'border-primary/30',
|
||||
isDragOver && 'border-primary bg-primary/5'
|
||||
)}
|
||||
/>
|
||||
{hasFiles && !isDragOver && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
|
||||
files attached
|
||||
</div>
|
||||
)}
|
||||
{isDragOver && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-xs text-primary font-medium">
|
||||
<Paperclip className="w-3 h-3" />
|
||||
Drop here
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model Selector */}
|
||||
<AgentModelSelector
|
||||
value={modelSelection}
|
||||
onChange={onModelSelect}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
|
||||
{/* File Attachment Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onToggleImageDropZone}
|
||||
disabled={!isConnected}
|
||||
className={cn(
|
||||
'h-11 w-11 rounded-xl border-border',
|
||||
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
|
||||
hasFiles && 'border-primary/30 text-primary'
|
||||
)}
|
||||
title="Attach files (images, .txt, .md)"
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* Stop Button (only when processing) */}
|
||||
{isProcessing && (
|
||||
<Button
|
||||
onClick={onStop}
|
||||
disabled={!isConnected}
|
||||
className="h-11 px-4 rounded-xl"
|
||||
variant="destructive"
|
||||
data-testid="stop-agent"
|
||||
title="Stop generation"
|
||||
>
|
||||
<Square className="w-4 h-4 fill-current" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Send / Queue Button */}
|
||||
<Button
|
||||
onClick={onSend}
|
||||
disabled={!canSend}
|
||||
className="h-11 px-4 rounded-xl"
|
||||
variant={isProcessing ? 'outline' : 'default'}
|
||||
data-testid="send-message"
|
||||
title={isProcessing ? 'Add to queue' : 'Send message'}
|
||||
>
|
||||
{isProcessing ? <ListOrdered className="w-4 h-4" /> : <Send className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Keyboard hint */}
|
||||
<p className="text-[11px] text-muted-foreground mt-2 text-center">
|
||||
Press <kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to
|
||||
send,{' '}
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Shift+Enter</kbd>{' '}
|
||||
for new line
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface QueueItem {
|
||||
id: string;
|
||||
message: string;
|
||||
imagePaths?: string[];
|
||||
}
|
||||
|
||||
interface QueueDisplayProps {
|
||||
serverQueue: QueueItem[];
|
||||
onRemoveFromQueue: (id: string) => void;
|
||||
onClearQueue: () => void;
|
||||
}
|
||||
|
||||
export function QueueDisplay({ serverQueue, onRemoveFromQueue, onClearQueue }: QueueDisplayProps) {
|
||||
if (serverQueue.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{serverQueue.length} prompt{serverQueue.length > 1 ? 's' : ''} queued
|
||||
</p>
|
||||
<button
|
||||
onClick={onClearQueue}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{serverQueue.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="group flex items-center gap-2 text-sm bg-muted/50 rounded-lg px-3 py-2 border border-border"
|
||||
>
|
||||
<span className="text-xs text-muted-foreground font-medium min-w-[1.5rem]">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<span className="flex-1 truncate text-foreground">{item.message}</span>
|
||||
{item.imagePaths && item.imagePaths.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{item.imagePaths.length} file{item.imagePaths.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onRemoveFromQueue(item.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-destructive/10 hover:text-destructive rounded transition-all"
|
||||
title="Remove from queue"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Re-export PhaseModelSelector in compact mode for use in agent chat view.
|
||||
* This ensures we have a single source of truth for model selection logic.
|
||||
*/
|
||||
|
||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||
import type { PhaseModelEntry } from '@automaker/types';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { PhaseModelEntry };
|
||||
|
||||
interface AgentModelSelectorProps {
|
||||
/** Current model selection (model + optional thinking level) */
|
||||
value: PhaseModelEntry;
|
||||
/** Callback when model is selected */
|
||||
onChange: (entry: PhaseModelEntry) => void;
|
||||
/** Disabled state */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function AgentModelSelector({ value, onChange, disabled }: AgentModelSelectorProps) {
|
||||
return (
|
||||
<PhaseModelSelector value={value} onChange={onChange} disabled={disabled} compact align="end" />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Agent view constants
|
||||
|
||||
export const WELCOME_MESSAGE = {
|
||||
id: 'welcome',
|
||||
role: 'assistant' as const,
|
||||
content:
|
||||
"Hello! I'm the Automaker Agent. I can help you build software autonomously. I can read and modify files in this project, run commands, and execute tests. What would you like to create today?",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
2
apps/ui/src/components/views/agent-view/shared/index.ts
Normal file
2
apps/ui/src/components/views/agent-view/shared/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AgentModelSelector } from './agent-model-selector';
|
||||
export * from './constants';
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { useAppStore, FileTreeNode, ProjectAnalysis } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -21,6 +22,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const logger = createLogger('AnalysisView');
|
||||
|
||||
const IGNORE_PATTERNS = [
|
||||
'node_modules',
|
||||
'.git',
|
||||
@@ -109,7 +112,7 @@ export function AnalysisView() {
|
||||
|
||||
return nodes;
|
||||
} catch (error) {
|
||||
console.error('Failed to scan directory:', path, error);
|
||||
logger.error('Failed to scan directory:', path, error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
@@ -165,7 +168,7 @@ export function AnalysisView() {
|
||||
|
||||
setProjectAnalysis(analysis);
|
||||
} catch (error) {
|
||||
console.error('Analysis failed:', error);
|
||||
logger.error('Analysis failed:', error);
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
@@ -373,7 +376,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
setSpecError(writeResult.error || 'Failed to write spec file');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate spec:', error);
|
||||
logger.error('Failed to generate spec:', error);
|
||||
setSpecError(error instanceof Error ? error.message : 'Failed to generate spec');
|
||||
} finally {
|
||||
setIsGeneratingSpec(false);
|
||||
@@ -639,12 +642,14 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
category: detectedFeature.category,
|
||||
description: detectedFeature.description,
|
||||
status: 'backlog',
|
||||
});
|
||||
// Initialize with empty steps so the object satisfies the Feature type
|
||||
steps: [],
|
||||
} as any);
|
||||
}
|
||||
|
||||
setFeatureListGenerated(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate feature list:', error);
|
||||
logger.error('Failed to generate feature list:', error);
|
||||
setFeatureListError(
|
||||
error instanceof Error ? error.message : 'Failed to generate feature list'
|
||||
);
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
// @ts-nocheck
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import {
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
rectIntersection,
|
||||
pointerWithin,
|
||||
type PointerEvent as DndPointerEvent,
|
||||
} from '@dnd-kit/core';
|
||||
|
||||
// Custom pointer sensor that ignores drag events from within dialogs
|
||||
class DialogAwarePointerSensor extends PointerSensor {
|
||||
static activators = [
|
||||
{
|
||||
eventName: 'onPointerDown' as const,
|
||||
handler: ({ nativeEvent: event }: { nativeEvent: DndPointerEvent }) => {
|
||||
// Don't start drag if the event originated from inside a dialog
|
||||
if ((event.target as Element)?.closest?.('[role="dialog"]')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
import { useAppStore, Feature } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import type { AutoModeEvent } from '@/types/electron';
|
||||
import type { BacklogPlanResult } from '@automaker/types';
|
||||
import type { ModelAlias, CursorModelId, BacklogPlanResult } from '@automaker/types';
|
||||
import { pathsEqual } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||
@@ -21,10 +40,7 @@ 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 { GraphView } from './graph-view';
|
||||
import {
|
||||
AddFeatureDialog,
|
||||
AgentOutputModal,
|
||||
@@ -33,7 +49,6 @@ import {
|
||||
ArchiveAllVerifiedDialog,
|
||||
DeleteCompletedFeatureDialog,
|
||||
EditFeatureDialog,
|
||||
FeatureSuggestionsDialog,
|
||||
FollowUpDialog,
|
||||
PlanApprovalDialog,
|
||||
} from './board-view/dialogs';
|
||||
@@ -56,24 +71,24 @@ import {
|
||||
useBoardBackground,
|
||||
useBoardPersistence,
|
||||
useFollowUpState,
|
||||
useSuggestionsState,
|
||||
useSelectionMode,
|
||||
} from './board-view/hooks';
|
||||
import { SelectionActionBar } from './board-view/components';
|
||||
import { MassEditDialog } from './board-view/dialogs';
|
||||
import { InitScriptIndicator } from './board-view/init-script-indicator';
|
||||
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
|
||||
|
||||
// Stable empty array to avoid infinite loop in selector
|
||||
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
|
||||
|
||||
const logger = createLogger('Board');
|
||||
|
||||
export function BoardView() {
|
||||
const {
|
||||
currentProject,
|
||||
maxConcurrency,
|
||||
setMaxConcurrency,
|
||||
defaultSkipTests,
|
||||
showProfilesOnly,
|
||||
aiProfiles,
|
||||
kanbanCardDetailLevel,
|
||||
setKanbanCardDetailLevel,
|
||||
boardViewMode,
|
||||
setBoardViewMode,
|
||||
specCreatingForProject,
|
||||
setSpecCreatingForProject,
|
||||
pendingPlanApproval,
|
||||
@@ -85,12 +100,23 @@ export function BoardView() {
|
||||
setWorktrees,
|
||||
useWorktrees,
|
||||
enableDependencyBlocking,
|
||||
skipVerificationInAutoMode,
|
||||
planUseSelectedWorktreeBranch,
|
||||
addFeatureUseSelectedWorktreeBranch,
|
||||
isPrimaryWorktreeBranch,
|
||||
getPrimaryWorktreeBranch,
|
||||
setPipelineConfig,
|
||||
} = useAppStore();
|
||||
// Subscribe to pipelineConfigByProject to trigger re-renders when it changes
|
||||
const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject);
|
||||
// Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes
|
||||
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
|
||||
// Subscribe to showInitScriptIndicatorByProject to trigger re-renders when it changes
|
||||
const showInitScriptIndicatorByProject = useAppStore(
|
||||
(state) => state.showInitScriptIndicatorByProject
|
||||
);
|
||||
const getShowInitScriptIndicator = useAppStore((state) => state.getShowInitScriptIndicator);
|
||||
const getDefaultDeleteBranch = useAppStore((state) => state.getDefaultDeleteBranch);
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
const {
|
||||
features: hookFeatures,
|
||||
@@ -145,27 +171,29 @@ export function BoardView() {
|
||||
followUpPrompt,
|
||||
followUpImagePaths,
|
||||
followUpPreviewMap,
|
||||
followUpPromptHistory,
|
||||
setShowFollowUpDialog,
|
||||
setFollowUpFeature,
|
||||
setFollowUpPrompt,
|
||||
setFollowUpImagePaths,
|
||||
setFollowUpPreviewMap,
|
||||
handleFollowUpDialogChange,
|
||||
addToPromptHistory,
|
||||
} = useFollowUpState();
|
||||
|
||||
// Suggestions state hook
|
||||
// Selection mode hook for mass editing
|
||||
const {
|
||||
showSuggestionsDialog,
|
||||
suggestionsCount,
|
||||
featureSuggestions,
|
||||
isGeneratingSuggestions,
|
||||
setShowSuggestionsDialog,
|
||||
setSuggestionsCount,
|
||||
setFeatureSuggestions,
|
||||
setIsGeneratingSuggestions,
|
||||
updateSuggestions,
|
||||
closeSuggestionsDialog,
|
||||
} = useSuggestionsState();
|
||||
isSelectionMode,
|
||||
selectedFeatureIds,
|
||||
selectedCount,
|
||||
toggleSelectionMode,
|
||||
toggleFeatureSelection,
|
||||
selectAll,
|
||||
clearSelection,
|
||||
exitSelectionMode,
|
||||
} = useSelectionMode();
|
||||
const [showMassEditDialog, setShowMassEditDialog] = useState(false);
|
||||
|
||||
// Search filter for Kanban cards
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
// Plan approval loading state
|
||||
@@ -188,7 +216,7 @@ export function BoardView() {
|
||||
|
||||
return result.success && result.exists === true;
|
||||
} catch (error) {
|
||||
console.error('[Board] Error checking context:', error);
|
||||
logger.error('Error checking context:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
@@ -200,9 +228,6 @@ export function BoardView() {
|
||||
currentProject,
|
||||
specCreatingForProject,
|
||||
setSpecCreatingForProject,
|
||||
setSuggestionsCount,
|
||||
setFeatureSuggestions,
|
||||
setIsGeneratingSuggestions,
|
||||
checkContextExists,
|
||||
features: hookFeatures,
|
||||
isLoading,
|
||||
@@ -222,7 +247,7 @@ export function BoardView() {
|
||||
setPipelineConfig(currentProject.path, result.config);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Board] Failed to load pipeline config:', error);
|
||||
logger.error('Failed to load pipeline config:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -237,6 +262,9 @@ export function BoardView() {
|
||||
// Window state hook for compact dialog mode
|
||||
const { isMaximized } = useWindowState();
|
||||
|
||||
// Init script events hook - subscribe to worktree init script events
|
||||
useInitScriptEvents(currentProject?.path ?? null);
|
||||
|
||||
// Keyboard shortcuts hook will be initialized after actions hook
|
||||
|
||||
// Prevent hydration issues
|
||||
@@ -245,7 +273,7 @@ export function BoardView() {
|
||||
}, []);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
useSensor(DialogAwarePointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
@@ -288,7 +316,7 @@ export function BoardView() {
|
||||
setBranchSuggestions(localBranches);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BoardView] Error fetching branches:', error);
|
||||
logger.error('Error fetching branches:', error);
|
||||
setBranchSuggestions([]);
|
||||
}
|
||||
};
|
||||
@@ -462,6 +490,111 @@ export function BoardView() {
|
||||
currentWorktreeBranch,
|
||||
});
|
||||
|
||||
// Handler for bulk updating multiple features
|
||||
const handleBulkUpdate = useCallback(
|
||||
async (updates: Partial<Feature>) => {
|
||||
if (!currentProject || selectedFeatureIds.size === 0) return;
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const featureIds = Array.from(selectedFeatureIds);
|
||||
const result = await api.features.bulkUpdate(currentProject.path, featureIds, updates);
|
||||
|
||||
if (result.success) {
|
||||
// Update local state
|
||||
featureIds.forEach((featureId) => {
|
||||
updateFeature(featureId, updates);
|
||||
});
|
||||
toast.success(`Updated ${result.updatedCount} features`);
|
||||
exitSelectionMode();
|
||||
} else {
|
||||
toast.error('Failed to update some features', {
|
||||
description: `${result.failedCount} features failed to update`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Bulk update failed:', error);
|
||||
toast.error('Failed to update features');
|
||||
}
|
||||
},
|
||||
[currentProject, selectedFeatureIds, updateFeature, exitSelectionMode]
|
||||
);
|
||||
|
||||
// Handler for bulk deleting multiple features
|
||||
const handleBulkDelete = useCallback(async () => {
|
||||
if (!currentProject || selectedFeatureIds.size === 0) return;
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const featureIds = Array.from(selectedFeatureIds);
|
||||
const result = await api.features.bulkDelete(currentProject.path, featureIds);
|
||||
|
||||
const successfullyDeletedIds =
|
||||
result.results?.filter((r) => r.success).map((r) => r.featureId) ?? [];
|
||||
|
||||
if (successfullyDeletedIds.length > 0) {
|
||||
// Delete from local state without calling the API again
|
||||
successfullyDeletedIds.forEach((featureId) => {
|
||||
useAppStore.getState().removeFeature(featureId);
|
||||
});
|
||||
toast.success(`Deleted ${successfullyDeletedIds.length} features`);
|
||||
}
|
||||
|
||||
if (result.failedCount && result.failedCount > 0) {
|
||||
toast.error('Failed to delete some features', {
|
||||
description: `${result.failedCount} features failed to delete`,
|
||||
});
|
||||
}
|
||||
|
||||
// Exit selection mode and reload if the operation was at least partially processed.
|
||||
if (result.results) {
|
||||
exitSelectionMode();
|
||||
loadFeatures();
|
||||
} else if (!result.success) {
|
||||
toast.error('Failed to delete features', { description: result.error });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Bulk delete failed:', error);
|
||||
toast.error('Failed to delete features');
|
||||
}
|
||||
}, [currentProject, selectedFeatureIds, exitSelectionMode, loadFeatures]);
|
||||
|
||||
// Get selected features for mass edit dialog
|
||||
const selectedFeatures = useMemo(() => {
|
||||
return hookFeatures.filter((f) => selectedFeatureIds.has(f.id));
|
||||
}, [hookFeatures, selectedFeatureIds]);
|
||||
|
||||
// Get backlog feature IDs in current branch for "Select All"
|
||||
const allSelectableFeatureIds = useMemo(() => {
|
||||
return hookFeatures
|
||||
.filter((f) => {
|
||||
// Only backlog features
|
||||
if (f.status !== 'backlog') return false;
|
||||
|
||||
// Filter by current worktree branch
|
||||
const featureBranch = f.branchName;
|
||||
if (!featureBranch) {
|
||||
// No branch assigned - only selectable on primary worktree
|
||||
return currentWorktreePath === null;
|
||||
}
|
||||
if (currentWorktreeBranch === null) {
|
||||
// Viewing main but branch hasn't been initialized
|
||||
return currentProject?.path
|
||||
? isPrimaryWorktreeBranch(currentProject.path, featureBranch)
|
||||
: false;
|
||||
}
|
||||
// Match by branch name
|
||||
return featureBranch === currentWorktreeBranch;
|
||||
})
|
||||
.map((f) => f.id);
|
||||
}, [
|
||||
hookFeatures,
|
||||
currentWorktreePath,
|
||||
currentWorktreeBranch,
|
||||
currentProject?.path,
|
||||
isPrimaryWorktreeBranch,
|
||||
]);
|
||||
|
||||
// Handler for addressing PR comments - creates a feature and starts it automatically
|
||||
const handleAddressPRComments = useCallback(
|
||||
async (worktree: WorktreeInfo, prInfo: PRInfo) => {
|
||||
@@ -497,7 +630,7 @@ export function BoardView() {
|
||||
if (newFeature) {
|
||||
await handleStartImplementation(newFeature);
|
||||
} else {
|
||||
console.error('Could not find newly created feature to start it automatically.');
|
||||
logger.error('Could not find newly created feature to start it automatically.');
|
||||
toast.error('Failed to auto-start feature', {
|
||||
description: 'The feature was created but could not be started automatically.',
|
||||
});
|
||||
@@ -538,7 +671,7 @@ export function BoardView() {
|
||||
if (newFeature) {
|
||||
await handleStartImplementation(newFeature);
|
||||
} else {
|
||||
console.error('Could not find newly created feature to start it automatically.');
|
||||
logger.error('Could not find newly created feature to start it automatically.');
|
||||
toast.error('Failed to auto-start feature', {
|
||||
description: 'The feature was created but could not be started automatically.',
|
||||
});
|
||||
@@ -561,7 +694,7 @@ export function BoardView() {
|
||||
if (newFeature) {
|
||||
await handleStartImplementation(newFeature);
|
||||
} else {
|
||||
console.error('Could not find newly created feature to start it automatically.');
|
||||
logger.error('Could not find newly created feature to start it automatically.');
|
||||
toast.error('Failed to auto-start feature', {
|
||||
description: 'The feature was created but could not be started automatically.',
|
||||
});
|
||||
@@ -665,10 +798,17 @@ export function BoardView() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
logger.info(
|
||||
'[AutoMode] Effect triggered - isRunning:',
|
||||
autoMode.isRunning,
|
||||
'hasProject:',
|
||||
!!currentProject
|
||||
);
|
||||
if (!autoMode.isRunning || !currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('[AutoMode] Starting auto mode polling loop for project:', currentProject.path);
|
||||
let isChecking = false;
|
||||
let isActive = true; // Track if this effect is still active
|
||||
|
||||
@@ -688,6 +828,14 @@ export function BoardView() {
|
||||
try {
|
||||
// Double-check auto mode is still running before proceeding
|
||||
if (!isActive || !autoModeRunningRef.current || !currentProject) {
|
||||
logger.debug(
|
||||
'[AutoMode] Skipping check - isActive:',
|
||||
isActive,
|
||||
'autoModeRunning:',
|
||||
autoModeRunningRef.current,
|
||||
'hasProject:',
|
||||
!!currentProject
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -695,6 +843,12 @@ export function BoardView() {
|
||||
// Use ref to get the latest running tasks without causing effect re-runs
|
||||
const currentRunning = runningAutoTasksRef.current.length + pendingFeaturesRef.current.size;
|
||||
const availableSlots = maxConcurrency - currentRunning;
|
||||
logger.debug(
|
||||
'[AutoMode] Checking features - running:',
|
||||
currentRunning,
|
||||
'available slots:',
|
||||
availableSlots
|
||||
);
|
||||
|
||||
// No available slots, skip check
|
||||
if (availableSlots <= 0) {
|
||||
@@ -702,10 +856,12 @@ export function BoardView() {
|
||||
}
|
||||
|
||||
// Filter backlog features by the currently selected worktree branch
|
||||
// This logic mirrors use-board-column-features.ts for consistency
|
||||
// This logic mirrors use-board-column-features.ts for consistency.
|
||||
// HOWEVER: auto mode should still run even if the user is viewing a non-primary worktree,
|
||||
// so we fall back to "all backlog features" when none are visible in the current view.
|
||||
// Use ref to get the latest features without causing effect re-runs
|
||||
const currentFeatures = hookFeaturesRef.current;
|
||||
const backlogFeatures = currentFeatures.filter((f) => {
|
||||
const backlogFeaturesInView = currentFeatures.filter((f) => {
|
||||
if (f.status !== 'backlog') return false;
|
||||
|
||||
const featureBranch = f.branchName;
|
||||
@@ -729,7 +885,25 @@ export function BoardView() {
|
||||
return featureBranch === currentWorktreeBranch;
|
||||
});
|
||||
|
||||
const backlogFeatures =
|
||||
backlogFeaturesInView.length > 0
|
||||
? backlogFeaturesInView
|
||||
: currentFeatures.filter((f) => f.status === 'backlog');
|
||||
|
||||
logger.debug(
|
||||
'[AutoMode] Features - total:',
|
||||
currentFeatures.length,
|
||||
'backlog in view:',
|
||||
backlogFeaturesInView.length,
|
||||
'backlog total:',
|
||||
backlogFeatures.length
|
||||
);
|
||||
|
||||
if (backlogFeatures.length === 0) {
|
||||
logger.debug(
|
||||
'[AutoMode] No backlog features found, statuses:',
|
||||
currentFeatures.map((f) => f.status).join(', ')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -739,12 +913,25 @@ export function BoardView() {
|
||||
);
|
||||
|
||||
// Filter out features with blocking dependencies if dependency blocking is enabled
|
||||
const eligibleFeatures = enableDependencyBlocking
|
||||
? sortedBacklog.filter((f) => {
|
||||
const blockingDeps = getBlockingDependencies(f, currentFeatures);
|
||||
return blockingDeps.length === 0;
|
||||
})
|
||||
: sortedBacklog;
|
||||
// NOTE: skipVerificationInAutoMode means "ignore unmet dependency verification" so we
|
||||
// should NOT exclude blocked features in that mode.
|
||||
const eligibleFeatures =
|
||||
enableDependencyBlocking && !skipVerificationInAutoMode
|
||||
? sortedBacklog.filter((f) => {
|
||||
const blockingDeps = getBlockingDependencies(f, currentFeatures);
|
||||
if (blockingDeps.length > 0) {
|
||||
logger.debug('[AutoMode] Feature', f.id, 'blocked by deps:', blockingDeps);
|
||||
}
|
||||
return blockingDeps.length === 0;
|
||||
})
|
||||
: sortedBacklog;
|
||||
|
||||
logger.debug(
|
||||
'[AutoMode] Eligible features after dep check:',
|
||||
eligibleFeatures.length,
|
||||
'dependency blocking enabled:',
|
||||
enableDependencyBlocking
|
||||
);
|
||||
|
||||
// Start features up to available slots
|
||||
const featuresToStart = eligibleFeatures.slice(0, availableSlots);
|
||||
@@ -753,6 +940,13 @@ export function BoardView() {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
'[AutoMode] Starting',
|
||||
featuresToStart.length,
|
||||
'features:',
|
||||
featuresToStart.map((f) => f.id).join(', ')
|
||||
);
|
||||
|
||||
for (const feature of featuresToStart) {
|
||||
// Check again before starting each feature
|
||||
if (!isActive || !autoModeRunningRef.current || !currentProject) {
|
||||
@@ -760,8 +954,9 @@ export function BoardView() {
|
||||
}
|
||||
|
||||
// Simplified: No worktree creation on client - server derives workDir from feature.branchName
|
||||
// If feature has no branchName and primary worktree is selected, assign primary branch
|
||||
if (currentWorktreePath === null && !feature.branchName) {
|
||||
// If feature has no branchName, assign it to the primary branch so it can run consistently
|
||||
// even when the user is viewing a non-primary worktree.
|
||||
if (!feature.branchName) {
|
||||
const primaryBranch =
|
||||
(currentProject.path ? getPrimaryWorktreeBranch(currentProject.path) : null) ||
|
||||
'main';
|
||||
@@ -811,6 +1006,7 @@ export function BoardView() {
|
||||
getPrimaryWorktreeBranch,
|
||||
isPrimaryWorktreeBranch,
|
||||
enableDependencyBlocking,
|
||||
skipVerificationInAutoMode,
|
||||
persistFeatureUpdate,
|
||||
]);
|
||||
|
||||
@@ -889,10 +1085,10 @@ export function BoardView() {
|
||||
// Reload features from server to ensure sync
|
||||
loadFeatures();
|
||||
} else {
|
||||
console.error('[Board] Failed to approve plan:', result.error);
|
||||
logger.error('Failed to approve plan:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Board] Error approving plan:', error);
|
||||
logger.error('Error approving plan:', error);
|
||||
} finally {
|
||||
setIsPlanApprovalLoading(false);
|
||||
setPendingPlanApproval(null);
|
||||
@@ -945,10 +1141,10 @@ export function BoardView() {
|
||||
// Reload features from server to ensure sync
|
||||
loadFeatures();
|
||||
} else {
|
||||
console.error('[Board] Failed to reject plan:', result.error);
|
||||
logger.error('Failed to reject plan:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Board] Error rejecting plan:', error);
|
||||
logger.error('Error rejecting plan:', error);
|
||||
} finally {
|
||||
setIsPlanApprovalLoading(false);
|
||||
setPendingPlanApproval(null);
|
||||
@@ -1008,7 +1204,7 @@ export function BoardView() {
|
||||
>
|
||||
{/* Header */}
|
||||
<BoardHeader
|
||||
projectName={currentProject.name}
|
||||
projectPath={currentProject.path}
|
||||
maxConcurrency={maxConcurrency}
|
||||
runningAgentsCount={runningAutoTasks.length}
|
||||
onConcurrencyChange={setMaxConcurrency}
|
||||
@@ -1020,137 +1216,117 @@ export function BoardView() {
|
||||
autoMode.stop();
|
||||
}
|
||||
}}
|
||||
onAddFeature={() => setShowAddDialog(true)}
|
||||
onOpenPlanDialog={() => setShowPlanDialog(true)}
|
||||
addFeatureShortcut={{
|
||||
key: shortcuts.addFeature,
|
||||
action: () => setShowAddDialog(true),
|
||||
description: 'Add new feature',
|
||||
}}
|
||||
isMounted={isMounted}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
isCreatingSpec={isCreatingSpec}
|
||||
creatingSpecProjectPath={creatingSpecProjectPath}
|
||||
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
||||
onShowCompletedModal={() => setShowCompletedModal(true)}
|
||||
completedCount={completedFeatures.length}
|
||||
/>
|
||||
|
||||
{/* Worktree Panel */}
|
||||
<WorktreePanel
|
||||
refreshTrigger={worktreeRefreshKey}
|
||||
projectPath={currentProject.path}
|
||||
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
|
||||
onDeleteWorktree={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowDeleteWorktreeDialog(true);
|
||||
}}
|
||||
onCommit={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCommitWorktreeDialog(true);
|
||||
}}
|
||||
onCreatePR={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCreatePRDialog(true);
|
||||
}}
|
||||
onCreateBranch={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCreateBranchDialog(true);
|
||||
}}
|
||||
onAddressPRComments={handleAddressPRComments}
|
||||
onResolveConflicts={handleResolveConflicts}
|
||||
onRemovedWorktrees={handleRemovedWorktrees}
|
||||
runningFeatureIds={runningAutoTasks}
|
||||
branchCardCounts={branchCardCounts}
|
||||
features={hookFeatures.map((f) => ({
|
||||
id: f.id,
|
||||
branchName: f.branchName,
|
||||
}))}
|
||||
/>
|
||||
{/* Worktree Panel - conditionally rendered based on visibility setting */}
|
||||
{(worktreePanelVisibleByProject[currentProject.path] ?? true) && (
|
||||
<WorktreePanel
|
||||
refreshTrigger={worktreeRefreshKey}
|
||||
projectPath={currentProject.path}
|
||||
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
|
||||
onDeleteWorktree={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowDeleteWorktreeDialog(true);
|
||||
}}
|
||||
onCommit={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCommitWorktreeDialog(true);
|
||||
}}
|
||||
onCreatePR={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCreatePRDialog(true);
|
||||
}}
|
||||
onCreateBranch={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCreateBranchDialog(true);
|
||||
}}
|
||||
onAddressPRComments={handleAddressPRComments}
|
||||
onResolveConflicts={handleResolveConflicts}
|
||||
onRemovedWorktrees={handleRemovedWorktrees}
|
||||
runningFeatureIds={runningAutoTasks}
|
||||
branchCardCounts={branchCardCounts}
|
||||
features={hookFeatures.map((f) => ({
|
||||
id: f.id,
|
||||
branchName: f.branchName,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Search Bar Row */}
|
||||
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
|
||||
<BoardSearchBar
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
isCreatingSpec={isCreatingSpec}
|
||||
creatingSpecProjectPath={creatingSpecProjectPath ?? undefined}
|
||||
currentProjectPath={currentProject?.path}
|
||||
/>
|
||||
|
||||
{/* Board Background & Detail Level Controls */}
|
||||
<BoardControls
|
||||
isMounted={isMounted}
|
||||
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
||||
onShowCompletedModal={() => setShowCompletedModal(true)}
|
||||
completedCount={completedFeatures.length}
|
||||
kanbanCardDetailLevel={kanbanCardDetailLevel}
|
||||
onDetailLevelChange={setKanbanCardDetailLevel}
|
||||
boardViewMode={boardViewMode}
|
||||
onBoardViewModeChange={setBoardViewMode}
|
||||
/>
|
||||
</div>
|
||||
{/* View Content - Kanban or Graph */}
|
||||
{boardViewMode === 'kanban' ? (
|
||||
<KanbanBoard
|
||||
sensors={sensors}
|
||||
collisionDetectionStrategy={collisionDetectionStrategy}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
activeFeature={activeFeature}
|
||||
getColumnFeatures={getColumnFeatures}
|
||||
backgroundImageStyle={backgroundImageStyle}
|
||||
backgroundSettings={backgroundSettings}
|
||||
onEdit={(feature) => setEditingFeature(feature)}
|
||||
onDelete={(featureId) => handleDeleteFeature(featureId)}
|
||||
onViewOutput={handleViewOutput}
|
||||
onVerify={handleVerifyFeature}
|
||||
onResume={handleResumeFeature}
|
||||
onForceStop={handleForceStopFeature}
|
||||
onManualVerify={handleManualVerify}
|
||||
onMoveBackToInProgress={handleMoveBackToInProgress}
|
||||
onFollowUp={handleOpenFollowUp}
|
||||
onCommit={handleCommitFeature}
|
||||
onComplete={handleCompleteFeature}
|
||||
onImplement={handleStartImplementation}
|
||||
onViewPlan={(feature) => setViewPlanFeature(feature)}
|
||||
onApprovePlan={handleOpenApprovalDialog}
|
||||
onSpawnTask={(feature) => {
|
||||
setSpawnParentFeature(feature);
|
||||
setShowAddDialog(true);
|
||||
}}
|
||||
featuresWithContext={featuresWithContext}
|
||||
runningAutoTasks={runningAutoTasks}
|
||||
shortcuts={shortcuts}
|
||||
onStartNextFeatures={handleStartNextFeatures}
|
||||
onShowSuggestions={() => setShowSuggestionsDialog(true)}
|
||||
suggestionsCount={suggestionsCount}
|
||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
||||
pipelineConfig={
|
||||
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
|
||||
}
|
||||
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
|
||||
/>
|
||||
) : (
|
||||
<GraphView
|
||||
features={hookFeatures}
|
||||
runningAutoTasks={runningAutoTasks}
|
||||
currentWorktreePath={currentWorktreePath}
|
||||
currentWorktreeBranch={currentWorktreeBranch}
|
||||
projectPath={currentProject?.path || null}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
onEditFeature={(feature) => setEditingFeature(feature)}
|
||||
onViewOutput={handleViewOutput}
|
||||
onStartTask={handleStartImplementation}
|
||||
onStopTask={handleForceStopFeature}
|
||||
onResumeTask={handleResumeFeature}
|
||||
onUpdateFeature={updateFeature}
|
||||
onSpawnTask={(feature) => {
|
||||
setSpawnParentFeature(feature);
|
||||
setShowAddDialog(true);
|
||||
}}
|
||||
onDeleteTask={(feature) => handleDeleteFeature(feature.id)}
|
||||
/>
|
||||
)}
|
||||
{/* View Content - Kanban Board */}
|
||||
<KanbanBoard
|
||||
sensors={sensors}
|
||||
collisionDetectionStrategy={collisionDetectionStrategy}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
activeFeature={activeFeature}
|
||||
getColumnFeatures={getColumnFeatures}
|
||||
backgroundImageStyle={backgroundImageStyle}
|
||||
backgroundSettings={backgroundSettings}
|
||||
onEdit={(feature) => setEditingFeature(feature)}
|
||||
onDelete={(featureId) => handleDeleteFeature(featureId)}
|
||||
onViewOutput={handleViewOutput}
|
||||
onVerify={handleVerifyFeature}
|
||||
onResume={handleResumeFeature}
|
||||
onForceStop={handleForceStopFeature}
|
||||
onManualVerify={handleManualVerify}
|
||||
onMoveBackToInProgress={handleMoveBackToInProgress}
|
||||
onFollowUp={handleOpenFollowUp}
|
||||
onComplete={handleCompleteFeature}
|
||||
onImplement={handleStartImplementation}
|
||||
onViewPlan={(feature) => setViewPlanFeature(feature)}
|
||||
onApprovePlan={handleOpenApprovalDialog}
|
||||
onSpawnTask={(feature) => {
|
||||
setSpawnParentFeature(feature);
|
||||
setShowAddDialog(true);
|
||||
}}
|
||||
featuresWithContext={featuresWithContext}
|
||||
runningAutoTasks={runningAutoTasks}
|
||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
||||
onAddFeature={() => setShowAddDialog(true)}
|
||||
pipelineConfig={
|
||||
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
|
||||
}
|
||||
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedFeatureIds={selectedFeatureIds}
|
||||
onToggleFeatureSelection={toggleFeatureSelection}
|
||||
onToggleSelectionMode={toggleSelectionMode}
|
||||
isDragging={activeFeature !== null}
|
||||
onAiSuggest={() => setShowPlanDialog(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Selection Action Bar */}
|
||||
{isSelectionMode && (
|
||||
<SelectionActionBar
|
||||
selectedCount={selectedCount}
|
||||
totalCount={allSelectableFeatureIds.length}
|
||||
onEdit={() => setShowMassEditDialog(true)}
|
||||
onDelete={handleBulkDelete}
|
||||
onClear={clearSelection}
|
||||
onSelectAll={() => selectAll(allSelectableFeatureIds)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mass Edit Dialog */}
|
||||
<MassEditDialog
|
||||
open={showMassEditDialog}
|
||||
onClose={() => setShowMassEditDialog(false)}
|
||||
selectedFeatures={selectedFeatures}
|
||||
onApply={handleBulkUpdate}
|
||||
/>
|
||||
|
||||
{/* Board Background Modal */}
|
||||
<BoardBackgroundModal
|
||||
open={showBoardBackgroundModal}
|
||||
@@ -1196,10 +1372,16 @@ export function BoardView() {
|
||||
defaultBranch={selectedWorktreeBranch}
|
||||
currentBranch={currentWorktreeBranch || undefined}
|
||||
isMaximized={isMaximized}
|
||||
showProfilesOnly={showProfilesOnly}
|
||||
aiProfiles={aiProfiles}
|
||||
parentFeature={spawnParentFeature}
|
||||
allFeatures={hookFeatures}
|
||||
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
|
||||
selectedNonMainWorktreeBranch={
|
||||
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
|
||||
? currentWorktreeBranch || undefined
|
||||
: undefined
|
||||
}
|
||||
// When the worktree setting is disabled, force 'current' branch mode
|
||||
forceCurrentBranchMode={!addFeatureUseSelectedWorktreeBranch}
|
||||
/>
|
||||
|
||||
{/* Edit Feature Dialog */}
|
||||
@@ -1212,8 +1394,6 @@ export function BoardView() {
|
||||
branchCardCounts={branchCardCounts}
|
||||
currentBranch={currentWorktreeBranch || undefined}
|
||||
isMaximized={isMaximized}
|
||||
showProfilesOnly={showProfilesOnly}
|
||||
aiProfiles={aiProfiles}
|
||||
allFeatures={hookFeatures}
|
||||
/>
|
||||
|
||||
@@ -1267,17 +1447,8 @@ export function BoardView() {
|
||||
onPreviewMapChange={setFollowUpPreviewMap}
|
||||
onSend={handleSendFollowUp}
|
||||
isMaximized={isMaximized}
|
||||
/>
|
||||
|
||||
{/* Feature Suggestions Dialog */}
|
||||
<FeatureSuggestionsDialog
|
||||
open={showSuggestionsDialog}
|
||||
onClose={closeSuggestionsDialog}
|
||||
projectPath={currentProject.path}
|
||||
suggestions={featureSuggestions}
|
||||
setSuggestions={updateSuggestions}
|
||||
isGenerating={isGeneratingSuggestions}
|
||||
setIsGenerating={setIsGeneratingSuggestions}
|
||||
promptHistory={followUpPromptHistory}
|
||||
onHistoryAdd={addToPromptHistory}
|
||||
/>
|
||||
|
||||
{/* Backlog Plan Dialog */}
|
||||
@@ -1290,6 +1461,7 @@ export function BoardView() {
|
||||
setPendingPlanResult={setPendingBacklogPlan}
|
||||
isGeneratingPlan={isGeneratingPlan}
|
||||
setIsGeneratingPlan={setIsGeneratingPlan}
|
||||
currentBranch={planUseSelectedWorktreeBranch ? selectedWorktreeBranch : undefined}
|
||||
/>
|
||||
|
||||
{/* Plan Approval Dialog */}
|
||||
@@ -1357,6 +1529,7 @@ export function BoardView() {
|
||||
? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length
|
||||
: 0
|
||||
}
|
||||
defaultDeleteBranch={getDefaultDeleteBranch(currentProject.path)}
|
||||
onDeleted={(deletedWorktree, _deletedBranch) => {
|
||||
// Reset features that were assigned to the deleted worktree (by branch)
|
||||
hookFeatures.forEach((feature) => {
|
||||
@@ -1407,7 +1580,7 @@ export function BoardView() {
|
||||
// Persist changes asynchronously and in parallel
|
||||
Promise.all(
|
||||
featuresToUpdate.map((feature) => persistFeatureUpdate(feature.id, { prUrl }))
|
||||
).catch(console.error);
|
||||
).catch((err) => logger.error('Error in handleMove:', err));
|
||||
}
|
||||
setWorktreeRefreshKey((k) => k + 1);
|
||||
setSelectedWorktreeForAction(null);
|
||||
@@ -1424,6 +1597,11 @@ export function BoardView() {
|
||||
setSelectedWorktreeForAction(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Init Script Indicator - floating overlay for worktree init script status */}
|
||||
{getShowInitScriptIndicator(currentProject.path) && (
|
||||
<InitScriptIndicator projectPath={currentProject.path} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { ImageIcon, Archive, Minimize2, Square, Maximize2, Columns3, Network } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BoardViewMode } from '@/store/app-store';
|
||||
import { ImageIcon, Archive } from 'lucide-react';
|
||||
|
||||
interface BoardControlsProps {
|
||||
isMounted: boolean;
|
||||
onShowBoardBackground: () => void;
|
||||
onShowCompletedModal: () => void;
|
||||
completedCount: number;
|
||||
kanbanCardDetailLevel: 'minimal' | 'standard' | 'detailed';
|
||||
onDetailLevelChange: (level: 'minimal' | 'standard' | 'detailed') => void;
|
||||
boardViewMode: BoardViewMode;
|
||||
onBoardViewModeChange: (mode: BoardViewMode) => void;
|
||||
}
|
||||
|
||||
export function BoardControls({
|
||||
@@ -20,61 +14,12 @@ export function BoardControls({
|
||||
onShowBoardBackground,
|
||||
onShowCompletedModal,
|
||||
completedCount,
|
||||
kanbanCardDetailLevel,
|
||||
onDetailLevelChange,
|
||||
boardViewMode,
|
||||
onBoardViewModeChange,
|
||||
}: BoardControlsProps) {
|
||||
if (!isMounted) return null;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{/* View Mode Toggle - Kanban / Graph */}
|
||||
<div
|
||||
className="flex items-center rounded-lg bg-secondary border border-border"
|
||||
data-testid="view-mode-toggle"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onBoardViewModeChange('kanban')}
|
||||
className={cn(
|
||||
'p-2 rounded-l-lg transition-colors',
|
||||
boardViewMode === 'kanban'
|
||||
? 'bg-brand-500/20 text-brand-500'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
data-testid="view-mode-kanban"
|
||||
>
|
||||
<Columns3 className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Kanban Board View</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onBoardViewModeChange('graph')}
|
||||
className={cn(
|
||||
'p-2 rounded-r-lg transition-colors',
|
||||
boardViewMode === 'graph'
|
||||
? 'bg-brand-500/20 text-brand-500'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
data-testid="view-mode-graph"
|
||||
>
|
||||
<Network className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Dependency Graph View</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Board Background Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -115,70 +60,6 @@ export function BoardControls({
|
||||
<p>Completed Features ({completedCount})</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Kanban Card Detail Level Toggle */}
|
||||
<div
|
||||
className="flex items-center rounded-lg bg-secondary border border-border"
|
||||
data-testid="kanban-detail-toggle"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
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'
|
||||
)}
|
||||
data-testid="kanban-toggle-minimal"
|
||||
>
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Minimal - Title & category only</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => 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'
|
||||
)}
|
||||
data-testid="kanban-toggle-standard"
|
||||
>
|
||||
<Square className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Standard - Steps & progress</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => 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'
|
||||
)}
|
||||
data-testid="kanban-toggle-detailed"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Detailed - Model, tools & tasks</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -1,92 +1,216 @@
|
||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Plus, Bot, Wand2 } from 'lucide-react';
|
||||
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||
import { ClaudeUsagePopover } from '@/components/claude-usage-popover';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Bot, Wand2, Settings2, GitBranch } from 'lucide-react';
|
||||
import { UsagePopover } from '@/components/usage-popover';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
|
||||
import { WorktreeSettingsDialog } from './dialogs/worktree-settings-dialog';
|
||||
import { PlanSettingsDialog } from './dialogs/plan-settings-dialog';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { BoardSearchBar } from './board-search-bar';
|
||||
import { BoardControls } from './board-controls';
|
||||
|
||||
interface BoardHeaderProps {
|
||||
projectName: string;
|
||||
projectPath: string;
|
||||
maxConcurrency: number;
|
||||
runningAgentsCount: number;
|
||||
onConcurrencyChange: (value: number) => void;
|
||||
isAutoModeRunning: boolean;
|
||||
onAutoModeToggle: (enabled: boolean) => void;
|
||||
onAddFeature: () => void;
|
||||
onOpenPlanDialog: () => void;
|
||||
addFeatureShortcut: KeyboardShortcut;
|
||||
isMounted: boolean;
|
||||
// Search bar props
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
isCreatingSpec: boolean;
|
||||
creatingSpecProjectPath?: string;
|
||||
// Board controls props
|
||||
onShowBoardBackground: () => void;
|
||||
onShowCompletedModal: () => void;
|
||||
completedCount: number;
|
||||
}
|
||||
|
||||
// Shared styles for header control containers
|
||||
const controlContainerClass =
|
||||
'flex items-center gap-1.5 px-3 h-8 rounded-md bg-secondary border border-border';
|
||||
|
||||
export function BoardHeader({
|
||||
projectName,
|
||||
projectPath,
|
||||
maxConcurrency,
|
||||
runningAgentsCount,
|
||||
onConcurrencyChange,
|
||||
isAutoModeRunning,
|
||||
onAutoModeToggle,
|
||||
onAddFeature,
|
||||
onOpenPlanDialog,
|
||||
addFeatureShortcut,
|
||||
isMounted,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
isCreatingSpec,
|
||||
creatingSpecProjectPath,
|
||||
onShowBoardBackground,
|
||||
onShowCompletedModal,
|
||||
completedCount,
|
||||
}: BoardHeaderProps) {
|
||||
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
|
||||
const [showWorktreeSettings, setShowWorktreeSettings] = useState(false);
|
||||
const [showPlanSettings, setShowPlanSettings] = useState(false);
|
||||
const apiKeys = useAppStore((state) => state.apiKeys);
|
||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||
const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode);
|
||||
const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode);
|
||||
const planUseSelectedWorktreeBranch = useAppStore((state) => state.planUseSelectedWorktreeBranch);
|
||||
const setPlanUseSelectedWorktreeBranch = useAppStore(
|
||||
(state) => state.setPlanUseSelectedWorktreeBranch
|
||||
);
|
||||
const addFeatureUseSelectedWorktreeBranch = useAppStore(
|
||||
(state) => state.addFeatureUseSelectedWorktreeBranch
|
||||
);
|
||||
const setAddFeatureUseSelectedWorktreeBranch = useAppStore(
|
||||
(state) => state.setAddFeatureUseSelectedWorktreeBranch
|
||||
);
|
||||
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
||||
|
||||
// Hide usage tracking when using API key (only show for Claude Code CLI users)
|
||||
// Check both user-entered API key and environment variable ANTHROPIC_API_KEY
|
||||
// Worktree panel visibility (per-project)
|
||||
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
|
||||
const setWorktreePanelVisible = useAppStore((state) => state.setWorktreePanelVisible);
|
||||
const isWorktreePanelVisible = worktreePanelVisibleByProject[projectPath] ?? true;
|
||||
|
||||
const handleWorktreePanelToggle = useCallback(
|
||||
async (visible: boolean) => {
|
||||
// Update local store
|
||||
setWorktreePanelVisible(projectPath, visible);
|
||||
|
||||
// Persist to server
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
await httpClient.settings.updateProject(projectPath, {
|
||||
worktreePanelVisible: visible,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to persist worktree panel visibility:', error);
|
||||
}
|
||||
},
|
||||
[projectPath, setWorktreePanelVisible]
|
||||
);
|
||||
|
||||
// Claude usage tracking visibility logic
|
||||
// Hide when using API key (only show for Claude Code CLI users)
|
||||
// Also hide on Windows for now (CLI usage command not supported)
|
||||
// Only show if CLI has been verified/authenticated
|
||||
const isWindows =
|
||||
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
|
||||
const hasApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
|
||||
const isCliVerified =
|
||||
const hasClaudeApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
|
||||
const isClaudeCliVerified =
|
||||
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
|
||||
const showUsageTracking = !hasApiKey && !isWindows && isCliVerified;
|
||||
const showClaudeUsage = !hasClaudeApiKey && !isWindows && isClaudeCliVerified;
|
||||
|
||||
// Codex usage tracking visibility logic
|
||||
// Show if Codex is authenticated (CLI or API key)
|
||||
const showCodexUsage = !!codexAuthStatus?.authenticated;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Kanban Board</h1>
|
||||
<p className="text-sm text-muted-foreground">{projectName}</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<BoardSearchBar
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={onSearchChange}
|
||||
isCreatingSpec={isCreatingSpec}
|
||||
creatingSpecProjectPath={creatingSpecProjectPath}
|
||||
currentProjectPath={projectPath}
|
||||
/>
|
||||
<BoardControls
|
||||
isMounted={isMounted}
|
||||
onShowBoardBackground={onShowBoardBackground}
|
||||
onShowCompletedModal={onShowCompletedModal}
|
||||
completedCount={completedCount}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{/* Usage Popover - only show for CLI users (not API key users) */}
|
||||
{isMounted && showUsageTracking && <ClaudeUsagePopover />}
|
||||
{/* Usage Popover - show if either provider is authenticated */}
|
||||
{isMounted && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
||||
|
||||
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
|
||||
{/* Worktrees Toggle - only show after mount to prevent hydration issues */}
|
||||
{isMounted && (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border"
|
||||
data-testid="concurrency-slider-container"
|
||||
>
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Agents</span>
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="w-20"
|
||||
data-testid="concurrency-slider"
|
||||
<div className={controlContainerClass} data-testid="worktrees-toggle-container">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<Label htmlFor="worktrees-toggle" className="text-sm font-medium cursor-pointer">
|
||||
Worktrees
|
||||
</Label>
|
||||
<Switch
|
||||
id="worktrees-toggle"
|
||||
checked={isWorktreePanelVisible}
|
||||
onCheckedChange={handleWorktreePanelToggle}
|
||||
data-testid="worktrees-toggle"
|
||||
/>
|
||||
<span
|
||||
className="text-sm text-muted-foreground min-w-[5ch] text-center"
|
||||
data-testid="concurrency-value"
|
||||
<button
|
||||
onClick={() => setShowWorktreeSettings(true)}
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Worktree Settings"
|
||||
data-testid="worktree-settings-button"
|
||||
>
|
||||
{runningAgentsCount} / {maxConcurrency}
|
||||
</span>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Worktree Settings Dialog */}
|
||||
<WorktreeSettingsDialog
|
||||
open={showWorktreeSettings}
|
||||
onOpenChange={setShowWorktreeSettings}
|
||||
addFeatureUseSelectedWorktreeBranch={addFeatureUseSelectedWorktreeBranch}
|
||||
onAddFeatureUseSelectedWorktreeBranchChange={setAddFeatureUseSelectedWorktreeBranch}
|
||||
/>
|
||||
|
||||
{/* Concurrency Control - only show after mount to prevent hydration issues */}
|
||||
{isMounted && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={`${controlContainerClass} cursor-pointer hover:bg-accent/50 transition-colors`}
|
||||
data-testid="concurrency-slider-container"
|
||||
>
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Agents</span>
|
||||
<span className="text-sm text-muted-foreground" data-testid="concurrency-value">
|
||||
{runningAgentsCount}/{maxConcurrency}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="end">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-sm mb-1">Max Concurrent Agents</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Controls how many AI agents can run simultaneously. Higher values process more
|
||||
features in parallel but use more API resources.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
data-testid="concurrency-slider"
|
||||
/>
|
||||
<span className="text-sm font-medium min-w-[2ch] text-right">
|
||||
{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
||||
{isMounted && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border">
|
||||
<div className={controlContainerClass} data-testid="auto-mode-toggle-container">
|
||||
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
|
||||
Auto Mode
|
||||
</Label>
|
||||
@@ -96,29 +220,52 @@ export function BoardHeader({
|
||||
onCheckedChange={onAutoModeToggle}
|
||||
data-testid="auto-mode-toggle"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowAutoModeSettings(true)}
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Auto Mode Settings"
|
||||
data-testid="auto-mode-settings-button"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onOpenPlanDialog}
|
||||
data-testid="plan-backlog-button"
|
||||
>
|
||||
<Wand2 className="w-4 h-4 mr-2" />
|
||||
Plan
|
||||
</Button>
|
||||
{/* Auto Mode Settings Dialog */}
|
||||
<AutoModeSettingsDialog
|
||||
open={showAutoModeSettings}
|
||||
onOpenChange={setShowAutoModeSettings}
|
||||
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
||||
onSkipVerificationChange={setSkipVerificationInAutoMode}
|
||||
/>
|
||||
|
||||
<HotkeyButton
|
||||
size="sm"
|
||||
onClick={onAddFeature}
|
||||
hotkey={addFeatureShortcut}
|
||||
hotkeyActive={false}
|
||||
data-testid="add-feature-button"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Feature
|
||||
</HotkeyButton>
|
||||
{/* Plan Button with Settings */}
|
||||
<div className={controlContainerClass} data-testid="plan-button-container">
|
||||
<button
|
||||
onClick={onOpenPlanDialog}
|
||||
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
|
||||
data-testid="plan-backlog-button"
|
||||
>
|
||||
<Wand2 className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Plan</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowPlanSettings(true)}
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Plan Settings"
|
||||
data-testid="plan-settings-button"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Plan Settings Dialog */}
|
||||
<PlanSettingsDialog
|
||||
open={showPlanSettings}
|
||||
onOpenChange={setShowPlanSettings}
|
||||
planUseSelectedWorktreeBranch={planUseSelectedWorktreeBranch}
|
||||
onPlanUseSelectedWorktreeBranchChange={setPlanUseSelectedWorktreeBranch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { memo } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Kbd } from '@/components/ui/kbd';
|
||||
import { formatShortcut } from '@/store/app-store';
|
||||
import { getEmptyStateConfig, type EmptyStateConfig } from '../constants';
|
||||
import { Lightbulb, Play, Clock, CheckCircle2, Sparkles, Wand2 } from 'lucide-react';
|
||||
|
||||
const ICON_MAP = {
|
||||
lightbulb: Lightbulb,
|
||||
play: Play,
|
||||
clock: Clock,
|
||||
check: CheckCircle2,
|
||||
sparkles: Sparkles,
|
||||
} as const;
|
||||
|
||||
interface EmptyStateCardProps {
|
||||
columnId: string;
|
||||
columnTitle?: string;
|
||||
/** Keyboard shortcut for adding features (from settings) */
|
||||
addFeatureShortcut?: string;
|
||||
/** Whether the column is empty due to active filters */
|
||||
isFilteredEmpty?: boolean;
|
||||
/** Whether we're in read-only mode (hide actions) */
|
||||
isReadOnly?: boolean;
|
||||
/** Called when user clicks "Use AI Suggestions" */
|
||||
onAiSuggest?: () => void;
|
||||
/** Card opacity (matches board settings) */
|
||||
opacity?: number;
|
||||
/** Enable glassmorphism effect */
|
||||
glassmorphism?: boolean;
|
||||
/** Custom config override for pipeline steps */
|
||||
customConfig?: Partial<EmptyStateConfig>;
|
||||
}
|
||||
|
||||
export const EmptyStateCard = memo(function EmptyStateCard({
|
||||
columnId,
|
||||
addFeatureShortcut,
|
||||
isFilteredEmpty = false,
|
||||
isReadOnly = false,
|
||||
onAiSuggest,
|
||||
customConfig,
|
||||
}: EmptyStateCardProps) {
|
||||
// Get base config and merge with custom overrides
|
||||
const baseConfig = getEmptyStateConfig(columnId);
|
||||
const config: EmptyStateConfig = { ...baseConfig, ...customConfig };
|
||||
|
||||
const IconComponent = ICON_MAP[config.icon];
|
||||
const showActions = !isReadOnly && !isFilteredEmpty;
|
||||
const showShortcut = columnId === 'backlog' && addFeatureShortcut && showActions;
|
||||
|
||||
// Action button handler
|
||||
const handlePrimaryAction = () => {
|
||||
if (!config.primaryAction) return;
|
||||
if (config.primaryAction.actionType === 'ai-suggest') {
|
||||
onAiSuggest?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full h-full min-h-[200px] flex-1',
|
||||
'flex flex-col items-center justify-center',
|
||||
'text-center px-4',
|
||||
'transition-all duration-300 ease-out',
|
||||
'animate-in fade-in duration-300',
|
||||
'group'
|
||||
)}
|
||||
data-testid={`empty-state-card-${columnId}`}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="mb-3 text-muted-foreground/30">
|
||||
<IconComponent className="w-8 h-8" />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h4 className="font-medium text-sm text-muted-foreground/50 mb-1">
|
||||
{isFilteredEmpty ? 'No Matching Items' : config.title}
|
||||
</h4>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-xs text-muted-foreground/40 leading-relaxed max-w-[180px]">
|
||||
{isFilteredEmpty ? 'No features match your current filters.' : config.description}
|
||||
</p>
|
||||
|
||||
{/* Keyboard shortcut hint for backlog */}
|
||||
{showShortcut && (
|
||||
<div className="flex items-center gap-1.5 mt-3 text-muted-foreground/40">
|
||||
<span className="text-xs">Press</span>
|
||||
<Kbd className="bg-muted/30 border-0 px-1.5 py-0.5 text-[10px] text-muted-foreground/50">
|
||||
{formatShortcut(addFeatureShortcut, true)}
|
||||
</Kbd>
|
||||
<span className="text-xs">to add</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Suggest action for backlog */}
|
||||
{showActions && config.primaryAction && config.primaryAction.actionType === 'ai-suggest' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-4 h-7 text-xs text-muted-foreground/50 hover:text-muted-foreground/70"
|
||||
onClick={handlePrimaryAction}
|
||||
data-testid={`empty-state-primary-action-${columnId}`}
|
||||
>
|
||||
<Wand2 className="w-3 h-3 mr-1.5" />
|
||||
{config.primaryAction.label}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Filtered empty state hint */}
|
||||
{isFilteredEmpty && (
|
||||
<p className="text-[10px] mt-2 text-muted-foreground/30 italic">
|
||||
Clear filters to see all items
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,2 +1,4 @@
|
||||
export { KanbanCard } from './kanban-card/kanban-card';
|
||||
export { KanbanColumn } from './kanban-column';
|
||||
export { SelectionActionBar } from './selection-action-bar';
|
||||
export { EmptyStateCard } from './empty-state-card';
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// @ts-nocheck
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Feature, ThinkingLevel, useAppStore } from '@/store/app-store';
|
||||
import { Feature, ThinkingLevel } from '@/store/app-store';
|
||||
import type { ReasoningEffort } from '@automaker/types';
|
||||
import { getProviderFromModel } from '@/lib/utils';
|
||||
import {
|
||||
AgentTaskInfo,
|
||||
parseAgentContext,
|
||||
@@ -8,7 +11,6 @@ import {
|
||||
} from '@/lib/agent-context-parser';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Cpu,
|
||||
Brain,
|
||||
ListTodo,
|
||||
Sparkles,
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { SummaryDialog } from './summary-dialog';
|
||||
import { getProviderIconForModel } from '@/components/ui/provider-icon';
|
||||
|
||||
/**
|
||||
* Formats thinking level for compact display
|
||||
@@ -36,6 +39,22 @@ function formatThinkingLevel(level: ThinkingLevel | undefined): string {
|
||||
return labels[level];
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats reasoning effort for compact display
|
||||
*/
|
||||
function formatReasoningEffort(effort: ReasoningEffort | undefined): string {
|
||||
if (!effort || effort === 'none') return '';
|
||||
const labels: Record<ReasoningEffort, string> = {
|
||||
none: '',
|
||||
minimal: 'Min',
|
||||
low: 'Low',
|
||||
medium: 'Med',
|
||||
high: 'High',
|
||||
xhigh: 'XHigh',
|
||||
};
|
||||
return labels[effort];
|
||||
}
|
||||
|
||||
interface AgentInfoPanelProps {
|
||||
feature: Feature;
|
||||
contextContent?: string;
|
||||
@@ -49,11 +68,9 @@ export function AgentInfoPanel({
|
||||
summary,
|
||||
isCurrentAutoTask,
|
||||
}: AgentInfoPanelProps) {
|
||||
const { kanbanCardDetailLevel } = useAppStore();
|
||||
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
||||
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
||||
|
||||
const showAgentInfo = kanbanCardDetailLevel === 'detailed';
|
||||
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadContext = async () => {
|
||||
@@ -104,15 +121,22 @@ export function AgentInfoPanel({
|
||||
}
|
||||
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
|
||||
// Model/Preset Info for Backlog Cards
|
||||
if (showAgentInfo && feature.status === 'backlog') {
|
||||
if (feature.status === 'backlog') {
|
||||
const provider = getProviderFromModel(feature.model);
|
||||
const isCodex = provider === 'codex';
|
||||
const isClaude = provider === 'claude';
|
||||
|
||||
return (
|
||||
<div className="mb-3 space-y-2 overflow-hidden">
|
||||
<div className="flex items-center gap-2 text-[11px] flex-wrap">
|
||||
<div className="flex items-center gap-1 text-[var(--status-info)]">
|
||||
<Cpu className="w-3 h-3" />
|
||||
{(() => {
|
||||
const ProviderIcon = getProviderIconForModel(feature.model);
|
||||
return <ProviderIcon className="w-3 h-3" />;
|
||||
})()}
|
||||
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
|
||||
</div>
|
||||
{feature.thinkingLevel && feature.thinkingLevel !== 'none' ? (
|
||||
{isClaude && feature.thinkingLevel && feature.thinkingLevel !== 'none' ? (
|
||||
<div className="flex items-center gap-1 text-purple-400">
|
||||
<Brain className="w-3 h-3" />
|
||||
<span className="font-medium">
|
||||
@@ -120,20 +144,31 @@ export function AgentInfoPanel({
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{isCodex && feature.reasoningEffort && feature.reasoningEffort !== 'none' ? (
|
||||
<div className="flex items-center gap-1 text-purple-400">
|
||||
<Brain className="w-3 h-3" />
|
||||
<span className="font-medium">
|
||||
{formatReasoningEffort(feature.reasoningEffort as ReasoningEffort)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Agent Info Panel for non-backlog cards
|
||||
if (showAgentInfo && feature.status !== 'backlog' && agentInfo) {
|
||||
if (feature.status !== 'backlog' && agentInfo) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 space-y-2 overflow-hidden">
|
||||
{/* Model & Phase */}
|
||||
<div className="flex items-center gap-2 text-[11px] flex-wrap">
|
||||
<div className="flex items-center gap-1 text-[var(--status-info)]">
|
||||
<Cpu className="w-3 h-3" />
|
||||
{(() => {
|
||||
const ProviderIcon = getProviderIconForModel(feature.model);
|
||||
return <ProviderIcon className="w-3 h-3" />;
|
||||
})()}
|
||||
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
|
||||
</div>
|
||||
{agentInfo.currentPhase && (
|
||||
@@ -163,32 +198,47 @@ export function AgentInfoPanel({
|
||||
{agentInfo.todos.length} tasks
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-0.5 max-h-16 overflow-y-auto">
|
||||
{agentInfo.todos.slice(0, 3).map((todo, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
|
||||
{todo.status === 'completed' ? (
|
||||
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
|
||||
) : todo.status === 'in_progress' ? (
|
||||
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'break-words hyphens-auto line-clamp-2 leading-relaxed',
|
||||
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
|
||||
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
|
||||
todo.status === 'pending' && 'text-muted-foreground/80'
|
||||
<div
|
||||
className={cn(
|
||||
'space-y-0.5 overflow-y-auto',
|
||||
isTodosExpanded ? 'max-h-40' : 'max-h-16'
|
||||
)}
|
||||
>
|
||||
{(isTodosExpanded ? agentInfo.todos : agentInfo.todos.slice(0, 3)).map(
|
||||
(todo, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
|
||||
{todo.status === 'completed' ? (
|
||||
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
|
||||
) : todo.status === 'in_progress' ? (
|
||||
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
|
||||
)}
|
||||
>
|
||||
{todo.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<span
|
||||
className={cn(
|
||||
'break-words hyphens-auto line-clamp-2 leading-relaxed',
|
||||
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
|
||||
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
|
||||
todo.status === 'pending' && 'text-muted-foreground/80'
|
||||
)}
|
||||
>
|
||||
{todo.content}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{agentInfo.todos.length > 3 && (
|
||||
<p className="text-[10px] text-muted-foreground/60 pl-4">
|
||||
+{agentInfo.todos.length - 3} more
|
||||
</p>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsTodosExpanded(!isTodosExpanded);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="text-[10px] text-muted-foreground/60 pl-4 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
{isTodosExpanded ? 'Show less' : `+${agentInfo.todos.length - 3} more`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -218,7 +268,11 @@ export function AgentInfoPanel({
|
||||
<Expand className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground/70 line-clamp-3 break-words hyphens-auto leading-relaxed overflow-hidden">
|
||||
<p
|
||||
className="text-[10px] text-muted-foreground/70 line-clamp-3 break-words hyphens-auto leading-relaxed overflow-hidden select-text cursor-text"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{feature.summary || summary || agentInfo.summary}
|
||||
</p>
|
||||
</div>
|
||||
@@ -255,19 +309,15 @@ export function AgentInfoPanel({
|
||||
);
|
||||
}
|
||||
|
||||
// Always render SummaryDialog if showAgentInfo is true (even if no agentInfo yet)
|
||||
// Always render SummaryDialog (even if no agentInfo yet)
|
||||
// This ensures the dialog can be opened from the expand button
|
||||
return (
|
||||
<>
|
||||
{showAgentInfo && (
|
||||
<SummaryDialog
|
||||
feature={feature}
|
||||
agentInfo={agentInfo}
|
||||
summary={summary}
|
||||
isOpen={isSummaryDialogOpen}
|
||||
onOpenChange={setIsSummaryDialogOpen}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<SummaryDialog
|
||||
feature={feature}
|
||||
agentInfo={agentInfo}
|
||||
summary={summary}
|
||||
isOpen={isSummaryDialogOpen}
|
||||
onOpenChange={setIsSummaryDialogOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -17,6 +18,7 @@ interface CardActionsProps {
|
||||
isCurrentAutoTask: boolean;
|
||||
hasContext?: boolean;
|
||||
shortcutKey?: string;
|
||||
isSelectionMode?: boolean;
|
||||
onEdit: () => void;
|
||||
onViewOutput?: () => void;
|
||||
onVerify?: () => void;
|
||||
@@ -35,6 +37,7 @@ export function CardActions({
|
||||
isCurrentAutoTask,
|
||||
hasContext,
|
||||
shortcutKey,
|
||||
isSelectionMode = false,
|
||||
onEdit,
|
||||
onViewOutput,
|
||||
onVerify,
|
||||
@@ -47,6 +50,11 @@ export function CardActions({
|
||||
onViewPlan,
|
||||
onApprovePlan,
|
||||
}: CardActionsProps) {
|
||||
// Hide all actions when in selection mode
|
||||
if (isSelectionMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 -mx-3 -mb-3 px-3 pb-3">
|
||||
{isCurrentAutoTask && (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Feature, useAppStore } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -5,118 +6,44 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||
import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react';
|
||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||
|
||||
interface CardBadgeProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
'data-testid'?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium',
|
||||
className
|
||||
)}
|
||||
data-testid={dataTestId}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/** Uniform badge style for all card badges */
|
||||
const uniformBadgeClass =
|
||||
'inline-flex items-center justify-center w-6 h-6 rounded-md border-[1.5px]';
|
||||
|
||||
interface CardBadgesProps {
|
||||
feature: Feature;
|
||||
}
|
||||
|
||||
/**
|
||||
* CardBadges - Shows error badges below the card header
|
||||
* Note: Blocked/Lock badges are now shown in PriorityBadges for visual consistency
|
||||
*/
|
||||
export function CardBadges({ feature }: CardBadgesProps) {
|
||||
const { enableDependencyBlocking, features } = useAppStore();
|
||||
|
||||
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
||||
const blockingDependencies = useMemo(() => {
|
||||
if (!enableDependencyBlocking || feature.status !== 'backlog') {
|
||||
return [];
|
||||
}
|
||||
return getBlockingDependencies(feature, features);
|
||||
}, [enableDependencyBlocking, feature, features]);
|
||||
|
||||
// Status badges row (error, blocked)
|
||||
const showStatusBadges =
|
||||
feature.error ||
|
||||
(blockingDependencies.length > 0 &&
|
||||
!feature.error &&
|
||||
!feature.skipTests &&
|
||||
feature.status === 'backlog');
|
||||
|
||||
if (!showStatusBadges) {
|
||||
if (!feature.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-3 pt-1.5 min-h-[24px]">
|
||||
{/* Error badge */}
|
||||
{feature.error && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<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)]'
|
||||
)}
|
||||
data-testid={`error-badge-${feature.id}`}
|
||||
>
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
|
||||
<p>{feature.error}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Blocked badge */}
|
||||
{blockingDependencies.length > 0 &&
|
||||
!feature.error &&
|
||||
!feature.skipTests &&
|
||||
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'
|
||||
)}
|
||||
data-testid={`blocked-badge-${feature.id}`}
|
||||
>
|
||||
<Lock className="w-3 h-3" />
|
||||
</div>
|
||||
</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'}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{blockingDependencies
|
||||
.map((depId) => {
|
||||
const dep = features.find((f) => f.id === depId);
|
||||
return dep?.description || depId;
|
||||
})
|
||||
.join(', ')}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
uniformBadgeClass,
|
||||
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]'
|
||||
)}
|
||||
data-testid={`error-badge-${feature.id}`}
|
||||
>
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
|
||||
<p>{feature.error}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -126,8 +53,17 @@ interface PriorityBadgesProps {
|
||||
}
|
||||
|
||||
export function PriorityBadges({ feature }: PriorityBadgesProps) {
|
||||
const { enableDependencyBlocking, features } = useAppStore();
|
||||
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
||||
|
||||
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
||||
const blockingDependencies = useMemo(() => {
|
||||
if (!enableDependencyBlocking || feature.status !== 'backlog') {
|
||||
return [];
|
||||
}
|
||||
return getBlockingDependencies(feature, features);
|
||||
}, [enableDependencyBlocking, feature, features]);
|
||||
|
||||
const isJustFinished = useMemo(() => {
|
||||
if (!feature.justFinishedAt || feature.status !== 'waiting_approval' || feature.error) {
|
||||
return false;
|
||||
@@ -161,25 +97,27 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
|
||||
};
|
||||
}, [feature.justFinishedAt, feature.status, currentTime]);
|
||||
|
||||
const showPriorityBadges =
|
||||
feature.priority ||
|
||||
(feature.skipTests && !feature.error && feature.status === 'backlog') ||
|
||||
isJustFinished;
|
||||
const isBlocked =
|
||||
blockingDependencies.length > 0 && !feature.error && feature.status === 'backlog';
|
||||
const showManualVerification =
|
||||
feature.skipTests && !feature.error && feature.status === 'backlog';
|
||||
|
||||
if (!showPriorityBadges) {
|
||||
const showBadges = feature.priority || showManualVerification || isBlocked || isJustFinished;
|
||||
|
||||
if (!showBadges) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute top-2 left-2 flex items-center gap-1.5">
|
||||
<div className="absolute top-2 left-2 flex items-center gap-1">
|
||||
{/* Priority badge */}
|
||||
{feature.priority && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CardBadge
|
||||
<div
|
||||
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
|
||||
uniformBadgeClass,
|
||||
feature.priority === 1 &&
|
||||
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]',
|
||||
feature.priority === 2 &&
|
||||
@@ -189,14 +127,10 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
|
||||
)}
|
||||
data-testid={`priority-badge-${feature.id}`}
|
||||
>
|
||||
{feature.priority === 1 ? (
|
||||
<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">L</span>
|
||||
)}
|
||||
</CardBadge>
|
||||
<span className="font-bold text-xs">
|
||||
{feature.priority === 1 ? 'H' : feature.priority === 2 ? 'M' : 'L'}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<p>
|
||||
@@ -210,17 +144,21 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Manual verification badge */}
|
||||
{feature.skipTests && !feature.error && feature.status === 'backlog' && (
|
||||
{showManualVerification && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CardBadge
|
||||
className="bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]"
|
||||
<div
|
||||
className={cn(
|
||||
uniformBadgeClass,
|
||||
'bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]'
|
||||
)}
|
||||
data-testid={`skip-tests-badge-${feature.id}`}
|
||||
>
|
||||
<Hand className="w-3 h-3" />
|
||||
</CardBadge>
|
||||
<Hand className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<p>Manual verification required</p>
|
||||
@@ -229,15 +167,59 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Blocked badge */}
|
||||
{isBlocked && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
uniformBadgeClass,
|
||||
'bg-orange-500/20 border-orange-500/50 text-orange-500'
|
||||
)}
|
||||
data-testid={`blocked-badge-${feature.id}`}
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</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'}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{blockingDependencies
|
||||
.map((depId) => {
|
||||
const dep = features.find((f) => f.id === depId);
|
||||
return dep?.description || depId;
|
||||
})
|
||||
.join(', ')}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Just Finished badge */}
|
||||
{isJustFinished && (
|
||||
<CardBadge
|
||||
className="bg-[var(--status-success-bg)] border-[var(--status-success)]/40 text-[var(--status-success)] animate-pulse"
|
||||
data-testid={`just-finished-badge-${feature.id}`}
|
||||
title="Agent just finished working on this feature"
|
||||
>
|
||||
<Sparkles className="w-3 h-3" />
|
||||
</CardBadge>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
uniformBadgeClass,
|
||||
'bg-[var(--status-success-bg)] border-[var(--status-success)]/40 text-[var(--status-success)] animate-pulse'
|
||||
)}
|
||||
data-testid={`just-finished-badge-${feature.id}`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<p>Agent just finished working on this feature</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { GitBranch, GitPullRequest, ExternalLink } from 'lucide-react';
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import { useState } from 'react';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -18,17 +19,18 @@ import {
|
||||
MoreVertical,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Cpu,
|
||||
GitFork,
|
||||
} 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';
|
||||
import { getProviderIconForModel } from '@/components/ui/provider-icon';
|
||||
|
||||
interface CardHeaderProps {
|
||||
feature: Feature;
|
||||
isDraggable: boolean;
|
||||
isCurrentAutoTask: boolean;
|
||||
isSelectionMode?: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onViewOutput?: () => void;
|
||||
@@ -39,6 +41,7 @@ export function CardHeaderSection({
|
||||
feature,
|
||||
isDraggable,
|
||||
isCurrentAutoTask,
|
||||
isSelectionMode = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewOutput,
|
||||
@@ -59,7 +62,7 @@ export function CardHeaderSection({
|
||||
return (
|
||||
<CardHeader className="p-3 pb-2 block">
|
||||
{/* Running task header */}
|
||||
{isCurrentAutoTask && (
|
||||
{isCurrentAutoTask && !isSelectionMode && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
<div className="flex items-center justify-center gap-2 bg-[var(--status-in-progress)]/15 border border-[var(--status-in-progress)]/50 rounded-md px-2 py-0.5">
|
||||
<Loader2 className="w-3.5 h-3.5 text-[var(--status-in-progress)] animate-spin" />
|
||||
@@ -107,19 +110,24 @@ export function CardHeaderSection({
|
||||
Spawn Sub-Task
|
||||
</DropdownMenuItem>
|
||||
{/* Model info in dropdown */}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const ProviderIcon = getProviderIconForModel(feature.model);
|
||||
return (
|
||||
<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">
|
||||
<ProviderIcon className="w-3 h-3" />
|
||||
<span>{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backlog header */}
|
||||
{!isCurrentAutoTask && feature.status === 'backlog' && (
|
||||
{!isCurrentAutoTask && !isSelectionMode && feature.status === 'backlog' && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -150,6 +158,7 @@ export function CardHeaderSection({
|
||||
|
||||
{/* Waiting approval / Verified header */}
|
||||
{!isCurrentAutoTask &&
|
||||
!isSelectionMode &&
|
||||
(feature.status === 'waiting_approval' || feature.status === 'verified') && (
|
||||
<>
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
@@ -285,12 +294,17 @@ export function CardHeaderSection({
|
||||
Spawn Sub-Task
|
||||
</DropdownMenuItem>
|
||||
{/* Model info in dropdown */}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const ProviderIcon = getProviderIconForModel(feature.model);
|
||||
return (
|
||||
<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">
|
||||
<ProviderIcon className="w-3 h-3" />
|
||||
<span>{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// @ts-nocheck
|
||||
import React, { memo, useLayoutEffect, useState } from 'react';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Feature, useAppStore } from '@/store/app-store';
|
||||
import { CardBadges, PriorityBadges } from './card-badges';
|
||||
import { CardHeaderSection } from './card-header';
|
||||
@@ -22,7 +24,12 @@ function getCardBorderStyle(enabled: boolean, opacity: number): React.CSSPropert
|
||||
return {};
|
||||
}
|
||||
|
||||
function getCursorClass(isOverlay: boolean | undefined, isDraggable: boolean): string {
|
||||
function getCursorClass(
|
||||
isOverlay: boolean | undefined,
|
||||
isDraggable: boolean,
|
||||
isSelectionMode: boolean
|
||||
): string {
|
||||
if (isSelectionMode) return 'cursor-pointer';
|
||||
if (isOverlay) return 'cursor-grabbing';
|
||||
if (isDraggable) return 'cursor-grab active:cursor-grabbing';
|
||||
return 'cursor-default';
|
||||
@@ -54,6 +61,10 @@ interface KanbanCardProps {
|
||||
cardBorderEnabled?: boolean;
|
||||
cardBorderOpacity?: number;
|
||||
isOverlay?: boolean;
|
||||
// Selection mode props
|
||||
isSelectionMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
onToggleSelect?: () => void;
|
||||
}
|
||||
|
||||
export const KanbanCard = memo(function KanbanCard({
|
||||
@@ -82,6 +93,9 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
cardBorderEnabled = true,
|
||||
cardBorderOpacity = 100,
|
||||
isOverlay,
|
||||
isSelectionMode = false,
|
||||
isSelected = false,
|
||||
onToggleSelect,
|
||||
}: KanbanCardProps) {
|
||||
const { useWorktrees } = useAppStore();
|
||||
const [isLifted, setIsLifted] = useState(false);
|
||||
@@ -95,13 +109,14 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
}, [isOverlay]);
|
||||
|
||||
const isDraggable =
|
||||
feature.status === 'backlog' ||
|
||||
feature.status === 'waiting_approval' ||
|
||||
feature.status === 'verified' ||
|
||||
(feature.status === 'in_progress' && !isCurrentAutoTask);
|
||||
!isSelectionMode &&
|
||||
(feature.status === 'backlog' ||
|
||||
feature.status === 'waiting_approval' ||
|
||||
feature.status === 'verified' ||
|
||||
(feature.status === 'in_progress' && !isCurrentAutoTask));
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: feature.id,
|
||||
disabled: !isDraggable || isOverlay,
|
||||
disabled: !isDraggable || isOverlay || isSelectionMode,
|
||||
});
|
||||
|
||||
const dndStyle = {
|
||||
@@ -110,9 +125,12 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
|
||||
const cardStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity);
|
||||
|
||||
// Only allow selection for backlog features
|
||||
const isSelectable = isSelectionMode && feature.status === 'backlog';
|
||||
|
||||
const wrapperClasses = cn(
|
||||
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
|
||||
getCursorClass(isOverlay, isDraggable),
|
||||
getCursorClass(isOverlay, isDraggable, isSelectable),
|
||||
isOverlay && isLifted && 'scale-105 rotate-1 z-50'
|
||||
);
|
||||
|
||||
@@ -127,14 +145,24 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
!isCurrentAutoTask &&
|
||||
cardBorderEnabled &&
|
||||
(cardBorderOpacity === 100 ? 'border-border/50' : 'border'),
|
||||
hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg'
|
||||
hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
|
||||
isSelected && isSelectable && 'ring-2 ring-brand-500 ring-offset-1 ring-offset-background'
|
||||
);
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
if (isSelectable && onToggleSelect) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggleSelect();
|
||||
}
|
||||
};
|
||||
|
||||
const renderCardContent = () => (
|
||||
<Card
|
||||
style={isCurrentAutoTask ? undefined : cardStyle}
|
||||
className={innerCardClasses}
|
||||
onDoubleClick={onEdit}
|
||||
onDoubleClick={isSelectionMode ? undefined : onEdit}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
{/* Background overlay with opacity */}
|
||||
{(!isDragging || isOverlay) && (
|
||||
@@ -150,8 +178,16 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
{/* Status Badges Row */}
|
||||
<CardBadges feature={feature} />
|
||||
|
||||
{/* Category row */}
|
||||
<div className="px-3 pt-4">
|
||||
{/* Category row with selection checkbox */}
|
||||
<div className="px-3 pt-3 flex items-center gap-2">
|
||||
{isSelectionMode && !isOverlay && feature.status === 'backlog' && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleSelect?.()}
|
||||
className="h-4 w-4 border-2 data-[state=checked]:bg-brand-500 data-[state=checked]:border-brand-500 shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground/70 font-medium">{feature.category}</span>
|
||||
</div>
|
||||
|
||||
@@ -163,6 +199,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
feature={feature}
|
||||
isDraggable={isDraggable}
|
||||
isCurrentAutoTask={!!isCurrentAutoTask}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onViewOutput={onViewOutput}
|
||||
@@ -187,6 +224,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
isCurrentAutoTask={!!isCurrentAutoTask}
|
||||
hasContext={hasContext}
|
||||
shortcutKey={shortcutKey}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onEdit={onEdit}
|
||||
onViewOutput={onViewOutput}
|
||||
onVerify={onVerify}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { AgentTaskInfo } from '@/lib/agent-context-parser';
|
||||
import {
|
||||
@@ -30,8 +31,11 @@ export function SummaryDialog({
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col"
|
||||
className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col select-text"
|
||||
data-testid={`summary-dialog-${feature.id}`}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
|
||||
@@ -10,6 +10,8 @@ interface KanbanColumnProps {
|
||||
count: number;
|
||||
children: ReactNode;
|
||||
headerAction?: ReactNode;
|
||||
/** Floating action button at the bottom of the column */
|
||||
footerAction?: ReactNode;
|
||||
opacity?: number;
|
||||
showBorder?: boolean;
|
||||
hideScrollbar?: boolean;
|
||||
@@ -24,6 +26,7 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
count,
|
||||
children,
|
||||
headerAction,
|
||||
footerAction,
|
||||
opacity = 100,
|
||||
showBorder = true,
|
||||
hideScrollbar = false,
|
||||
@@ -79,12 +82,21 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
hideScrollbar &&
|
||||
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
|
||||
// Smooth scrolling
|
||||
'scroll-smooth'
|
||||
'scroll-smooth',
|
||||
// Add padding at bottom if there's a footer action
|
||||
footerAction && 'pb-14'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Floating Footer Action */}
|
||||
{footerAction && (
|
||||
<div className="absolute bottom-0 left-0 right-0 z-20 p-2 bg-gradient-to-t from-card/95 via-card/80 to-transparent pt-6">
|
||||
{footerAction}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drop zone indicator when dragging over */}
|
||||
{isOver && (
|
||||
<div className="absolute inset-0 rounded-xl bg-primary/5 pointer-events-none z-5 border-2 border-dashed border-primary/20" />
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Pencil, X, CheckSquare, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface SelectionActionBarProps {
|
||||
selectedCount: number;
|
||||
totalCount: number;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onClear: () => void;
|
||||
onSelectAll: () => void;
|
||||
}
|
||||
|
||||
export function SelectionActionBar({
|
||||
selectedCount,
|
||||
totalCount,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onClear,
|
||||
onSelectAll,
|
||||
}: SelectionActionBarProps) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
if (selectedCount === 0) return null;
|
||||
|
||||
const allSelected = selectedCount === totalCount;
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
setShowDeleteDialog(false);
|
||||
onDelete();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-6 left-1/2 -translate-x-1/2 z-50',
|
||||
'flex items-center gap-3 px-4 py-3 rounded-xl',
|
||||
'bg-background/95 backdrop-blur-sm border border-border shadow-lg',
|
||||
'animate-in slide-in-from-bottom-4 fade-in duration-200'
|
||||
)}
|
||||
data-testid="selection-action-bar"
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{selectedCount} feature{selectedCount !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
className="h-8 bg-brand-500 hover:bg-brand-600"
|
||||
data-testid="selection-edit-button"
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-1.5" />
|
||||
Edit Selected
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDeleteClick}
|
||||
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
data-testid="selection-delete-button"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1.5" />
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
{!allSelected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onSelectAll}
|
||||
className="h-8"
|
||||
data-testid="selection-select-all-button"
|
||||
>
|
||||
<CheckSquare className="w-4 h-4 mr-1.5" />
|
||||
Select All ({totalCount})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClear}
|
||||
className="h-8 text-muted-foreground hover:text-foreground"
|
||||
data-testid="selection-clear-button"
|
||||
>
|
||||
<X className="w-4 h-4 mr-1.5" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<DialogContent data-testid="bulk-delete-confirmation-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<Trash2 className="w-5 h-5" />
|
||||
Delete Selected Features?
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to permanently delete {selectedCount} feature
|
||||
{selectedCount !== 1 ? 's' : ''}?
|
||||
<span className="block mt-2 text-destructive font-medium">
|
||||
This action cannot be undone.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowDeleteDialog(false)}
|
||||
data-testid="cancel-bulk-delete-button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirmDelete}
|
||||
data-testid="confirm-bulk-delete-button"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,69 @@ import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types
|
||||
|
||||
export type ColumnId = Feature['status'];
|
||||
|
||||
/**
|
||||
* Empty state configuration for each column type
|
||||
*/
|
||||
export interface EmptyStateConfig {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: 'lightbulb' | 'play' | 'clock' | 'check' | 'sparkles';
|
||||
shortcutKey?: string; // Keyboard shortcut label (e.g., 'N', 'A')
|
||||
shortcutHint?: string; // Human-readable shortcut hint
|
||||
primaryAction?: {
|
||||
label: string;
|
||||
actionType: 'ai-suggest' | 'none';
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default empty state configurations per column type
|
||||
*/
|
||||
export const EMPTY_STATE_CONFIGS: Record<string, EmptyStateConfig> = {
|
||||
backlog: {
|
||||
title: 'Ready for Ideas',
|
||||
description:
|
||||
'Add your first feature idea to get started using the button below, or let AI help generate ideas.',
|
||||
icon: 'lightbulb',
|
||||
shortcutHint: 'Press',
|
||||
primaryAction: {
|
||||
label: 'Use AI Suggestions',
|
||||
actionType: 'none',
|
||||
},
|
||||
},
|
||||
in_progress: {
|
||||
title: 'Nothing in Progress',
|
||||
description: 'Drag a feature from the backlog here or click implement to start working on it.',
|
||||
icon: 'play',
|
||||
},
|
||||
waiting_approval: {
|
||||
title: 'No Items Awaiting Approval',
|
||||
description: 'Features will appear here after implementation is complete and need your review.',
|
||||
icon: 'clock',
|
||||
},
|
||||
verified: {
|
||||
title: 'No Verified Features',
|
||||
description: 'Approved features will appear here. They can then be completed and archived.',
|
||||
icon: 'check',
|
||||
},
|
||||
// Pipeline step default configuration
|
||||
pipeline_default: {
|
||||
title: 'Pipeline Step Empty',
|
||||
description: 'Features will flow through this step during the automated pipeline process.',
|
||||
icon: 'sparkles',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get empty state config for a column, with fallback for pipeline columns
|
||||
*/
|
||||
export function getEmptyStateConfig(columnId: string): EmptyStateConfig {
|
||||
if (columnId.startsWith('pipeline_')) {
|
||||
return EMPTY_STATE_CONFIGS.pipeline_default;
|
||||
}
|
||||
return EMPTY_STATE_CONFIGS[columnId] || EMPTY_STATE_CONFIGS.default;
|
||||
}
|
||||
|
||||
export interface Column {
|
||||
id: FeatureStatusWithPipeline;
|
||||
title: string;
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
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 { Textarea } from '@/components/ui/textarea';
|
||||
import { Upload } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { PipelineStep } from '@automaker/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { STEP_TEMPLATES } from './pipeline-step-templates';
|
||||
|
||||
// Color options for pipeline columns
|
||||
const COLOR_OPTIONS = [
|
||||
{ value: 'bg-blue-500/20', label: 'Blue', preview: 'bg-blue-500' },
|
||||
{ value: 'bg-purple-500/20', label: 'Purple', preview: 'bg-purple-500' },
|
||||
{ value: 'bg-green-500/20', label: 'Green', preview: 'bg-green-500' },
|
||||
{ value: 'bg-orange-500/20', label: 'Orange', preview: 'bg-orange-500' },
|
||||
{ value: 'bg-red-500/20', label: 'Red', preview: 'bg-red-500' },
|
||||
{ value: 'bg-pink-500/20', label: 'Pink', preview: 'bg-pink-500' },
|
||||
{ value: 'bg-cyan-500/20', label: 'Cyan', preview: 'bg-cyan-500' },
|
||||
{ value: 'bg-amber-500/20', label: 'Amber', preview: 'bg-amber-500' },
|
||||
{ value: 'bg-indigo-500/20', label: 'Indigo', preview: 'bg-indigo-500' },
|
||||
];
|
||||
|
||||
interface AddEditPipelineStepDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (step: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'> & { id?: string }) => void;
|
||||
existingStep?: PipelineStep | null;
|
||||
defaultOrder: number;
|
||||
}
|
||||
|
||||
export function AddEditPipelineStepDialog({
|
||||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
existingStep,
|
||||
defaultOrder,
|
||||
}: AddEditPipelineStepDialogProps) {
|
||||
const isEditing = !!existingStep;
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [instructions, setInstructions] = useState('');
|
||||
const [colorClass, setColorClass] = useState(COLOR_OPTIONS[0].value);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||||
|
||||
// Reset form when dialog opens/closes or existingStep changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (existingStep) {
|
||||
setName(existingStep.name);
|
||||
setInstructions(existingStep.instructions);
|
||||
setColorClass(existingStep.colorClass);
|
||||
setSelectedTemplate(null);
|
||||
} else {
|
||||
setName('');
|
||||
setInstructions('');
|
||||
setColorClass(COLOR_OPTIONS[defaultOrder % COLOR_OPTIONS.length].value);
|
||||
setSelectedTemplate(null);
|
||||
}
|
||||
}
|
||||
}, [open, existingStep, defaultOrder]);
|
||||
|
||||
const handleTemplateClick = (templateId: string) => {
|
||||
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
|
||||
if (template) {
|
||||
setName(template.name);
|
||||
setInstructions(template.instructions);
|
||||
setColorClass(template.colorClass);
|
||||
setSelectedTemplate(templateId);
|
||||
toast.success(`Loaded "${template.name}" template`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const content = await file.text();
|
||||
setInstructions(content);
|
||||
toast.success('Instructions loaded from file');
|
||||
} catch {
|
||||
toast.error('Failed to load file');
|
||||
}
|
||||
|
||||
// Reset the input so the same file can be selected again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim()) {
|
||||
toast.error('Step name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!instructions.trim()) {
|
||||
toast.error('Step instructions are required');
|
||||
return;
|
||||
}
|
||||
|
||||
onSave({
|
||||
id: existingStep?.id,
|
||||
name: name.trim(),
|
||||
instructions: instructions.trim(),
|
||||
colorClass,
|
||||
order: existingStep?.order ?? defaultOrder,
|
||||
});
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
{/* Hidden file input for loading instructions from .md files */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".md,.txt"
|
||||
className="hidden"
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? 'Edit Pipeline Step' : 'Add Pipeline Step'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing
|
||||
? 'Modify the step configuration below.'
|
||||
: 'Configure a new step for your pipeline. Choose a template to get started quickly, or create from scratch.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-4 space-y-6">
|
||||
{/* Template Quick Start - Only show for new steps */}
|
||||
{!isEditing && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">Quick Start from Template</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{STEP_TEMPLATES.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
onClick={() => handleTemplateClick(template.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg border transition-all text-sm',
|
||||
selectedTemplate === template.id
|
||||
? 'border-primary bg-primary/10 ring-1 ring-primary'
|
||||
: 'border-border hover:border-primary/50 hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn('w-2 h-2 rounded-full', template.colorClass.replace('/20', ''))}
|
||||
/>
|
||||
{template.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click a template to pre-fill the form, then customize as needed.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
{!isEditing && <div className="border-t" />}
|
||||
|
||||
{/* Step Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="step-name">
|
||||
Step Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="step-name"
|
||||
placeholder="e.g., Code Review, Testing, Documentation"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoFocus={isEditing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Color Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>Column Color</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COLOR_OPTIONS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-full transition-all',
|
||||
color.preview,
|
||||
colorClass === color.value
|
||||
? 'ring-2 ring-offset-2 ring-primary'
|
||||
: 'opacity-60 hover:opacity-100'
|
||||
)}
|
||||
onClick={() => setColorClass(color.value)}
|
||||
title={color.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Instructions */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="step-instructions">
|
||||
Agent Instructions <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={handleFileUpload}>
|
||||
<Upload className="h-3 w-3 mr-1" />
|
||||
Load from file
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
id="step-instructions"
|
||||
placeholder="Instructions for the agent to follow during this pipeline step. Use markdown formatting for best results."
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
rows={10}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
These instructions will be sent to the agent when this step runs. Be specific about
|
||||
what you want the agent to review, check, or modify.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>{isEditing ? 'Update Step' : 'Add to Pipeline'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
// @ts-nocheck
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -7,11 +9,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
|
||||
import {
|
||||
DescriptionImageDropZone,
|
||||
@@ -19,49 +21,67 @@ import {
|
||||
FeatureTextFilePath as DescriptionTextFilePath,
|
||||
ImagePreviewMap,
|
||||
} from '@/components/ui/description-image-dropzone';
|
||||
import {
|
||||
MessageSquare,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
Play,
|
||||
} from 'lucide-react';
|
||||
import { Play, Cpu, FolderKanban } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { modelSupportsThinking } from '@/lib/utils';
|
||||
import {
|
||||
useAppStore,
|
||||
AgentModel,
|
||||
ModelAlias,
|
||||
ThinkingLevel,
|
||||
FeatureImage,
|
||||
AIProfile,
|
||||
PlanningMode,
|
||||
Feature,
|
||||
} from '@/store/app-store';
|
||||
import type { ReasoningEffort, PhaseModelEntry } from '@automaker/types';
|
||||
import { supportsReasoningEffort, isClaudeModel } from '@automaker/types';
|
||||
import {
|
||||
ModelSelector,
|
||||
ThinkingLevelSelector,
|
||||
ProfileQuickSelect,
|
||||
TestingTabContent,
|
||||
PrioritySelector,
|
||||
BranchSelector,
|
||||
PlanningModeSelector,
|
||||
WorkModeSelector,
|
||||
PlanningModeSelect,
|
||||
AncestorContextSection,
|
||||
EnhanceWithAI,
|
||||
EnhancementHistoryButton,
|
||||
type BaseHistoryEntry,
|
||||
} from '../shared';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import type { WorkMode } from '../shared';
|
||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
getAncestors,
|
||||
formatAncestorContextForPrompt,
|
||||
type AncestorContext,
|
||||
} from '@automaker/dependency-resolver';
|
||||
|
||||
const logger = createLogger('AddFeatureDialog');
|
||||
|
||||
/**
|
||||
* Determines the default work mode based on global settings and current worktree selection.
|
||||
*
|
||||
* Priority:
|
||||
* 1. If forceCurrentBranchMode is true, always defaults to 'current' (work on current branch)
|
||||
* 2. If a non-main worktree is selected in the board header, defaults to 'custom' (use that branch)
|
||||
* 3. If useWorktrees global setting is enabled, defaults to 'auto' (automatic worktree creation)
|
||||
* 4. Otherwise, defaults to 'current' (work on current branch without isolation)
|
||||
*/
|
||||
const getDefaultWorkMode = (
|
||||
useWorktrees: boolean,
|
||||
selectedNonMainWorktreeBranch?: string,
|
||||
forceCurrentBranchMode?: boolean
|
||||
): WorkMode => {
|
||||
// If force current branch mode is enabled (worktree setting is off), always use 'current'
|
||||
if (forceCurrentBranchMode) {
|
||||
return 'current';
|
||||
}
|
||||
// If a non-main worktree is selected, default to 'custom' mode with that branch
|
||||
if (selectedNonMainWorktreeBranch) {
|
||||
return 'custom';
|
||||
}
|
||||
// Otherwise, respect the global worktree setting
|
||||
return useWorktrees ? 'auto' : 'current';
|
||||
};
|
||||
|
||||
type FeatureData = {
|
||||
title: string;
|
||||
category: string;
|
||||
@@ -72,11 +92,13 @@ type FeatureData = {
|
||||
skipTests: boolean;
|
||||
model: AgentModel;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
branchName: string; // Can be empty string to use current branch
|
||||
reasoningEffort: ReasoningEffort;
|
||||
branchName: string;
|
||||
priority: number;
|
||||
planningMode: PlanningMode;
|
||||
requirePlanApproval: boolean;
|
||||
dependencies?: string[];
|
||||
workMode: WorkMode;
|
||||
};
|
||||
|
||||
interface AddFeatureDialogProps {
|
||||
@@ -86,16 +108,30 @@ interface AddFeatureDialogProps {
|
||||
onAddAndStart?: (feature: FeatureData) => void;
|
||||
categorySuggestions: string[];
|
||||
branchSuggestions: string[];
|
||||
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
|
||||
branchCardCounts?: Record<string, number>;
|
||||
defaultSkipTests: boolean;
|
||||
defaultBranch?: string;
|
||||
currentBranch?: string;
|
||||
isMaximized: boolean;
|
||||
showProfilesOnly: boolean;
|
||||
aiProfiles: AIProfile[];
|
||||
// Spawn task mode props
|
||||
parentFeature?: Feature | null;
|
||||
allFeatures?: Feature[];
|
||||
/**
|
||||
* When a non-main worktree is selected in the board header, this will be set to that worktree's branch.
|
||||
* When set, the dialog will default to 'custom' work mode with this branch pre-filled.
|
||||
*/
|
||||
selectedNonMainWorktreeBranch?: string;
|
||||
/**
|
||||
* When true, forces the dialog to default to 'current' work mode (work on current branch).
|
||||
* This is used when the "Use selected worktree branch" setting is disabled.
|
||||
*/
|
||||
forceCurrentBranchMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single entry in the description history
|
||||
*/
|
||||
interface DescriptionHistoryEntry extends BaseHistoryEntry {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function AddFeatureDialog({
|
||||
@@ -110,77 +146,76 @@ export function AddFeatureDialog({
|
||||
defaultBranch = 'main',
|
||||
currentBranch,
|
||||
isMaximized,
|
||||
showProfilesOnly,
|
||||
aiProfiles,
|
||||
parentFeature = null,
|
||||
allFeatures = [],
|
||||
selectedNonMainWorktreeBranch,
|
||||
forceCurrentBranchMode,
|
||||
}: AddFeatureDialogProps) {
|
||||
const isSpawnMode = !!parentFeature;
|
||||
const navigate = useNavigate();
|
||||
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
|
||||
const [newFeature, setNewFeature] = useState({
|
||||
title: '',
|
||||
category: '',
|
||||
description: '',
|
||||
images: [] as FeatureImage[],
|
||||
imagePaths: [] as DescriptionImagePath[],
|
||||
textFilePaths: [] as DescriptionTextFilePath[],
|
||||
skipTests: false,
|
||||
model: 'opus' as AgentModel,
|
||||
thinkingLevel: 'none' as ThinkingLevel,
|
||||
branchName: '',
|
||||
priority: 2 as number, // Default to medium priority
|
||||
});
|
||||
const [newFeaturePreviewMap, setNewFeaturePreviewMap] = useState<ImagePreviewMap>(
|
||||
() => new Map()
|
||||
);
|
||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
||||
const [descriptionError, setDescriptionError] = useState(false);
|
||||
const [isEnhancing, setIsEnhancing] = useState(false);
|
||||
const [enhancementMode, setEnhancementMode] = useState<
|
||||
'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||
>('improve');
|
||||
const [workMode, setWorkMode] = useState<WorkMode>('current');
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [images, setImages] = useState<FeatureImage[]>([]);
|
||||
const [imagePaths, setImagePaths] = useState<DescriptionImagePath[]>([]);
|
||||
const [textFilePaths, setTextFilePaths] = useState<DescriptionTextFilePath[]>([]);
|
||||
const [skipTests, setSkipTests] = useState(false);
|
||||
const [branchName, setBranchName] = useState('');
|
||||
const [priority, setPriority] = useState(2);
|
||||
|
||||
// Model selection state
|
||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'opus' });
|
||||
|
||||
// Check if current model supports planning mode (Claude/Anthropic only)
|
||||
const modelSupportsPlanningMode = isClaudeModel(modelEntry.model);
|
||||
|
||||
// Planning mode state
|
||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||
|
||||
// UI state
|
||||
const [previewMap, setPreviewMap] = useState<ImagePreviewMap>(() => new Map());
|
||||
const [descriptionError, setDescriptionError] = useState(false);
|
||||
|
||||
// Description history state
|
||||
const [descriptionHistory, setDescriptionHistory] = useState<DescriptionHistoryEntry[]>([]);
|
||||
|
||||
// Spawn mode state
|
||||
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
|
||||
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Get enhancement model, planning mode defaults, and worktrees setting from store
|
||||
const {
|
||||
enhancementModel,
|
||||
defaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
defaultAIProfileId,
|
||||
useWorktrees,
|
||||
} = useAppStore();
|
||||
// Get defaults from store
|
||||
const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore();
|
||||
|
||||
// Sync defaults when dialog opens
|
||||
// Track previous open state to detect when dialog opens
|
||||
const wasOpenRef = useRef(false);
|
||||
|
||||
// Sync defaults only when dialog opens (transitions from closed to open)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Find the default profile if one is set
|
||||
const defaultProfile = defaultAIProfileId
|
||||
? aiProfiles.find((p) => p.id === defaultAIProfileId)
|
||||
: null;
|
||||
const justOpened = open && !wasOpenRef.current;
|
||||
wasOpenRef.current = open;
|
||||
|
||||
setNewFeature((prev) => ({
|
||||
...prev,
|
||||
skipTests: defaultSkipTests,
|
||||
branchName: defaultBranch || '',
|
||||
// Use default profile's model/thinkingLevel if set, else fallback to defaults
|
||||
model: defaultProfile?.model ?? 'opus',
|
||||
thinkingLevel: defaultProfile?.thinkingLevel ?? 'none',
|
||||
}));
|
||||
setUseCurrentBranch(true);
|
||||
if (justOpened) {
|
||||
setSkipTests(defaultSkipTests);
|
||||
// When a non-main worktree is selected, use its branch name for custom mode
|
||||
// Otherwise, use the default branch
|
||||
setBranchName(selectedNonMainWorktreeBranch || defaultBranch || '');
|
||||
setWorkMode(
|
||||
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
|
||||
);
|
||||
setPlanningMode(defaultPlanningMode);
|
||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||
setModelEntry({ model: 'opus' });
|
||||
|
||||
// Initialize description history (empty for new feature)
|
||||
setDescriptionHistory([]);
|
||||
|
||||
// Initialize ancestors for spawn mode
|
||||
if (parentFeature) {
|
||||
const ancestorList = getAncestors(parentFeature, allFeatures);
|
||||
setAncestors(ancestorList);
|
||||
// Only select parent by default - ancestors are optional context
|
||||
setSelectedAncestorIds(new Set([parentFeature.id]));
|
||||
} else {
|
||||
setAncestors([]);
|
||||
@@ -193,39 +228,45 @@ export function AddFeatureDialog({
|
||||
defaultBranch,
|
||||
defaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
defaultAIProfileId,
|
||||
aiProfiles,
|
||||
useWorktrees,
|
||||
selectedNonMainWorktreeBranch,
|
||||
forceCurrentBranchMode,
|
||||
parentFeature,
|
||||
allFeatures,
|
||||
]);
|
||||
|
||||
const handleModelChange = (entry: PhaseModelEntry) => {
|
||||
setModelEntry(entry);
|
||||
};
|
||||
|
||||
const buildFeatureData = (): FeatureData | null => {
|
||||
if (!newFeature.description.trim()) {
|
||||
if (!description.trim()) {
|
||||
setDescriptionError(true);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate branch selection when "other branch" is selected
|
||||
if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) {
|
||||
if (workMode === 'custom' && !branchName.trim()) {
|
||||
toast.error('Please select a branch name');
|
||||
return null;
|
||||
}
|
||||
|
||||
const category = newFeature.category || 'Uncategorized';
|
||||
const selectedModel = newFeature.model;
|
||||
const finalCategory = category || 'Uncategorized';
|
||||
const selectedModel = modelEntry.model;
|
||||
const normalizedThinking = modelSupportsThinking(selectedModel)
|
||||
? newFeature.thinkingLevel
|
||||
? modelEntry.thinkingLevel || 'none'
|
||||
: 'none';
|
||||
const normalizedReasoning = supportsReasoningEffort(selectedModel)
|
||||
? modelEntry.reasoningEffort || 'none'
|
||||
: 'none';
|
||||
|
||||
// Use current branch if toggle is on
|
||||
// If currentBranch is provided (non-primary worktree), use it
|
||||
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
|
||||
const finalBranchName = useCurrentBranch ? currentBranch || '' : newFeature.branchName || '';
|
||||
// For 'current' mode, use empty string (work on current branch)
|
||||
// For 'auto' mode, use empty string (will be auto-generated in use-board-actions)
|
||||
// For 'custom' mode, use the specified branch name
|
||||
const finalBranchName = workMode === 'custom' ? branchName || '' : '';
|
||||
|
||||
// Build final description - prepend ancestor context in spawn mode
|
||||
let finalDescription = newFeature.description;
|
||||
// Build final description with ancestor context in spawn mode
|
||||
let finalDescription = description;
|
||||
if (isSpawnMode && parentFeature && selectedAncestorIds.size > 0) {
|
||||
// Create parent context as an AncestorContext
|
||||
const parentContext: AncestorContext = {
|
||||
id: parentFeature.id,
|
||||
title: parentFeature.title,
|
||||
@@ -242,119 +283,75 @@ export function AddFeatureDialog({
|
||||
);
|
||||
|
||||
if (contextText) {
|
||||
finalDescription = `${contextText}\n\n---\n\n## Task Description\n\n${newFeature.description}`;
|
||||
finalDescription = `${contextText}\n\n---\n\n## Task Description\n\n${description}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: newFeature.title,
|
||||
category,
|
||||
title,
|
||||
category: finalCategory,
|
||||
description: finalDescription,
|
||||
images: newFeature.images,
|
||||
imagePaths: newFeature.imagePaths,
|
||||
textFilePaths: newFeature.textFilePaths,
|
||||
skipTests: newFeature.skipTests,
|
||||
images,
|
||||
imagePaths,
|
||||
textFilePaths,
|
||||
skipTests,
|
||||
model: selectedModel,
|
||||
thinkingLevel: normalizedThinking,
|
||||
reasoningEffort: normalizedReasoning,
|
||||
branchName: finalBranchName,
|
||||
priority: newFeature.priority,
|
||||
priority,
|
||||
planningMode,
|
||||
requirePlanApproval,
|
||||
// In spawn mode, automatically add parent as dependency
|
||||
dependencies: isSpawnMode && parentFeature ? [parentFeature.id] : undefined,
|
||||
workMode,
|
||||
};
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setNewFeature({
|
||||
title: '',
|
||||
category: '',
|
||||
description: '',
|
||||
images: [],
|
||||
imagePaths: [],
|
||||
textFilePaths: [],
|
||||
skipTests: defaultSkipTests,
|
||||
model: 'opus',
|
||||
priority: 2,
|
||||
thinkingLevel: 'none',
|
||||
branchName: '',
|
||||
});
|
||||
setUseCurrentBranch(true);
|
||||
setTitle('');
|
||||
setCategory('');
|
||||
setDescription('');
|
||||
setImages([]);
|
||||
setImagePaths([]);
|
||||
setTextFilePaths([]);
|
||||
setSkipTests(defaultSkipTests);
|
||||
// When a non-main worktree is selected, use its branch name for custom mode
|
||||
setBranchName(selectedNonMainWorktreeBranch || '');
|
||||
setPriority(2);
|
||||
setModelEntry({ model: 'opus' });
|
||||
setWorkMode(
|
||||
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
|
||||
);
|
||||
setPlanningMode(defaultPlanningMode);
|
||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||
setNewFeaturePreviewMap(new Map());
|
||||
setShowAdvancedOptions(false);
|
||||
setPreviewMap(new Map());
|
||||
setDescriptionError(false);
|
||||
setDescriptionHistory([]);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleAction = (actionFn?: (data: FeatureData) => void) => {
|
||||
if (!actionFn) return;
|
||||
|
||||
const featureData = buildFeatureData();
|
||||
if (!featureData) return;
|
||||
|
||||
actionFn(featureData);
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const handleAdd = () => handleAction(onAdd);
|
||||
|
||||
const handleAddAndStart = () => handleAction(onAddAndStart);
|
||||
|
||||
const handleDialogClose = (open: boolean) => {
|
||||
onOpenChange(open);
|
||||
if (!open) {
|
||||
setNewFeaturePreviewMap(new Map());
|
||||
setShowAdvancedOptions(false);
|
||||
setPreviewMap(new Map());
|
||||
setDescriptionError(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnhanceDescription = async () => {
|
||||
if (!newFeature.description.trim() || isEnhancing) return;
|
||||
|
||||
setIsEnhancing(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.enhancePrompt?.enhance(
|
||||
newFeature.description,
|
||||
enhancementMode,
|
||||
enhancementModel
|
||||
);
|
||||
|
||||
if (result?.success && result.enhancedText) {
|
||||
const enhancedText = result.enhancedText;
|
||||
setNewFeature((prev) => ({ ...prev, description: enhancedText }));
|
||||
toast.success('Description enhanced!');
|
||||
} else {
|
||||
toast.error(result?.error || 'Failed to enhance description');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Enhancement failed:', error);
|
||||
toast.error('Failed to enhance description');
|
||||
} finally {
|
||||
setIsEnhancing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelSelect = (model: AgentModel) => {
|
||||
setNewFeature({
|
||||
...newFeature,
|
||||
model,
|
||||
thinkingLevel: modelSupportsThinking(model) ? newFeature.thinkingLevel : 'none',
|
||||
});
|
||||
};
|
||||
|
||||
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
|
||||
setNewFeature({
|
||||
...newFeature,
|
||||
model,
|
||||
thinkingLevel,
|
||||
});
|
||||
};
|
||||
|
||||
const newModelAllowsThinking = modelSupportsThinking(newFeature.model);
|
||||
// Shared card styling
|
||||
const cardClass = 'rounded-lg border border-border/50 bg-muted/30 p-4 space-y-3';
|
||||
const sectionHeaderClass = 'flex items-center gap-2 text-sm font-medium text-foreground';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleDialogClose}>
|
||||
@@ -382,221 +379,245 @@ export function AddFeatureDialog({
|
||||
: 'Create a new feature card for the Kanban board.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="prompt" className="py-4 flex-1 min-h-0 flex flex-col">
|
||||
<TabsList className="w-full grid grid-cols-3 mb-4">
|
||||
<TabsTrigger value="prompt" data-testid="tab-prompt">
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Prompt
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="model" data-testid="tab-model">
|
||||
<Settings2 className="w-4 h-4 mr-2" />
|
||||
Model
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="options" data-testid="tab-options">
|
||||
<SlidersHorizontal className="w-4 h-4 mr-2" />
|
||||
Options
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Prompt Tab */}
|
||||
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default">
|
||||
{/* Ancestor Context Section - only in spawn mode */}
|
||||
{isSpawnMode && parentFeature && (
|
||||
<AncestorContextSection
|
||||
parentFeature={{
|
||||
id: parentFeature.id,
|
||||
title: parentFeature.title,
|
||||
description: parentFeature.description,
|
||||
spec: parentFeature.spec,
|
||||
summary: parentFeature.summary,
|
||||
}}
|
||||
ancestors={ancestors}
|
||||
selectedAncestorIds={selectedAncestorIds}
|
||||
onSelectionChange={setSelectedAncestorIds}
|
||||
/>
|
||||
)}
|
||||
<div className="py-4 space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||
{/* Ancestor Context Section - only in spawn mode */}
|
||||
{isSpawnMode && parentFeature && (
|
||||
<AncestorContextSection
|
||||
parentFeature={{
|
||||
id: parentFeature.id,
|
||||
title: parentFeature.title,
|
||||
description: parentFeature.description,
|
||||
spec: parentFeature.spec,
|
||||
summary: parentFeature.summary,
|
||||
}}
|
||||
ancestors={ancestors}
|
||||
selectedAncestorIds={selectedAncestorIds}
|
||||
onSelectionChange={setSelectedAncestorIds}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Task Details Section */}
|
||||
<div className={cardClass}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
{/* Version History Button */}
|
||||
<EnhancementHistoryButton
|
||||
history={descriptionHistory}
|
||||
currentValue={description}
|
||||
onRestore={setDescription}
|
||||
valueAccessor={(entry) => entry.description}
|
||||
title="Version History"
|
||||
restoreMessage="Description restored from history"
|
||||
/>
|
||||
</div>
|
||||
<DescriptionImageDropZone
|
||||
value={newFeature.description}
|
||||
value={description}
|
||||
onChange={(value) => {
|
||||
setNewFeature({ ...newFeature, description: value });
|
||||
if (value.trim()) {
|
||||
setDescriptionError(false);
|
||||
}
|
||||
setDescription(value);
|
||||
if (value.trim()) setDescriptionError(false);
|
||||
}}
|
||||
images={newFeature.imagePaths}
|
||||
onImagesChange={(images) => setNewFeature({ ...newFeature, imagePaths: images })}
|
||||
textFiles={newFeature.textFilePaths}
|
||||
onTextFilesChange={(textFiles) =>
|
||||
setNewFeature({ ...newFeature, textFilePaths: textFiles })
|
||||
}
|
||||
images={imagePaths}
|
||||
onImagesChange={setImagePaths}
|
||||
textFiles={textFilePaths}
|
||||
onTextFilesChange={setTextFilePaths}
|
||||
placeholder="Describe the feature..."
|
||||
previewMap={newFeaturePreviewMap}
|
||||
onPreviewMapChange={setNewFeaturePreviewMap}
|
||||
previewMap={previewMap}
|
||||
onPreviewMapChange={setPreviewMap}
|
||||
autoFocus
|
||||
error={descriptionError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title (optional)</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={newFeature.title}
|
||||
onChange={(e) => setNewFeature({ ...newFeature, title: e.target.value })}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Leave blank to auto-generate"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-fit items-center gap-3 select-none cursor-default">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-[200px] justify-between">
|
||||
{enhancementMode === 'improve' && 'Improve Clarity'}
|
||||
{enhancementMode === 'technical' && 'Add Technical Details'}
|
||||
{enhancementMode === 'simplify' && 'Simplify'}
|
||||
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
|
||||
<ChevronDown className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
|
||||
Improve Clarity
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
|
||||
Add Technical Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
|
||||
Simplify
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
|
||||
Add Acceptance Criteria
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEnhanceDescription}
|
||||
disabled={!newFeature.description.trim() || isEnhancing}
|
||||
loading={isEnhancing}
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Enhance with AI
|
||||
</Button>
|
||||
{/* Enhancement Section */}
|
||||
<EnhanceWithAI
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
onHistoryAdd={({ mode, originalText, enhancedText }) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
setDescriptionHistory((prev) => {
|
||||
const newHistory = [...prev];
|
||||
// Add original text first (so user can restore to pre-enhancement state)
|
||||
// Only add if it's different from the last entry to avoid duplicates
|
||||
const lastEntry = prev[prev.length - 1];
|
||||
if (!lastEntry || lastEntry.description !== originalText) {
|
||||
newHistory.push({
|
||||
description: originalText,
|
||||
timestamp,
|
||||
source: prev.length === 0 ? 'initial' : 'edit',
|
||||
});
|
||||
}
|
||||
// Add enhanced text
|
||||
newHistory.push({
|
||||
description: enhancedText,
|
||||
timestamp,
|
||||
source: 'enhance',
|
||||
enhancementMode: mode,
|
||||
});
|
||||
return newHistory;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI & Execution Section */}
|
||||
<div className={cardClass}>
|
||||
<div className={sectionHeaderClass}>
|
||||
<Cpu className="w-4 h-4 text-muted-foreground" />
|
||||
<span>AI & Execution</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Category (optional)</Label>
|
||||
<CategoryAutocomplete
|
||||
value={newFeature.category}
|
||||
onChange={(value) => setNewFeature({ ...newFeature, category: value })}
|
||||
suggestions={categorySuggestions}
|
||||
placeholder="e.g., Core, UI, API"
|
||||
data-testid="feature-category-input"
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Model</Label>
|
||||
<PhaseModelSelector
|
||||
value={modelEntry}
|
||||
onChange={handleModelChange}
|
||||
compact
|
||||
align="end"
|
||||
/>
|
||||
</div>
|
||||
{useWorktrees && (
|
||||
<BranchSelector
|
||||
useCurrentBranch={useCurrentBranch}
|
||||
onUseCurrentBranchChange={setUseCurrentBranch}
|
||||
branchName={newFeature.branchName}
|
||||
onBranchNameChange={(value) => setNewFeature({ ...newFeature, branchName: value })}
|
||||
|
||||
<div className="grid gap-3 grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
className={cn(
|
||||
'text-xs text-muted-foreground',
|
||||
!modelSupportsPlanningMode && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
Planning
|
||||
</Label>
|
||||
{modelSupportsPlanningMode ? (
|
||||
<PlanningModeSelect
|
||||
mode={planningMode}
|
||||
onModeChange={setPlanningMode}
|
||||
testIdPrefix="add-feature-planning"
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<PlanningModeSelect
|
||||
mode="skip"
|
||||
onModeChange={() => {}}
|
||||
testIdPrefix="add-feature-planning"
|
||||
compact
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Planning modes are only available for Claude Provider</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Options</Label>
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="add-feature-skip-tests"
|
||||
checked={!skipTests}
|
||||
onCheckedChange={(checked) => setSkipTests(!checked)}
|
||||
data-testid="add-feature-skip-tests-checkbox"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="add-feature-skip-tests"
|
||||
className="text-xs font-normal cursor-pointer"
|
||||
>
|
||||
Run tests
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="add-feature-require-approval"
|
||||
checked={requirePlanApproval}
|
||||
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
|
||||
disabled={
|
||||
!modelSupportsPlanningMode ||
|
||||
planningMode === 'skip' ||
|
||||
planningMode === 'lite'
|
||||
}
|
||||
data-testid="add-feature-require-approval-checkbox"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="add-feature-require-approval"
|
||||
className={cn(
|
||||
'text-xs font-normal',
|
||||
!modelSupportsPlanningMode ||
|
||||
planningMode === 'skip' ||
|
||||
planningMode === 'lite'
|
||||
? 'cursor-not-allowed text-muted-foreground'
|
||||
: 'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
Require approval
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organization Section */}
|
||||
<div className={cardClass}>
|
||||
<div className={sectionHeaderClass}>
|
||||
<FolderKanban className="w-4 h-4 text-muted-foreground" />
|
||||
<span>Organization</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Category</Label>
|
||||
<CategoryAutocomplete
|
||||
value={category}
|
||||
onChange={setCategory}
|
||||
suggestions={categorySuggestions}
|
||||
placeholder="e.g., Core, UI, API"
|
||||
data-testid="feature-category-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Priority</Label>
|
||||
<PrioritySelector
|
||||
selectedPriority={priority}
|
||||
onPrioritySelect={setPriority}
|
||||
testIdPrefix="priority"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Work Mode Selector */}
|
||||
<div className="pt-2">
|
||||
<WorkModeSelector
|
||||
workMode={workMode}
|
||||
onWorkModeChange={setWorkMode}
|
||||
branchName={branchName}
|
||||
onBranchNameChange={setBranchName}
|
||||
branchSuggestions={branchSuggestions}
|
||||
branchCardCounts={branchCardCounts}
|
||||
currentBranch={currentBranch}
|
||||
testIdPrefix="feature"
|
||||
testIdPrefix="feature-work-mode"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority Selector */}
|
||||
<PrioritySelector
|
||||
selectedPriority={newFeature.priority}
|
||||
onPrioritySelect={(priority) => setNewFeature({ ...newFeature, priority })}
|
||||
testIdPrefix="priority"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Model Tab */}
|
||||
<TabsContent value="model" className="space-y-4 overflow-y-auto cursor-default">
|
||||
{/* Show Advanced Options Toggle */}
|
||||
{showProfilesOnly && (
|
||||
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">Simple Mode Active</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only showing AI profiles. Advanced model tweaking is hidden.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
|
||||
data-testid="show-advanced-options-toggle"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 mr-2" />
|
||||
{showAdvancedOptions ? 'Hide' : 'Show'} Advanced
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Select Profile Section */}
|
||||
<ProfileQuickSelect
|
||||
profiles={aiProfiles}
|
||||
selectedModel={newFeature.model}
|
||||
selectedThinkingLevel={newFeature.thinkingLevel}
|
||||
onSelect={handleProfileSelect}
|
||||
showManageLink
|
||||
onManageLinkClick={() => {
|
||||
onOpenChange(false);
|
||||
navigate({ to: '/profiles' });
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Separator */}
|
||||
{aiProfiles.length > 0 && (!showProfilesOnly || showAdvancedOptions) && (
|
||||
<div className="border-t border-border" />
|
||||
)}
|
||||
|
||||
{/* Claude Models Section */}
|
||||
{(!showProfilesOnly || showAdvancedOptions) && (
|
||||
<>
|
||||
<ModelSelector selectedModel={newFeature.model} onModelSelect={handleModelSelect} />
|
||||
{newModelAllowsThinking && (
|
||||
<ThinkingLevelSelector
|
||||
selectedLevel={newFeature.thinkingLevel}
|
||||
onLevelSelect={(level) =>
|
||||
setNewFeature({ ...newFeature, thinkingLevel: level })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Options Tab */}
|
||||
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default">
|
||||
{/* Planning Mode Section */}
|
||||
<PlanningModeSelector
|
||||
mode={planningMode}
|
||||
onModeChange={setPlanningMode}
|
||||
requireApproval={requirePlanApproval}
|
||||
onRequireApprovalChange={setRequirePlanApproval}
|
||||
featureDescription={newFeature.description}
|
||||
testIdPrefix="add-feature"
|
||||
compact
|
||||
/>
|
||||
|
||||
<div className="border-t border-border my-4" />
|
||||
|
||||
{/* Testing Section */}
|
||||
<TestingTabContent
|
||||
skipTests={newFeature.skipTests}
|
||||
onSkipTestsChange={(skipTests) => setNewFeature({ ...newFeature, skipTests })}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
@@ -606,7 +627,7 @@ export function AddFeatureDialog({
|
||||
onClick={handleAddAndStart}
|
||||
variant="secondary"
|
||||
data-testid="confirm-add-and-start-feature"
|
||||
disabled={useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()}
|
||||
disabled={workMode === 'custom' && !branchName.trim()}
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Make
|
||||
@@ -617,7 +638,7 @@ export function AddFeatureDialog({
|
||||
hotkey={{ key: 'Enter', cmdCtrl: true }}
|
||||
hotkeyActive={open}
|
||||
data-testid="confirm-add-feature"
|
||||
disabled={useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()}
|
||||
disabled={workMode === 'custom' && !branchName.trim()}
|
||||
>
|
||||
{isSpawnMode ? 'Spawn Task' : 'Add Feature'}
|
||||
</HotkeyButton>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Loader2, List, FileText, GitBranch } from 'lucide-react';
|
||||
import { Loader2, List, FileText, GitBranch, ClipboardList } from 'lucide-react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { LogViewer } from '@/components/ui/log-viewer';
|
||||
import { GitDiffPanel } from '@/components/ui/git-diff-panel';
|
||||
import { TaskProgressPanel } from '@/components/ui/task-progress-panel';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { extractSummary } from '@/lib/log-parser';
|
||||
import type { AutoModeEvent } from '@/types/electron';
|
||||
|
||||
interface AgentOutputModalProps {
|
||||
@@ -27,7 +29,7 @@ interface AgentOutputModalProps {
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
type ViewMode = 'parsed' | 'raw' | 'changes';
|
||||
type ViewMode = 'summary' | 'parsed' | 'raw' | 'changes';
|
||||
|
||||
export function AgentOutputModal({
|
||||
open,
|
||||
@@ -40,8 +42,14 @@ export function AgentOutputModal({
|
||||
}: AgentOutputModalProps) {
|
||||
const [output, setOutput] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('parsed');
|
||||
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
|
||||
const [projectPath, setProjectPath] = useState<string>('');
|
||||
|
||||
// Extract summary from output
|
||||
const summary = useMemo(() => extractSummary(output), [output]);
|
||||
|
||||
// Determine the effective view mode - default to summary if available, otherwise parsed
|
||||
const effectiveViewMode = viewMode ?? (summary ? 'summary' : 'parsed');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
const projectPathRef = useRef<string>('');
|
||||
@@ -299,8 +307,8 @@ export function AgentOutputModal({
|
||||
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col"
|
||||
data-testid="agent-output-modal"
|
||||
>
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogHeader className="shrink-0">
|
||||
<div className="flex items-center justify-between pr-8">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
|
||||
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
||||
@@ -308,10 +316,24 @@ export function AgentOutputModal({
|
||||
Agent Output
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
|
||||
{summary && (
|
||||
<button
|
||||
onClick={() => setViewMode('summary')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
effectiveViewMode === 'summary'
|
||||
? 'bg-primary/20 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
}`}
|
||||
data-testid="view-mode-summary"
|
||||
>
|
||||
<ClipboardList className="w-3.5 h-3.5" />
|
||||
Summary
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setViewMode('parsed')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
viewMode === 'parsed'
|
||||
effectiveViewMode === 'parsed'
|
||||
? 'bg-primary/20 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
}`}
|
||||
@@ -323,7 +345,7 @@ export function AgentOutputModal({
|
||||
<button
|
||||
onClick={() => setViewMode('changes')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
viewMode === 'changes'
|
||||
effectiveViewMode === 'changes'
|
||||
? 'bg-primary/20 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
}`}
|
||||
@@ -335,7 +357,7 @@ export function AgentOutputModal({
|
||||
<button
|
||||
onClick={() => setViewMode('raw')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
viewMode === 'raw'
|
||||
effectiveViewMode === 'raw'
|
||||
? 'bg-primary/20 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
}`}
|
||||
@@ -361,7 +383,7 @@ export function AgentOutputModal({
|
||||
className="flex-shrink-0 mx-1"
|
||||
/>
|
||||
|
||||
{viewMode === 'changes' ? (
|
||||
{effectiveViewMode === 'changes' ? (
|
||||
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
|
||||
{projectPath ? (
|
||||
<GitDiffPanel
|
||||
@@ -378,6 +400,10 @@ export function AgentOutputModal({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : effectiveViewMode === 'summary' && summary ? (
|
||||
<div className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 min-h-[400px] max-h-[60vh] scrollbar-visible">
|
||||
<Markdown>{summary}</Markdown>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
@@ -394,7 +420,7 @@ export function AgentOutputModal({
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
No output yet. The agent will stream output here as it works.
|
||||
</div>
|
||||
) : viewMode === 'parsed' ? (
|
||||
) : effectiveViewMode === 'parsed' ? (
|
||||
<LogViewer output={output} />
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap break-words text-zinc-300">{output}</div>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { FastForward, Settings2 } from 'lucide-react';
|
||||
|
||||
interface AutoModeSettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
skipVerificationInAutoMode: boolean;
|
||||
onSkipVerificationChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function AutoModeSettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
skipVerificationInAutoMode,
|
||||
onSkipVerificationChange,
|
||||
}: AutoModeSettingsDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md" data-testid="auto-mode-settings-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Settings2 className="w-5 h-5" />
|
||||
Auto Mode Settings
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure how auto mode handles feature execution and dependencies.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Skip Verification Setting */}
|
||||
<div className="flex items-start space-x-3 p-3 rounded-lg bg-secondary/50">
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor="skip-verification-toggle"
|
||||
className="text-sm font-medium cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<FastForward className="w-4 h-4 text-brand-500" />
|
||||
Skip verification requirement
|
||||
</Label>
|
||||
<Switch
|
||||
id="skip-verification-toggle"
|
||||
checked={skipVerificationInAutoMode}
|
||||
onCheckedChange={onSkipVerificationChange}
|
||||
data-testid="skip-verification-toggle"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
When enabled, auto mode will grab features even if their dependencies are not
|
||||
verified, as long as they are not currently running. This allows faster pipeline
|
||||
execution without waiting for manual verification.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,35 @@ import {
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { BacklogPlanResult, BacklogChange } from '@automaker/types';
|
||||
import type {
|
||||
BacklogPlanResult,
|
||||
BacklogChange,
|
||||
ModelAlias,
|
||||
CursorModelId,
|
||||
PhaseModelEntry,
|
||||
} from '@automaker/types';
|
||||
import { ModelOverrideTrigger } from '@/components/shared/model-override-trigger';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
/**
|
||||
* Normalize PhaseModelEntry or string to PhaseModelEntry
|
||||
*/
|
||||
function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
|
||||
if (typeof entry === 'string') {
|
||||
return { model: entry as ModelAlias | CursorModelId };
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract model string from PhaseModelEntry or string
|
||||
*/
|
||||
function extractModel(entry: PhaseModelEntry | string): ModelAlias | CursorModelId {
|
||||
if (typeof entry === 'string') {
|
||||
return entry as ModelAlias | CursorModelId;
|
||||
}
|
||||
return entry.model;
|
||||
}
|
||||
|
||||
interface BacklogPlanDialogProps {
|
||||
open: boolean;
|
||||
@@ -35,6 +63,8 @@ interface BacklogPlanDialogProps {
|
||||
setPendingPlanResult: (result: BacklogPlanResult | null) => void;
|
||||
isGeneratingPlan: boolean;
|
||||
setIsGeneratingPlan: (generating: boolean) => void;
|
||||
// Branch to use for created features (defaults to 'main' when applying)
|
||||
currentBranch?: string;
|
||||
}
|
||||
|
||||
type DialogMode = 'input' | 'review' | 'applying';
|
||||
@@ -48,11 +78,15 @@ export function BacklogPlanDialog({
|
||||
setPendingPlanResult,
|
||||
isGeneratingPlan,
|
||||
setIsGeneratingPlan,
|
||||
currentBranch,
|
||||
}: BacklogPlanDialogProps) {
|
||||
const [mode, setMode] = useState<DialogMode>('input');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [expandedChanges, setExpandedChanges] = useState<Set<number>>(new Set());
|
||||
const [selectedChanges, setSelectedChanges] = useState<Set<number>>(new Set());
|
||||
const [modelOverride, setModelOverride] = useState<PhaseModelEntry | null>(null);
|
||||
|
||||
const { phaseModels } = useAppStore();
|
||||
|
||||
// Set mode based on whether we have a pending result
|
||||
useEffect(() => {
|
||||
@@ -83,7 +117,10 @@ export function BacklogPlanDialog({
|
||||
// Start generation in background
|
||||
setIsGeneratingPlan(true);
|
||||
|
||||
const result = await api.backlogPlan.generate(projectPath, prompt);
|
||||
// Use model override if set, otherwise use global default (extract model string from PhaseModelEntry)
|
||||
const effectiveModelEntry = modelOverride || normalizeEntry(phaseModels.backlogPlanningModel);
|
||||
const effectiveModel = effectiveModelEntry.model;
|
||||
const result = await api.backlogPlan.generate(projectPath, prompt, effectiveModel);
|
||||
if (!result.success) {
|
||||
setIsGeneratingPlan(false);
|
||||
toast.error(result.error || 'Failed to start plan generation');
|
||||
@@ -96,7 +133,7 @@ export function BacklogPlanDialog({
|
||||
});
|
||||
setPrompt('');
|
||||
onClose();
|
||||
}, [projectPath, prompt, setIsGeneratingPlan, onClose]);
|
||||
}, [projectPath, prompt, modelOverride, phaseModels, setIsGeneratingPlan, onClose]);
|
||||
|
||||
const handleApply = useCallback(async () => {
|
||||
if (!pendingPlanResult) return;
|
||||
@@ -133,7 +170,11 @@ export function BacklogPlanDialog({
|
||||
}) || [],
|
||||
};
|
||||
|
||||
const result = await api.backlogPlan.apply(projectPath, filteredPlanResult);
|
||||
const result = await api.backlogPlan.apply(
|
||||
projectPath,
|
||||
filteredPlanResult,
|
||||
currentBranch ?? 'main'
|
||||
);
|
||||
if (result.success) {
|
||||
toast.success(`Applied ${result.appliedChanges?.length || 0} changes`);
|
||||
setPendingPlanResult(null);
|
||||
@@ -150,6 +191,7 @@ export function BacklogPlanDialog({
|
||||
setPendingPlanResult,
|
||||
onPlanApplied,
|
||||
onClose,
|
||||
currentBranch,
|
||||
]);
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
@@ -358,6 +400,10 @@ export function BacklogPlanDialog({
|
||||
}
|
||||
};
|
||||
|
||||
// Get effective model entry (override or global default)
|
||||
const effectiveModelEntry = modelOverride || normalizeEntry(phaseModels.backlogPlanningModel);
|
||||
const effectiveModel = effectiveModelEntry.model;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
@@ -378,6 +424,17 @@ export function BacklogPlanDialog({
|
||||
<DialogFooter>
|
||||
{mode === 'input' && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mr-auto">
|
||||
<span className="text-xs text-muted-foreground">Model:</span>
|
||||
<ModelOverrideTrigger
|
||||
currentModelEntry={effectiveModelEntry}
|
||||
onModelChange={setModelOverride}
|
||||
phase="backlogPlanningModel"
|
||||
size="sm"
|
||||
variant="button"
|
||||
isOverridden={modelOverride !== null}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -22,6 +23,8 @@ interface WorktreeInfo {
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
const logger = createLogger('CreateBranchDialog');
|
||||
|
||||
interface CreateBranchDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -77,7 +80,7 @@ export function CreateBranchDialog({
|
||||
setError(result.error || 'Failed to create branch');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Create branch failed:', err);
|
||||
logger.error('Create branch failed:', err);
|
||||
setError('Failed to create branch');
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
|
||||
@@ -10,10 +10,73 @@ import {
|
||||
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 { GitBranch, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/**
|
||||
* Parse git/worktree error messages and return user-friendly versions
|
||||
*/
|
||||
function parseWorktreeError(error: string): { title: string; description?: string } {
|
||||
const errorLower = error.toLowerCase();
|
||||
|
||||
// Worktree already exists
|
||||
if (errorLower.includes('already exists') && errorLower.includes('worktree')) {
|
||||
return {
|
||||
title: 'A worktree with this name already exists',
|
||||
description: 'Try a different branch name or delete the existing worktree first.',
|
||||
};
|
||||
}
|
||||
|
||||
// Branch already checked out in another worktree
|
||||
if (
|
||||
errorLower.includes('already checked out') ||
|
||||
errorLower.includes('is already used by worktree')
|
||||
) {
|
||||
return {
|
||||
title: 'This branch is already in use',
|
||||
description: 'The branch is checked out in another worktree. Use a different branch name.',
|
||||
};
|
||||
}
|
||||
|
||||
// Branch name conflicts with existing branch
|
||||
if (errorLower.includes('already exists') && errorLower.includes('branch')) {
|
||||
return {
|
||||
title: 'A branch with this name already exists',
|
||||
description: 'The worktree will use the existing branch, or try a different name.',
|
||||
};
|
||||
}
|
||||
|
||||
// Not a git repository
|
||||
if (errorLower.includes('not a git repository')) {
|
||||
return {
|
||||
title: 'Not a git repository',
|
||||
description: 'Initialize git in this project first with "git init".',
|
||||
};
|
||||
}
|
||||
|
||||
// Lock file exists (another git operation in progress)
|
||||
if (errorLower.includes('.lock') || errorLower.includes('lock file')) {
|
||||
return {
|
||||
title: 'Another git operation is in progress',
|
||||
description: 'Wait for it to complete or remove stale lock files.',
|
||||
};
|
||||
}
|
||||
|
||||
// Permission denied
|
||||
if (errorLower.includes('permission denied') || errorLower.includes('access denied')) {
|
||||
return {
|
||||
title: 'Permission denied',
|
||||
description: 'Check file permissions for the project directory.',
|
||||
};
|
||||
}
|
||||
|
||||
// Default: return original error but cleaned up
|
||||
return {
|
||||
title: error.replace(/^(fatal|error):\s*/i, '').split('\n')[0],
|
||||
};
|
||||
}
|
||||
|
||||
interface CreatedWorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
@@ -34,20 +97,21 @@ export function CreateWorktreeDialog({
|
||||
}: CreateWorktreeDialogProps) {
|
||||
const [branchName, setBranchName] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<{ title: string; description?: string } | null>(null);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!branchName.trim()) {
|
||||
setError('Branch name is required');
|
||||
setError({ title: 'Branch name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate branch name (git-compatible)
|
||||
const validBranchRegex = /^[a-zA-Z0-9._/-]+$/;
|
||||
if (!validBranchRegex.test(branchName)) {
|
||||
setError(
|
||||
'Invalid branch name. Use only letters, numbers, dots, underscores, hyphens, and slashes.'
|
||||
);
|
||||
setError({
|
||||
title: 'Invalid branch name',
|
||||
description: 'Use only letters, numbers, dots, underscores, hyphens, and slashes.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,7 +121,7 @@ export function CreateWorktreeDialog({
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.create) {
|
||||
setError('Worktree API not available');
|
||||
setError({ title: 'Worktree API not available' });
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.create(projectPath, branchName);
|
||||
@@ -70,10 +134,12 @@ export function CreateWorktreeDialog({
|
||||
onOpenChange(false);
|
||||
setBranchName('');
|
||||
} else {
|
||||
setError(result.error || 'Failed to create worktree');
|
||||
setError(parseWorktreeError(result.error || 'Failed to create worktree'));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create worktree');
|
||||
setError(
|
||||
parseWorktreeError(err instanceof Error ? err.message : 'Failed to create worktree')
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -114,7 +180,17 @@ export function CreateWorktreeDialog({
|
||||
className="font-mono text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-destructive/10 border border-destructive/20">
|
||||
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-destructive">{error.title}</p>
|
||||
{error.description && (
|
||||
<p className="text-xs text-destructive/80">{error.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -30,6 +30,8 @@ interface DeleteWorktreeDialogProps {
|
||||
onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
|
||||
/** Number of features assigned to this worktree's branch */
|
||||
affectedFeatureCount?: number;
|
||||
/** Default value for the "delete branch" checkbox */
|
||||
defaultDeleteBranch?: boolean;
|
||||
}
|
||||
|
||||
export function DeleteWorktreeDialog({
|
||||
@@ -39,10 +41,18 @@ export function DeleteWorktreeDialog({
|
||||
worktree,
|
||||
onDeleted,
|
||||
affectedFeatureCount = 0,
|
||||
defaultDeleteBranch = false,
|
||||
}: DeleteWorktreeDialogProps) {
|
||||
const [deleteBranch, setDeleteBranch] = useState(false);
|
||||
const [deleteBranch, setDeleteBranch] = useState(defaultDeleteBranch);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Reset deleteBranch to default when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDeleteBranch(defaultDeleteBranch);
|
||||
}
|
||||
}, [open, defaultDeleteBranch]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Feature } from '@/store/app-store';
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -7,11 +9,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
|
||||
import {
|
||||
DescriptionImageDropZone,
|
||||
@@ -19,41 +21,27 @@ import {
|
||||
FeatureTextFilePath as DescriptionTextFilePath,
|
||||
ImagePreviewMap,
|
||||
} from '@/components/ui/description-image-dropzone';
|
||||
import {
|
||||
MessageSquare,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
GitBranch,
|
||||
} from 'lucide-react';
|
||||
import { GitBranch, Cpu, FolderKanban } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { modelSupportsThinking } from '@/lib/utils';
|
||||
import { cn, modelSupportsThinking } from '@/lib/utils';
|
||||
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
|
||||
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
|
||||
import {
|
||||
Feature,
|
||||
AgentModel,
|
||||
ThinkingLevel,
|
||||
AIProfile,
|
||||
useAppStore,
|
||||
PlanningMode,
|
||||
} from '@/store/app-store';
|
||||
import {
|
||||
ModelSelector,
|
||||
ThinkingLevelSelector,
|
||||
ProfileQuickSelect,
|
||||
TestingTabContent,
|
||||
PrioritySelector,
|
||||
BranchSelector,
|
||||
PlanningModeSelector,
|
||||
WorkModeSelector,
|
||||
PlanningModeSelect,
|
||||
EnhanceWithAI,
|
||||
EnhancementHistoryButton,
|
||||
type EnhancementMode,
|
||||
} from '../shared';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { WorkMode } from '../shared';
|
||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { DependencyTreeDialog } from './dependency-tree-dialog';
|
||||
import { isClaudeModel, supportsReasoningEffort } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('EditFeatureDialog');
|
||||
|
||||
interface EditFeatureDialogProps {
|
||||
feature: Feature | null;
|
||||
@@ -65,23 +53,25 @@ interface EditFeatureDialogProps {
|
||||
category: string;
|
||||
description: string;
|
||||
skipTests: boolean;
|
||||
model: AgentModel;
|
||||
model: ModelAlias;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
reasoningEffort: ReasoningEffort;
|
||||
imagePaths: DescriptionImagePath[];
|
||||
textFilePaths: DescriptionTextFilePath[];
|
||||
branchName: string; // Can be empty string to use current branch
|
||||
priority: number;
|
||||
planningMode: PlanningMode;
|
||||
requirePlanApproval: boolean;
|
||||
}
|
||||
},
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: EnhancementMode,
|
||||
preEnhancementDescription?: string
|
||||
) => void;
|
||||
categorySuggestions: string[];
|
||||
branchSuggestions: string[];
|
||||
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
|
||||
currentBranch?: string;
|
||||
isMaximized: boolean;
|
||||
showProfilesOnly: boolean;
|
||||
aiProfiles: AIProfile[];
|
||||
allFeatures: Feature[];
|
||||
}
|
||||
|
||||
@@ -94,71 +84,99 @@ export function EditFeatureDialog({
|
||||
branchCardCounts,
|
||||
currentBranch,
|
||||
isMaximized,
|
||||
showProfilesOnly,
|
||||
aiProfiles,
|
||||
allFeatures,
|
||||
}: EditFeatureDialogProps) {
|
||||
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
|
||||
const [useCurrentBranch, setUseCurrentBranch] = useState(() => {
|
||||
// If feature has no branchName, default to using current branch
|
||||
return !feature?.branchName;
|
||||
// Derive initial workMode from feature's branchName
|
||||
const [workMode, setWorkMode] = useState<WorkMode>(() => {
|
||||
// If feature has a branchName, it's using 'custom' mode
|
||||
// Otherwise, it's on 'current' branch (no worktree isolation)
|
||||
return feature?.branchName ? 'custom' : 'current';
|
||||
});
|
||||
const [editFeaturePreviewMap, setEditFeaturePreviewMap] = useState<ImagePreviewMap>(
|
||||
() => new Map()
|
||||
);
|
||||
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
|
||||
const [isEnhancing, setIsEnhancing] = useState(false);
|
||||
const [enhancementMode, setEnhancementMode] = useState<
|
||||
'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||
>('improve');
|
||||
const [showDependencyTree, setShowDependencyTree] = useState(false);
|
||||
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
|
||||
const [requirePlanApproval, setRequirePlanApproval] = useState(
|
||||
feature?.requirePlanApproval ?? false
|
||||
);
|
||||
|
||||
// Get enhancement model and worktrees setting from store
|
||||
const { enhancementModel, useWorktrees } = useAppStore();
|
||||
// Model selection state
|
||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>(() => ({
|
||||
model: (feature?.model as ModelAlias) || 'opus',
|
||||
thinkingLevel: feature?.thinkingLevel || 'none',
|
||||
reasoningEffort: feature?.reasoningEffort || 'none',
|
||||
}));
|
||||
|
||||
// Check if current model supports planning mode (Claude/Anthropic only)
|
||||
const modelSupportsPlanningMode = isClaudeModel(modelEntry.model);
|
||||
|
||||
// Track the source of description changes for history
|
||||
const [descriptionChangeSource, setDescriptionChangeSource] = useState<
|
||||
{ source: 'enhance'; mode: EnhancementMode } | 'edit' | null
|
||||
>(null);
|
||||
// Track the original description when the dialog opened for comparison
|
||||
const [originalDescription, setOriginalDescription] = useState(feature?.description ?? '');
|
||||
// Track the description before enhancement (so it can be restored)
|
||||
const [preEnhancementDescription, setPreEnhancementDescription] = useState<string | null>(null);
|
||||
// Local history state for real-time display (combines persisted + session history)
|
||||
const [localHistory, setLocalHistory] = useState<DescriptionHistoryEntry[]>(
|
||||
feature?.descriptionHistory ?? []
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setEditingFeature(feature);
|
||||
if (feature) {
|
||||
setPlanningMode(feature.planningMode ?? 'skip');
|
||||
setRequirePlanApproval(feature.requirePlanApproval ?? false);
|
||||
// If feature has no branchName, default to using current branch
|
||||
setUseCurrentBranch(!feature.branchName);
|
||||
// Derive workMode from feature's branchName
|
||||
setWorkMode(feature.branchName ? 'custom' : 'current');
|
||||
// Reset history tracking state
|
||||
setOriginalDescription(feature.description ?? '');
|
||||
setDescriptionChangeSource(null);
|
||||
setPreEnhancementDescription(null);
|
||||
setLocalHistory(feature.descriptionHistory ?? []);
|
||||
// Reset model entry
|
||||
setModelEntry({
|
||||
model: (feature.model as ModelAlias) || 'opus',
|
||||
thinkingLevel: feature.thinkingLevel || 'none',
|
||||
reasoningEffort: feature.reasoningEffort || 'none',
|
||||
});
|
||||
} else {
|
||||
setEditFeaturePreviewMap(new Map());
|
||||
setShowEditAdvancedOptions(false);
|
||||
setDescriptionChangeSource(null);
|
||||
setPreEnhancementDescription(null);
|
||||
setLocalHistory([]);
|
||||
}
|
||||
}, [feature]);
|
||||
|
||||
const handleModelChange = (entry: PhaseModelEntry) => {
|
||||
setModelEntry(entry);
|
||||
};
|
||||
|
||||
const handleUpdate = () => {
|
||||
if (!editingFeature) return;
|
||||
|
||||
// Validate branch selection when "other branch" is selected and branch selector is enabled
|
||||
// Validate branch selection for custom mode
|
||||
const isBranchSelectorEnabled = editingFeature.status === 'backlog';
|
||||
if (
|
||||
useWorktrees &&
|
||||
isBranchSelectorEnabled &&
|
||||
!useCurrentBranch &&
|
||||
!editingFeature.branchName?.trim()
|
||||
) {
|
||||
if (isBranchSelectorEnabled && workMode === 'custom' && !editingFeature.branchName?.trim()) {
|
||||
toast.error('Please select a branch name');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedModel = (editingFeature.model ?? 'opus') as AgentModel;
|
||||
const selectedModel = modelEntry.model;
|
||||
const normalizedThinking: ThinkingLevel = modelSupportsThinking(selectedModel)
|
||||
? (editingFeature.thinkingLevel ?? 'none')
|
||||
? (modelEntry.thinkingLevel ?? 'none')
|
||||
: 'none';
|
||||
const normalizedReasoning: ReasoningEffort = supportsReasoningEffort(selectedModel)
|
||||
? (modelEntry.reasoningEffort ?? 'none')
|
||||
: 'none';
|
||||
|
||||
// Use current branch if toggle is on
|
||||
// If currentBranch is provided (non-primary worktree), use it
|
||||
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
|
||||
const finalBranchName = useCurrentBranch
|
||||
? currentBranch || ''
|
||||
: editingFeature.branchName || '';
|
||||
// For 'current' mode, use empty string (work on current branch)
|
||||
// For 'auto' mode, use empty string (will be auto-generated in use-board-actions)
|
||||
// For 'custom' mode, use the specified branch name
|
||||
const finalBranchName = workMode === 'custom' ? editingFeature.branchName || '' : '';
|
||||
|
||||
const updates = {
|
||||
title: editingFeature.title ?? '',
|
||||
@@ -167,17 +185,38 @@ export function EditFeatureDialog({
|
||||
skipTests: editingFeature.skipTests ?? false,
|
||||
model: selectedModel,
|
||||
thinkingLevel: normalizedThinking,
|
||||
reasoningEffort: normalizedReasoning,
|
||||
imagePaths: editingFeature.imagePaths ?? [],
|
||||
textFilePaths: editingFeature.textFilePaths ?? [],
|
||||
branchName: finalBranchName,
|
||||
priority: editingFeature.priority ?? 2,
|
||||
planningMode,
|
||||
requirePlanApproval,
|
||||
workMode,
|
||||
};
|
||||
|
||||
onUpdate(editingFeature.id, updates);
|
||||
// Determine if description changed and what source to use
|
||||
const descriptionChanged = editingFeature.description !== originalDescription;
|
||||
let historySource: 'enhance' | 'edit' | undefined;
|
||||
let historyEnhancementMode: 'improve' | 'technical' | 'simplify' | 'acceptance' | undefined;
|
||||
|
||||
if (descriptionChanged && descriptionChangeSource) {
|
||||
if (descriptionChangeSource === 'edit') {
|
||||
historySource = 'edit';
|
||||
} else {
|
||||
historySource = 'enhance';
|
||||
historyEnhancementMode = descriptionChangeSource.mode;
|
||||
}
|
||||
}
|
||||
|
||||
onUpdate(
|
||||
editingFeature.id,
|
||||
updates,
|
||||
historySource,
|
||||
historyEnhancementMode,
|
||||
preEnhancementDescription ?? undefined
|
||||
);
|
||||
setEditFeaturePreviewMap(new Map());
|
||||
setShowEditAdvancedOptions(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -187,57 +226,14 @@ export function EditFeatureDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelSelect = (model: AgentModel) => {
|
||||
if (!editingFeature) return;
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
model,
|
||||
thinkingLevel: modelSupportsThinking(model) ? editingFeature.thinkingLevel : 'none',
|
||||
});
|
||||
};
|
||||
|
||||
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
|
||||
if (!editingFeature) return;
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
model,
|
||||
thinkingLevel,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnhanceDescription = async () => {
|
||||
if (!editingFeature?.description.trim() || isEnhancing) return;
|
||||
|
||||
setIsEnhancing(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.enhancePrompt?.enhance(
|
||||
editingFeature.description,
|
||||
enhancementMode,
|
||||
enhancementModel
|
||||
);
|
||||
|
||||
if (result?.success && result.enhancedText) {
|
||||
const enhancedText = result.enhancedText;
|
||||
setEditingFeature((prev) => (prev ? { ...prev, description: enhancedText } : prev));
|
||||
toast.success('Description enhanced!');
|
||||
} else {
|
||||
toast.error(result?.error || 'Failed to enhance description');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Enhancement failed:', error);
|
||||
toast.error('Failed to enhance description');
|
||||
} finally {
|
||||
setIsEnhancing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const editModelAllowsThinking = modelSupportsThinking(editingFeature?.model);
|
||||
|
||||
if (!editingFeature) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Shared card styling
|
||||
const cardClass = 'rounded-lg border border-border/50 bg-muted/30 p-4 space-y-3';
|
||||
const sectionHeaderClass = 'flex items-center gap-2 text-sm font-medium text-foreground';
|
||||
|
||||
return (
|
||||
<Dialog open={!!editingFeature} onOpenChange={handleDialogClose}>
|
||||
<DialogContent
|
||||
@@ -260,34 +256,38 @@ export function EditFeatureDialog({
|
||||
<DialogTitle>Edit Feature</DialogTitle>
|
||||
<DialogDescription>Modify the feature details.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="prompt" className="py-4 flex-1 min-h-0 flex flex-col">
|
||||
<TabsList className="w-full grid grid-cols-3 mb-4">
|
||||
<TabsTrigger value="prompt" data-testid="edit-tab-prompt">
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Prompt
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="model" data-testid="edit-tab-model">
|
||||
<Settings2 className="w-4 h-4 mr-2" />
|
||||
Model
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="options" data-testid="edit-tab-options">
|
||||
<SlidersHorizontal className="w-4 h-4 mr-2" />
|
||||
Options
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Prompt Tab */}
|
||||
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default">
|
||||
<div className="py-4 space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||
{/* Task Details Section */}
|
||||
<div className={cardClass}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-description">Description</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="edit-description">Description</Label>
|
||||
{/* Version History Button - uses local history for real-time updates */}
|
||||
<EnhancementHistoryButton
|
||||
history={localHistory}
|
||||
currentValue={editingFeature.description}
|
||||
onRestore={(description) => {
|
||||
setEditingFeature((prev) => (prev ? { ...prev, description } : prev));
|
||||
setDescriptionChangeSource('edit');
|
||||
}}
|
||||
valueAccessor={(entry) => entry.description}
|
||||
title="Version History"
|
||||
restoreMessage="Description restored from history"
|
||||
/>
|
||||
</div>
|
||||
<DescriptionImageDropZone
|
||||
value={editingFeature.description}
|
||||
onChange={(value) =>
|
||||
onChange={(value) => {
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
description: value,
|
||||
})
|
||||
}
|
||||
});
|
||||
// Track that this change was a manual edit (unless already enhanced)
|
||||
if (!descriptionChangeSource || descriptionChangeSource === 'edit') {
|
||||
setDescriptionChangeSource('edit');
|
||||
}
|
||||
}}
|
||||
images={editingFeature.imagePaths ?? []}
|
||||
onImagesChange={(images) =>
|
||||
setEditingFeature({
|
||||
@@ -308,6 +308,7 @@ export function EditFeatureDialog({
|
||||
data-testid="edit-feature-description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-title">Title (optional)</Label>
|
||||
<Input
|
||||
@@ -323,64 +324,191 @@ export function EditFeatureDialog({
|
||||
data-testid="edit-feature-title"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-fit items-center gap-3 select-none cursor-default">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-[180px] justify-between">
|
||||
{enhancementMode === 'improve' && 'Improve Clarity'}
|
||||
{enhancementMode === 'technical' && 'Add Technical Details'}
|
||||
{enhancementMode === 'simplify' && 'Simplify'}
|
||||
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
|
||||
<ChevronDown className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
|
||||
Improve Clarity
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
|
||||
Add Technical Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
|
||||
Simplify
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
|
||||
Add Acceptance Criteria
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEnhanceDescription}
|
||||
disabled={!editingFeature.description.trim() || isEnhancing}
|
||||
loading={isEnhancing}
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Enhance with AI
|
||||
</Button>
|
||||
{/* Enhancement Section */}
|
||||
<EnhanceWithAI
|
||||
value={editingFeature.description}
|
||||
onChange={(enhanced) =>
|
||||
setEditingFeature((prev) => (prev ? { ...prev, description: enhanced } : prev))
|
||||
}
|
||||
onHistoryAdd={({ mode, originalText, enhancedText }) => {
|
||||
setDescriptionChangeSource({ source: 'enhance', mode });
|
||||
setPreEnhancementDescription(originalText);
|
||||
|
||||
// Update local history for real-time display
|
||||
const timestamp = new Date().toISOString();
|
||||
setLocalHistory((prev) => {
|
||||
const newHistory = [...prev];
|
||||
// Add original text first (so user can restore to pre-enhancement state)
|
||||
const lastEntry = prev[prev.length - 1];
|
||||
if (!lastEntry || lastEntry.description !== originalText) {
|
||||
newHistory.push({
|
||||
description: originalText,
|
||||
timestamp,
|
||||
source: prev.length === 0 ? 'initial' : 'edit',
|
||||
});
|
||||
}
|
||||
// Add enhanced text
|
||||
newHistory.push({
|
||||
description: enhancedText,
|
||||
timestamp,
|
||||
source: 'enhance',
|
||||
enhancementMode: mode,
|
||||
});
|
||||
return newHistory;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI & Execution Section */}
|
||||
<div className={cardClass}>
|
||||
<div className={sectionHeaderClass}>
|
||||
<Cpu className="w-4 h-4 text-muted-foreground" />
|
||||
<span>AI & Execution</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-category">Category (optional)</Label>
|
||||
<CategoryAutocomplete
|
||||
value={editingFeature.category}
|
||||
onChange={(value) =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
category: value,
|
||||
})
|
||||
}
|
||||
suggestions={categorySuggestions}
|
||||
placeholder="e.g., Core, UI, API"
|
||||
data-testid="edit-feature-category"
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Model</Label>
|
||||
<PhaseModelSelector
|
||||
value={modelEntry}
|
||||
onChange={handleModelChange}
|
||||
compact
|
||||
align="end"
|
||||
/>
|
||||
</div>
|
||||
{useWorktrees && (
|
||||
<BranchSelector
|
||||
useCurrentBranch={useCurrentBranch}
|
||||
onUseCurrentBranchChange={setUseCurrentBranch}
|
||||
|
||||
<div className="grid gap-3 grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
className={cn(
|
||||
'text-xs text-muted-foreground',
|
||||
!modelSupportsPlanningMode && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
Planning
|
||||
</Label>
|
||||
{modelSupportsPlanningMode ? (
|
||||
<PlanningModeSelect
|
||||
mode={planningMode}
|
||||
onModeChange={setPlanningMode}
|
||||
testIdPrefix="edit-feature-planning"
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<PlanningModeSelect
|
||||
mode="skip"
|
||||
onModeChange={() => {}}
|
||||
testIdPrefix="edit-feature-planning"
|
||||
compact
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Planning modes are only available for Claude Provider</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Options</Label>
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="edit-feature-skip-tests"
|
||||
checked={!(editingFeature.skipTests ?? false)}
|
||||
onCheckedChange={(checked) =>
|
||||
setEditingFeature({ ...editingFeature, skipTests: !checked })
|
||||
}
|
||||
data-testid="edit-feature-skip-tests-checkbox"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="edit-feature-skip-tests"
|
||||
className="text-xs font-normal cursor-pointer"
|
||||
>
|
||||
Run tests
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="edit-feature-require-approval"
|
||||
checked={requirePlanApproval}
|
||||
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
|
||||
disabled={
|
||||
!modelSupportsPlanningMode ||
|
||||
planningMode === 'skip' ||
|
||||
planningMode === 'lite'
|
||||
}
|
||||
data-testid="edit-feature-require-approval-checkbox"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="edit-feature-require-approval"
|
||||
className={cn(
|
||||
'text-xs font-normal',
|
||||
!modelSupportsPlanningMode ||
|
||||
planningMode === 'skip' ||
|
||||
planningMode === 'lite'
|
||||
? 'cursor-not-allowed text-muted-foreground'
|
||||
: 'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
Require approval
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organization Section */}
|
||||
<div className={cardClass}>
|
||||
<div className={sectionHeaderClass}>
|
||||
<FolderKanban className="w-4 h-4 text-muted-foreground" />
|
||||
<span>Organization</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Category</Label>
|
||||
<CategoryAutocomplete
|
||||
value={editingFeature.category}
|
||||
onChange={(value) =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
category: value,
|
||||
})
|
||||
}
|
||||
suggestions={categorySuggestions}
|
||||
placeholder="e.g., Core, UI, API"
|
||||
data-testid="edit-feature-category"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Priority</Label>
|
||||
<PrioritySelector
|
||||
selectedPriority={editingFeature.priority ?? 2}
|
||||
onPrioritySelect={(priority) =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
priority,
|
||||
})
|
||||
}
|
||||
testIdPrefix="edit-priority"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Work Mode Selector */}
|
||||
<div className="pt-2">
|
||||
<WorkModeSelector
|
||||
workMode={workMode}
|
||||
onWorkModeChange={setWorkMode}
|
||||
branchName={editingFeature.branchName ?? ''}
|
||||
onBranchNameChange={(value) =>
|
||||
setEditingFeature({
|
||||
@@ -392,107 +520,12 @@ export function EditFeatureDialog({
|
||||
branchCardCounts={branchCardCounts}
|
||||
currentBranch={currentBranch}
|
||||
disabled={editingFeature.status !== 'backlog'}
|
||||
testIdPrefix="edit-feature"
|
||||
testIdPrefix="edit-feature-work-mode"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority Selector */}
|
||||
<PrioritySelector
|
||||
selectedPriority={editingFeature.priority ?? 2}
|
||||
onPrioritySelect={(priority) =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
priority,
|
||||
})
|
||||
}
|
||||
testIdPrefix="edit-priority"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Model Tab */}
|
||||
<TabsContent value="model" className="space-y-4 overflow-y-auto cursor-default">
|
||||
{/* Show Advanced Options Toggle */}
|
||||
{showProfilesOnly && (
|
||||
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">Simple Mode Active</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only showing AI profiles. Advanced model tweaking is hidden.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowEditAdvancedOptions(!showEditAdvancedOptions)}
|
||||
data-testid="edit-show-advanced-options-toggle"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 mr-2" />
|
||||
{showEditAdvancedOptions ? 'Hide' : 'Show'} Advanced
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Select Profile Section */}
|
||||
<ProfileQuickSelect
|
||||
profiles={aiProfiles}
|
||||
selectedModel={editingFeature.model ?? 'opus'}
|
||||
selectedThinkingLevel={editingFeature.thinkingLevel ?? 'none'}
|
||||
onSelect={handleProfileSelect}
|
||||
testIdPrefix="edit-profile-quick-select"
|
||||
/>
|
||||
|
||||
{/* Separator */}
|
||||
{aiProfiles.length > 0 && (!showProfilesOnly || showEditAdvancedOptions) && (
|
||||
<div className="border-t border-border" />
|
||||
)}
|
||||
|
||||
{/* Claude Models Section */}
|
||||
{(!showProfilesOnly || showEditAdvancedOptions) && (
|
||||
<>
|
||||
<ModelSelector
|
||||
selectedModel={(editingFeature.model ?? 'opus') as AgentModel}
|
||||
onModelSelect={handleModelSelect}
|
||||
testIdPrefix="edit-model-select"
|
||||
/>
|
||||
{editModelAllowsThinking && (
|
||||
<ThinkingLevelSelector
|
||||
selectedLevel={editingFeature.thinkingLevel ?? 'none'}
|
||||
onLevelSelect={(level) =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
thinkingLevel: level,
|
||||
})
|
||||
}
|
||||
testIdPrefix="edit-thinking-level"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Options Tab */}
|
||||
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default">
|
||||
{/* Planning Mode Section */}
|
||||
<PlanningModeSelector
|
||||
mode={planningMode}
|
||||
onModeChange={setPlanningMode}
|
||||
requireApproval={requirePlanApproval}
|
||||
onRequireApprovalChange={setRequirePlanApproval}
|
||||
featureDescription={editingFeature.description}
|
||||
testIdPrefix="edit-feature"
|
||||
compact
|
||||
/>
|
||||
|
||||
<div className="border-t border-border my-4" />
|
||||
|
||||
{/* Testing Section */}
|
||||
<TestingTabContent
|
||||
skipTests={editingFeature.skipTests ?? false}
|
||||
onSkipTestsChange={(skipTests) => setEditingFeature({ ...editingFeature, skipTests })}
|
||||
testIdPrefix="edit"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<DialogFooter className="sm:!justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -512,9 +545,8 @@ export function EditFeatureDialog({
|
||||
hotkeyActive={!!editingFeature}
|
||||
data-testid="confirm-edit-feature"
|
||||
disabled={
|
||||
useWorktrees &&
|
||||
editingFeature.status === 'backlog' &&
|
||||
!useCurrentBranch &&
|
||||
workMode === 'custom' &&
|
||||
!editingFeature.branchName?.trim()
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,575 +0,0 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
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';
|
||||
import {
|
||||
Loader2,
|
||||
Lightbulb,
|
||||
Download,
|
||||
StopCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Zap,
|
||||
List,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getElectronAPI,
|
||||
FeatureSuggestion,
|
||||
SuggestionsEvent,
|
||||
SuggestionType,
|
||||
} from '@/lib/electron';
|
||||
import { useAppStore, Feature } from '@/store/app-store';
|
||||
import { toast } from 'sonner';
|
||||
import { LogViewer } from '@/components/ui/log-viewer';
|
||||
|
||||
interface FeatureSuggestionsDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
projectPath: string;
|
||||
// Props to persist state across dialog open/close
|
||||
suggestions: FeatureSuggestion[];
|
||||
setSuggestions: (suggestions: FeatureSuggestion[]) => void;
|
||||
isGenerating: boolean;
|
||||
setIsGenerating: (generating: boolean) => void;
|
||||
}
|
||||
|
||||
// Configuration for each suggestion type
|
||||
const suggestionTypeConfig: Record<
|
||||
SuggestionType,
|
||||
{
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
description: string;
|
||||
color: string;
|
||||
}
|
||||
> = {
|
||||
features: {
|
||||
label: 'Feature Suggestions',
|
||||
icon: Lightbulb,
|
||||
description: 'Discover missing features and improvements',
|
||||
color: 'text-yellow-500',
|
||||
},
|
||||
refactoring: {
|
||||
label: 'Refactoring Suggestions',
|
||||
icon: RefreshCw,
|
||||
description: 'Find code smells and refactoring opportunities',
|
||||
color: 'text-blue-500',
|
||||
},
|
||||
security: {
|
||||
label: 'Security Suggestions',
|
||||
icon: Shield,
|
||||
description: 'Identify security vulnerabilities and issues',
|
||||
color: 'text-red-500',
|
||||
},
|
||||
performance: {
|
||||
label: 'Performance Suggestions',
|
||||
icon: Zap,
|
||||
description: 'Discover performance bottlenecks and optimizations',
|
||||
color: 'text-green-500',
|
||||
},
|
||||
};
|
||||
|
||||
export function FeatureSuggestionsDialog({
|
||||
open,
|
||||
onClose,
|
||||
projectPath,
|
||||
suggestions,
|
||||
setSuggestions,
|
||||
isGenerating,
|
||||
setIsGenerating,
|
||||
}: FeatureSuggestionsDialogProps) {
|
||||
const [progress, setProgress] = useState<string[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [currentSuggestionType, setCurrentSuggestionType] = useState<SuggestionType | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'parsed' | 'raw'>('parsed');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
|
||||
const { features, setFeatures } = useAppStore();
|
||||
|
||||
// Initialize selectedIds when suggestions change
|
||||
useEffect(() => {
|
||||
if (suggestions.length > 0 && selectedIds.size === 0) {
|
||||
setSelectedIds(new Set(suggestions.map((s) => s.id)));
|
||||
}
|
||||
}, [suggestions, selectedIds.size]);
|
||||
|
||||
// Auto-scroll progress when new content arrives
|
||||
useEffect(() => {
|
||||
if (autoScrollRef.current && scrollRef.current && isGenerating) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [progress, isGenerating]);
|
||||
|
||||
// Listen for suggestion events when dialog is open
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const api = getElectronAPI();
|
||||
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';
|
||||
const toolInput = event.input ? JSON.stringify(event.input, null, 2) : '';
|
||||
const formattedTool = `\n🔧 Tool: ${toolName}\n${toolInput ? `Input: ${toolInput}\n` : ''}`;
|
||||
setProgress((prev) => [...prev, formattedTool]);
|
||||
} 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';
|
||||
toast.success(`Generated ${event.suggestions.length} ${typeLabel}!`);
|
||||
} else {
|
||||
toast.info('No suggestions generated. Try again.');
|
||||
}
|
||||
} else if (event.type === 'suggestions_error') {
|
||||
setIsGenerating(false);
|
||||
toast.error(`Error: ${event.error}`);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [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;
|
||||
}
|
||||
|
||||
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');
|
||||
setIsGenerating(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate suggestions:', error);
|
||||
toast.error('Failed to start generation');
|
||||
setIsGenerating(false);
|
||||
}
|
||||
},
|
||||
[projectPath, setIsGenerating, setSuggestions]
|
||||
);
|
||||
|
||||
// Stop generating
|
||||
const handleStop = useCallback(async () => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.suggestions) return;
|
||||
|
||||
try {
|
||||
await api.suggestions.stop();
|
||||
setIsGenerating(false);
|
||||
toast.info('Generation stopped');
|
||||
} catch (error) {
|
||||
console.error('Failed to stop generation:', error);
|
||||
}
|
||||
}, [setIsGenerating]);
|
||||
|
||||
// Toggle suggestion selection
|
||||
const toggleSelection = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Toggle expand/collapse for a suggestion
|
||||
const toggleExpanded = useCallback((id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Select/deselect all
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
if (selectedIds.size === suggestions.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(suggestions.map((s) => s.id)));
|
||||
}
|
||||
}, [selectedIds.size, suggestions]);
|
||||
|
||||
// Import selected suggestions as features
|
||||
const handleImport = useCallback(async () => {
|
||||
if (selectedIds.size === 0) {
|
||||
toast.warning('No suggestions selected');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsImporting(true);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const selectedSuggestions = suggestions.filter((s) => selectedIds.has(s.id));
|
||||
|
||||
// Create new features from selected suggestions
|
||||
const newFeatures: Feature[] = selectedSuggestions.map((s) => ({
|
||||
id: `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
category: s.category,
|
||||
description: s.description,
|
||||
steps: [], // Required empty steps array for new features
|
||||
status: 'backlog' as const,
|
||||
skipTests: true, // As specified, testing mode true
|
||||
priority: s.priority, // Preserve priority from suggestion
|
||||
}));
|
||||
|
||||
// Create each new feature using the features API
|
||||
if (api.features) {
|
||||
for (const feature of newFeatures) {
|
||||
await api.features.create(projectPath, feature);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge with existing features for store update
|
||||
const updatedFeatures = [...features, ...newFeatures];
|
||||
|
||||
// Update store
|
||||
setFeatures(updatedFeatures);
|
||||
|
||||
toast.success(`Imported ${newFeatures.length} features to backlog!`);
|
||||
|
||||
// Clear suggestions after importing
|
||||
setSuggestions([]);
|
||||
setSelectedIds(new Set());
|
||||
setProgress([]);
|
||||
setCurrentSuggestionType(null);
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to import features:', error);
|
||||
toast.error('Failed to import features');
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [selectedIds, suggestions, features, setFeatures, setSuggestions, projectPath, onClose]);
|
||||
|
||||
// Handle scroll to detect if user scrolled up
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||
autoScrollRef.current = isAtBottom;
|
||||
};
|
||||
|
||||
// Go back to type selection
|
||||
const handleBackToSelection = useCallback(() => {
|
||||
setSuggestions([]);
|
||||
setSelectedIds(new Set());
|
||||
setProgress([]);
|
||||
setCurrentSuggestionType(null);
|
||||
}, [setSuggestions]);
|
||||
|
||||
const hasStarted = isGenerating || progress.length > 0 || suggestions.length > 0;
|
||||
const hasSuggestions = suggestions.length > 0;
|
||||
const currentConfig = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType] : null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className="w-[70vw] max-w-[70vw] max-h-[85vh] flex flex-col"
|
||||
data-testid="feature-suggestions-dialog"
|
||||
>
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{currentConfig ? (
|
||||
<>
|
||||
<currentConfig.icon className={`w-5 h-5 ${currentConfig.color}`} />
|
||||
{currentConfig.label}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lightbulb className="w-5 h-5 text-yellow-500" />
|
||||
AI Suggestions
|
||||
</>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{currentConfig
|
||||
? currentConfig.description
|
||||
: 'Analyze your project to discover improvements. Choose a suggestion type below.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!hasStarted ? (
|
||||
// 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:
|
||||
</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>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : isGenerating ? (
|
||||
// Generating state - show progress
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Analyzing project...
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setViewMode('parsed')}
|
||||
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium transition-all ${
|
||||
viewMode === 'parsed'
|
||||
? 'bg-primary/20 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
}`}
|
||||
data-testid="view-mode-parsed"
|
||||
>
|
||||
<List className="w-3 h-3" />
|
||||
Logs
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('raw')}
|
||||
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium transition-all ${
|
||||
viewMode === 'raw'
|
||||
? 'bg-primary/20 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
}`}
|
||||
data-testid="view-mode-raw"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
Raw
|
||||
</button>
|
||||
</div>
|
||||
<Button variant="destructive" size="sm" onClick={handleStop}>
|
||||
<StopCircle className="w-4 h-4 mr-2" />
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[200px] max-h-[400px]"
|
||||
>
|
||||
{progress.length === 0 ? (
|
||||
<div className="flex items-center justify-center min-h-[168px] text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||
Waiting for AI response...
|
||||
</div>
|
||||
) : viewMode === 'parsed' ? (
|
||||
<LogViewer output={progress.join('')} />
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap break-words text-zinc-300">
|
||||
{progress.join('')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : hasSuggestions ? (
|
||||
// Results state - show suggestions list
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{suggestions.length} suggestions generated
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={toggleSelectAll}>
|
||||
{selectedIds.size === suggestions.length ? 'Deselect All' : 'Select All'}
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-sm font-medium">{selectedIds.size} selected</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto space-y-2 min-h-[200px] max-h-[400px] pr-2"
|
||||
>
|
||||
{suggestions.map((suggestion) => {
|
||||
const isSelected = selectedIds.has(suggestion.id);
|
||||
const isExpanded = expandedIds.has(suggestion.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={suggestion.id}
|
||||
className={`border rounded-lg p-3 transition-colors ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
data-testid={`suggestion-${suggestion.id}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id={suggestion.id}
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelection(suggestion.id)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<button
|
||||
onClick={() => toggleExpanded(suggestion.id)}
|
||||
className="flex items-center gap-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/20 text-primary font-medium">
|
||||
#{suggestion.priority}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-secondary text-secondary-foreground">
|
||||
{suggestion.category}
|
||||
</span>
|
||||
</div>
|
||||
<Label
|
||||
htmlFor={suggestion.id}
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
{suggestion.description}
|
||||
</Label>
|
||||
|
||||
{isExpanded && suggestion.reasoning && (
|
||||
<div className="mt-3 text-sm">
|
||||
<p className="text-muted-foreground italic">{suggestion.reasoning}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// No results state
|
||||
<div className="flex-1 flex flex-col items-center justify-center py-8 text-center">
|
||||
<p className="text-muted-foreground mb-4">
|
||||
No suggestions were generated. Try running the analysis again.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleBackToSelection}>
|
||||
Back to Selection
|
||||
</Button>
|
||||
{currentSuggestionType && (
|
||||
<Button onClick={() => handleGenerate(currentSuggestionType)}>
|
||||
<Lightbulb className="w-4 h-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
{hasSuggestions && (
|
||||
<div className="flex gap-2 w-full justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleBackToSelection}>
|
||||
Back
|
||||
</Button>
|
||||
{currentSuggestionType && (
|
||||
<Button variant="outline" onClick={() => handleGenerate(currentSuggestionType)}>
|
||||
{currentConfig && <currentConfig.icon className="w-4 h-4 mr-2" />}
|
||||
Regenerate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
onClick={handleImport}
|
||||
disabled={selectedIds.size === 0 || isImporting}
|
||||
hotkey={{ key: 'Enter', cmdCtrl: true }}
|
||||
hotkeyActive={open && hasSuggestions}
|
||||
>
|
||||
{isImporting ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Import {selectedIds.size} Feature
|
||||
{selectedIds.size !== 1 ? 's' : ''}
|
||||
</HotkeyButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!hasSuggestions && !isGenerating && hasStarted && (
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -17,6 +18,21 @@ import {
|
||||
} from '@/components/ui/description-image-dropzone';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import {
|
||||
EnhanceWithAI,
|
||||
EnhancementHistoryButton,
|
||||
type EnhancementMode,
|
||||
type BaseHistoryEntry,
|
||||
} from '../shared';
|
||||
|
||||
const logger = createLogger('FollowUpDialog');
|
||||
|
||||
/**
|
||||
* A single entry in the follow-up prompt history
|
||||
*/
|
||||
export interface FollowUpHistoryEntry extends BaseHistoryEntry {
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
interface FollowUpDialogProps {
|
||||
open: boolean;
|
||||
@@ -30,6 +46,10 @@ interface FollowUpDialogProps {
|
||||
onPreviewMapChange: (map: ImagePreviewMap) => void;
|
||||
onSend: () => void;
|
||||
isMaximized: boolean;
|
||||
/** History of prompt versions for restoration */
|
||||
promptHistory?: FollowUpHistoryEntry[];
|
||||
/** Callback to add a new entry to prompt history */
|
||||
onHistoryAdd?: (entry: FollowUpHistoryEntry) => void;
|
||||
}
|
||||
|
||||
export function FollowUpDialog({
|
||||
@@ -44,9 +64,11 @@ export function FollowUpDialog({
|
||||
onPreviewMapChange,
|
||||
onSend,
|
||||
isMaximized,
|
||||
promptHistory = [],
|
||||
onHistoryAdd,
|
||||
}: FollowUpDialogProps) {
|
||||
const handleClose = (open: boolean) => {
|
||||
if (!open) {
|
||||
const handleClose = (openState: boolean) => {
|
||||
if (!openState) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
@@ -77,7 +99,18 @@ export function FollowUpDialog({
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4 overflow-y-auto flex-1 min-h-0">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="follow-up-prompt">Instructions</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="follow-up-prompt">Instructions</Label>
|
||||
{/* Version History Button */}
|
||||
<EnhancementHistoryButton
|
||||
history={promptHistory}
|
||||
currentValue={prompt}
|
||||
onRestore={onPromptChange}
|
||||
valueAccessor={(entry) => entry.prompt}
|
||||
title="Prompt History"
|
||||
restoreMessage="Prompt restored from history"
|
||||
/>
|
||||
</div>
|
||||
<DescriptionImageDropZone
|
||||
value={prompt}
|
||||
onChange={onPromptChange}
|
||||
@@ -88,6 +121,33 @@ export function FollowUpDialog({
|
||||
onPreviewMapChange={onPreviewMapChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Enhancement Section */}
|
||||
<EnhanceWithAI
|
||||
value={prompt}
|
||||
onChange={onPromptChange}
|
||||
onHistoryAdd={({ mode, originalText, enhancedText }) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
// Add original text first (so user can restore to pre-enhancement state)
|
||||
// Only add if it's different from the last history entry
|
||||
const lastEntry = promptHistory[promptHistory.length - 1];
|
||||
if (!lastEntry || lastEntry.prompt !== originalText) {
|
||||
onHistoryAdd?.({
|
||||
prompt: originalText,
|
||||
timestamp,
|
||||
source: promptHistory.length === 0 ? 'initial' : 'edit',
|
||||
});
|
||||
}
|
||||
// Add enhanced text
|
||||
onHistoryAdd?.({
|
||||
prompt: enhancedText,
|
||||
timestamp,
|
||||
source: 'enhance',
|
||||
enhancementMode: mode,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<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.
|
||||
|
||||
@@ -5,6 +5,6 @@ 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 { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
|
||||
export { PlanApprovalDialog } from './plan-approval-dialog';
|
||||
export { MassEditDialog } from './mass-edit-dialog';
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
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 { AlertCircle } from 'lucide-react';
|
||||
import { modelSupportsThinking } from '@/lib/utils';
|
||||
import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store';
|
||||
import { TestingTabContent, PrioritySelect, PlanningModeSelect } from '../shared';
|
||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||
import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
interface MassEditDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
selectedFeatures: Feature[];
|
||||
onApply: (updates: Partial<Feature>) => Promise<void>;
|
||||
}
|
||||
|
||||
interface ApplyState {
|
||||
model: boolean;
|
||||
thinkingLevel: boolean;
|
||||
planningMode: boolean;
|
||||
requirePlanApproval: boolean;
|
||||
priority: boolean;
|
||||
skipTests: boolean;
|
||||
}
|
||||
|
||||
function getMixedValues(features: Feature[]): Record<string, boolean> {
|
||||
if (features.length === 0) return {};
|
||||
const first = features[0];
|
||||
return {
|
||||
model: !features.every((f) => f.model === first.model),
|
||||
thinkingLevel: !features.every((f) => f.thinkingLevel === first.thinkingLevel),
|
||||
planningMode: !features.every((f) => f.planningMode === first.planningMode),
|
||||
requirePlanApproval: !features.every(
|
||||
(f) => f.requirePlanApproval === first.requirePlanApproval
|
||||
),
|
||||
priority: !features.every((f) => f.priority === first.priority),
|
||||
skipTests: !features.every((f) => f.skipTests === first.skipTests),
|
||||
};
|
||||
}
|
||||
|
||||
function getInitialValue<T>(features: Feature[], key: keyof Feature, defaultValue: T): T {
|
||||
if (features.length === 0) return defaultValue;
|
||||
return (features[0][key] as T) ?? defaultValue;
|
||||
}
|
||||
|
||||
interface FieldWrapperProps {
|
||||
label: string;
|
||||
isMixed: boolean;
|
||||
willApply: boolean;
|
||||
onApplyChange: (apply: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function FieldWrapper({ label, isMixed, willApply, onApplyChange, children }: FieldWrapperProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'p-3 rounded-lg border transition-colors',
|
||||
willApply ? 'border-brand-500/50 bg-brand-500/5' : 'border-border bg-muted/20'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={willApply}
|
||||
onCheckedChange={(checked) => onApplyChange(!!checked)}
|
||||
className="data-[state=checked]:bg-brand-500 data-[state=checked]:border-brand-500"
|
||||
/>
|
||||
<Label
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
onClick={() => onApplyChange(!willApply)}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
{isMixed && (
|
||||
<span className="flex items-center gap-1 text-xs text-amber-500">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Mixed values
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn(!willApply && 'opacity-50 pointer-events-none')}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: MassEditDialogProps) {
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
|
||||
// Track which fields to apply
|
||||
const [applyState, setApplyState] = useState<ApplyState>({
|
||||
model: false,
|
||||
thinkingLevel: false,
|
||||
planningMode: false,
|
||||
requirePlanApproval: false,
|
||||
priority: false,
|
||||
skipTests: false,
|
||||
});
|
||||
|
||||
// Field values
|
||||
const [model, setModel] = useState<ModelAlias>('sonnet');
|
||||
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>('none');
|
||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||
const [priority, setPriority] = useState(2);
|
||||
const [skipTests, setSkipTests] = useState(false);
|
||||
|
||||
// Calculate mixed values
|
||||
const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]);
|
||||
|
||||
// Reset state when dialog opens with new features
|
||||
useEffect(() => {
|
||||
if (open && selectedFeatures.length > 0) {
|
||||
setApplyState({
|
||||
model: false,
|
||||
thinkingLevel: false,
|
||||
planningMode: false,
|
||||
requirePlanApproval: false,
|
||||
priority: false,
|
||||
skipTests: false,
|
||||
});
|
||||
setModel(getInitialValue(selectedFeatures, 'model', 'sonnet') as ModelAlias);
|
||||
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
|
||||
setPlanningMode(getInitialValue(selectedFeatures, 'planningMode', 'skip') as PlanningMode);
|
||||
setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false));
|
||||
setPriority(getInitialValue(selectedFeatures, 'priority', 2));
|
||||
setSkipTests(getInitialValue(selectedFeatures, 'skipTests', false));
|
||||
}
|
||||
}, [open, selectedFeatures]);
|
||||
|
||||
const handleApply = async () => {
|
||||
const updates: Partial<Feature> = {};
|
||||
|
||||
if (applyState.model) updates.model = model;
|
||||
if (applyState.thinkingLevel) updates.thinkingLevel = thinkingLevel;
|
||||
if (applyState.planningMode) updates.planningMode = planningMode;
|
||||
if (applyState.requirePlanApproval) updates.requirePlanApproval = requirePlanApproval;
|
||||
if (applyState.priority) updates.priority = priority;
|
||||
if (applyState.skipTests) updates.skipTests = skipTests;
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsApplying(true);
|
||||
try {
|
||||
await onApply(updates);
|
||||
onClose();
|
||||
} finally {
|
||||
setIsApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasAnyApply = Object.values(applyState).some(Boolean);
|
||||
const isCurrentModelCursor = isCursorModel(model);
|
||||
const modelAllowsThinking = !isCurrentModelCursor && modelSupportsThinking(model);
|
||||
const modelSupportsPlanningMode = isClaudeModel(model);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-2xl" data-testid="mass-edit-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit {selectedFeatures.length} Features</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select which settings to apply to all selected features.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 pr-4 space-y-4 max-h-[60vh] overflow-y-auto">
|
||||
{/* Model Selector */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">AI Model</Label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Select a specific model configuration
|
||||
</p>
|
||||
<PhaseModelSelector
|
||||
value={{ model, thinkingLevel }}
|
||||
onChange={(entry: PhaseModelEntry) => {
|
||||
setModel(entry.model as ModelAlias);
|
||||
setThinkingLevel(entry.thinkingLevel || 'none');
|
||||
// Auto-enable model and thinking level for apply state
|
||||
setApplyState((prev) => ({
|
||||
...prev,
|
||||
model: true,
|
||||
thinkingLevel: true,
|
||||
}));
|
||||
}}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Planning Mode */}
|
||||
{modelSupportsPlanningMode ? (
|
||||
<FieldWrapper
|
||||
label="Planning Mode"
|
||||
isMixed={mixedValues.planningMode || mixedValues.requirePlanApproval}
|
||||
willApply={applyState.planningMode || applyState.requirePlanApproval}
|
||||
onApplyChange={(apply) =>
|
||||
setApplyState((prev) => ({
|
||||
...prev,
|
||||
planningMode: apply,
|
||||
requirePlanApproval: apply,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<PlanningModeSelect
|
||||
mode={planningMode}
|
||||
onModeChange={(newMode) => {
|
||||
setPlanningMode(newMode);
|
||||
// Auto-suggest approval based on mode, but user can override
|
||||
setRequirePlanApproval(newMode === 'spec' || newMode === 'full');
|
||||
}}
|
||||
requireApproval={requirePlanApproval}
|
||||
onRequireApprovalChange={setRequirePlanApproval}
|
||||
testIdPrefix="mass-edit-planning"
|
||||
/>
|
||||
</FieldWrapper>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'p-3 rounded-lg border transition-colors border-border bg-muted/20 opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox checked={false} disabled className="opacity-50" />
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
Planning Mode
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="opacity-50 pointer-events-none">
|
||||
<PlanningModeSelect
|
||||
mode="skip"
|
||||
onModeChange={() => {}}
|
||||
testIdPrefix="mass-edit-planning"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Planning modes are only available for Claude Provider</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Priority */}
|
||||
<FieldWrapper
|
||||
label="Priority"
|
||||
isMixed={mixedValues.priority}
|
||||
willApply={applyState.priority}
|
||||
onApplyChange={(apply) => setApplyState((prev) => ({ ...prev, priority: apply }))}
|
||||
>
|
||||
<PrioritySelect
|
||||
selectedPriority={priority}
|
||||
onPrioritySelect={setPriority}
|
||||
testIdPrefix="mass-edit-priority"
|
||||
/>
|
||||
</FieldWrapper>
|
||||
|
||||
{/* Testing */}
|
||||
<FieldWrapper
|
||||
label="Testing"
|
||||
isMixed={mixedValues.skipTests}
|
||||
willApply={applyState.skipTests}
|
||||
onApplyChange={(apply) => setApplyState((prev) => ({ ...prev, skipTests: apply }))}
|
||||
>
|
||||
<TestingTabContent
|
||||
skipTests={skipTests}
|
||||
onSkipTestsChange={setSkipTests}
|
||||
testIdPrefix="mass-edit"
|
||||
/>
|
||||
</FieldWrapper>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose} disabled={isApplying}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApply}
|
||||
disabled={!hasAnyApply || isApplying}
|
||||
loading={isApplying}
|
||||
data-testid="mass-edit-apply-button"
|
||||
>
|
||||
Apply to {selectedFeatures.length} Features
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,223 +8,11 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Plus, Trash2, ChevronUp, ChevronDown, Upload, Pencil, X, FileText } from 'lucide-react';
|
||||
import { Plus, Trash2, ChevronUp, ChevronDown, Pencil } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { PipelineConfig, PipelineStep } from '@automaker/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Color options for pipeline columns
|
||||
const COLOR_OPTIONS = [
|
||||
{ value: 'bg-blue-500/20', label: 'Blue', preview: 'bg-blue-500' },
|
||||
{ value: 'bg-purple-500/20', label: 'Purple', preview: 'bg-purple-500' },
|
||||
{ value: 'bg-green-500/20', label: 'Green', preview: 'bg-green-500' },
|
||||
{ value: 'bg-orange-500/20', label: 'Orange', preview: 'bg-orange-500' },
|
||||
{ value: 'bg-red-500/20', label: 'Red', preview: 'bg-red-500' },
|
||||
{ value: 'bg-pink-500/20', label: 'Pink', preview: 'bg-pink-500' },
|
||||
{ value: 'bg-cyan-500/20', label: 'Cyan', preview: 'bg-cyan-500' },
|
||||
{ value: 'bg-amber-500/20', label: 'Amber', preview: 'bg-amber-500' },
|
||||
{ value: 'bg-indigo-500/20', label: 'Indigo', preview: 'bg-indigo-500' },
|
||||
];
|
||||
|
||||
// Pre-built step templates with well-designed prompts
|
||||
const STEP_TEMPLATES = [
|
||||
{
|
||||
id: 'code-review',
|
||||
name: 'Code Review',
|
||||
colorClass: 'bg-blue-500/20',
|
||||
instructions: `## Code Review
|
||||
|
||||
Please perform a thorough code review of the changes made in this feature. Focus on:
|
||||
|
||||
### Code Quality
|
||||
- **Readability**: Is the code easy to understand? Are variable/function names descriptive?
|
||||
- **Maintainability**: Will this code be easy to modify in the future?
|
||||
- **DRY Principle**: Is there any duplicated code that should be abstracted?
|
||||
- **Single Responsibility**: Do functions and classes have a single, clear purpose?
|
||||
|
||||
### Best Practices
|
||||
- Follow established patterns and conventions used in the codebase
|
||||
- Ensure proper error handling is in place
|
||||
- Check for appropriate logging where needed
|
||||
- Verify that magic numbers/strings are replaced with named constants
|
||||
|
||||
### Performance
|
||||
- Identify any potential performance bottlenecks
|
||||
- Check for unnecessary re-renders (React) or redundant computations
|
||||
- Ensure efficient data structures are used
|
||||
|
||||
### Testing
|
||||
- Verify that new code has appropriate test coverage
|
||||
- Check that edge cases are handled
|
||||
|
||||
### Action Required
|
||||
After reviewing, make any necessary improvements directly. If you find issues:
|
||||
1. Fix them immediately if they are straightforward
|
||||
2. For complex issues, document them clearly with suggested solutions
|
||||
|
||||
Provide a brief summary of changes made or issues found.`,
|
||||
},
|
||||
{
|
||||
id: 'security-review',
|
||||
name: 'Security Review',
|
||||
colorClass: 'bg-red-500/20',
|
||||
instructions: `## Security Review
|
||||
|
||||
Perform a comprehensive security audit of the changes made in this feature. Check for vulnerabilities in the following areas:
|
||||
|
||||
### Input Validation & Sanitization
|
||||
- Verify all user inputs are properly validated and sanitized
|
||||
- Check for SQL injection vulnerabilities
|
||||
- Check for XSS (Cross-Site Scripting) vulnerabilities
|
||||
- Ensure proper encoding of output data
|
||||
|
||||
### Authentication & Authorization
|
||||
- Verify authentication checks are in place where needed
|
||||
- Ensure authorization logic correctly restricts access
|
||||
- Check for privilege escalation vulnerabilities
|
||||
- Verify session management is secure
|
||||
|
||||
### Data Protection
|
||||
- Ensure sensitive data is not logged or exposed
|
||||
- Check that secrets/credentials are not hardcoded
|
||||
- Verify proper encryption is used for sensitive data
|
||||
- Check for secure transmission of data (HTTPS, etc.)
|
||||
|
||||
### Common Vulnerabilities (OWASP Top 10)
|
||||
- Injection flaws
|
||||
- Broken authentication
|
||||
- Sensitive data exposure
|
||||
- XML External Entities (XXE)
|
||||
- Broken access control
|
||||
- Security misconfiguration
|
||||
- Cross-Site Scripting (XSS)
|
||||
- Insecure deserialization
|
||||
- Using components with known vulnerabilities
|
||||
- Insufficient logging & monitoring
|
||||
|
||||
### Action Required
|
||||
1. Fix any security vulnerabilities immediately
|
||||
2. For complex security issues, document them with severity levels
|
||||
3. Add security-related comments where appropriate
|
||||
|
||||
Provide a security assessment summary with any issues found and fixes applied.`,
|
||||
},
|
||||
{
|
||||
id: 'testing',
|
||||
name: 'Testing',
|
||||
colorClass: 'bg-green-500/20',
|
||||
instructions: `## Testing Step
|
||||
|
||||
Please ensure comprehensive test coverage for the changes made in this feature.
|
||||
|
||||
### Unit Tests
|
||||
- Write unit tests for all new functions and methods
|
||||
- Ensure edge cases are covered
|
||||
- Test error handling paths
|
||||
- Aim for high code coverage on new code
|
||||
|
||||
### Integration Tests
|
||||
- Test interactions between components/modules
|
||||
- Verify API endpoints work correctly
|
||||
- Test database operations if applicable
|
||||
|
||||
### Test Quality
|
||||
- Tests should be readable and well-documented
|
||||
- Each test should have a clear purpose
|
||||
- Use descriptive test names that explain the scenario
|
||||
- Follow the Arrange-Act-Assert pattern
|
||||
|
||||
### Run Tests
|
||||
After writing tests, run the full test suite and ensure:
|
||||
1. All new tests pass
|
||||
2. No existing tests are broken
|
||||
3. Test coverage meets project standards
|
||||
|
||||
Provide a summary of tests added and any issues found during testing.`,
|
||||
},
|
||||
{
|
||||
id: 'documentation',
|
||||
name: 'Documentation',
|
||||
colorClass: 'bg-amber-500/20',
|
||||
instructions: `## Documentation Step
|
||||
|
||||
Please ensure all changes are properly documented.
|
||||
|
||||
### Code Documentation
|
||||
- Add/update JSDoc or docstrings for new functions and classes
|
||||
- Document complex algorithms or business logic
|
||||
- Add inline comments for non-obvious code
|
||||
|
||||
### API Documentation
|
||||
- Document any new or modified API endpoints
|
||||
- Include request/response examples
|
||||
- Document error responses
|
||||
|
||||
### README Updates
|
||||
- Update README if new setup steps are required
|
||||
- Document any new environment variables
|
||||
- Update architecture diagrams if applicable
|
||||
|
||||
### Changelog
|
||||
- Document notable changes for the changelog
|
||||
- Include breaking changes if any
|
||||
|
||||
Provide a summary of documentation added or updated.`,
|
||||
},
|
||||
{
|
||||
id: 'optimization',
|
||||
name: 'Performance Optimization',
|
||||
colorClass: 'bg-cyan-500/20',
|
||||
instructions: `## Performance Optimization Step
|
||||
|
||||
Review and optimize the performance of the changes made in this feature.
|
||||
|
||||
### Code Performance
|
||||
- Identify and optimize slow algorithms (O(n²) → O(n log n), etc.)
|
||||
- Remove unnecessary computations or redundant operations
|
||||
- Optimize loops and iterations
|
||||
- Use appropriate data structures
|
||||
|
||||
### Memory Usage
|
||||
- Check for memory leaks
|
||||
- Optimize memory-intensive operations
|
||||
- Ensure proper cleanup of resources
|
||||
|
||||
### Database/API
|
||||
- Optimize database queries (add indexes, reduce N+1 queries)
|
||||
- Implement caching where appropriate
|
||||
- Batch API calls when possible
|
||||
|
||||
### Frontend (if applicable)
|
||||
- Minimize bundle size
|
||||
- Optimize render performance
|
||||
- Implement lazy loading where appropriate
|
||||
- Use memoization for expensive computations
|
||||
|
||||
### Action Required
|
||||
1. Profile the code to identify bottlenecks
|
||||
2. Apply optimizations
|
||||
3. Measure improvements
|
||||
|
||||
Provide a summary of optimizations applied and performance improvements achieved.`,
|
||||
},
|
||||
];
|
||||
|
||||
// Helper to get template color class
|
||||
const getTemplateColorClass = (templateId: string): string => {
|
||||
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
|
||||
return template?.colorClass || COLOR_OPTIONS[0].value;
|
||||
};
|
||||
import { AddEditPipelineStepDialog } from './add-edit-pipeline-step-dialog';
|
||||
|
||||
interface PipelineSettingsDialogProps {
|
||||
open: boolean;
|
||||
@@ -234,18 +22,10 @@ interface PipelineSettingsDialogProps {
|
||||
onSave: (config: PipelineConfig) => Promise<void>;
|
||||
}
|
||||
|
||||
interface EditingStep {
|
||||
id?: string;
|
||||
name: string;
|
||||
instructions: string;
|
||||
colorClass: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export function PipelineSettingsDialog({
|
||||
open,
|
||||
onClose,
|
||||
projectPath,
|
||||
projectPath: _projectPath,
|
||||
pipelineConfig,
|
||||
onSave,
|
||||
}: PipelineSettingsDialogProps) {
|
||||
@@ -262,9 +42,11 @@ export function PipelineSettingsDialog({
|
||||
};
|
||||
|
||||
const [steps, setSteps] = useState<PipelineStep[]>(() => validateSteps(pipelineConfig?.steps));
|
||||
const [editingStep, setEditingStep] = useState<EditingStep | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Sub-dialog state
|
||||
const [addEditDialogOpen, setAddEditDialogOpen] = useState(false);
|
||||
const [editingStep, setEditingStep] = useState<PipelineStep | null>(null);
|
||||
|
||||
// Sync steps when dialog opens or pipelineConfig changes
|
||||
useEffect(() => {
|
||||
@@ -276,22 +58,13 @@ export function PipelineSettingsDialog({
|
||||
const sortedSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
const handleAddStep = () => {
|
||||
setEditingStep({
|
||||
name: '',
|
||||
instructions: '',
|
||||
colorClass: COLOR_OPTIONS[steps.length % COLOR_OPTIONS.length].value,
|
||||
order: steps.length,
|
||||
});
|
||||
setEditingStep(null);
|
||||
setAddEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditStep = (step: PipelineStep) => {
|
||||
setEditingStep({
|
||||
id: step.id,
|
||||
name: step.name,
|
||||
instructions: step.instructions,
|
||||
colorClass: step.colorClass,
|
||||
order: step.order,
|
||||
});
|
||||
setEditingStep(step);
|
||||
setAddEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteStep = (stepId: string) => {
|
||||
@@ -323,53 +96,21 @@ export function PipelineSettingsDialog({
|
||||
setSteps(newSteps);
|
||||
};
|
||||
|
||||
const handleFileUpload = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const content = await file.text();
|
||||
setEditingStep((prev) => (prev ? { ...prev, instructions: content } : null));
|
||||
toast.success('Instructions loaded from file');
|
||||
} catch (error) {
|
||||
toast.error('Failed to load file');
|
||||
}
|
||||
|
||||
// Reset the input so the same file can be selected again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveStep = () => {
|
||||
if (!editingStep) return;
|
||||
|
||||
if (!editingStep.name.trim()) {
|
||||
toast.error('Step name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingStep.instructions.trim()) {
|
||||
toast.error('Step instructions are required');
|
||||
return;
|
||||
}
|
||||
|
||||
const handleSaveStep = (
|
||||
stepData: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'> & { id?: string }
|
||||
) => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (editingStep.id) {
|
||||
if (stepData.id) {
|
||||
// Update existing step
|
||||
setSteps((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === editingStep.id
|
||||
s.id === stepData.id
|
||||
? {
|
||||
...s,
|
||||
name: editingStep.name,
|
||||
instructions: editingStep.instructions,
|
||||
colorClass: editingStep.colorClass,
|
||||
name: stepData.name,
|
||||
instructions: stepData.instructions,
|
||||
colorClass: stepData.colorClass,
|
||||
updatedAt: now,
|
||||
}
|
||||
: s
|
||||
@@ -379,90 +120,21 @@ export function PipelineSettingsDialog({
|
||||
// Add new step
|
||||
const newStep: PipelineStep = {
|
||||
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
|
||||
name: editingStep.name,
|
||||
instructions: editingStep.instructions,
|
||||
colorClass: editingStep.colorClass,
|
||||
name: stepData.name,
|
||||
instructions: stepData.instructions,
|
||||
colorClass: stepData.colorClass,
|
||||
order: steps.length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
setSteps((prev) => [...prev, newStep]);
|
||||
}
|
||||
|
||||
setEditingStep(null);
|
||||
};
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// If the user is currently editing a step and clicks "Save Configuration",
|
||||
// include that step in the config (common expectation) instead of silently dropping it.
|
||||
let effectiveSteps = steps;
|
||||
if (editingStep) {
|
||||
if (!editingStep.name.trim()) {
|
||||
toast.error('Step name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingStep.instructions.trim()) {
|
||||
toast.error('Step instructions are required');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
if (editingStep.id) {
|
||||
// Update existing (or add if missing for some reason)
|
||||
const existingIdx = effectiveSteps.findIndex((s) => s.id === editingStep.id);
|
||||
if (existingIdx >= 0) {
|
||||
effectiveSteps = effectiveSteps.map((s) =>
|
||||
s.id === editingStep.id
|
||||
? {
|
||||
...s,
|
||||
name: editingStep.name,
|
||||
instructions: editingStep.instructions,
|
||||
colorClass: editingStep.colorClass,
|
||||
updatedAt: now,
|
||||
}
|
||||
: s
|
||||
);
|
||||
} else {
|
||||
effectiveSteps = [
|
||||
...effectiveSteps,
|
||||
{
|
||||
id: editingStep.id,
|
||||
name: editingStep.name,
|
||||
instructions: editingStep.instructions,
|
||||
colorClass: editingStep.colorClass,
|
||||
order: effectiveSteps.length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// Add new step
|
||||
effectiveSteps = [
|
||||
...effectiveSteps,
|
||||
{
|
||||
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
|
||||
name: editingStep.name,
|
||||
instructions: editingStep.instructions,
|
||||
colorClass: editingStep.colorClass,
|
||||
order: effectiveSteps.length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Keep local UI state consistent with what we are saving.
|
||||
setSteps(effectiveSteps);
|
||||
setEditingStep(null);
|
||||
}
|
||||
|
||||
const sortedEffectiveSteps = [...effectiveSteps].sort(
|
||||
(a, b) => (a.order ?? 0) - (b.order ?? 0)
|
||||
);
|
||||
const sortedEffectiveSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: sortedEffectiveSteps.map((s, index) => ({ ...s, order: index })),
|
||||
@@ -470,7 +142,7 @@ export function PipelineSettingsDialog({
|
||||
await onSave(config);
|
||||
toast.success('Pipeline configuration saved');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('Failed to save pipeline configuration');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
@@ -478,259 +150,121 @@ export function PipelineSettingsDialog({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
{/* Hidden file input for loading instructions from .md files */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".md,.txt"
|
||||
className="hidden"
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pipeline Settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure custom pipeline steps that run after a feature completes "In Progress". Each
|
||||
step will automatically prompt the agent with its instructions.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-4 space-y-4">
|
||||
{/* Steps List */}
|
||||
{sortedSteps.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{sortedSteps.map((step, index) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => handleMoveStep(step.id, 'up')}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => handleMoveStep(step.id, 'down')}
|
||||
disabled={index === sortedSteps.length - 1}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pipeline Settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure custom pipeline steps that run after a feature completes "In Progress". Each
|
||||
step will automatically prompt the agent with its instructions.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-4 space-y-4">
|
||||
{/* Steps List */}
|
||||
{sortedSteps.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{sortedSteps.map((step, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
'w-3 h-8 rounded',
|
||||
(step.colorClass || 'bg-blue-500/20').replace('/20', '')
|
||||
)}
|
||||
/>
|
||||
key={step.id}
|
||||
className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => handleMoveStep(step.id, 'up')}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => handleMoveStep(step.id, 'down')}
|
||||
disabled={index === sortedSteps.length - 1}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{step.name || 'Unnamed Step'}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{(step.instructions || '').substring(0, 100)}
|
||||
{(step.instructions || '').length > 100 ? '...' : ''}
|
||||
<div
|
||||
className={cn(
|
||||
'w-3 h-8 rounded',
|
||||
(step.colorClass || 'bg-blue-500/20').replace('/20', '')
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{step.name || 'Unnamed Step'}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{(step.instructions || '').substring(0, 100)}
|
||||
{(step.instructions || '').length > 100 ? '...' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleEditStep(step)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDeleteStep(step.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>No pipeline steps configured.</p>
|
||||
<p className="text-sm">
|
||||
Add steps to create a custom workflow after features complete.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleEditStep(step)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDeleteStep(step.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>No pipeline steps configured.</p>
|
||||
<p className="text-sm">
|
||||
Add steps to create a custom workflow after features complete.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Step Button */}
|
||||
{!editingStep && (
|
||||
{/* Add Step Button */}
|
||||
<Button variant="outline" className="w-full" onClick={handleAddStep}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Pipeline Step
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit/Add Step Form */}
|
||||
{editingStep && (
|
||||
<div className="border rounded-lg p-4 space-y-4 bg-muted/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">{editingStep.id ? 'Edit Step' : 'New Step'}</h4>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setEditingStep(null)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveConfig} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : 'Save Pipeline'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Template Selector - only show for new steps */}
|
||||
{!editingStep.id && (
|
||||
<div className="space-y-2">
|
||||
<Label>Start from Template</Label>
|
||||
<Select
|
||||
onValueChange={(templateId) => {
|
||||
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
|
||||
if (template) {
|
||||
setEditingStep((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
name: template.name,
|
||||
instructions: template.instructions,
|
||||
colorClass: template.colorClass,
|
||||
}
|
||||
: null
|
||||
);
|
||||
toast.success(`Loaded "${template.name}" template`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Choose a template (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STEP_TEMPLATES.map((template) => (
|
||||
<SelectItem key={template.id} value={template.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
template.colorClass.replace('/20', '')
|
||||
)}
|
||||
/>
|
||||
{template.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select a pre-built template to populate the form, or create your own from
|
||||
scratch.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="step-name">Step Name</Label>
|
||||
<Input
|
||||
id="step-name"
|
||||
placeholder="e.g., Code Review, Testing, Documentation"
|
||||
value={editingStep.name}
|
||||
onChange={(e) =>
|
||||
setEditingStep((prev) => (prev ? { ...prev, name: e.target.value } : null))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Color</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COLOR_OPTIONS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-full transition-all',
|
||||
color.preview,
|
||||
editingStep.colorClass === color.value
|
||||
? 'ring-2 ring-offset-2 ring-primary'
|
||||
: 'opacity-60 hover:opacity-100'
|
||||
)}
|
||||
onClick={() =>
|
||||
setEditingStep((prev) =>
|
||||
prev ? { ...prev, colorClass: color.value } : null
|
||||
)
|
||||
}
|
||||
title={color.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="step-instructions">Agent Instructions</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={handleFileUpload}
|
||||
>
|
||||
<Upload className="h-3 w-3 mr-1" />
|
||||
Load from .md file
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
id="step-instructions"
|
||||
placeholder="Instructions for the agent to follow during this pipeline step..."
|
||||
value={editingStep.instructions}
|
||||
onChange={(e) =>
|
||||
setEditingStep((prev) =>
|
||||
prev ? { ...prev, instructions: e.target.value } : null
|
||||
)
|
||||
}
|
||||
rows={6}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setEditingStep(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveStep}>
|
||||
{editingStep.id ? 'Update Step' : 'Add Step'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveConfig} disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? 'Saving...'
|
||||
: editingStep
|
||||
? 'Save Step & Configuration'
|
||||
: 'Save Configuration'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* Sub-dialog for adding/editing steps */}
|
||||
<AddEditPipelineStepDialog
|
||||
open={addEditDialogOpen}
|
||||
onClose={() => {
|
||||
setAddEditDialogOpen(false);
|
||||
setEditingStep(null);
|
||||
}}
|
||||
onSave={handleSaveStep}
|
||||
existingStep={editingStep}
|
||||
defaultOrder={steps.length}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
export const codeReviewTemplate = {
|
||||
id: 'code-review',
|
||||
name: 'Code Review',
|
||||
colorClass: 'bg-blue-500/20',
|
||||
instructions: `## Code Review & Update
|
||||
|
||||
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODE ⚠️
|
||||
|
||||
**THIS IS NOT OPTIONAL. AFTER REVIEWING, YOU MUST MODIFY THE CODE WITH YOUR FINDINGS.**
|
||||
|
||||
This step has TWO mandatory phases:
|
||||
1. **REVIEW** the code (identify issues)
|
||||
2. **UPDATE** the code (fix the issues you found)
|
||||
|
||||
**You cannot complete this step by only reviewing. You MUST make code changes based on your review findings.**
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: Review Phase
|
||||
Perform a thorough code review of the changes made in this feature. Focus on:
|
||||
|
||||
#### Code Quality
|
||||
- **Readability**: Is the code easy to understand? Are variable/function names descriptive?
|
||||
- **Maintainability**: Will this code be easy to modify in the future?
|
||||
- **DRY Principle**: Is there any duplicated code that should be abstracted?
|
||||
- **Single Responsibility**: Do functions and classes have a single, clear purpose?
|
||||
|
||||
#### Best Practices
|
||||
- Follow established patterns and conventions used in the codebase
|
||||
- Ensure proper error handling is in place
|
||||
- Check for appropriate logging where needed
|
||||
- Verify that magic numbers/strings are replaced with named constants
|
||||
|
||||
#### Performance
|
||||
- Identify any potential performance bottlenecks
|
||||
- Check for unnecessary re-renders (React) or redundant computations
|
||||
- Ensure efficient data structures are used
|
||||
|
||||
#### Testing
|
||||
- Verify that new code has appropriate test coverage
|
||||
- Check that edge cases are handled
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
|
||||
|
||||
**YOU MUST NOW MODIFY THE CODE BASED ON YOUR REVIEW FINDINGS.**
|
||||
|
||||
**This is not optional. Every issue you identify must be addressed with code changes.**
|
||||
|
||||
#### Action Steps (You MUST complete these):
|
||||
|
||||
1. **Fix Issues Immediately**: For every issue you found during review:
|
||||
- ✅ Refactor code for better readability
|
||||
- ✅ Extract duplicated code into reusable functions
|
||||
- ✅ Improve variable/function names for clarity
|
||||
- ✅ Add missing error handling
|
||||
- ✅ Replace magic numbers/strings with named constants
|
||||
- ✅ Optimize performance bottlenecks
|
||||
- ✅ Fix any code quality issues you identify
|
||||
- ✅ **MAKE THE ACTUAL CODE CHANGES - DO NOT JUST DOCUMENT THEM**
|
||||
|
||||
2. **Apply All Improvements**: Don't just identify problems - fix them in code:
|
||||
- ✅ Improve code structure and organization
|
||||
- ✅ Enhance error handling and logging
|
||||
- ✅ Optimize performance where possible
|
||||
- ✅ Ensure consistency with codebase patterns
|
||||
- ✅ Add or improve comments where needed
|
||||
- ✅ **MODIFY THE FILES DIRECTLY WITH YOUR IMPROVEMENTS**
|
||||
|
||||
3. **For Complex Issues**: If you encounter issues that require significant refactoring:
|
||||
- ✅ Make the improvements you can make safely
|
||||
- ✅ Document remaining issues with clear explanations
|
||||
- ✅ Provide specific suggestions for future improvements
|
||||
- ✅ **STILL MAKE AS MANY CODE CHANGES AS POSSIBLE**
|
||||
|
||||
---
|
||||
|
||||
### Summary Required
|
||||
After completing BOTH review AND update phases, provide:
|
||||
- A summary of issues found during review
|
||||
- **A detailed list of ALL code changes and improvements made (this proves you updated the code)**
|
||||
- Any remaining issues that need attention (if applicable)
|
||||
|
||||
---
|
||||
|
||||
# ⚠️ FINAL REMINDER ⚠️
|
||||
|
||||
**Reviewing without updating is INCOMPLETE and UNACCEPTABLE.**
|
||||
|
||||
**You MUST modify the code files directly with your improvements.**
|
||||
**You MUST show evidence of code changes in your summary.**
|
||||
**This step is only complete when code has been updated.**`,
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
export const documentationTemplate = {
|
||||
id: 'documentation',
|
||||
name: 'Documentation',
|
||||
colorClass: 'bg-amber-500/20',
|
||||
instructions: `## Documentation Step
|
||||
|
||||
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODE WITH DOCUMENTATION ⚠️
|
||||
|
||||
**THIS IS NOT OPTIONAL. YOU MUST ADD/UPDATE DOCUMENTATION IN THE CODEBASE.**
|
||||
|
||||
This step requires you to:
|
||||
1. **REVIEW** what needs documentation
|
||||
2. **UPDATE** the code by adding/updating documentation files and code comments
|
||||
|
||||
**You cannot complete this step by only identifying what needs documentation. You MUST add the documentation directly to the codebase.**
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: Review Phase
|
||||
Identify what documentation is needed:
|
||||
|
||||
- Review new functions, classes, and modules
|
||||
- Identify new or modified API endpoints
|
||||
- Check for missing README updates
|
||||
- Identify changelog entries needed
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
|
||||
|
||||
**YOU MUST NOW ADD/UPDATE DOCUMENTATION IN THE CODEBASE.**
|
||||
|
||||
**This is not optional. You must modify files to add documentation.**
|
||||
|
||||
#### Action Steps (You MUST complete these):
|
||||
|
||||
1. **Code Documentation** - UPDATE THE CODE FILES:
|
||||
- ✅ Add/update JSDoc or docstrings for new functions and classes
|
||||
- ✅ Document complex algorithms or business logic
|
||||
- ✅ Add inline comments for non-obvious code
|
||||
- ✅ **MODIFY THE SOURCE FILES DIRECTLY WITH DOCUMENTATION**
|
||||
|
||||
2. **API Documentation** - UPDATE API DOCUMENTATION FILES:
|
||||
- ✅ Document any new or modified API endpoints
|
||||
- ✅ Include request/response examples
|
||||
- ✅ Document error responses
|
||||
- ✅ **UPDATE THE API DOCUMENTATION FILES DIRECTLY**
|
||||
|
||||
3. **README Updates** - UPDATE THE README FILE:
|
||||
- ✅ Update README if new setup steps are required
|
||||
- ✅ Document any new environment variables
|
||||
- ✅ Update architecture diagrams if applicable
|
||||
- ✅ **MODIFY THE README FILE DIRECTLY**
|
||||
|
||||
4. **Changelog** - UPDATE THE CHANGELOG FILE:
|
||||
- ✅ Document notable changes for the changelog
|
||||
- ✅ Include breaking changes if any
|
||||
- ✅ **UPDATE THE CHANGELOG FILE DIRECTLY**
|
||||
|
||||
---
|
||||
|
||||
### Summary Required
|
||||
After completing BOTH review AND update phases, provide:
|
||||
- A summary of documentation needs identified
|
||||
- **A detailed list of ALL documentation files and code comments added/updated (this proves you updated the code)**
|
||||
- Specific files modified with documentation
|
||||
|
||||
---
|
||||
|
||||
# ⚠️ FINAL REMINDER ⚠️
|
||||
|
||||
**Identifying documentation needs without adding documentation is INCOMPLETE and UNACCEPTABLE.**
|
||||
|
||||
**You MUST modify the code files directly to add documentation.**
|
||||
**You MUST show evidence of documentation changes in your summary.**
|
||||
**This step is only complete when documentation has been added to the codebase.**`,
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { codeReviewTemplate } from './code-review';
|
||||
import { securityReviewTemplate } from './security-review';
|
||||
import { uxReviewTemplate } from './ux-review';
|
||||
import { testingTemplate } from './testing';
|
||||
import { documentationTemplate } from './documentation';
|
||||
import { optimizationTemplate } from './optimization';
|
||||
|
||||
export interface PipelineStepTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
colorClass: string;
|
||||
instructions: string;
|
||||
}
|
||||
|
||||
export const STEP_TEMPLATES: PipelineStepTemplate[] = [
|
||||
codeReviewTemplate,
|
||||
securityReviewTemplate,
|
||||
uxReviewTemplate,
|
||||
testingTemplate,
|
||||
documentationTemplate,
|
||||
optimizationTemplate,
|
||||
];
|
||||
|
||||
// Helper to get template color class
|
||||
export const getTemplateColorClass = (templateId: string): string => {
|
||||
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
|
||||
return template?.colorClass || 'bg-blue-500/20';
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
export const optimizationTemplate = {
|
||||
id: 'optimization',
|
||||
name: 'Performance',
|
||||
colorClass: 'bg-cyan-500/20',
|
||||
instructions: `## Performance Optimization Step
|
||||
|
||||
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODE WITH OPTIMIZATIONS ⚠️
|
||||
|
||||
**THIS IS NOT OPTIONAL. AFTER IDENTIFYING OPTIMIZATION OPPORTUNITIES, YOU MUST UPDATE THE CODE.**
|
||||
|
||||
This step has TWO mandatory phases:
|
||||
1. **REVIEW** the code for performance issues (identify bottlenecks)
|
||||
2. **UPDATE** the code with optimizations (fix the performance issues)
|
||||
|
||||
**You cannot complete this step by only identifying performance issues. You MUST modify the code to optimize it.**
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: Review Phase
|
||||
Identify performance bottlenecks and optimization opportunities:
|
||||
|
||||
#### Code Performance
|
||||
- Identify slow algorithms (O(n²) → O(n log n), etc.)
|
||||
- Find unnecessary computations or redundant operations
|
||||
- Identify inefficient loops and iterations
|
||||
- Check for inappropriate data structures
|
||||
|
||||
#### Memory Usage
|
||||
- Check for memory leaks
|
||||
- Identify memory-intensive operations
|
||||
- Check for proper cleanup of resources
|
||||
|
||||
#### Database/API
|
||||
- Identify slow database queries (N+1 queries, missing indexes)
|
||||
- Find opportunities for caching
|
||||
- Identify API calls that could be batched
|
||||
|
||||
#### Frontend (if applicable)
|
||||
- Identify bundle size issues
|
||||
- Find render performance problems
|
||||
- Identify opportunities for lazy loading
|
||||
- Find expensive computations that need memoization
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
|
||||
|
||||
**YOU MUST NOW MODIFY THE CODE TO APPLY OPTIMIZATIONS.**
|
||||
|
||||
**This is not optional. Every performance issue you identify must be addressed with code changes.**
|
||||
|
||||
#### Action Steps (You MUST complete these):
|
||||
|
||||
1. **Optimize Code Performance** - UPDATE THE CODE:
|
||||
- ✅ Optimize slow algorithms (O(n²) → O(n log n), etc.)
|
||||
- ✅ Remove unnecessary computations or redundant operations
|
||||
- ✅ Optimize loops and iterations
|
||||
- ✅ Use appropriate data structures
|
||||
- ✅ **MODIFY THE SOURCE FILES DIRECTLY WITH OPTIMIZATIONS**
|
||||
|
||||
2. **Fix Memory Issues** - UPDATE THE CODE:
|
||||
- ✅ Fix memory leaks
|
||||
- ✅ Optimize memory-intensive operations
|
||||
- ✅ Ensure proper cleanup of resources
|
||||
- ✅ **MAKE THE ACTUAL CODE CHANGES**
|
||||
|
||||
3. **Optimize Database/API** - UPDATE THE CODE:
|
||||
- ✅ Optimize database queries (add indexes, reduce N+1 queries)
|
||||
- ✅ Implement caching where appropriate
|
||||
- ✅ Batch API calls when possible
|
||||
- ✅ **MODIFY THE DATABASE/API CODE DIRECTLY**
|
||||
|
||||
4. **Optimize Frontend** (if applicable) - UPDATE THE CODE:
|
||||
- ✅ Minimize bundle size
|
||||
- ✅ Optimize render performance
|
||||
- ✅ Implement lazy loading where appropriate
|
||||
- ✅ Use memoization for expensive computations
|
||||
- ✅ **MODIFY THE FRONTEND CODE DIRECTLY**
|
||||
|
||||
5. **Profile and Measure**:
|
||||
- ✅ Profile the code to verify bottlenecks are fixed
|
||||
- ✅ Measure improvements achieved
|
||||
- ✅ **DOCUMENT THE PERFORMANCE IMPROVEMENTS**
|
||||
|
||||
---
|
||||
|
||||
### Summary Required
|
||||
After completing BOTH review AND update phases, provide:
|
||||
- A summary of performance issues identified
|
||||
- **A detailed list of ALL optimizations applied to the code (this proves you updated the code)**
|
||||
- Performance improvements achieved (with metrics if possible)
|
||||
- Any remaining optimization opportunities
|
||||
|
||||
---
|
||||
|
||||
# ⚠️ FINAL REMINDER ⚠️
|
||||
|
||||
**Identifying performance issues without optimizing the code is INCOMPLETE and UNACCEPTABLE.**
|
||||
|
||||
**You MUST modify the code files directly with optimizations.**
|
||||
**You MUST show evidence of optimization changes in your summary.**
|
||||
**This step is only complete when code has been optimized.**`,
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
export const securityReviewTemplate = {
|
||||
id: 'security-review',
|
||||
name: 'Security Review',
|
||||
colorClass: 'bg-red-500/20',
|
||||
instructions: `## Security Review & Update
|
||||
|
||||
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODE TO FIX SECURITY ISSUES ⚠️
|
||||
|
||||
**THIS IS NOT OPTIONAL. AFTER REVIEWING FOR SECURITY ISSUES, YOU MUST FIX THEM IN THE CODE.**
|
||||
|
||||
This step has TWO mandatory phases:
|
||||
1. **REVIEW** the code for security vulnerabilities (identify issues)
|
||||
2. **UPDATE** the code to fix vulnerabilities (secure the code)
|
||||
|
||||
**You cannot complete this step by only identifying security issues. You MUST modify the code to fix them.**
|
||||
|
||||
**Security vulnerabilities left unfixed are unacceptable. You must address them with code changes.**
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: Review Phase
|
||||
Perform a comprehensive security audit of the changes made in this feature. Check for vulnerabilities in the following areas:
|
||||
|
||||
#### Input Validation & Sanitization
|
||||
- Verify all user inputs are properly validated and sanitized
|
||||
- Check for SQL injection vulnerabilities
|
||||
- Check for XSS (Cross-Site Scripting) vulnerabilities
|
||||
- Ensure proper encoding of output data
|
||||
|
||||
#### Authentication & Authorization
|
||||
- Verify authentication checks are in place where needed
|
||||
- Ensure authorization logic correctly restricts access
|
||||
- Check for privilege escalation vulnerabilities
|
||||
- Verify session management is secure
|
||||
|
||||
#### Data Protection
|
||||
- Ensure sensitive data is not logged or exposed
|
||||
- Check that secrets/credentials are not hardcoded
|
||||
- Verify proper encryption is used for sensitive data
|
||||
- Check for secure transmission of data (HTTPS, etc.)
|
||||
|
||||
#### Common Vulnerabilities (OWASP Top 10)
|
||||
- Injection flaws
|
||||
- Broken authentication
|
||||
- Sensitive data exposure
|
||||
- XML External Entities (XXE)
|
||||
- Broken access control
|
||||
- Security misconfiguration
|
||||
- Cross-Site Scripting (XSS)
|
||||
- Insecure deserialization
|
||||
- Using components with known vulnerabilities
|
||||
- Insufficient logging & monitoring
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
|
||||
|
||||
**YOU MUST NOW MODIFY THE CODE TO FIX ALL SECURITY VULNERABILITIES.**
|
||||
|
||||
**This is not optional. Every security issue you identify must be fixed with code changes.**
|
||||
|
||||
**Security vulnerabilities cannot be left unfixed. You must address them immediately.**
|
||||
|
||||
#### Action Steps (You MUST complete these):
|
||||
|
||||
1. **Fix Vulnerabilities Immediately** - UPDATE THE CODE:
|
||||
- ✅ Add input validation and sanitization where missing
|
||||
- ✅ Fix SQL injection vulnerabilities by using parameterized queries
|
||||
- ✅ Fix XSS vulnerabilities by properly encoding output
|
||||
- ✅ Add authentication/authorization checks where needed
|
||||
- ✅ Remove hardcoded secrets and credentials
|
||||
- ✅ Implement proper encryption for sensitive data
|
||||
- ✅ Fix broken access control
|
||||
- ✅ Add security headers and configurations
|
||||
- ✅ Fix any other security vulnerabilities you find
|
||||
- ✅ **MODIFY THE SOURCE FILES DIRECTLY TO FIX SECURITY ISSUES**
|
||||
|
||||
2. **Apply Security Best Practices** - UPDATE THE CODE:
|
||||
- ✅ Implement proper input validation on all user inputs
|
||||
- ✅ Ensure all outputs are properly encoded
|
||||
- ✅ Add authentication checks to protected routes/endpoints
|
||||
- ✅ Implement proper authorization logic
|
||||
- ✅ Remove or secure any exposed sensitive data
|
||||
- ✅ Add security logging and monitoring
|
||||
- ✅ Update dependencies with known vulnerabilities
|
||||
- ✅ **MAKE THE ACTUAL CODE CHANGES - DO NOT JUST DOCUMENT THEM**
|
||||
|
||||
3. **For Complex Security Issues** - UPDATE THE CODE:
|
||||
- ✅ Fix what you can fix safely
|
||||
- ✅ Document critical security issues with severity levels
|
||||
- ✅ Provide specific remediation steps for complex issues
|
||||
- ✅ Add security-related comments explaining protections in place
|
||||
- ✅ **STILL MAKE AS MANY SECURITY FIXES AS POSSIBLE**
|
||||
|
||||
---
|
||||
|
||||
### Summary Required
|
||||
After completing BOTH review AND update phases, provide:
|
||||
- A security assessment summary of vulnerabilities found
|
||||
- **A detailed list of ALL security fixes applied to the code (this proves you updated the code)**
|
||||
- Any remaining security concerns that need attention (if applicable)
|
||||
- Severity levels for any unfixed issues
|
||||
|
||||
---
|
||||
|
||||
# ⚠️ FINAL REMINDER ⚠️
|
||||
|
||||
**Reviewing security without fixing vulnerabilities is INCOMPLETE, UNACCEPTABLE, and DANGEROUS.**
|
||||
|
||||
**You MUST modify the code files directly to fix security issues.**
|
||||
**You MUST show evidence of security fixes in your summary.**
|
||||
**This step is only complete when security vulnerabilities have been fixed in the code.**
|
||||
**Security issues cannot be left as documentation - they must be fixed.**`,
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
export const testingTemplate = {
|
||||
id: 'testing',
|
||||
name: 'Testing',
|
||||
colorClass: 'bg-green-500/20',
|
||||
instructions: `## Testing Step
|
||||
|
||||
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODEBASE WITH TESTS ⚠️
|
||||
|
||||
**THIS IS NOT OPTIONAL. YOU MUST WRITE AND ADD TESTS TO THE CODEBASE.**
|
||||
|
||||
This step requires you to:
|
||||
1. **REVIEW** what needs testing
|
||||
2. **UPDATE** the codebase by writing and adding test files
|
||||
|
||||
**You cannot complete this step by only identifying what needs testing. You MUST create test files and write tests.**
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: Review Phase
|
||||
Identify what needs test coverage:
|
||||
|
||||
- Review new functions, methods, and classes
|
||||
- Identify new API endpoints
|
||||
- Check for edge cases that need testing
|
||||
- Identify integration points that need testing
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
|
||||
|
||||
**YOU MUST NOW WRITE AND ADD TESTS TO THE CODEBASE.**
|
||||
|
||||
**This is not optional. You must create test files and write actual test code.**
|
||||
|
||||
#### Action Steps (You MUST complete these):
|
||||
|
||||
1. **Write Unit Tests** - CREATE TEST FILES:
|
||||
- ✅ Write unit tests for all new functions and methods
|
||||
- ✅ Ensure edge cases are covered
|
||||
- ✅ Test error handling paths
|
||||
- ✅ Aim for high code coverage on new code
|
||||
- ✅ **CREATE TEST FILES AND WRITE THE ACTUAL TEST CODE**
|
||||
|
||||
2. **Write Integration Tests** - CREATE TEST FILES:
|
||||
- ✅ Test interactions between components/modules
|
||||
- ✅ Verify API endpoints work correctly
|
||||
- ✅ Test database operations if applicable
|
||||
- ✅ **CREATE INTEGRATION TEST FILES AND WRITE THE ACTUAL TEST CODE**
|
||||
|
||||
3. **Ensure Test Quality** - WRITE QUALITY TESTS:
|
||||
- ✅ Tests should be readable and well-documented
|
||||
- ✅ Each test should have a clear purpose
|
||||
- ✅ Use descriptive test names that explain the scenario
|
||||
- ✅ Follow the Arrange-Act-Assert pattern
|
||||
- ✅ **WRITE COMPLETE, FUNCTIONAL TESTS**
|
||||
|
||||
4. **Run Tests** - VERIFY TESTS WORK:
|
||||
- ✅ Run the full test suite and ensure all new tests pass
|
||||
- ✅ Verify no existing tests are broken
|
||||
- ✅ Check that test coverage meets project standards
|
||||
- ✅ **FIX ANY FAILING TESTS**
|
||||
|
||||
---
|
||||
|
||||
### Summary Required
|
||||
After completing BOTH review AND update phases, provide:
|
||||
- A summary of testing needs identified
|
||||
- **A detailed list of ALL test files created and tests written (this proves you updated the codebase)**
|
||||
- Test coverage metrics achieved
|
||||
- Any issues found during testing and how they were resolved
|
||||
|
||||
---
|
||||
|
||||
# ⚠️ FINAL REMINDER ⚠️
|
||||
|
||||
**Identifying what needs testing without writing tests is INCOMPLETE and UNACCEPTABLE.**
|
||||
|
||||
**You MUST create test files and write actual test code.**
|
||||
**You MUST show evidence of test files created in your summary.**
|
||||
**This step is only complete when tests have been written and added to the codebase.**`,
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
export const uxReviewTemplate = {
|
||||
id: 'ux-reviewer',
|
||||
name: 'User Experience',
|
||||
colorClass: 'bg-purple-500/20',
|
||||
instructions: `## User Experience Review & Update
|
||||
|
||||
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODE TO IMPROVE UX ⚠️
|
||||
|
||||
**THIS IS NOT OPTIONAL. AFTER REVIEWING THE USER EXPERIENCE, YOU MUST UPDATE THE CODE.**
|
||||
|
||||
This step has TWO mandatory phases:
|
||||
1. **REVIEW** the user experience (identify UX issues)
|
||||
2. **UPDATE** the code to improve UX (fix the issues you found)
|
||||
|
||||
**You cannot complete this step by only reviewing UX. You MUST modify the code to improve the user experience.**
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: Review Phase
|
||||
Review the changes made in this feature from a user experience and design perspective. Focus on creating an exceptional user experience.
|
||||
|
||||
#### User-Centered Design
|
||||
- **User Goals**: Does this feature solve a real user problem?
|
||||
- **Clarity**: Is the interface clear and easy to understand?
|
||||
- **Simplicity**: Can the feature be simplified without losing functionality?
|
||||
- **Consistency**: Does it follow existing design patterns and conventions?
|
||||
|
||||
#### Visual Design & Hierarchy
|
||||
- **Layout**: Is the visual hierarchy clear? Does important information stand out?
|
||||
- **Spacing**: Is there appropriate whitespace and grouping?
|
||||
- **Typography**: Is text readable with proper sizing and contrast?
|
||||
- **Color**: Does color usage support functionality and meet accessibility standards?
|
||||
|
||||
#### Accessibility (WCAG 2.1)
|
||||
- **Keyboard Navigation**: Can all functionality be accessed via keyboard?
|
||||
- **Screen Readers**: Are ARIA labels and semantic HTML used appropriately?
|
||||
- **Color Contrast**: Does text meet WCAG AA standards (4.5:1 for body, 3:1 for large)?
|
||||
- **Focus Indicators**: Are focus states visible and clear?
|
||||
- **Touch Targets**: Are interactive elements at least 44x44px on mobile?
|
||||
|
||||
#### Responsive Design
|
||||
- **Mobile Experience**: Does it work well on small screens?
|
||||
- **Touch Targets**: Are buttons and links easy to tap?
|
||||
- **Content Adaptation**: Does content adapt appropriately to different screen sizes?
|
||||
- **Navigation**: Is navigation accessible and intuitive on mobile?
|
||||
|
||||
#### User Feedback & States
|
||||
- **Loading States**: Are loading indicators shown for async operations?
|
||||
- **Error States**: Are error messages clear and actionable?
|
||||
- **Empty States**: Do empty states guide users on what to do next?
|
||||
- **Success States**: Are successful actions clearly confirmed?
|
||||
|
||||
#### Performance & Perceived Performance
|
||||
- **Loading Speed**: Does the feature load quickly?
|
||||
- **Skeleton Screens**: Are skeleton screens used for better perceived performance?
|
||||
- **Optimistic Updates**: Can optimistic UI updates improve perceived speed?
|
||||
- **Micro-interactions**: Do animations and transitions enhance the experience?
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
|
||||
|
||||
**YOU MUST NOW MODIFY THE CODE TO IMPROVE THE USER EXPERIENCE.**
|
||||
|
||||
**This is not optional. Every UX issue you identify must be addressed with code changes.**
|
||||
|
||||
#### Action Steps (You MUST complete these):
|
||||
|
||||
1. **Fix UX Issues Immediately** - UPDATE THE CODE:
|
||||
- ✅ Improve visual hierarchy and layout
|
||||
- ✅ Fix spacing and typography issues
|
||||
- ✅ Add missing ARIA labels and semantic HTML
|
||||
- ✅ Fix color contrast issues
|
||||
- ✅ Add or improve focus indicators
|
||||
- ✅ Ensure touch targets meet size requirements
|
||||
- ✅ Add missing loading, error, empty, and success states
|
||||
- ✅ Improve responsive design for mobile
|
||||
- ✅ Add keyboard navigation support
|
||||
- ✅ Fix any accessibility issues
|
||||
- ✅ **MODIFY THE UI COMPONENT FILES DIRECTLY WITH UX IMPROVEMENTS**
|
||||
|
||||
2. **Apply UX Improvements** - UPDATE THE CODE:
|
||||
- ✅ Refactor components for better clarity and simplicity
|
||||
- ✅ Improve visual design and spacing
|
||||
- ✅ Enhance accessibility features
|
||||
- ✅ Add user feedback mechanisms (loading, error, success states)
|
||||
- ✅ Optimize for mobile and responsive design
|
||||
- ✅ Improve micro-interactions and animations
|
||||
- ✅ Ensure consistency with design system
|
||||
- ✅ **MAKE THE ACTUAL CODE CHANGES - DO NOT JUST DOCUMENT THEM**
|
||||
|
||||
3. **For Complex UX Issues** - UPDATE THE CODE:
|
||||
- ✅ Make the improvements you can make safely
|
||||
- ✅ Document UX considerations and recommendations
|
||||
- ✅ Provide specific suggestions for major UX improvements
|
||||
- ✅ **STILL MAKE AS MANY UX IMPROVEMENTS AS POSSIBLE**
|
||||
|
||||
---
|
||||
|
||||
### Summary Required
|
||||
After completing BOTH review AND update phases, provide:
|
||||
- A summary of UX issues found during review
|
||||
- **A detailed list of ALL UX improvements made to the code (this proves you updated the code)**
|
||||
- Any remaining UX considerations that need attention (if applicable)
|
||||
- Recommendations for future UX enhancements
|
||||
|
||||
---
|
||||
|
||||
# ⚠️ FINAL REMINDER ⚠️
|
||||
|
||||
**Reviewing UX without updating the code is INCOMPLETE and UNACCEPTABLE.**
|
||||
|
||||
**You MUST modify the UI component files directly with UX improvements.**
|
||||
**You MUST show evidence of UX code changes in your summary.**
|
||||
**This step is only complete when code has been updated to improve the user experience.**`,
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { GitBranch, Settings2 } from 'lucide-react';
|
||||
|
||||
interface PlanSettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
planUseSelectedWorktreeBranch: boolean;
|
||||
onPlanUseSelectedWorktreeBranchChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function PlanSettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
planUseSelectedWorktreeBranch,
|
||||
onPlanUseSelectedWorktreeBranchChange,
|
||||
}: PlanSettingsDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md" data-testid="plan-settings-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Settings2 className="w-5 h-5" />
|
||||
Plan Settings
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure how the Plan feature creates and organizes new features.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Use Selected Worktree Branch Setting */}
|
||||
<div className="flex items-start space-x-3 p-3 rounded-lg bg-secondary/50">
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor="plan-worktree-branch-toggle"
|
||||
className="text-sm font-medium cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<GitBranch className="w-4 h-4 text-brand-500" />
|
||||
Use selected worktree branch
|
||||
</Label>
|
||||
<Switch
|
||||
id="plan-worktree-branch-toggle"
|
||||
checked={planUseSelectedWorktreeBranch}
|
||||
onCheckedChange={onPlanUseSelectedWorktreeBranchChange}
|
||||
data-testid="plan-worktree-branch-toggle"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
When enabled, features created via the Plan dialog will be assigned to the currently
|
||||
selected worktree branch. When disabled, features will be added to the main branch.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { GitBranch, Settings2 } from 'lucide-react';
|
||||
|
||||
interface WorktreeSettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
addFeatureUseSelectedWorktreeBranch: boolean;
|
||||
onAddFeatureUseSelectedWorktreeBranchChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function WorktreeSettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
addFeatureUseSelectedWorktreeBranch,
|
||||
onAddFeatureUseSelectedWorktreeBranchChange,
|
||||
}: WorktreeSettingsDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md" data-testid="worktree-settings-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Settings2 className="w-5 h-5" />
|
||||
Worktree Settings
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure how worktrees affect feature creation and organization.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Use Selected Worktree Branch Setting */}
|
||||
<div className="flex items-start space-x-3 p-3 rounded-lg bg-secondary/50">
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor="worktree-branch-toggle"
|
||||
className="text-sm font-medium cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<GitBranch className="w-4 h-4 text-brand-500" />
|
||||
Use selected worktree branch
|
||||
</Label>
|
||||
<Switch
|
||||
id="worktree-branch-toggle"
|
||||
checked={addFeatureUseSelectedWorktreeBranch}
|
||||
onCheckedChange={onAddFeatureUseSelectedWorktreeBranchChange}
|
||||
data-testid="worktree-branch-toggle"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
When enabled, the Add Feature dialog will default to custom branch mode with the
|
||||
currently selected worktree branch pre-filled.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user