Merge branch 'v0.9.0rc' into feat/subagents-skills

This commit is contained in:
webdevcody
2026-01-08 00:33:30 -05:00
218 changed files with 18537 additions and 4390 deletions

View 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>
);
}

View File

@@ -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);

View File

@@ -5,34 +5,16 @@
* Prompts them to either restart the app in a container or reload to try again.
*/
import { useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { ShieldX, RefreshCw, Container, Copy, Check } from 'lucide-react';
const logger = createLogger('SandboxRejectionScreen');
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) {
logger.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">
@@ -49,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

View File

@@ -6,10 +6,7 @@
*/
import { useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { ShieldAlert, Copy, Check } from 'lucide-react';
const logger = createLogger('SandboxRiskDialog');
import { ShieldAlert } from 'lucide-react';
import {
Dialog,
DialogContent,
@@ -28,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 = () => {
@@ -40,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) {
logger.error('Failed to copy:', err);
}
};
return (
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent
@@ -81,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>

View File

@@ -52,7 +52,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',

View File

@@ -254,7 +254,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}`,
});
}

View File

@@ -132,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');
@@ -204,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');

View File

@@ -42,6 +42,9 @@ export function useSpecRegeneration({
}
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('');
@@ -49,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', {

View File

@@ -0,0 +1,154 @@
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',
cursor: 'cursor',
gemini: 'gemini',
grok: 'grok',
} as const;
type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS;
interface ProviderIconDefinition {
viewBox: string;
path: string;
}
const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition> = {
anthropic: {
viewBox: '0 0 24 24',
path: 'M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z',
},
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',
},
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',
},
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',
},
};
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="currentColor" />
</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 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 const PROVIDER_ICON_COMPONENTS: Record<
ModelProvider,
ComponentType<{ className?: string }>
> = {
claude: AnthropicIcon,
cursor: CursorIcon, // Default for Cursor provider (will be overridden by getProviderIconForModel)
codex: OpenAIIcon,
};
/**
* 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 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';
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,
cursor: CursorIcon,
gemini: GeminiIcon,
grok: GrokIcon,
};
return iconMap[iconKey] || AnthropicIcon;
}

View File

@@ -52,10 +52,12 @@ export function TaskProgressPanel({
}
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) => ({

View 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>
);
}

View File

@@ -161,7 +161,6 @@ export function AgentView() {
isConnected={isConnected}
isProcessing={isProcessing}
currentTool={currentTool}
agentError={agentError}
messagesCount={messages.length}
showSessionManager={showSessionManager}
onToggleSessionManager={() => setShowSessionManager(!showSessionManager)}

View File

@@ -7,7 +7,6 @@ interface AgentHeaderProps {
isConnected: boolean;
isProcessing: boolean;
currentTool: string | null;
agentError: string | null;
messagesCount: number;
showSessionManager: boolean;
onToggleSessionManager: () => void;
@@ -20,7 +19,6 @@ export function AgentHeader({
isConnected,
isProcessing,
currentTool,
agentError,
messagesCount,
showSessionManager,
onToggleSessionManager,
@@ -61,7 +59,6 @@ export function AgentHeader({
<span className="font-medium">{currentTool}</span>
</div>
)}
{agentError && <span className="text-xs text-destructive font-medium">{agentError}</span>}
{currentSessionId && messagesCount > 0 && (
<Button
variant="ghost"

View File

@@ -7,6 +7,7 @@ interface Message {
role: 'user' | 'assistant';
content: string;
timestamp: string;
isError?: boolean;
images?: ImageAttachment[];
}

View File

@@ -1,4 +1,4 @@
import { Bot, User, ImageIcon } from 'lucide-react';
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';
@@ -9,6 +9,7 @@ interface Message {
content: string;
timestamp: string;
images?: ImageAttachment[];
isError?: boolean;
}
interface MessageBubbleProps {
@@ -16,6 +17,8 @@ interface MessageBubbleProps {
}
export function MessageBubble({ message }: MessageBubbleProps) {
const isError = message.isError && message.role === 'assistant';
return (
<div
className={cn(
@@ -27,12 +30,16 @@ export function MessageBubble({ message }: MessageBubbleProps) {
<div
className={cn(
'w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm',
message.role === 'assistant'
? 'bg-primary/10 ring-1 ring-primary/20'
: 'bg-muted ring-1 ring-border'
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'
)}
>
{message.role === 'assistant' ? (
{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" />
@@ -43,13 +50,22 @@ export function MessageBubble({ message }: MessageBubbleProps) {
<div
className={cn(
'flex-1 max-w-[85%] rounded-2xl px-4 py-3 shadow-sm',
message.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-card border border-border'
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="text-sm text-foreground prose-p:leading-relaxed prose-headings:text-foreground prose-strong:text-foreground prose-code:text-primary prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded">
<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>
) : (
@@ -95,7 +111,11 @@ export function MessageBubble({ message }: MessageBubbleProps) {
<p
className={cn(
'text-[11px] mt-2 font-medium',
message.role === 'user' ? 'text-primary-foreground/70' : 'text-muted-foreground'
isError
? 'text-red-500/70'
: message.role === 'user'
? 'text-primary-foreground/70'
: 'text-muted-foreground'
)}
>
{new Date(message.timestamp).toLocaleTimeString([], {

View File

@@ -642,7 +642,9 @@ ${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);

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
@@ -56,7 +57,10 @@ import {
useBoardBackground,
useBoardPersistence,
useFollowUpState,
useSelectionMode,
} from './board-view/hooks';
import { SelectionActionBar } from './board-view/components';
import { MassEditDialog } from './board-view/dialogs';
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
@@ -86,6 +90,7 @@ export function BoardView() {
setWorktrees,
useWorktrees,
enableDependencyBlocking,
skipVerificationInAutoMode,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
setPipelineConfig,
@@ -154,6 +159,19 @@ export function BoardView() {
handleFollowUpDialogChange,
} = useFollowUpState();
// Selection mode hook for mass editing
const {
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
@@ -447,6 +465,72 @@ 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]
);
// 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) => {
@@ -650,10 +734,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
@@ -673,6 +764,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;
}
@@ -680,6 +779,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) {
@@ -687,10 +792,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;
@@ -714,7 +821,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;
}
@@ -724,12 +849,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);
@@ -738,6 +876,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) {
@@ -745,8 +890,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';
@@ -796,6 +942,7 @@ export function BoardView() {
getPrimaryWorktreeBranch,
isPrimaryWorktreeBranch,
enableDependencyBlocking,
skipVerificationInAutoMode,
persistFeatureUpdate,
]);
@@ -1091,7 +1238,6 @@ export function BoardView() {
onManualVerify={handleManualVerify}
onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp}
onCommit={handleCommitFeature}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
@@ -1102,13 +1248,15 @@ export function BoardView() {
}}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
shortcuts={shortcuts}
onStartNextFeatures={handleStartNextFeatures}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
pipelineConfig={
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
}
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
isSelectionMode={isSelectionMode}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onToggleSelectionMode={toggleSelectionMode}
/>
) : (
<GraphView
@@ -1134,6 +1282,27 @@ export function BoardView() {
)}
</div>
{/* Selection Action Bar */}
{isSelectionMode && (
<SelectionActionBar
selectedCount={selectedCount}
totalCount={allSelectableFeatureIds.length}
onEdit={() => setShowMassEditDialog(true)}
onClear={clearSelection}
onSelectAll={() => selectAll(allSelectableFeatureIds)}
/>
)}
{/* Mass Edit Dialog */}
<MassEditDialog
open={showMassEditDialog}
onClose={() => setShowMassEditDialog(false)}
selectedFeatures={selectedFeatures}
onApply={handleBulkUpdate}
showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles}
/>
{/* Board Background Modal */}
<BoardBackgroundModal
open={showBoardBackgroundModal}

View File

@@ -1,13 +1,15 @@
import { useState } from 'react';
import { HotkeyButton } from '@/components/ui/hotkey-button';
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 { Plus, Bot, Wand2, Settings2 } from 'lucide-react';
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
import { ClaudeUsagePopover } from '@/components/claude-usage-popover';
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';
interface BoardHeaderProps {
projectName: string;
@@ -38,19 +40,26 @@ export function BoardHeader({
addFeatureShortcut,
isMounted,
}: BoardHeaderProps) {
const [showAutoModeSettings, setShowAutoModeSettings] = 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 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
// 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">
@@ -59,8 +68,8 @@ export function BoardHeader({
<p className="text-sm text-muted-foreground">{projectName}</p>
</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 */}
{isMounted && (
@@ -97,9 +106,25 @@ 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>
)}
{/* Auto Mode Settings Dialog */}
<AutoModeSettingsDialog
open={showAutoModeSettings}
onOpenChange={setShowAutoModeSettings}
skipVerificationInAutoMode={skipVerificationInAutoMode}
onSkipVerificationChange={setSkipVerificationInAutoMode}
/>
<Button
size="sm"
variant="outline"

View File

@@ -1,2 +1,3 @@
export { KanbanCard } from './kanban-card/kanban-card';
export { KanbanColumn } from './kanban-column';
export { SelectionActionBar } from './selection-action-bar';

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { useEffect, useState } from 'react';
import { Feature, ThinkingLevel, useAppStore } from '@/store/app-store';
import {
@@ -8,7 +9,6 @@ import {
} from '@/lib/agent-context-parser';
import { cn } from '@/lib/utils';
import {
Cpu,
Brain,
ListTodo,
Sparkles,
@@ -20,6 +20,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
@@ -109,7 +110,10 @@ export function AgentInfoPanel({
<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' ? (
@@ -133,7 +137,10 @@ export function AgentInfoPanel({
{/* 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 && (

View File

@@ -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 && (

View File

@@ -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>
);

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { Feature } from '@/store/app-store';
import { GitBranch, GitPullRequest, ExternalLink } from 'lucide-react';

View File

@@ -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>

View File

@@ -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}

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { Feature } from '@/store/app-store';
import { AgentTaskInfo } from '@/lib/agent-context-parser';
import {

View File

@@ -0,0 +1,78 @@
import { Button } from '@/components/ui/button';
import { Pencil, X, CheckSquare } from 'lucide-react';
import { cn } from '@/lib/utils';
interface SelectionActionBarProps {
selectedCount: number;
totalCount: number;
onEdit: () => void;
onClear: () => void;
onSelectAll: () => void;
}
export function SelectionActionBar({
selectedCount,
totalCount,
onEdit,
onClear,
onSelectAll,
}: SelectionActionBarProps) {
if (selectedCount === 0) return null;
const allSelected = selectedCount === totalCount;
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>
{!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>
);
}

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { useState, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {

View File

@@ -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>
);
}

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import {
Dialog,
DialogContent,

View File

@@ -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';

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { useState, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
@@ -27,6 +28,7 @@ import {
Sparkles,
ChevronDown,
GitBranch,
History,
} from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
@@ -55,6 +57,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { DescriptionHistoryEntry } from '@automaker/types';
import { DependencyTreeDialog } from './dependency-tree-dialog';
import { isCursorModel, PROVIDER_PREFIXES } from '@automaker/types';
@@ -78,7 +82,9 @@ interface EditFeatureDialogProps {
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
}
},
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
) => void;
categorySuggestions: string[];
branchSuggestions: string[];
@@ -121,6 +127,14 @@ export function EditFeatureDialog({
const [requirePlanApproval, setRequirePlanApproval] = useState(
feature?.requirePlanApproval ?? false
);
// Track the source of description changes for history
const [descriptionChangeSource, setDescriptionChangeSource] = useState<
{ source: 'enhance'; mode: 'improve' | 'technical' | 'simplify' | 'acceptance' } | 'edit' | null
>(null);
// Track the original description when the dialog opened for comparison
const [originalDescription, setOriginalDescription] = useState(feature?.description ?? '');
// Track if history dropdown is open
const [showHistory, setShowHistory] = useState(false);
// Get worktrees setting from store
const { useWorktrees } = useAppStore();
@@ -135,9 +149,15 @@ export function EditFeatureDialog({
setRequirePlanApproval(feature.requirePlanApproval ?? false);
// If feature has no branchName, default to using current branch
setUseCurrentBranch(!feature.branchName);
// Reset history tracking state
setOriginalDescription(feature.description ?? '');
setDescriptionChangeSource(null);
setShowHistory(false);
} else {
setEditFeaturePreviewMap(new Map());
setShowEditAdvancedOptions(false);
setDescriptionChangeSource(null);
setShowHistory(false);
}
}, [feature]);
@@ -183,7 +203,21 @@ export function EditFeatureDialog({
requirePlanApproval,
};
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);
setEditFeaturePreviewMap(new Map());
setShowEditAdvancedOptions(false);
onClose();
@@ -247,6 +281,8 @@ export function EditFeatureDialog({
if (result?.success && result.enhancedText) {
const enhancedText = result.enhancedText;
setEditingFeature((prev) => (prev ? { ...prev, description: enhancedText } : prev));
// Track that this change was from enhancement
setDescriptionChangeSource({ source: 'enhance', mode: enhancementMode });
toast.success('Description enhanced!');
} else {
toast.error(result?.error || 'Failed to enhance description');
@@ -312,12 +348,16 @@ export function EditFeatureDialog({
<Label htmlFor="edit-description">Description</Label>
<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({
@@ -400,6 +440,80 @@ export function EditFeatureDialog({
size="sm"
variant="icon"
/>
{/* Version History Button */}
{feature?.descriptionHistory && feature.descriptionHistory.length > 0 && (
<Popover open={showHistory} onOpenChange={setShowHistory}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" size="sm" className="gap-2">
<History className="w-4 h-4" />
History ({feature.descriptionHistory.length})
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="start">
<div className="p-3 border-b">
<h4 className="font-medium text-sm">Version History</h4>
<p className="text-xs text-muted-foreground mt-1">
Click a version to restore it
</p>
</div>
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{[...(feature.descriptionHistory || [])]
.reverse()
.map((entry: DescriptionHistoryEntry, index: number) => {
const isCurrentVersion = entry.description === editingFeature.description;
const date = new Date(entry.timestamp);
const formattedDate = date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const sourceLabel =
entry.source === 'initial'
? 'Original'
: entry.source === 'enhance'
? `Enhanced (${entry.enhancementMode || 'improve'})`
: 'Edited';
return (
<button
key={`${entry.timestamp}-${index}`}
onClick={() => {
setEditingFeature((prev) =>
prev ? { ...prev, description: entry.description } : prev
);
// Mark as edit since user is restoring from history
setDescriptionChangeSource('edit');
setShowHistory(false);
toast.success('Description restored from history');
}}
className={`w-full text-left p-2 rounded-md hover:bg-muted transition-colors ${
isCurrentVersion ? 'bg-muted/50 border border-primary/20' : ''
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{sourceLabel}</span>
<span className="text-xs text-muted-foreground">
{formattedDate}
</span>
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{entry.description.slice(0, 100)}
{entry.description.length > 100 ? '...' : ''}
</p>
{isCurrentVersion && (
<span className="text-xs text-primary font-medium mt-1 block">
Current version
</span>
)}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
)}
</div>
<div className="space-y-2">
<Label htmlFor="edit-category">Category (optional)</Label>

View File

@@ -7,3 +7,4 @@ export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog'
export { EditFeatureDialog } from './edit-feature-dialog';
export { FollowUpDialog } from './follow-up-dialog';
export { PlanApprovalDialog } from './plan-approval-dialog';
export { MassEditDialog } from './mass-edit-dialog';

View File

@@ -0,0 +1,325 @@
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, AIProfile, PlanningMode } from '@/store/app-store';
import { ProfileSelect, TestingTabContent, PrioritySelect, PlanningModeSelect } from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { isCursorModel, PROVIDER_PREFIXES, type PhaseModelEntry } from '@automaker/types';
import { cn } from '@/lib/utils';
interface MassEditDialogProps {
open: boolean;
onClose: () => void;
selectedFeatures: Feature[];
onApply: (updates: Partial<Feature>) => Promise<void>;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
}
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,
showProfilesOnly,
aiProfiles,
}: 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 handleModelSelect = (newModel: string) => {
const isCursor = isCursorModel(newModel);
setModel(newModel as ModelAlias);
if (isCursor || !modelSupportsThinking(newModel)) {
setThinkingLevel('none');
}
};
const handleProfileSelect = (profile: AIProfile) => {
if (profile.provider === 'cursor') {
const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
setModel(cursorModel as ModelAlias);
setThinkingLevel('none');
} else {
setModel((profile.model || 'sonnet') as ModelAlias);
setThinkingLevel(profile.thinkingLevel || 'none');
}
setApplyState((prev) => ({ ...prev, model: true, thinkingLevel: true }));
};
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);
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">
{/* Quick Select Profile Section */}
{aiProfiles.length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Quick Select Profile</Label>
<p className="text-xs text-muted-foreground mb-2">
Selecting a profile will automatically enable model settings
</p>
<ProfileSelect
profiles={aiProfiles}
selectedModel={model}
selectedThinkingLevel={thinkingLevel}
selectedCursorModel={isCurrentModelCursor ? model : undefined}
onSelect={handleProfileSelect}
testIdPrefix="mass-edit-profile"
/>
</div>
)}
{/* 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">
Or 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 */}
<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>
{/* 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>
);
}

View File

@@ -7,3 +7,4 @@ export { useBoardEffects } from './use-board-effects';
export { useBoardBackground } from './use-board-background';
export { useBoardPersistence } from './use-board-persistence';
export { useFollowUpState } from './use-follow-up-state';
export { useSelectionMode } from './use-selection-mode';

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { useCallback } from 'react';
import {
Feature,
@@ -23,7 +24,12 @@ interface UseBoardActionsProps {
runningAutoTasks: string[];
loadFeatures: () => Promise<void>;
persistFeatureCreate: (feature: Feature) => Promise<void>;
persistFeatureUpdate: (featureId: string, updates: Partial<Feature>) => Promise<void>;
persistFeatureUpdate: (
featureId: string,
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
) => Promise<void>;
persistFeatureDelete: (featureId: string) => Promise<void>;
saveCategory: (category: string) => Promise<void>;
setEditingFeature: (feature: Feature | null) => void;
@@ -79,6 +85,7 @@ export function useBoardActions({
moveFeature,
useWorktrees,
enableDependencyBlocking,
skipVerificationInAutoMode,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
} = useAppStore();
@@ -220,7 +227,9 @@ export function useBoardActions({
priority: number;
planningMode?: PlanningMode;
requirePlanApproval?: boolean;
}
},
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
) => {
const finalBranchName = updates.branchName || undefined;
@@ -264,7 +273,7 @@ export function useBoardActions({
};
updateFeature(featureId, finalUpdates);
persistFeatureUpdate(featureId, finalUpdates);
persistFeatureUpdate(featureId, finalUpdates, descriptionHistorySource, enhancementMode);
if (updates.category) {
saveCategory(updates.category);
}
@@ -805,12 +814,14 @@ export function useBoardActions({
// Sort by priority (lower number = higher priority, priority 1 is highest)
// Features with blocking dependencies are sorted to the end
const sortedBacklog = [...backlogFeatures].sort((a, b) => {
const aBlocked = enableDependencyBlocking
? getBlockingDependencies(a, features).length > 0
: false;
const bBlocked = enableDependencyBlocking
? getBlockingDependencies(b, features).length > 0
: false;
const aBlocked =
enableDependencyBlocking && !skipVerificationInAutoMode
? getBlockingDependencies(a, features).length > 0
: false;
const bBlocked =
enableDependencyBlocking && !skipVerificationInAutoMode
? getBlockingDependencies(b, features).length > 0
: false;
// Blocked features go to the end
if (aBlocked && !bBlocked) return 1;
@@ -822,14 +833,14 @@ export function useBoardActions({
// Find the first feature without blocking dependencies
const featureToStart = sortedBacklog.find((f) => {
if (!enableDependencyBlocking) return true;
if (!enableDependencyBlocking || skipVerificationInAutoMode) return true;
return getBlockingDependencies(f, features).length === 0;
});
if (!featureToStart) {
toast.info('No eligible features', {
description:
'All backlog features have unmet dependencies. Complete their dependencies first.',
'All backlog features have unmet dependencies. Complete their dependencies first (or enable "Skip verification requirement" in Auto Mode settings).',
});
return;
}
@@ -846,6 +857,7 @@ export function useBoardActions({
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
enableDependencyBlocking,
skipVerificationInAutoMode,
]);
const handleArchiveAllVerified = useCallback(async () => {

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { useMemo, useCallback } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import { resolveDependencies, getBlockingDependencies } from '@automaker/dependency-resolver';

View File

@@ -15,7 +15,12 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
// Persist feature update to API (replaces saveFeatures)
const persistFeatureUpdate = useCallback(
async (featureId: string, updates: Partial<Feature>) => {
async (
featureId: string,
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
) => {
if (!currentProject) return;
try {
@@ -25,7 +30,13 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
return;
}
const result = await api.features.update(currentProject.path, featureId, updates);
const result = await api.features.update(
currentProject.path,
featureId,
updates,
descriptionHistorySource,
enhancementMode
);
if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature);
}

View File

@@ -0,0 +1,82 @@
import { useState, useCallback, useEffect } from 'react';
interface UseSelectionModeReturn {
isSelectionMode: boolean;
selectedFeatureIds: Set<string>;
selectedCount: number;
toggleSelectionMode: () => void;
toggleFeatureSelection: (featureId: string) => void;
selectAll: (featureIds: string[]) => void;
clearSelection: () => void;
isFeatureSelected: (featureId: string) => boolean;
exitSelectionMode: () => void;
}
export function useSelectionMode(): UseSelectionModeReturn {
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedFeatureIds, setSelectedFeatureIds] = useState<Set<string>>(new Set());
const toggleSelectionMode = useCallback(() => {
setIsSelectionMode((prev) => {
if (prev) {
// Exiting selection mode - clear selection
setSelectedFeatureIds(new Set());
}
return !prev;
});
}, []);
const exitSelectionMode = useCallback(() => {
setIsSelectionMode(false);
setSelectedFeatureIds(new Set());
}, []);
const toggleFeatureSelection = useCallback((featureId: string) => {
setSelectedFeatureIds((prev) => {
const next = new Set(prev);
if (next.has(featureId)) {
next.delete(featureId);
} else {
next.add(featureId);
}
return next;
});
}, []);
const selectAll = useCallback((featureIds: string[]) => {
setSelectedFeatureIds(new Set(featureIds));
}, []);
const clearSelection = useCallback(() => {
setSelectedFeatureIds(new Set());
}, []);
const isFeatureSelected = useCallback(
(featureId: string) => selectedFeatureIds.has(featureId),
[selectedFeatureIds]
);
// Handle Escape key to exit selection mode
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isSelectionMode) {
exitSelectionMode();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isSelectionMode, exitSelectionMode]);
return {
isSelectionMode,
selectedFeatureIds,
selectedCount: selectedFeatureIds.size,
toggleSelectionMode,
toggleFeatureSelection,
selectAll,
clearSelection,
isFeatureSelected,
exitSelectionMode,
};
}

View File

@@ -2,13 +2,11 @@ import { useMemo } from 'react';
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { KanbanColumn, KanbanCard } from './components';
import { Feature } from '@/store/app-store';
import { FastForward, Archive, Plus, Settings2 } from 'lucide-react';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { Archive, Settings2, CheckSquare, GripVertical } from 'lucide-react';
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
import { getColumnsWithPipeline, type Column, type ColumnId } from './constants';
import { getColumnsWithPipeline, type ColumnId } from './constants';
import type { PipelineConfig } from '@automaker/types';
interface KanbanBoardProps {
@@ -37,7 +35,6 @@ interface KanbanBoardProps {
onManualVerify: (feature: Feature) => void;
onMoveBackToInProgress: (feature: Feature) => void;
onFollowUp: (feature: Feature) => void;
onCommit: (feature: Feature) => void;
onComplete: (feature: Feature) => void;
onImplement: (feature: Feature) => void;
onViewPlan: (feature: Feature) => void;
@@ -45,11 +42,14 @@ interface KanbanBoardProps {
onSpawnTask?: (feature: Feature) => void;
featuresWithContext: Set<string>;
runningAutoTasks: string[];
shortcuts: ReturnType<typeof useKeyboardShortcutsConfig>;
onStartNextFeatures: () => void;
onArchiveAllVerified: () => void;
pipelineConfig: PipelineConfig | null;
onOpenPipelineSettings?: () => void;
// Selection mode props
isSelectionMode?: boolean;
selectedFeatureIds?: Set<string>;
onToggleFeatureSelection?: (featureId: string) => void;
onToggleSelectionMode?: () => void;
}
export function KanbanBoard({
@@ -70,7 +70,6 @@ export function KanbanBoard({
onManualVerify,
onMoveBackToInProgress,
onFollowUp,
onCommit,
onComplete,
onImplement,
onViewPlan,
@@ -78,11 +77,13 @@ export function KanbanBoard({
onSpawnTask,
featuresWithContext,
runningAutoTasks,
shortcuts,
onStartNextFeatures,
onArchiveAllVerified,
pipelineConfig,
onOpenPipelineSettings,
isSelectionMode = false,
selectedFeatureIds = new Set(),
onToggleFeatureSelection,
onToggleSelectionMode,
}: KanbanBoardProps) {
// Generate columns including pipeline steps
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
@@ -126,20 +127,26 @@ export function KanbanBoard({
Complete All
</Button>
) : column.id === 'backlog' ? (
columnFeatures.length > 0 && (
<HotkeyButton
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
onClick={onStartNextFeatures}
hotkey={shortcuts.startNext}
hotkeyActive={false}
data-testid="start-next-button"
>
<FastForward className="w-3 h-3 mr-1" />
Make
</HotkeyButton>
)
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs ${isSelectionMode ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={onToggleSelectionMode}
title={isSelectionMode ? 'Switch to Drag Mode' : 'Select Multiple'}
data-testid="selection-mode-button"
>
{isSelectionMode ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
</>
) : (
<>
<CheckSquare className="w-3.5 h-3.5 mr-1" />
Select
</>
)}
</Button>
) : column.id === 'in_progress' ? (
<Button
variant="ghost"
@@ -200,6 +207,9 @@ export function KanbanBoard({
glassmorphism={backgroundSettings.cardGlassmorphism}
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
isSelectionMode={isSelectionMode}
isSelected={selectedFeatureIds.has(feature.id)}
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
/>
);
})}

View File

@@ -2,8 +2,11 @@ export * from './model-constants';
export * from './model-selector';
export * from './thinking-level-selector';
export * from './profile-quick-select';
export * from './profile-select';
export * from './testing-tab-content';
export * from './priority-selector';
export * from './priority-select';
export * from './branch-selector';
export * from './planning-mode-selector';
export * from './planning-mode-select';
export * from './ancestor-context-section';

View File

@@ -1,6 +1,6 @@
import type { ModelAlias, ThinkingLevel } from '@/store/app-store';
import type { ModelProvider } from '@automaker/types';
import { CURSOR_MODEL_MAP } from '@automaker/types';
import type { ModelAlias } from '@/store/app-store';
import type { ModelProvider, ThinkingLevel, ReasoningEffort } from '@automaker/types';
import { CURSOR_MODEL_MAP, CODEX_MODEL_MAP } from '@automaker/types';
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
export type ModelOption = {
@@ -51,9 +51,64 @@ export const CURSOR_MODELS: ModelOption[] = Object.entries(CURSOR_MODEL_MAP).map
);
/**
* All available models (Claude + Cursor)
* Codex/OpenAI models
* Official models from https://developers.openai.com/codex/models/
*/
export const ALL_MODELS: ModelOption[] = [...CLAUDE_MODELS, ...CURSOR_MODELS];
export const CODEX_MODELS: ModelOption[] = [
{
id: CODEX_MODEL_MAP.gpt52Codex,
label: 'GPT-5.2-Codex',
description: 'Most advanced agentic coding model (default for ChatGPT users).',
badge: 'Premium',
provider: 'codex',
hasThinking: true,
},
{
id: CODEX_MODEL_MAP.gpt5Codex,
label: 'GPT-5-Codex',
description: 'Purpose-built for Codex CLI (default for CLI users).',
badge: 'Balanced',
provider: 'codex',
hasThinking: true,
},
{
id: CODEX_MODEL_MAP.gpt5CodexMini,
label: 'GPT-5-Codex-Mini',
description: 'Faster workflows for code Q&A and editing.',
badge: 'Speed',
provider: 'codex',
hasThinking: false,
},
{
id: CODEX_MODEL_MAP.codex1,
label: 'Codex-1',
description: 'o3-based model optimized for software engineering.',
badge: 'Premium',
provider: 'codex',
hasThinking: true,
},
{
id: CODEX_MODEL_MAP.codexMiniLatest,
label: 'Codex-Mini-Latest',
description: 'o4-mini-based model for faster workflows.',
badge: 'Balanced',
provider: 'codex',
hasThinking: false,
},
{
id: CODEX_MODEL_MAP.gpt5,
label: 'GPT-5',
description: 'GPT-5 base flagship model.',
badge: 'Balanced',
provider: 'codex',
hasThinking: true,
},
];
/**
* All available models (Claude + Cursor + Codex)
*/
export const ALL_MODELS: ModelOption[] = [...CLAUDE_MODELS, ...CURSOR_MODELS, ...CODEX_MODELS];
export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
@@ -65,6 +120,28 @@ export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
ultrathink: 'Ultra',
};
/**
* Reasoning effort levels for Codex/OpenAI models
* All models support reasoning effort levels
*/
export const REASONING_EFFORT_LEVELS: ReasoningEffort[] = [
'none',
'minimal',
'low',
'medium',
'high',
'xhigh',
];
export const REASONING_EFFORT_LABELS: Record<ReasoningEffort, string> = {
none: 'None',
minimal: 'Min',
low: 'Low',
medium: 'Med',
high: 'High',
xhigh: 'XHigh',
};
// Profile icon mapping
export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
Brain,

View File

@@ -1,13 +1,15 @@
// @ts-nocheck
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Brain, Bot, Terminal, AlertTriangle } from 'lucide-react';
import { Brain, AlertTriangle } from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
import { cn } from '@/lib/utils';
import type { ModelAlias } from '@/store/app-store';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { getModelProvider, PROVIDER_PREFIXES, stripProviderPrefix } from '@automaker/types';
import type { ModelProvider } from '@automaker/types';
import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, ModelOption } from './model-constants';
interface ModelSelectorProps {
selectedModel: string; // Can be ModelAlias or "cursor-{id}"
@@ -21,13 +23,16 @@ export function ModelSelector({
testIdPrefix = 'model-select',
}: ModelSelectorProps) {
const { enabledCursorModels, cursorDefaultModel } = useAppStore();
const { cursorCliStatus } = useSetupStore();
const { cursorCliStatus, codexCliStatus } = useSetupStore();
const selectedProvider = getModelProvider(selectedModel);
// Check if Cursor CLI is available
const isCursorAvailable = cursorCliStatus?.installed && cursorCliStatus?.auth?.authenticated;
// Check if Codex CLI is available
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated;
// Filter Cursor models based on enabled models from global settings
const filteredCursorModels = CURSOR_MODELS.filter((model) => {
// Extract the cursor model ID from the prefixed ID (e.g., "cursor-auto" -> "auto")
@@ -39,6 +44,9 @@ export function ModelSelector({
if (provider === 'cursor' && selectedProvider !== 'cursor') {
// Switch to Cursor's default model (from global settings)
onModelSelect(`${PROVIDER_PREFIXES.cursor}${cursorDefaultModel}`);
} else if (provider === 'codex' && selectedProvider !== 'codex') {
// Switch to Codex's default model (gpt-5.2)
onModelSelect('gpt-5.2');
} else if (provider === 'claude' && selectedProvider !== 'claude') {
// Switch to Claude's default model
onModelSelect('sonnet');
@@ -62,7 +70,7 @@ export function ModelSelector({
)}
data-testid={`${testIdPrefix}-provider-claude`}
>
<Bot className="w-4 h-4" />
<AnthropicIcon className="w-4 h-4" />
Claude
</button>
<button
@@ -76,9 +84,23 @@ export function ModelSelector({
)}
data-testid={`${testIdPrefix}-provider-cursor`}
>
<Terminal className="w-4 h-4" />
<CursorIcon className="w-4 h-4" />
Cursor CLI
</button>
<button
type="button"
onClick={() => handleProviderChange('codex')}
className={cn(
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
selectedProvider === 'codex'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`${testIdPrefix}-provider-codex`}
>
<OpenAIIcon className="w-4 h-4" />
Codex CLI
</button>
</div>
</div>
@@ -136,7 +158,7 @@ export function ModelSelector({
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<Terminal className="w-4 h-4 text-primary" />
<CursorIcon className="w-4 h-4 text-primary" />
Cursor Model
</Label>
<span className="text-[11px] px-2 py-0.5 rounded-full border border-amber-500/40 text-amber-600 dark:text-amber-400">
@@ -188,6 +210,67 @@ export function ModelSelector({
</div>
</div>
)}
{/* Codex Models */}
{selectedProvider === 'codex' && (
<div className="space-y-3">
{/* Warning when Codex CLI is not available */}
{!isCodexAvailable && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="w-4 h-4 text-amber-400 mt-0.5 shrink-0" />
<div className="text-sm text-amber-400">
Codex CLI is not installed or authenticated. Configure it in Settings AI
Providers.
</div>
</div>
)}
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<OpenAIIcon className="w-4 h-4 text-primary" />
Codex Model
</Label>
<span className="text-[11px] px-2 py-0.5 rounded-full border border-emerald-500/40 text-emerald-600 dark:text-emerald-400">
CLI
</span>
</div>
<div className="flex flex-col gap-2">
{CODEX_MODELS.map((option) => {
const isSelected = selectedModel === option.id;
return (
<button
key={option.id}
type="button"
onClick={() => onModelSelect(option.id)}
title={option.description}
className={cn(
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
isSelected
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`${testIdPrefix}-${option.id}`}
>
<span>{option.label}</span>
{option.badge && (
<Badge
variant="outline"
className={cn(
'text-xs',
isSelected
? 'border-primary-foreground/50 text-primary-foreground'
: 'border-muted-foreground/50 text-muted-foreground'
)}
>
{option.badge}
</Badge>
)}
</button>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,148 @@
import { Zap, ClipboardList, FileText, ScrollText } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import type { PlanningMode } from '@automaker/types';
interface PlanningModeSelectProps {
mode: PlanningMode;
onModeChange: (mode: PlanningMode) => void;
requireApproval?: boolean;
onRequireApprovalChange?: (require: boolean) => void;
testIdPrefix?: string;
className?: string;
disabled?: boolean;
}
const modes = [
{
value: 'skip' as const,
label: 'Skip',
description: 'Direct implementation, no upfront planning',
icon: Zap,
color: 'text-emerald-500',
},
{
value: 'lite' as const,
label: 'Lite',
description: 'Think through approach, create task list',
icon: ClipboardList,
color: 'text-blue-500',
},
{
value: 'spec' as const,
label: 'Spec',
description: 'Generate spec with acceptance criteria',
icon: FileText,
color: 'text-purple-500',
},
{
value: 'full' as const,
label: 'Full',
description: 'Comprehensive spec with phased plan',
icon: ScrollText,
color: 'text-amber-500',
},
];
/**
* PlanningModeSelect - Compact dropdown selector for planning modes
*
* A lightweight alternative to PlanningModeSelector for contexts where
* spec management UI is not needed (e.g., mass edit, bulk operations).
*
* Shows icon + label in dropdown, with description text below.
* Does not include spec generation, approval, or require-approval checkbox.
*
* @example
* ```tsx
* <PlanningModeSelect
* mode={planningMode}
* onModeChange={(mode) => {
* setPlanningMode(mode);
* setRequireApproval(mode === 'spec' || mode === 'full');
* }}
* testIdPrefix="mass-edit-planning"
* />
* ```
*/
export function PlanningModeSelect({
mode,
onModeChange,
requireApproval,
onRequireApprovalChange,
testIdPrefix = 'planning-mode',
className,
disabled = false,
}: PlanningModeSelectProps) {
const selectedMode = modes.find((m) => m.value === mode);
// Disable approval checkbox for skip/lite modes since they don't use planning
const isApprovalDisabled = disabled || mode === 'skip' || mode === 'lite';
return (
<div className={cn('space-y-2', className)}>
<Select
value={mode}
onValueChange={(value: string) => onModeChange(value as PlanningMode)}
disabled={disabled}
>
<SelectTrigger className="h-9" data-testid={`${testIdPrefix}-select-trigger`}>
<SelectValue>
{selectedMode && (
<div className="flex items-center gap-2">
<selectedMode.icon className={cn('h-4 w-4', selectedMode.color)} />
<span>{selectedMode.label}</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{modes.map((m) => {
const Icon = m.icon;
return (
<SelectItem
key={m.value}
value={m.value}
data-testid={`${testIdPrefix}-option-${m.value}`}
>
<div className="flex items-center gap-2">
<Icon className={cn('h-3.5 w-3.5', m.color)} />
<span>{m.label}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
{selectedMode && <p className="text-xs text-muted-foreground">{selectedMode.description}</p>}
{onRequireApprovalChange && (
<div className="flex items-center gap-2 pt-1">
<Checkbox
id={`${testIdPrefix}-require-approval`}
checked={requireApproval && !isApprovalDisabled}
onCheckedChange={(checked) => onRequireApprovalChange(!!checked)}
disabled={isApprovalDisabled}
data-testid={`${testIdPrefix}-require-approval-checkbox`}
/>
<Label
htmlFor={`${testIdPrefix}-require-approval`}
className={cn(
'text-sm font-normal',
isApprovalDisabled ? 'cursor-not-allowed text-muted-foreground' : 'cursor-pointer'
)}
>
Require plan approval before execution
</Label>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,112 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { AlertCircle, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
interface PrioritySelectProps {
selectedPriority: number;
onPrioritySelect: (priority: number) => void;
testIdPrefix?: string;
className?: string;
disabled?: boolean;
}
const priorities = [
{
value: 1,
label: 'High',
description: 'Urgent, needs immediate attention',
icon: ChevronUp,
color: 'text-red-500',
bgColor: 'bg-red-500/10',
},
{
value: 2,
label: 'Medium',
description: 'Normal priority, standard workflow',
icon: AlertCircle,
color: 'text-yellow-500',
bgColor: 'bg-yellow-500/10',
},
{
value: 3,
label: 'Low',
description: 'Can wait, not time-sensitive',
icon: ChevronDown,
color: 'text-blue-500',
bgColor: 'bg-blue-500/10',
},
];
/**
* PrioritySelect - Compact dropdown selector for feature priority
*
* A lightweight alternative to PrioritySelector for contexts where
* space is limited (e.g., mass edit, bulk operations).
*
* Shows icon + priority level in dropdown, with description below.
*
* @example
* ```tsx
* <PrioritySelect
* selectedPriority={priority}
* onPrioritySelect={setPriority}
* testIdPrefix="mass-edit-priority"
* />
* ```
*/
export function PrioritySelect({
selectedPriority,
onPrioritySelect,
testIdPrefix = 'priority',
className,
disabled = false,
}: PrioritySelectProps) {
const selectedPriorityObj = priorities.find((p) => p.value === selectedPriority);
return (
<div className={cn('space-y-2', className)}>
<Select
value={selectedPriority.toString()}
onValueChange={(value: string) => onPrioritySelect(parseInt(value, 10))}
disabled={disabled}
>
<SelectTrigger className="h-9" data-testid={`${testIdPrefix}-select-trigger`}>
<SelectValue>
{selectedPriorityObj && (
<div className="flex items-center gap-2">
<selectedPriorityObj.icon className={cn('h-4 w-4', selectedPriorityObj.color)} />
<span>{selectedPriorityObj.label}</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{priorities.map((p) => {
const Icon = p.icon;
return (
<SelectItem
key={p.value}
value={p.value.toString()}
data-testid={`${testIdPrefix}-option-${p.label.toLowerCase()}`}
>
<div className="flex items-center gap-2">
<Icon className={cn('h-3.5 w-3.5', p.color)} />
<span>{p.label}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
{selectedPriorityObj && (
<p className="text-xs text-muted-foreground">{selectedPriorityObj.description}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,175 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Brain, Terminal } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ModelAlias, ThinkingLevel, AIProfile, CursorModelId } from '@automaker/types';
import { CURSOR_MODEL_MAP, profileHasThinking, PROVIDER_PREFIXES } from '@automaker/types';
import { PROFILE_ICONS } from './model-constants';
/**
* Get display string for a profile's model configuration
*/
function getProfileModelDisplay(profile: AIProfile): string {
if (profile.provider === 'cursor') {
const cursorModel = profile.cursorModel || 'auto';
const modelConfig = CURSOR_MODEL_MAP[cursorModel];
return modelConfig?.label || cursorModel;
}
// Claude
return profile.model || 'sonnet';
}
/**
* Get display string for a profile's thinking configuration
*/
function getProfileThinkingDisplay(profile: AIProfile): string | null {
if (profile.provider === 'cursor') {
// For Cursor, thinking is embedded in the model
return profileHasThinking(profile) ? 'thinking' : null;
}
// Claude
return profile.thinkingLevel && profile.thinkingLevel !== 'none' ? profile.thinkingLevel : null;
}
interface ProfileSelectProps {
profiles: AIProfile[];
selectedModel: ModelAlias | CursorModelId;
selectedThinkingLevel: ThinkingLevel;
selectedCursorModel?: string; // For detecting cursor profile selection
onSelect: (profile: AIProfile) => void;
testIdPrefix?: string;
className?: string;
disabled?: boolean;
}
/**
* ProfileSelect - Compact dropdown selector for AI profiles
*
* A lightweight alternative to ProfileQuickSelect for contexts where
* space is limited (e.g., mass edit, bulk operations).
*
* Shows icon + profile name in dropdown, with model details below.
*
* @example
* ```tsx
* <ProfileSelect
* profiles={aiProfiles}
* selectedModel={model}
* selectedThinkingLevel={thinkingLevel}
* selectedCursorModel={isCurrentModelCursor ? model : undefined}
* onSelect={handleProfileSelect}
* testIdPrefix="mass-edit-profile"
* />
* ```
*/
export function ProfileSelect({
profiles,
selectedModel,
selectedThinkingLevel,
selectedCursorModel,
onSelect,
testIdPrefix = 'profile-select',
className,
disabled = false,
}: ProfileSelectProps) {
if (profiles.length === 0) {
return null;
}
// Check if a profile is selected
const isProfileSelected = (profile: AIProfile): boolean => {
if (profile.provider === 'cursor') {
// For cursor profiles, check if cursor model matches
const profileCursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
return selectedCursorModel === profileCursorModel;
}
// For Claude profiles
return selectedModel === profile.model && selectedThinkingLevel === profile.thinkingLevel;
};
const selectedProfile = profiles.find(isProfileSelected);
return (
<div className={cn('space-y-2', className)}>
<Select
value={selectedProfile?.id || 'none'}
onValueChange={(value: string) => {
if (value !== 'none') {
const profile = profiles.find((p) => p.id === value);
if (profile) {
onSelect(profile);
}
}
}}
disabled={disabled}
>
<SelectTrigger className="h-9" data-testid={`${testIdPrefix}-select-trigger`}>
<SelectValue>
{selectedProfile ? (
<div className="flex items-center gap-2">
{selectedProfile.provider === 'cursor' ? (
<Terminal className="h-4 w-4 text-amber-500" />
) : (
(() => {
const IconComponent = selectedProfile.icon
? PROFILE_ICONS[selectedProfile.icon]
: Brain;
return <IconComponent className="h-4 w-4 text-primary" />;
})()
)}
<span>{selectedProfile.name}</span>
</div>
) : (
<span className="text-muted-foreground">Select a profile...</span>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="text-muted-foreground">
No profile selected
</SelectItem>
{profiles.map((profile) => {
const isCursorProfile = profile.provider === 'cursor';
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
return (
<SelectItem
key={profile.id}
value={profile.id}
data-testid={`${testIdPrefix}-option-${profile.id}`}
>
<div className="flex items-center gap-2">
{isCursorProfile ? (
<Terminal className="h-3.5 w-3.5 text-amber-500" />
) : (
<IconComponent className="h-3.5 w-3.5 text-primary" />
)}
<div className="flex flex-col">
<span className="text-sm">{profile.name}</span>
<span className="text-[10px] text-muted-foreground">
{getProfileModelDisplay(profile)}
{getProfileThinkingDisplay(profile) &&
` + ${getProfileThinkingDisplay(profile)}`}
</span>
</div>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
{selectedProfile && (
<p className="text-xs text-muted-foreground">
{getProfileModelDisplay(selectedProfile)}
{getProfileThinkingDisplay(selectedProfile) &&
` + ${getProfileThinkingDisplay(selectedProfile)}`}
</p>
)}
</div>
);
}

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { Button } from '@/components/ui/button';
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
import { cn } from '@/lib/utils';

View File

@@ -1,8 +1,8 @@
import { useState, useEffect, useRef } from 'react';
import { useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { GitBranch, Plus, RefreshCw, PanelLeftOpen, PanelLeftClose } from 'lucide-react';
import { cn, pathsEqual } from '@/lib/utils';
import { getItem, setItem } from '@/lib/storage';
import { useAppStore } from '@/store/app-store';
import type { WorktreePanelProps, WorktreeInfo } from './types';
import {
useWorktrees,
@@ -14,8 +14,6 @@ import {
} from './hooks';
import { WorktreeTab } from './components';
const WORKTREE_PANEL_COLLAPSED_KEY = 'worktree-panel-collapsed';
export function WorktreePanel({
projectPath,
onCreateWorktree,
@@ -85,17 +83,11 @@ export function WorktreePanel({
features,
});
// Collapse state with localStorage persistence
const [isCollapsed, setIsCollapsed] = useState(() => {
const saved = getItem(WORKTREE_PANEL_COLLAPSED_KEY);
return saved === 'true';
});
// Collapse state from store (synced via API)
const isCollapsed = useAppStore((s) => s.worktreePanelCollapsed);
const setWorktreePanelCollapsed = useAppStore((s) => s.setWorktreePanelCollapsed);
useEffect(() => {
setItem(WORKTREE_PANEL_COLLAPSED_KEY, String(isCollapsed));
}, [isCollapsed]);
const toggleCollapsed = () => setIsCollapsed((prev) => !prev);
const toggleCollapsed = () => setWorktreePanelCollapsed(!isCollapsed);
// Periodic interval check (5 seconds) to detect branch changes on disk
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders

View File

@@ -496,6 +496,14 @@ export function ContextView() {
setNewMarkdownContent('');
} catch (error) {
logger.error('Failed to create markdown:', error);
// Close dialog and reset state even on error to avoid stuck dialog
setIsCreateMarkdownOpen(false);
setNewMarkdownName('');
setNewMarkdownDescription('');
setNewMarkdownContent('');
toast.error('Failed to create markdown file', {
description: error instanceof Error ? error.message : 'Unknown error occurred',
});
}
};

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { useState, useCallback, useMemo } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { CircleDot, RefreshCw } from 'lucide-react';

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { useState, useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { useState, useCallback, useRef, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore, Feature } from '@/store/app-store';

View File

@@ -0,0 +1,29 @@
import { useNavigate } from '@tanstack/react-router';
import { Button } from '@/components/ui/button';
import { LogOut } from 'lucide-react';
export function LoggedOutView() {
const navigate = useNavigate();
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<LogOut className="h-8 w-8 text-primary" />
</div>
<h1 className="mt-6 text-2xl font-bold tracking-tight">Youve been logged out</h1>
<p className="mt-2 text-sm text-muted-foreground">
Your session expired, or the server restarted. Please log in again.
</p>
</div>
<div className="space-y-3">
<Button className="w-full" onClick={() => navigate({ to: '/login' })}>
Go to login
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,50 +1,363 @@
/**
* Login View - Web mode authentication
*
* Prompts user to enter the API key shown in server console.
* On successful login, sets an HTTP-only session cookie.
* Uses a state machine for clear, maintainable flow:
*
* States:
* checking_server → server_error (after 5 retries)
* checking_server → awaiting_login (401/unauthenticated)
* checking_server → checking_setup (authenticated)
* awaiting_login → logging_in → login_error | checking_setup
* checking_setup → redirecting
*/
import { useState } from 'react';
import { useReducer, useEffect, useRef } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { login } from '@/lib/http-api-client';
import { login, getHttpApiClient, getServerUrlSync } from '@/lib/http-api-client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { KeyRound, AlertCircle, Loader2 } from 'lucide-react';
import { KeyRound, AlertCircle, Loader2, RefreshCw, ServerCrash } from 'lucide-react';
import { useAuthStore } from '@/store/auth-store';
import { useSetupStore } from '@/store/setup-store';
// =============================================================================
// State Machine Types
// =============================================================================
type State =
| { phase: 'checking_server'; attempt: number }
| { phase: 'server_error'; message: string }
| { phase: 'awaiting_login'; apiKey: string; error: string | null }
| { phase: 'logging_in'; apiKey: string }
| { phase: 'checking_setup' }
| { phase: 'redirecting'; to: string };
type Action =
| { type: 'SERVER_CHECK_RETRY'; attempt: number }
| { type: 'SERVER_ERROR'; message: string }
| { type: 'AUTH_REQUIRED' }
| { type: 'AUTH_VALID' }
| { type: 'UPDATE_API_KEY'; value: string }
| { type: 'SUBMIT_LOGIN' }
| { type: 'LOGIN_ERROR'; message: string }
| { type: 'REDIRECT'; to: string }
| { type: 'RETRY_SERVER_CHECK' };
const initialState: State = { phase: 'checking_server', attempt: 1 };
// =============================================================================
// State Machine Reducer
// =============================================================================
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'SERVER_CHECK_RETRY':
return { phase: 'checking_server', attempt: action.attempt };
case 'SERVER_ERROR':
return { phase: 'server_error', message: action.message };
case 'AUTH_REQUIRED':
return { phase: 'awaiting_login', apiKey: '', error: null };
case 'AUTH_VALID':
return { phase: 'checking_setup' };
case 'UPDATE_API_KEY':
if (state.phase !== 'awaiting_login') return state;
return { ...state, apiKey: action.value };
case 'SUBMIT_LOGIN':
if (state.phase !== 'awaiting_login') return state;
return { phase: 'logging_in', apiKey: state.apiKey };
case 'LOGIN_ERROR':
if (state.phase !== 'logging_in') return state;
return { phase: 'awaiting_login', apiKey: state.apiKey, error: action.message };
case 'REDIRECT':
return { phase: 'redirecting', to: action.to };
case 'RETRY_SERVER_CHECK':
return { phase: 'checking_server', attempt: 1 };
default:
return state;
}
}
// =============================================================================
// Constants
// =============================================================================
const MAX_RETRIES = 5;
const BACKOFF_BASE_MS = 400;
// =============================================================================
// Imperative Flow Logic (runs once on mount)
// =============================================================================
/**
* Check auth status without triggering side effects.
* Unlike the httpClient methods, this does NOT call handleUnauthorized()
* which would navigate us away to /logged-out.
*
* Relies on HTTP-only session cookie being sent via credentials: 'include'.
*
* Returns: { authenticated: true } or { authenticated: false }
* Throws: on network errors (for retry logic)
*/
async function checkAuthStatusSafe(): Promise<{ authenticated: boolean }> {
const serverUrl = getServerUrlSync();
const response = await fetch(`${serverUrl}/api/auth/status`, {
credentials: 'include', // Send HTTP-only session cookie
signal: AbortSignal.timeout(5000),
});
// Any response means server is reachable
const data = await response.json();
return { authenticated: data.authenticated === true };
}
/**
* Check if server is reachable and if we have a valid session.
*/
async function checkServerAndSession(
dispatch: React.Dispatch<Action>,
setAuthState: (state: { isAuthenticated: boolean; authChecked: boolean }) => void,
signal?: AbortSignal
): Promise<void> {
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
// Return early if the component has unmounted
if (signal?.aborted) {
return;
}
dispatch({ type: 'SERVER_CHECK_RETRY', attempt });
try {
const result = await checkAuthStatusSafe();
// Return early if the component has unmounted
if (signal?.aborted) {
return;
}
if (result.authenticated) {
// Server is reachable and we're authenticated
setAuthState({ isAuthenticated: true, authChecked: true });
dispatch({ type: 'AUTH_VALID' });
return;
}
// Server is reachable but we need to login
dispatch({ type: 'AUTH_REQUIRED' });
return;
} catch (error: unknown) {
// Network error - server is not reachable
console.debug(`Server check attempt ${attempt}/${MAX_RETRIES} failed:`, error);
if (attempt === MAX_RETRIES) {
// Return early if the component has unmounted
if (!signal?.aborted) {
dispatch({
type: 'SERVER_ERROR',
message: 'Unable to connect to server. Please check that the server is running.',
});
}
return;
}
// Exponential backoff before retry
const backoffMs = BACKOFF_BASE_MS * Math.pow(2, attempt - 1);
await new Promise((resolve) => setTimeout(resolve, backoffMs));
}
}
}
async function checkSetupStatus(
dispatch: React.Dispatch<Action>,
signal?: AbortSignal
): Promise<void> {
const httpClient = getHttpApiClient();
try {
const result = await httpClient.settings.getGlobal();
// Return early if aborted
if (signal?.aborted) {
return;
}
if (result.success && result.settings) {
// Check the setupComplete field from settings
// This is set to true when user completes the setup wizard
const setupComplete = (result.settings as { setupComplete?: boolean }).setupComplete === true;
// IMPORTANT: Update the Zustand store BEFORE redirecting
// Otherwise __root.tsx routing effect will override our redirect
// because it reads setupComplete from the store (which defaults to false)
useSetupStore.getState().setSetupComplete(setupComplete);
dispatch({ type: 'REDIRECT', to: setupComplete ? '/' : '/setup' });
} else {
// No settings yet = first run = need setup
useSetupStore.getState().setSetupComplete(false);
dispatch({ type: 'REDIRECT', to: '/setup' });
}
} catch {
// Return early if aborted
if (signal?.aborted) {
return;
}
// If we can't get settings, go to setup to be safe
useSetupStore.getState().setSetupComplete(false);
dispatch({ type: 'REDIRECT', to: '/setup' });
}
}
async function performLogin(
apiKey: string,
dispatch: React.Dispatch<Action>,
setAuthState: (state: { isAuthenticated: boolean; authChecked: boolean }) => void
): Promise<void> {
try {
const result = await login(apiKey.trim());
if (result.success) {
setAuthState({ isAuthenticated: true, authChecked: true });
dispatch({ type: 'AUTH_VALID' });
} else {
dispatch({ type: 'LOGIN_ERROR', message: result.error || 'Invalid API key' });
}
} catch {
dispatch({ type: 'LOGIN_ERROR', message: 'Failed to connect to server' });
}
}
// =============================================================================
// Component
// =============================================================================
export function LoginView() {
const navigate = useNavigate();
const setAuthState = useAuthStore((s) => s.setAuthState);
const setupComplete = useSetupStore((s) => s.setupComplete);
const [apiKey, setApiKey] = useState('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [state, dispatch] = useReducer(reducer, initialState);
const retryControllerRef = useRef<AbortController | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsLoading(true);
// Run initial server/session check on mount.
// IMPORTANT: Do not "run once" via a ref guard here.
// In React StrictMode (dev), effects mount -> cleanup -> mount.
// If we abort in cleanup and also skip the second run, we'll get stuck forever on "Connecting...".
useEffect(() => {
const controller = new AbortController();
checkServerAndSession(dispatch, setAuthState, controller.signal);
try {
const result = await login(apiKey.trim());
if (result.success) {
// Mark as authenticated for this session (cookie-based auth)
setAuthState({ isAuthenticated: true, authChecked: true });
return () => {
controller.abort();
retryControllerRef.current?.abort();
};
}, [setAuthState]);
// After auth, determine if setup is needed or go to app
navigate({ to: setupComplete ? '/' : '/setup' });
} else {
setError(result.error || 'Invalid API key');
}
} catch (err) {
setError('Failed to connect to server');
} finally {
setIsLoading(false);
// When we enter checking_setup phase, check setup status
useEffect(() => {
if (state.phase === 'checking_setup') {
const controller = new AbortController();
checkSetupStatus(dispatch, controller.signal);
return () => {
controller.abort();
};
}
}, [state.phase]);
// When we enter redirecting phase, navigate
useEffect(() => {
if (state.phase === 'redirecting') {
navigate({ to: state.to });
}
}, [state.phase, state.phase === 'redirecting' ? state.to : null, navigate]);
// Handle login form submission
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (state.phase !== 'awaiting_login' || !state.apiKey.trim()) return;
dispatch({ type: 'SUBMIT_LOGIN' });
performLogin(state.apiKey, dispatch, setAuthState);
};
// Handle retry button for server errors
const handleRetry = () => {
// Abort any previous retry request
retryControllerRef.current?.abort();
dispatch({ type: 'RETRY_SERVER_CHECK' });
const controller = new AbortController();
retryControllerRef.current = controller;
checkServerAndSession(dispatch, setAuthState, controller.signal);
};
// =============================================================================
// Render based on current state
// =============================================================================
// Checking server connectivity
if (state.phase === 'checking_server') {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="text-center space-y-4">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
<p className="text-sm text-muted-foreground">
Connecting to server
{state.attempt > 1 ? ` (attempt ${state.attempt}/${MAX_RETRIES})` : '...'}
</p>
</div>
</div>
);
}
// Server unreachable after retries
if (state.phase === 'server_error') {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md space-y-6 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
<ServerCrash className="h-8 w-8 text-destructive" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-bold tracking-tight">Server Unavailable</h1>
<p className="text-sm text-muted-foreground">{state.message}</p>
</div>
<Button onClick={handleRetry} variant="outline" className="gap-2">
<RefreshCw className="h-4 w-4" />
Retry Connection
</Button>
</div>
</div>
);
}
// Checking setup status after auth
if (state.phase === 'checking_setup' || state.phase === 'redirecting') {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="text-center space-y-4">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
<p className="text-sm text-muted-foreground">
{state.phase === 'checking_setup' ? 'Loading settings...' : 'Redirecting...'}
</p>
</div>
</div>
);
}
// Login form (awaiting_login or logging_in)
const isLoggingIn = state.phase === 'logging_in';
const apiKey = state.phase === 'awaiting_login' ? state.apiKey : state.apiKey;
const error = state.phase === 'awaiting_login' ? state.error : null;
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md space-y-8">
@@ -70,8 +383,8 @@ export function LoginView() {
type="password"
placeholder="Enter API key..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={isLoading}
onChange={(e) => dispatch({ type: 'UPDATE_API_KEY', value: e.target.value })}
disabled={isLoggingIn}
autoFocus
className="font-mono"
data-testid="login-api-key-input"
@@ -88,10 +401,10 @@ export function LoginView() {
<Button
type="submit"
className="w-full"
disabled={isLoading || !apiKey.trim()}
disabled={isLoggingIn || !apiKey.trim()}
data-testid="login-submit-button"
>
{isLoading ? (
{isLoggingIn ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Authenticating...

View File

@@ -7,7 +7,8 @@ import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { cn, modelSupportsThinking } from '@/lib/utils';
import { DialogFooter } from '@/components/ui/dialog';
import { Brain, Bot, Terminal } from 'lucide-react';
import { Brain } from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
import { toast } from 'sonner';
import type {
AIProfile,
@@ -15,8 +16,9 @@ import type {
ThinkingLevel,
ModelProvider,
CursorModelId,
CodexModelId,
} from '@automaker/types';
import { CURSOR_MODEL_MAP, cursorModelHasThinking } from '@automaker/types';
import { CURSOR_MODEL_MAP, cursorModelHasThinking, CODEX_MODEL_MAP } from '@automaker/types';
import { useAppStore } from '@/store/app-store';
import { CLAUDE_MODELS, THINKING_LEVELS, ICON_OPTIONS } from '../constants';
@@ -46,6 +48,8 @@ export function ProfileForm({
thinkingLevel: profile.thinkingLevel || ('none' as ThinkingLevel),
// Cursor-specific
cursorModel: profile.cursorModel || ('auto' as CursorModelId),
// Codex-specific - use a valid CodexModelId from CODEX_MODEL_MAP
codexModel: profile.codexModel || (CODEX_MODEL_MAP.gpt52Codex as CodexModelId),
icon: profile.icon || 'Brain',
});
@@ -59,6 +63,8 @@ export function ProfileForm({
model: provider === 'claude' ? 'sonnet' : formData.model,
thinkingLevel: provider === 'claude' ? 'none' : formData.thinkingLevel,
cursorModel: provider === 'cursor' ? 'auto' : formData.cursorModel,
codexModel:
provider === 'codex' ? (CODEX_MODEL_MAP.gpt52Codex as CodexModelId) : formData.codexModel,
});
};
@@ -76,6 +82,13 @@ export function ProfileForm({
});
};
const handleCodexModelChange = (codexModel: CodexModelId) => {
setFormData({
...formData,
codexModel,
});
};
const handleSubmit = () => {
if (!formData.name.trim()) {
toast.error('Please enter a profile name');
@@ -95,6 +108,11 @@ export function ProfileForm({
...baseProfile,
cursorModel: formData.cursorModel,
});
} else if (formData.provider === 'codex') {
onSave({
...baseProfile,
codexModel: formData.codexModel,
});
} else {
onSave({
...baseProfile,
@@ -158,34 +176,48 @@ export function ProfileForm({
{/* Provider Selection */}
<div className="space-y-2">
<Label>AI Provider</Label>
<div className="flex gap-2">
<div className="grid grid-cols-3 gap-2">
<button
type="button"
onClick={() => handleProviderChange('claude')}
className={cn(
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
'px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
formData.provider === 'claude'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid="provider-select-claude"
>
<Bot className="w-4 h-4" />
<AnthropicIcon className="w-4 h-4" />
Claude
</button>
<button
type="button"
onClick={() => handleProviderChange('cursor')}
className={cn(
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
'px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
formData.provider === 'cursor'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid="provider-select-cursor"
>
<Terminal className="w-4 h-4" />
Cursor CLI
<CursorIcon className="w-4 h-4" />
Cursor
</button>
<button
type="button"
onClick={() => handleProviderChange('codex')}
className={cn(
'px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
formData.provider === 'codex'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid="provider-select-codex"
>
<OpenAIIcon className="w-4 h-4" />
Codex
</button>
</div>
</div>
@@ -222,7 +254,7 @@ export function ProfileForm({
{formData.provider === 'cursor' && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Terminal className="w-4 h-4 text-primary" />
<CursorIcon className="w-4 h-4 text-primary" />
Cursor Model
</Label>
<div className="flex flex-col gap-2">
@@ -262,13 +294,13 @@ export function ProfileForm({
</Badge>
)}
<Badge
variant={config.tier === 'free' ? 'default' : 'secondary'}
variant="secondary"
className={cn(
'text-xs',
formData.cursorModel === id && 'bg-primary-foreground/20'
)}
>
{config.tier}
Tier
</Badge>
</div>
</button>
@@ -283,6 +315,68 @@ export function ProfileForm({
</div>
)}
{/* Codex Model Selection */}
{formData.provider === 'codex' && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<OpenAIIcon className="w-4 h-4 text-primary" />
Codex Model
</Label>
<div className="flex flex-col gap-2">
{Object.entries(CODEX_MODEL_MAP).map(([_, modelId]) => {
const modelConfig = {
label: modelId,
badge: 'Standard' as const,
hasReasoning: false,
};
return (
<button
key={modelId}
type="button"
onClick={() => handleCodexModelChange(modelId)}
className={cn(
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
formData.codexModel === modelId
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`codex-model-select-${modelId}`}
>
<span>{modelConfig.label}</span>
<div className="flex gap-1">
{modelConfig.hasReasoning && (
<Badge
variant="outline"
className={cn(
'text-xs',
formData.codexModel === modelId
? 'border-primary-foreground/50 text-primary-foreground'
: 'border-amber-500/50 text-amber-600 dark:text-amber-400'
)}
>
Reasoning
</Badge>
)}
<Badge
variant="outline"
className={cn(
'text-xs',
formData.codexModel === modelId
? 'border-primary-foreground/50 text-primary-foreground'
: 'border-muted-foreground/50 text-muted-foreground'
)}
>
{modelConfig.badge}
</Badge>
</div>
</button>
);
})}
</div>
</div>
)}
{/* Claude Thinking Level */}
{formData.provider === 'claude' && supportsThinking && (
<div className="space-y-2">

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { useSettingsView } from './settings-view/hooks';
import { useSettingsView, type SettingsViewId } from './settings-view/hooks';
import { NAV_ITEMS } from './settings-view/config/navigation';
import { SettingsHeader } from './settings-view/components/settings-header';
import { KeyboardMapDialog } from './settings-view/components/keyboard-map-dialog';
@@ -16,7 +16,9 @@ import { AudioSection } from './settings-view/audio/audio-section';
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
import { ProviderTabs } from './settings-view/providers';
import { AccountSection } from './settings-view/account';
import { SecuritySection } from './settings-view/security';
import { ClaudeSettingsTab, CursorSettingsTab, CodexSettingsTab } from './settings-view/providers';
import { MCPServersSection } from './settings-view/mcp-servers';
import { PromptCustomizationSection } from './settings-view/prompts';
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
@@ -31,6 +33,8 @@ export function SettingsView() {
setDefaultSkipTests,
enableDependencyBlocking,
setEnableDependencyBlocking,
skipVerificationInAutoMode,
setSkipVerificationInAutoMode,
useWorktrees,
setUseWorktrees,
showProfilesOnly,
@@ -48,12 +52,10 @@ export function SettingsView() {
aiProfiles,
autoLoadClaudeMd,
setAutoLoadClaudeMd,
enableSandboxMode,
setEnableSandboxMode,
skipSandboxWarning,
setSkipSandboxWarning,
promptCustomization,
setPromptCustomization,
skipSandboxWarning,
setSkipSandboxWarning,
} = useAppStore();
// Convert electron Project to settings-view Project type
@@ -86,15 +88,30 @@ export function SettingsView() {
// Use settings view navigation hook
const { activeView, navigateTo } = useSettingsView();
// Handle navigation - if navigating to 'providers', default to 'claude-provider'
const handleNavigate = (viewId: SettingsViewId) => {
if (viewId === 'providers') {
navigateTo('claude-provider');
} else {
navigateTo(viewId);
}
};
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
// Render the active section based on current view
const renderActiveSection = () => {
switch (activeView) {
case 'claude-provider':
return <ClaudeSettingsTab />;
case 'cursor-provider':
return <CursorSettingsTab />;
case 'codex-provider':
return <CodexSettingsTab />;
case 'providers':
case 'claude': // Backwards compatibility
return <ProviderTabs defaultTab={activeView === 'claude' ? 'claude' : undefined} />;
case 'claude': // Backwards compatibility - redirect to claude-provider
return <ClaudeSettingsTab />;
case 'mcp-servers':
return <MCPServersSection />;
case 'prompts':
@@ -109,9 +126,9 @@ export function SettingsView() {
case 'appearance':
return (
<AppearanceSection
effectiveTheme={effectiveTheme}
currentProject={settingsProject}
onThemeChange={handleSetTheme}
effectiveTheme={effectiveTheme as any}
currentProject={settingsProject as any}
onThemeChange={(theme) => handleSetTheme(theme as any)}
/>
);
case 'terminal':
@@ -130,6 +147,7 @@ export function SettingsView() {
showProfilesOnly={showProfilesOnly}
defaultSkipTests={defaultSkipTests}
enableDependencyBlocking={enableDependencyBlocking}
skipVerificationInAutoMode={skipVerificationInAutoMode}
useWorktrees={useWorktrees}
defaultPlanningMode={defaultPlanningMode}
defaultRequirePlanApproval={defaultRequirePlanApproval}
@@ -138,19 +156,27 @@ export function SettingsView() {
onShowProfilesOnlyChange={setShowProfilesOnly}
onDefaultSkipTestsChange={setDefaultSkipTests}
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
onUseWorktreesChange={setUseWorktrees}
onDefaultPlanningModeChange={setDefaultPlanningMode}
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
onDefaultAIProfileIdChange={setDefaultAIProfileId}
/>
);
case 'account':
return <AccountSection />;
case 'security':
return (
<SecuritySection
skipSandboxWarning={skipSandboxWarning}
onSkipSandboxWarningChange={setSkipSandboxWarning}
/>
);
case 'danger':
return (
<DangerZoneSection
project={settingsProject}
onDeleteClick={() => setShowDeleteDialog(true)}
skipSandboxWarning={skipSandboxWarning}
onResetSandboxWarning={() => setSkipSandboxWarning(false)}
/>
);
default:
@@ -170,7 +196,7 @@ export function SettingsView() {
navItems={NAV_ITEMS}
activeSection={activeView}
currentProject={currentProject}
onNavigate={navigateTo}
onNavigate={handleNavigate}
/>
{/* Content Panel - Shows only the active section */}

View File

@@ -0,0 +1,77 @@
import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { Button } from '@/components/ui/button';
import { LogOut, User } from 'lucide-react';
import { cn } from '@/lib/utils';
import { logout } from '@/lib/http-api-client';
import { useAuthStore } from '@/store/auth-store';
export function AccountSection() {
const navigate = useNavigate();
const [isLoggingOut, setIsLoggingOut] = useState(false);
const handleLogout = async () => {
setIsLoggingOut(true);
try {
await logout();
// Reset auth state
useAuthStore.getState().resetAuth();
// Navigate to logged out page
navigate({ to: '/logged-out' });
} catch (error) {
console.error('Logout failed:', error);
setIsLoggingOut(false);
}
};
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/80 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm'
)}
>
<div className="p-6 border-b border-border/30 bg-gradient-to-r from-primary/5 via-transparent to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center border border-primary/20">
<User className="w-5 h-5 text-primary" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Account</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">Manage your session and account.</p>
</div>
<div className="p-6 space-y-4">
{/* Logout */}
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-muted/30 border border-border/30">
<div className="flex items-center gap-3.5 min-w-0">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-muted/50 to-muted/30 border border-border/30 flex items-center justify-center shrink-0">
<LogOut className="w-5 h-5 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground">Log Out</p>
<p className="text-xs text-muted-foreground/70 mt-0.5">
End your current session and return to the login screen
</p>
</div>
</div>
<Button
variant="outline"
onClick={handleLogout}
disabled={isLoggingOut}
data-testid="logout-button"
className={cn(
'shrink-0 gap-2',
'transition-all duration-200 ease-out',
'hover:scale-[1.02] active:scale-[0.98]'
)}
>
<LogOut className="w-4 h-4" />
{isLoggingOut ? 'Logging out...' : 'Log Out'}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export { AccountSection } from './account-section';

View File

@@ -1,7 +1,7 @@
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { Button } from '@/components/ui/button';
import { Key, CheckCircle2, Settings, Trash2, Loader2 } from 'lucide-react';
import { Key, CheckCircle2, Trash2, Loader2 } from 'lucide-react';
import { ApiKeyField } from './api-key-field';
import { buildProviderConfigs } from '@/config/api-providers';
import { SecurityNotice } from './security-notice';
@@ -10,13 +10,13 @@ import { cn } from '@/lib/utils';
import { useState, useCallback } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { useNavigate } from '@tanstack/react-router';
export function ApiKeysSection() {
const { apiKeys, setApiKeys } = useAppStore();
const { claudeAuthStatus, setClaudeAuthStatus, setSetupComplete } = useSetupStore();
const { claudeAuthStatus, setClaudeAuthStatus, codexAuthStatus, setCodexAuthStatus } =
useSetupStore();
const [isDeletingAnthropicKey, setIsDeletingAnthropicKey] = useState(false);
const navigate = useNavigate();
const [isDeletingOpenaiKey, setIsDeletingOpenaiKey] = useState(false);
const { providerConfigParams, handleSave, saved } = useApiKeyManagement();
@@ -51,11 +51,33 @@ export function ApiKeysSection() {
}
}, [apiKeys, setApiKeys, claudeAuthStatus, setClaudeAuthStatus]);
// Open setup wizard
const openSetupWizard = useCallback(() => {
setSetupComplete(false);
navigate({ to: '/setup' });
}, [setSetupComplete, navigate]);
// Delete OpenAI API key
const deleteOpenaiKey = useCallback(async () => {
setIsDeletingOpenaiKey(true);
try {
const api = getElectronAPI();
if (!api.setup?.deleteApiKey) {
toast.error('Delete API not available');
return;
}
const result = await api.setup.deleteApiKey('openai');
if (result.success) {
setApiKeys({ ...apiKeys, openai: '' });
setCodexAuthStatus({
authenticated: false,
method: 'none',
});
toast.success('OpenAI API key deleted');
} else {
toast.error(result.error || 'Failed to delete API key');
}
} catch (error) {
toast.error('Failed to delete API key');
} finally {
setIsDeletingOpenaiKey(false);
}
}, [apiKeys, setApiKeys, setCodexAuthStatus]);
return (
<div
@@ -111,16 +133,6 @@ export function ApiKeysSection() {
)}
</Button>
<Button
onClick={openSetupWizard}
variant="outline"
className="h-10 border-border"
data-testid="run-setup-wizard"
>
<Settings className="w-4 h-4 mr-2" />
Run Setup Wizard
</Button>
{apiKeys.anthropic && (
<Button
onClick={deleteAnthropicKey}
@@ -137,6 +149,23 @@ export function ApiKeysSection() {
Delete Anthropic Key
</Button>
)}
{apiKeys.openai && (
<Button
onClick={deleteOpenaiKey}
disabled={isDeletingOpenaiKey}
variant="outline"
className="h-10 border-red-500/30 text-red-500 hover:bg-red-500/10 hover:border-red-500/50"
data-testid="delete-openai-key"
>
{isDeletingOpenaiKey ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Trash2 className="w-4 h-4 mr-2" />
)}
Delete OpenAI Key
</Button>
)}
</div>
</div>
</div>

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { useState, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
@@ -14,6 +15,7 @@ interface TestResult {
interface ApiKeyStatus {
hasAnthropicKey: boolean;
hasGoogleKey: boolean;
hasOpenaiKey: boolean;
}
/**
@@ -26,16 +28,20 @@ export function useApiKeyManagement() {
// API key values
const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic);
const [googleKey, setGoogleKey] = useState(apiKeys.google);
const [openaiKey, setOpenaiKey] = useState(apiKeys.openai);
// Visibility toggles
const [showAnthropicKey, setShowAnthropicKey] = useState(false);
const [showGoogleKey, setShowGoogleKey] = useState(false);
const [showOpenaiKey, setShowOpenaiKey] = useState(false);
// Test connection states
const [testingConnection, setTestingConnection] = useState(false);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [testingGeminiConnection, setTestingGeminiConnection] = useState(false);
const [geminiTestResult, setGeminiTestResult] = useState<TestResult | null>(null);
const [testingOpenaiConnection, setTestingOpenaiConnection] = useState(false);
const [openaiTestResult, setOpenaiTestResult] = useState<TestResult | null>(null);
// API key status from environment
const [apiKeyStatus, setApiKeyStatus] = useState<ApiKeyStatus | null>(null);
@@ -47,6 +53,7 @@ export function useApiKeyManagement() {
useEffect(() => {
setAnthropicKey(apiKeys.anthropic);
setGoogleKey(apiKeys.google);
setOpenaiKey(apiKeys.openai);
}, [apiKeys]);
// Check API key status from environment on mount
@@ -60,6 +67,7 @@ export function useApiKeyManagement() {
setApiKeyStatus({
hasAnthropicKey: status.hasAnthropicKey,
hasGoogleKey: status.hasGoogleKey,
hasOpenaiKey: status.hasOpenaiKey,
});
}
} catch (error) {
@@ -135,11 +143,42 @@ export function useApiKeyManagement() {
setTestingGeminiConnection(false);
};
// Test OpenAI/Codex connection
const handleTestOpenaiConnection = async () => {
setTestingOpenaiConnection(true);
setOpenaiTestResult(null);
try {
const api = getElectronAPI();
const data = await api.setup.verifyCodexAuth('api_key', openaiKey);
if (data.success && data.authenticated) {
setOpenaiTestResult({
success: true,
message: 'Connection successful! Codex responded.',
});
} else {
setOpenaiTestResult({
success: false,
message: data.error || 'Failed to connect to OpenAI API.',
});
}
} catch {
setOpenaiTestResult({
success: false,
message: 'Network error. Please check your connection.',
});
} finally {
setTestingOpenaiConnection(false);
}
};
// Save API keys
const handleSave = () => {
setApiKeys({
anthropic: anthropicKey,
google: googleKey,
openai: openaiKey,
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
@@ -166,6 +205,15 @@ export function useApiKeyManagement() {
onTest: handleTestGeminiConnection,
result: geminiTestResult,
},
openai: {
value: openaiKey,
setValue: setOpenaiKey,
show: showOpenaiKey,
setShow: setShowOpenaiKey,
testing: testingOpenaiConnection,
onTest: handleTestOpenaiConnection,
result: openaiTestResult,
},
};
return {

View File

@@ -1,13 +1,11 @@
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { FileCode, Shield } from 'lucide-react';
import { FileCode } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ClaudeMdSettingsProps {
autoLoadClaudeMd: boolean;
onAutoLoadClaudeMdChange: (enabled: boolean) => void;
enableSandboxMode: boolean;
onEnableSandboxModeChange: (enabled: boolean) => void;
}
/**
@@ -15,23 +13,18 @@ interface ClaudeMdSettingsProps {
*
* UI controls for Claude Agent SDK settings including:
* - Auto-loading of project instructions from .claude/CLAUDE.md files
* - Sandbox mode for isolated bash command execution
*
* Usage:
* ```tsx
* <ClaudeMdSettings
* autoLoadClaudeMd={autoLoadClaudeMd}
* onAutoLoadClaudeMdChange={setAutoLoadClaudeMd}
* enableSandboxMode={enableSandboxMode}
* onEnableSandboxModeChange={setEnableSandboxMode}
* />
* ```
*/
export function ClaudeMdSettings({
autoLoadClaudeMd,
onAutoLoadClaudeMdChange,
enableSandboxMode,
onEnableSandboxModeChange,
}: ClaudeMdSettingsProps) {
return (
<div
@@ -83,32 +76,6 @@ export function ClaudeMdSettings({
</p>
</div>
</div>
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 mt-2">
<Checkbox
id="enable-sandbox-mode"
checked={enableSandboxMode}
onCheckedChange={(checked) => onEnableSandboxModeChange(checked === true)}
className="mt-1"
data-testid="enable-sandbox-mode-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="enable-sandbox-mode"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Shield className="w-4 h-4 text-brand-500" />
Enable Sandbox Mode
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Run bash commands in an isolated sandbox environment for additional security.
<span className="block mt-1 text-warning/80">
Note: On some systems, enabling sandbox mode may cause the agent to hang without
responding. If you experience issues, try disabling this option.
</span>
</p>
</div>
</div>
</div>
</div>
);

View File

@@ -1,8 +1,9 @@
import { Button } from '@/components/ui/button';
import { Terminal, CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
import type { ClaudeAuthStatus } from '@/store/setup-store';
import { AnthropicIcon } from '@/components/ui/provider-icon';
interface CliStatusProps {
status: CliStatus | null;
@@ -95,7 +96,7 @@ export function ClaudeCliStatus({ status, authStatus, isChecking, onRefresh }: C
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Terminal className="w-5 h-5 text-brand-500" />
<AnthropicIcon className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Claude Code CLI

View File

@@ -0,0 +1,151 @@
import { Button } from '@/components/ui/button';
import { CheckCircle2, AlertCircle, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
interface CliStatusCardProps {
title: string;
description: string;
status: CliStatus | null;
isChecking: boolean;
onRefresh: () => void;
refreshTestId: string;
icon: React.ComponentType<{ className?: string }>;
fallbackRecommendation: string;
}
export function CliStatusCard({
title,
description,
status,
isChecking,
onRefresh,
refreshTestId,
icon: Icon,
fallbackRecommendation,
}: CliStatusCardProps) {
if (!status) return null;
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Icon className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">{title}</h2>
</div>
<Button
variant="ghost"
size="icon"
onClick={onRefresh}
disabled={isChecking}
data-testid={refreshTestId}
title={`Refresh ${title} detection`}
className={cn(
'h-9 w-9 rounded-lg',
'hover:bg-accent/50 hover:scale-105',
'transition-all duration-200'
)}
>
<RefreshCw className={cn('w-4 h-4', isChecking && 'animate-spin')} />
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">{description}</p>
</div>
<div className="p-6 space-y-4">
{status.success && status.status === 'installed' ? (
<div className="space-y-3">
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">{title} Installed</p>
<div className="text-xs text-emerald-400/70 mt-1.5 space-y-0.5">
{status.method && (
<p>
Method: <span className="font-mono">{status.method}</span>
</p>
)}
{status.version && (
<p>
Version: <span className="font-mono">{status.version}</span>
</p>
)}
{status.path && (
<p className="truncate" title={status.path}>
Path: <span className="font-mono text-[10px]">{status.path}</span>
</p>
)}
</div>
</div>
</div>
{status.recommendation && (
<p className="text-xs text-muted-foreground/70 ml-1">{status.recommendation}</p>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
<AlertCircle className="w-5 h-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-amber-400">{title} Not Detected</p>
<p className="text-xs text-amber-400/70 mt-1">
{status.recommendation || fallbackRecommendation}
</p>
</div>
</div>
{status.installCommands && (
<div className="space-y-3">
<p className="text-xs font-medium text-foreground/80">Installation Commands:</p>
<div className="space-y-2">
{status.installCommands.npm && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
npm
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.npm}
</code>
</div>
)}
{status.installCommands.macos && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
macOS/Linux
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.macos}
</code>
</div>
)}
{status.installCommands.windows && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
Windows (PowerShell)
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.windows}
</code>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,237 @@
import { Button } from '@/components/ui/button';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
import type { CodexAuthStatus } from '@/store/setup-store';
import { OpenAIIcon } from '@/components/ui/provider-icon';
interface CliStatusProps {
status: CliStatus | null;
authStatus?: CodexAuthStatus | null;
isChecking: boolean;
onRefresh: () => void;
}
function getAuthMethodLabel(method: string): string {
switch (method) {
case 'api_key':
return 'API Key';
case 'api_key_env':
return 'API Key (Environment)';
case 'cli_authenticated':
case 'oauth':
return 'CLI Authentication';
default:
return method || 'Unknown';
}
}
function SkeletonPulse({ className }: { className?: string }) {
return <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
}
function CodexCliStatusSkeleton() {
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<SkeletonPulse className="w-9 h-9 rounded-xl" />
<SkeletonPulse className="h-6 w-36" />
</div>
<SkeletonPulse className="w-9 h-9 rounded-lg" />
</div>
<div className="ml-12">
<SkeletonPulse className="h-4 w-80" />
</div>
</div>
<div className="p-6 space-y-4">
{/* Installation status skeleton */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
<SkeletonPulse className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<SkeletonPulse className="h-4 w-40" />
<SkeletonPulse className="h-3 w-32" />
<SkeletonPulse className="h-3 w-48" />
</div>
</div>
{/* Auth status skeleton */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
<SkeletonPulse className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<SkeletonPulse className="h-4 w-28" />
<SkeletonPulse className="h-3 w-36" />
</div>
</div>
</div>
</div>
);
}
export function CodexCliStatus({ status, authStatus, isChecking, onRefresh }: CliStatusProps) {
if (!status) return <CodexCliStatusSkeleton />;
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<OpenAIIcon className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Codex CLI</h2>
</div>
<Button
variant="ghost"
size="icon"
onClick={onRefresh}
disabled={isChecking}
data-testid="refresh-codex-cli"
title="Refresh Codex CLI detection"
className={cn(
'h-9 w-9 rounded-lg',
'hover:bg-accent/50 hover:scale-105',
'transition-all duration-200'
)}
>
<RefreshCw className={cn('w-4 h-4', isChecking && 'animate-spin')} />
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Codex CLI powers OpenAI models for coding and automation workflows.
</p>
</div>
<div className="p-6 space-y-4">
{status.success && status.status === 'installed' ? (
<div className="space-y-3">
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">Codex CLI Installed</p>
<div className="text-xs text-emerald-400/70 mt-1.5 space-y-0.5">
{status.method && (
<p>
Method: <span className="font-mono">{status.method}</span>
</p>
)}
{status.version && (
<p>
Version: <span className="font-mono">{status.version}</span>
</p>
)}
{status.path && (
<p className="truncate" title={status.path}>
Path: <span className="font-mono text-[10px]">{status.path}</span>
</p>
)}
</div>
</div>
</div>
{/* Authentication Status */}
{authStatus?.authenticated ? (
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">Authenticated</p>
<div className="text-xs text-emerald-400/70 mt-1.5">
<p>
Method:{' '}
<span className="font-mono">{getAuthMethodLabel(authStatus.method)}</span>
</p>
</div>
</div>
</div>
) : (
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
<XCircle className="w-5 h-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-amber-400">Not Authenticated</p>
<p className="text-xs text-amber-400/70 mt-1">
Run <code className="font-mono bg-amber-500/10 px-1 rounded">codex login</code>{' '}
or set an API key to authenticate.
</p>
</div>
</div>
)}
{status.recommendation && (
<p className="text-xs text-muted-foreground/70 ml-1">{status.recommendation}</p>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
<AlertCircle className="w-5 h-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-amber-400">Codex CLI Not Detected</p>
<p className="text-xs text-amber-400/70 mt-1">
{status.recommendation ||
'Install Codex CLI to unlock OpenAI models with tool support.'}
</p>
</div>
</div>
{status.installCommands && (
<div className="space-y-3">
<p className="text-xs font-medium text-foreground/80">Installation Commands:</p>
<div className="space-y-2">
{status.installCommands.npm && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
npm
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.npm}
</code>
</div>
)}
{status.installCommands.macos && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
macOS/Linux
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.macos}
</code>
</div>
)}
{status.installCommands.windows && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
Windows (PowerShell)
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.windows}
</code>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { Button } from '@/components/ui/button';
import { Terminal, CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { CursorIcon } from '@/components/ui/provider-icon';
interface CursorStatus {
installed: boolean;
@@ -215,7 +216,7 @@ export function CursorCliStatus({ status, isChecking, onRefresh }: CursorCliStat
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Terminal className="w-5 h-5 text-brand-500" />
<CursorIcon className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Cursor CLI</h2>
</div>

View File

@@ -0,0 +1,250 @@
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { FileCode, ShieldCheck, Globe, ImageIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { CodexApprovalPolicy, CodexSandboxMode } from '@automaker/types';
import { OpenAIIcon } from '@/components/ui/provider-icon';
interface CodexSettingsProps {
autoLoadCodexAgents: boolean;
codexSandboxMode: CodexSandboxMode;
codexApprovalPolicy: CodexApprovalPolicy;
codexEnableWebSearch: boolean;
codexEnableImages: boolean;
onAutoLoadCodexAgentsChange: (enabled: boolean) => void;
onCodexSandboxModeChange: (mode: CodexSandboxMode) => void;
onCodexApprovalPolicyChange: (policy: CodexApprovalPolicy) => void;
onCodexEnableWebSearchChange: (enabled: boolean) => void;
onCodexEnableImagesChange: (enabled: boolean) => void;
}
const CARD_TITLE = 'Codex CLI Settings';
const CARD_SUBTITLE = 'Configure Codex instructions, capabilities, and execution safety defaults.';
const AGENTS_TITLE = 'Auto-load AGENTS.md Instructions';
const AGENTS_DESCRIPTION = 'Automatically inject project instructions from';
const AGENTS_PATH = '.codex/AGENTS.md';
const AGENTS_SUFFIX = 'on each Codex run.';
const WEB_SEARCH_TITLE = 'Enable Web Search';
const WEB_SEARCH_DESCRIPTION =
'Allow Codex to search the web for current information using --search flag.';
const IMAGES_TITLE = 'Enable Image Support';
const IMAGES_DESCRIPTION = 'Allow Codex to process images attached to prompts using -i flag.';
const SANDBOX_TITLE = 'Sandbox Policy';
const APPROVAL_TITLE = 'Approval Policy';
const SANDBOX_SELECT_LABEL = 'Select sandbox policy';
const APPROVAL_SELECT_LABEL = 'Select approval policy';
const SANDBOX_OPTIONS: Array<{
value: CodexSandboxMode;
label: string;
description: string;
}> = [
{
value: 'read-only',
label: 'Read-only',
description: 'Only allow safe, non-mutating commands.',
},
{
value: 'workspace-write',
label: 'Workspace write',
description: 'Allow file edits inside the project workspace.',
},
{
value: 'danger-full-access',
label: 'Full access',
description: 'Allow unrestricted commands (use with care).',
},
];
const APPROVAL_OPTIONS: Array<{
value: CodexApprovalPolicy;
label: string;
description: string;
}> = [
{
value: 'untrusted',
label: 'Untrusted',
description: 'Ask for approval for most commands.',
},
{
value: 'on-failure',
label: 'On failure',
description: 'Ask only if a command fails in the sandbox.',
},
{
value: 'on-request',
label: 'On request',
description: 'Let the agent decide when to ask.',
},
{
value: 'never',
label: 'Never',
description: 'Never ask for approval (least restrictive).',
},
];
export function CodexSettings({
autoLoadCodexAgents,
codexSandboxMode,
codexApprovalPolicy,
codexEnableWebSearch,
codexEnableImages,
onAutoLoadCodexAgentsChange,
onCodexSandboxModeChange,
onCodexApprovalPolicyChange,
onCodexEnableWebSearchChange,
onCodexEnableImagesChange,
}: CodexSettingsProps) {
const sandboxOption = SANDBOX_OPTIONS.find((option) => option.value === codexSandboxMode);
const approvalOption = APPROVAL_OPTIONS.find((option) => option.value === codexApprovalPolicy);
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<OpenAIIcon className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">{CARD_TITLE}</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">{CARD_SUBTITLE}</p>
</div>
<div className="p-6 space-y-5">
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="auto-load-codex-agents"
checked={autoLoadCodexAgents}
onCheckedChange={(checked) => onAutoLoadCodexAgentsChange(checked === true)}
className="mt-1"
data-testid="auto-load-codex-agents-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="auto-load-codex-agents"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<FileCode className="w-4 h-4 text-brand-500" />
{AGENTS_TITLE}
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
{AGENTS_DESCRIPTION}{' '}
<code className="text-[10px] px-1 py-0.5 rounded bg-accent/50">{AGENTS_PATH}</code>{' '}
{AGENTS_SUFFIX}
</p>
</div>
</div>
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="codex-enable-web-search"
checked={codexEnableWebSearch}
onCheckedChange={(checked) => onCodexEnableWebSearchChange(checked === true)}
className="mt-1"
data-testid="codex-enable-web-search-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="codex-enable-web-search"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Globe className="w-4 h-4 text-brand-500" />
{WEB_SEARCH_TITLE}
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
{WEB_SEARCH_DESCRIPTION}
</p>
</div>
</div>
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="codex-enable-images"
checked={codexEnableImages}
onCheckedChange={(checked) => onCodexEnableImagesChange(checked === true)}
className="mt-1"
data-testid="codex-enable-images-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="codex-enable-images"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<ImageIcon className="w-4 h-4 text-brand-500" />
{IMAGES_TITLE}
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">{IMAGES_DESCRIPTION}</p>
</div>
</div>
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<div className="w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0 bg-brand-500/10">
<ShieldCheck className="w-5 h-5 text-brand-500" />
</div>
<div className="flex-1 space-y-4">
<div className="flex items-center justify-between gap-4">
<div>
<Label className="text-foreground font-medium">{SANDBOX_TITLE}</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
{sandboxOption?.description}
</p>
</div>
<Select
value={codexSandboxMode}
onValueChange={(value) => onCodexSandboxModeChange(value as CodexSandboxMode)}
>
<SelectTrigger className="w-[180px] h-8" data-testid="codex-sandbox-select">
<SelectValue aria-label={SANDBOX_SELECT_LABEL} />
</SelectTrigger>
<SelectContent>
{SANDBOX_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<Label className="text-foreground font-medium">{APPROVAL_TITLE}</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
{approvalOption?.description}
</p>
</div>
<Select
value={codexApprovalPolicy}
onValueChange={(value) => onCodexApprovalPolicyChange(value as CodexApprovalPolicy)}
>
<SelectTrigger className="w-[180px] h-8" data-testid="codex-approval-select">
<SelectValue aria-label={APPROVAL_SELECT_LABEL} />
</SelectTrigger>
<SelectContent>
{APPROVAL_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,238 @@
// @ts-nocheck
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { RefreshCw, AlertCircle } from 'lucide-react';
import { OpenAIIcon } from '@/components/ui/provider-icon';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import {
formatCodexCredits,
formatCodexPlanType,
formatCodexResetTime,
getCodexWindowLabel,
} from '@/lib/codex-usage-format';
import { useSetupStore } from '@/store/setup-store';
import { useAppStore, type CodexRateLimitWindow } from '@/store/app-store';
const ERROR_NO_API = 'Codex usage API not available';
const CODEX_USAGE_TITLE = 'Codex Usage';
const CODEX_USAGE_SUBTITLE = 'Shows usage limits reported by the Codex CLI.';
const CODEX_AUTH_WARNING = 'Authenticate Codex CLI to view usage limits.';
const CODEX_LOGIN_COMMAND = 'codex login';
const CODEX_NO_USAGE_MESSAGE =
'Usage limits are not available yet. Try refreshing if this persists.';
const UPDATED_LABEL = 'Updated';
const CODEX_FETCH_ERROR = 'Failed to fetch usage';
const CODEX_REFRESH_LABEL = 'Refresh Codex usage';
const PLAN_LABEL = 'Plan';
const CREDITS_LABEL = 'Credits';
const WARNING_THRESHOLD = 75;
const CAUTION_THRESHOLD = 50;
const MAX_PERCENTAGE = 100;
const REFRESH_INTERVAL_MS = 60_000;
const STALE_THRESHOLD_MS = 2 * 60_000;
const USAGE_COLOR_CRITICAL = 'bg-red-500';
const USAGE_COLOR_WARNING = 'bg-amber-500';
const USAGE_COLOR_OK = 'bg-emerald-500';
const isRateLimitWindow = (
limitWindow: CodexRateLimitWindow | null
): limitWindow is CodexRateLimitWindow => Boolean(limitWindow);
export function CodexUsageSection() {
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const canFetchUsage = !!codexAuthStatus?.authenticated;
const rateLimits = codexUsage?.rateLimits ?? null;
const primary = rateLimits?.primary ?? null;
const secondary = rateLimits?.secondary ?? null;
const credits = rateLimits?.credits ?? null;
const planType = rateLimits?.planType ?? null;
const rateLimitWindows = [primary, secondary].filter(isRateLimitWindow);
const hasMetrics = rateLimitWindows.length > 0;
const lastUpdatedLabel = codexUsage?.lastUpdated
? new Date(codexUsage.lastUpdated).toLocaleString()
: null;
const showAuthWarning = !canFetchUsage && !codexUsage && !isLoading;
const isStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > STALE_THRESHOLD_MS;
const fetchUsage = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api.codex) {
setError(ERROR_NO_API);
return;
}
const result = await api.codex.getUsage();
if ('error' in result) {
setError(result.message || result.error);
return;
}
setCodexUsage(result);
} catch (fetchError) {
const message = fetchError instanceof Error ? fetchError.message : CODEX_FETCH_ERROR;
setError(message);
} finally {
setIsLoading(false);
}
}, [setCodexUsage]);
useEffect(() => {
if (canFetchUsage && isStale) {
void fetchUsage();
}
}, [fetchUsage, canFetchUsage, isStale]);
useEffect(() => {
if (!canFetchUsage) return undefined;
const intervalId = setInterval(() => {
void fetchUsage();
}, REFRESH_INTERVAL_MS);
return () => clearInterval(intervalId);
}, [fetchUsage, canFetchUsage]);
const getUsageColor = (percentage: number) => {
if (percentage >= WARNING_THRESHOLD) {
return USAGE_COLOR_CRITICAL;
}
if (percentage >= CAUTION_THRESHOLD) {
return USAGE_COLOR_WARNING;
}
return USAGE_COLOR_OK;
};
const RateLimitCard = ({
title,
subtitle,
window: limitWindow,
}: {
title: string;
subtitle: string;
window: CodexRateLimitWindow;
}) => {
const safePercentage = Math.min(Math.max(limitWindow.usedPercent, 0), MAX_PERCENTAGE);
const resetLabel = formatCodexResetTime(limitWindow.resetsAt);
return (
<div className="rounded-xl border border-border/60 bg-card/50 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-foreground">{title}</p>
<p className="text-xs text-muted-foreground">{subtitle}</p>
</div>
<span className="text-sm font-semibold text-foreground">
{Math.round(safePercentage)}%
</span>
</div>
<div className="mt-3 h-2 w-full rounded-full bg-secondary/60">
<div
className={cn(
'h-full rounded-full transition-all duration-300',
getUsageColor(safePercentage)
)}
style={{ width: `${safePercentage}%` }}
/>
</div>
{resetLabel && <p className="mt-2 text-xs text-muted-foreground">{resetLabel}</p>}
</div>
);
};
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<OpenAIIcon className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
{CODEX_USAGE_TITLE}
</h2>
<Button
variant="ghost"
size="icon"
onClick={fetchUsage}
disabled={isLoading}
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
data-testid="refresh-codex-usage"
title={CODEX_REFRESH_LABEL}
>
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">{CODEX_USAGE_SUBTITLE}</p>
</div>
<div className="p-6 space-y-4">
{showAuthWarning && (
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<AlertCircle className="w-5 h-5 text-amber-500 mt-0.5" />
<div className="text-sm text-amber-400">
{CODEX_AUTH_WARNING} Run <span className="font-mono">{CODEX_LOGIN_COMMAND}</span>.
</div>
</div>
)}
{error && (
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
<AlertCircle className="w-5 h-5 text-red-500 mt-0.5" />
<div className="text-sm text-red-400">{error}</div>
</div>
)}
{hasMetrics && (
<div className="grid gap-3 sm:grid-cols-2">
{rateLimitWindows.map((limitWindow, index) => {
const { title, subtitle } = getCodexWindowLabel(limitWindow.windowDurationMins);
return (
<RateLimitCard
key={`${title}-${index}`}
title={title}
subtitle={subtitle}
window={limitWindow}
/>
);
})}
</div>
)}
{(planType || credits) && (
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground">
{planType && (
<div>
{PLAN_LABEL}:{' '}
<span className="text-foreground">{formatCodexPlanType(planType)}</span>
</div>
)}
{credits && (
<div>
{CREDITS_LABEL}:{' '}
<span className="text-foreground">{formatCodexCredits(credits)}</span>
</div>
)}
</div>
)}
{!hasMetrics && !error && canFetchUsage && !isLoading && (
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground">
{CODEX_NO_USAGE_MESSAGE}
</div>
)}
{lastUpdatedLabel && (
<div className="text-[10px] text-muted-foreground text-right">
{UPDATED_LABEL} {lastUpdatedLabel}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { cn } from '@/lib/utils';
import type { Project } from '@/lib/electron';
import type { NavigationItem } from '../config/navigation';
import { GLOBAL_NAV_ITEMS, PROJECT_NAV_ITEMS } from '../config/navigation';
import type { SettingsViewId } from '../hooks/use-settings-view';
interface SettingsNavigationProps {
@@ -10,33 +11,95 @@ interface SettingsNavigationProps {
onNavigate: (sectionId: SettingsViewId) => void;
}
export function SettingsNavigation({
navItems,
activeSection,
currentProject,
function NavButton({
item,
isActive,
onNavigate,
}: SettingsNavigationProps) {
}: {
item: NavigationItem;
isActive: boolean;
onNavigate: (sectionId: SettingsViewId) => void;
}) {
const Icon = item.icon;
return (
<nav
<button
key={item.id}
onClick={() => onNavigate(item.id)}
className={cn(
'hidden lg:block w-52 shrink-0',
'border-r border-border/50',
'bg-gradient-to-b from-card/80 via-card/60 to-card/40 backdrop-blur-xl'
'group w-full flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ease-out text-left relative overflow-hidden',
isActive
? [
'bg-gradient-to-r from-brand-500/15 via-brand-500/10 to-brand-600/5',
'text-foreground',
'border border-brand-500/25',
'shadow-sm shadow-brand-500/5',
]
: [
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50',
'border border-transparent hover:border-border/40',
],
'hover:scale-[1.01] active:scale-[0.98]'
)}
>
<div className="sticky top-0 p-4 space-y-1.5">
{navItems
.filter((item) => item.id !== 'danger' || currentProject)
.map((item) => {
const Icon = item.icon;
const isActive = activeSection === item.id;
{/* Active indicator bar */}
{isActive && (
<div className="absolute inset-y-0 left-0 w-0.5 bg-gradient-to-b from-brand-400 via-brand-500 to-brand-600 rounded-r-full" />
)}
<Icon
className={cn(
'w-4 h-4 shrink-0 transition-all duration-200',
isActive ? 'text-brand-500' : 'group-hover:text-brand-400 group-hover:scale-110'
)}
/>
<span className="truncate">{item.label}</span>
</button>
);
}
function NavItemWithSubItems({
item,
activeSection,
onNavigate,
}: {
item: NavigationItem;
activeSection: SettingsViewId;
onNavigate: (sectionId: SettingsViewId) => void;
}) {
const hasActiveSubItem = item.subItems?.some((subItem) => subItem.id === activeSection) ?? false;
const isParentActive = item.id === activeSection;
const Icon = item.icon;
return (
<div>
{/* Parent item - non-clickable label */}
<div
className={cn(
'w-full flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-sm font-medium text-muted-foreground',
isParentActive || (hasActiveSubItem && 'text-foreground')
)}
>
<Icon
className={cn(
'w-4 h-4 shrink-0 transition-all duration-200',
isParentActive || hasActiveSubItem ? 'text-brand-500' : 'text-muted-foreground'
)}
/>
<span className="truncate">{item.label}</span>
</div>
{/* Sub-items - always displayed */}
{item.subItems && (
<div className="ml-4 mt-1 space-y-1">
{item.subItems.map((subItem) => {
const SubIcon = subItem.icon;
const isSubActive = subItem.id === activeSection;
return (
<button
key={item.id}
onClick={() => onNavigate(item.id)}
key={subItem.id}
onClick={() => onNavigate(subItem.id)}
className={cn(
'group w-full flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ease-out text-left relative overflow-hidden',
isActive
'group w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 ease-out text-left relative overflow-hidden',
isSubActive
? [
'bg-gradient-to-r from-brand-500/15 via-brand-500/10 to-brand-600/5',
'text-foreground',
@@ -52,19 +115,91 @@ export function SettingsNavigation({
)}
>
{/* Active indicator bar */}
{isActive && (
{isSubActive && (
<div className="absolute inset-y-0 left-0 w-0.5 bg-gradient-to-b from-brand-400 via-brand-500 to-brand-600 rounded-r-full" />
)}
<Icon
<SubIcon
className={cn(
'w-4 h-4 shrink-0 transition-all duration-200',
isActive ? 'text-brand-500' : 'group-hover:text-brand-400 group-hover:scale-110'
isSubActive
? 'text-brand-500'
: 'group-hover:text-brand-400 group-hover:scale-110'
)}
/>
<span className="truncate">{item.label}</span>
<span className="truncate">{subItem.label}</span>
</button>
);
})}
</div>
)}
</div>
);
}
export function SettingsNavigation({
activeSection,
currentProject,
onNavigate,
}: SettingsNavigationProps) {
return (
<nav
className={cn(
'hidden lg:block w-52 shrink-0 overflow-y-auto',
'border-r border-border/50',
'bg-gradient-to-b from-card/80 via-card/60 to-card/40 backdrop-blur-xl'
)}
>
<div className="sticky top-0 p-4 space-y-1">
{/* Global Settings Label */}
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
Global Settings
</div>
{/* Global Settings Items */}
<div className="space-y-1">
{GLOBAL_NAV_ITEMS.map((item) =>
item.subItems ? (
<NavItemWithSubItems
key={item.id}
item={item}
activeSection={activeSection}
onNavigate={onNavigate}
/>
) : (
<NavButton
key={item.id}
item={item}
isActive={activeSection === item.id}
onNavigate={onNavigate}
/>
)
)}
</div>
{/* Project Settings - only show when a project is selected */}
{currentProject && (
<>
{/* Divider */}
<div className="my-4 border-t border-border/50" />
{/* Project Settings Label */}
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
Project Settings
</div>
{/* Project Settings Items */}
<div className="space-y-1">
{PROJECT_NAV_ITEMS.map((item) => (
<NavButton
key={item.id}
item={item}
isActive={activeSection === item.id}
onNavigate={onNavigate}
/>
))}
</div>
</>
)}
</div>
</nav>
);

View File

@@ -1,3 +1,4 @@
import React from 'react';
import type { LucideIcon } from 'lucide-react';
import {
Key,
@@ -11,19 +12,37 @@ import {
Workflow,
Plug,
MessageSquareText,
User,
Shield,
} from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
import type { SettingsViewId } from '../hooks/use-settings-view';
export interface NavigationItem {
id: SettingsViewId;
label: string;
icon: LucideIcon;
icon: LucideIcon | React.ComponentType<{ className?: string }>;
subItems?: NavigationItem[];
}
// Navigation items for the settings side panel
export const NAV_ITEMS: NavigationItem[] = [
export interface NavigationGroup {
label: string;
items: NavigationItem[];
}
// Global settings - always visible
export const GLOBAL_NAV_ITEMS: NavigationItem[] = [
{ id: 'api-keys', label: 'API Keys', icon: Key },
{ id: 'providers', label: 'AI Providers', icon: Bot },
{
id: 'providers',
label: 'AI Providers',
icon: Bot,
subItems: [
{ id: 'claude-provider', label: 'Claude', icon: AnthropicIcon },
{ id: 'cursor-provider', label: 'Cursor', icon: CursorIcon },
{ id: 'codex-provider', label: 'Codex', icon: OpenAIIcon },
],
},
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug },
{ id: 'prompts', label: 'Prompt Customization', icon: MessageSquareText },
{ id: 'model-defaults', label: 'Model Defaults', icon: Workflow },
@@ -32,5 +51,14 @@ export const NAV_ITEMS: NavigationItem[] = [
{ id: 'keyboard', label: 'Keyboard Shortcuts', icon: Settings2 },
{ id: 'audio', label: 'Audio', icon: Volume2 },
{ id: 'defaults', label: 'Feature Defaults', icon: FlaskConical },
{ id: 'account', label: 'Account', icon: User },
{ id: 'security', label: 'Security', icon: Shield },
];
// Project-specific settings - only visible when a project is selected
export const PROJECT_NAV_ITEMS: NavigationItem[] = [
{ id: 'danger', label: 'Danger Zone', icon: Trash2 },
];
// Legacy export for backwards compatibility
export const NAV_ITEMS: NavigationItem[] = [...GLOBAL_NAV_ITEMS, ...PROJECT_NAV_ITEMS];

View File

@@ -1,21 +1,14 @@
import { Button } from '@/components/ui/button';
import { Trash2, Folder, AlertTriangle, Shield, RotateCcw } from 'lucide-react';
import { Trash2, Folder, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { Project } from '../shared/types';
interface DangerZoneSectionProps {
project: Project | null;
onDeleteClick: () => void;
skipSandboxWarning: boolean;
onResetSandboxWarning: () => void;
}
export function DangerZoneSection({
project,
onDeleteClick,
skipSandboxWarning,
onResetSandboxWarning,
}: DangerZoneSectionProps) {
export function DangerZoneSection({ project, onDeleteClick }: DangerZoneSectionProps) {
return (
<div
className={cn(
@@ -32,43 +25,11 @@ export function DangerZoneSection({
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Danger Zone</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Destructive actions and reset options.
</p>
<p className="text-sm text-muted-foreground/80 ml-12">Destructive project actions.</p>
</div>
<div className="p-6 space-y-4">
{/* Sandbox Warning Reset */}
{skipSandboxWarning && (
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10">
<div className="flex items-center gap-3.5 min-w-0">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-destructive/15 to-destructive/10 border border-destructive/20 flex items-center justify-center shrink-0">
<Shield className="w-5 h-5 text-destructive" />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground">Sandbox Warning Disabled</p>
<p className="text-xs text-muted-foreground/70 mt-0.5">
The sandbox environment warning is hidden on startup
</p>
</div>
</div>
<Button
variant="outline"
onClick={onResetSandboxWarning}
data-testid="reset-sandbox-warning-button"
className={cn(
'shrink-0 gap-2',
'transition-all duration-200 ease-out',
'hover:scale-[1.02] active:scale-[0.98]'
)}
>
<RotateCcw className="w-4 h-4" />
Reset
</Button>
</div>
)}
{/* Project Delete */}
{project && (
{project ? (
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10">
<div className="flex items-center gap-3.5 min-w-0">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/15 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shrink-0">
@@ -94,13 +55,8 @@ export function DangerZoneSection({
Delete Project
</Button>
</div>
)}
{/* Empty state when nothing to show */}
{!skipSandboxWarning && !project && (
<p className="text-sm text-muted-foreground/60 text-center py-4">
No danger zone actions available.
</p>
) : (
<p className="text-sm text-muted-foreground/60 text-center py-4">No project selected.</p>
)}
</div>
</div>

View File

@@ -12,6 +12,7 @@ import {
ScrollText,
ShieldCheck,
User,
FastForward,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
@@ -29,6 +30,7 @@ interface FeatureDefaultsSectionProps {
showProfilesOnly: boolean;
defaultSkipTests: boolean;
enableDependencyBlocking: boolean;
skipVerificationInAutoMode: boolean;
useWorktrees: boolean;
defaultPlanningMode: PlanningMode;
defaultRequirePlanApproval: boolean;
@@ -37,6 +39,7 @@ interface FeatureDefaultsSectionProps {
onShowProfilesOnlyChange: (value: boolean) => void;
onDefaultSkipTestsChange: (value: boolean) => void;
onEnableDependencyBlockingChange: (value: boolean) => void;
onSkipVerificationInAutoModeChange: (value: boolean) => void;
onUseWorktreesChange: (value: boolean) => void;
onDefaultPlanningModeChange: (value: PlanningMode) => void;
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
@@ -47,6 +50,7 @@ export function FeatureDefaultsSection({
showProfilesOnly,
defaultSkipTests,
enableDependencyBlocking,
skipVerificationInAutoMode,
useWorktrees,
defaultPlanningMode,
defaultRequirePlanApproval,
@@ -55,6 +59,7 @@ export function FeatureDefaultsSection({
onShowProfilesOnlyChange,
onDefaultSkipTestsChange,
onEnableDependencyBlockingChange,
onSkipVerificationInAutoModeChange,
onUseWorktreesChange,
onDefaultPlanningModeChange,
onDefaultRequirePlanApprovalChange,
@@ -309,6 +314,34 @@ export function FeatureDefaultsSection({
{/* Separator */}
<div className="border-t border-border/30" />
{/* Skip Verification in Auto Mode Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="skip-verification-auto-mode"
checked={skipVerificationInAutoMode}
onCheckedChange={(checked) => onSkipVerificationInAutoModeChange(checked === true)}
className="mt-1"
data-testid="skip-verification-auto-mode-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="skip-verification-auto-mode"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<FastForward className="w-4 h-4 text-brand-500" />
Skip verification in auto mode
</Label>
<p className="text-xs text-muted-foreground/80 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>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Worktree Isolation Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox

View File

@@ -32,6 +32,53 @@ export function useCliStatus() {
const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false);
// Refresh Claude auth status from the server
const refreshAuthStatus = useCallback(async () => {
const api = getElectronAPI();
if (!api?.setup?.getClaudeStatus) return;
try {
const result = await api.setup.getClaudeStatus();
if (result.success && result.auth) {
// Cast to extended type that includes server-added fields
const auth = result.auth as typeof result.auth & {
oauthTokenValid?: boolean;
apiKeyValid?: boolean;
};
// Map server method names to client method types
// Server returns: oauth_token_env, oauth_token, api_key_env, api_key, credentials_file, cli_authenticated, none
const validMethods = [
'oauth_token_env',
'oauth_token',
'api_key',
'api_key_env',
'credentials_file',
'cli_authenticated',
'none',
] as const;
type AuthMethod = (typeof validMethods)[number];
const method: AuthMethod = validMethods.includes(auth.method as AuthMethod)
? (auth.method as AuthMethod)
: auth.authenticated
? 'api_key'
: 'none'; // Default authenticated to api_key, not none
const authStatus = {
authenticated: auth.authenticated,
method,
hasCredentialsFile: auth.hasCredentialsFile ?? false,
oauthTokenValid:
auth.oauthTokenValid || auth.hasStoredOAuthToken || auth.hasEnvOAuthToken,
apiKeyValid: auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey,
hasEnvOAuthToken: auth.hasEnvOAuthToken,
hasEnvApiKey: auth.hasEnvApiKey,
};
setClaudeAuthStatus(authStatus);
}
} catch (error) {
logger.error('Failed to refresh Claude auth status:', error);
}
}, [setClaudeAuthStatus]);
// Check CLI status on mount
useEffect(() => {
const checkCliStatus = async () => {
@@ -48,54 +95,13 @@ export function useCliStatus() {
}
// Check Claude auth status (re-fetch on mount to ensure persistence)
if (api?.setup?.getClaudeStatus) {
try {
const result = await api.setup.getClaudeStatus();
if (result.success && result.auth) {
// Cast to extended type that includes server-added fields
const auth = result.auth as typeof result.auth & {
oauthTokenValid?: boolean;
apiKeyValid?: boolean;
};
// Map server method names to client method types
// Server returns: oauth_token_env, oauth_token, api_key_env, api_key, credentials_file, cli_authenticated, none
const validMethods = [
'oauth_token_env',
'oauth_token',
'api_key',
'api_key_env',
'credentials_file',
'cli_authenticated',
'none',
] as const;
type AuthMethod = (typeof validMethods)[number];
const method: AuthMethod = validMethods.includes(auth.method as AuthMethod)
? (auth.method as AuthMethod)
: auth.authenticated
? 'api_key'
: 'none'; // Default authenticated to api_key, not none
const authStatus = {
authenticated: auth.authenticated,
method,
hasCredentialsFile: auth.hasCredentialsFile ?? false,
oauthTokenValid:
auth.oauthTokenValid || auth.hasStoredOAuthToken || auth.hasEnvOAuthToken,
apiKeyValid: auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey,
hasEnvOAuthToken: auth.hasEnvOAuthToken,
hasEnvApiKey: auth.hasEnvApiKey,
};
setClaudeAuthStatus(authStatus);
}
} catch (error) {
logger.error('Failed to check Claude auth status:', error);
}
}
await refreshAuthStatus();
};
checkCliStatus();
}, [setClaudeAuthStatus]);
}, [refreshAuthStatus]);
// Refresh Claude CLI status
// Refresh Claude CLI status and auth status
const handleRefreshClaudeCli = useCallback(async () => {
setIsCheckingClaudeCli(true);
try {
@@ -104,12 +110,14 @@ export function useCliStatus() {
const status = await api.checkClaudeCli();
setClaudeCliStatus(status);
}
// Also refresh auth status
await refreshAuthStatus();
} catch (error) {
logger.error('Failed to refresh Claude CLI status:', error);
} finally {
setIsCheckingClaudeCli(false);
}
}, []);
}, [refreshAuthStatus]);
return {
claudeCliStatus,

View File

@@ -4,6 +4,9 @@ export type SettingsViewId =
| 'api-keys'
| 'claude'
| 'providers'
| 'claude-provider'
| 'cursor-provider'
| 'codex-provider'
| 'mcp-servers'
| 'prompts'
| 'model-defaults'
@@ -12,6 +15,8 @@ export type SettingsViewId =
| 'keyboard'
| 'audio'
| 'defaults'
| 'account'
| 'security'
| 'danger';
interface UseSettingsViewOptions {

View File

@@ -19,10 +19,12 @@ import {
import {
CLAUDE_MODELS,
CURSOR_MODELS,
CODEX_MODELS,
THINKING_LEVELS,
THINKING_LEVEL_LABELS,
} from '@/components/views/board-view/shared/model-constants';
import { Check, ChevronsUpDown, Star, Brain, Sparkles, ChevronRight } from 'lucide-react';
import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
import { Button } from '@/components/ui/button';
import {
Command,
@@ -140,14 +142,14 @@ export function PhaseModelSelector({
return {
...claudeModel,
label: `${claudeModel.label}${thinkingLabel}`,
icon: Brain,
icon: AnthropicIcon,
};
}
const cursorModel = availableCursorModels.find(
(m) => stripProviderPrefix(m.id) === selectedModel
);
if (cursorModel) return { ...cursorModel, icon: Sparkles };
if (cursorModel) return { ...cursorModel, icon: CursorIcon };
// Check if selectedModel is part of a grouped model
const group = getModelGroup(selectedModel as CursorModelId);
@@ -158,10 +160,14 @@ export function PhaseModelSelector({
label: `${group.label} (${variant?.label || 'Unknown'})`,
description: group.description,
provider: 'cursor' as const,
icon: Sparkles,
icon: CursorIcon,
};
}
// Check Codex models
const codexModel = CODEX_MODELS.find((m) => m.id === selectedModel);
if (codexModel) return { ...codexModel, icon: OpenAIIcon };
return null;
}, [selectedModel, selectedThinkingLevel, availableCursorModels]);
@@ -199,10 +205,11 @@ export function PhaseModelSelector({
}, [availableCursorModels, enabledCursorModels]);
// Group models
const { favorites, claude, cursor } = React.useMemo(() => {
const { favorites, claude, cursor, codex } = React.useMemo(() => {
const favs: typeof CLAUDE_MODELS = [];
const cModels: typeof CLAUDE_MODELS = [];
const curModels: typeof CURSOR_MODELS = [];
const codModels: typeof CODEX_MODELS = [];
// Process Claude Models
CLAUDE_MODELS.forEach((model) => {
@@ -222,9 +229,71 @@ export function PhaseModelSelector({
}
});
return { favorites: favs, claude: cModels, cursor: curModels };
// Process Codex Models
CODEX_MODELS.forEach((model) => {
if (favoriteModels.includes(model.id)) {
favs.push(model);
} else {
codModels.push(model);
}
});
return { favorites: favs, claude: cModels, cursor: curModels, codex: codModels };
}, [favoriteModels, availableCursorModels]);
// Render Codex model item (no thinking level needed)
const renderCodexModelItem = (model: (typeof CODEX_MODELS)[0]) => {
const isSelected = selectedModel === model.id;
const isFavorite = favoriteModels.includes(model.id);
return (
<CommandItem
key={model.id}
value={model.label}
onSelect={() => {
onChange({ model: model.id });
setOpen(false);
}}
className="group flex items-center justify-between py-2"
>
<div className="flex items-center gap-3 overflow-hidden">
<OpenAIIcon
className={cn(
'h-4 w-4 shrink-0',
isSelected ? 'text-primary' : 'text-muted-foreground'
)}
/>
<div className="flex flex-col truncate">
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
{model.label}
</span>
<span className="truncate text-xs text-muted-foreground">{model.description}</span>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
<Button
variant="ghost"
size="icon"
className={cn(
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
isFavorite
? 'text-yellow-500 opacity-100'
: 'opacity-0 group-hover:opacity-100 text-muted-foreground'
)}
onClick={(e) => {
e.stopPropagation();
toggleFavoriteModel(model.id);
}}
>
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
</Button>
{isSelected && <Check className="h-4 w-4 text-primary shrink-0" />}
</div>
</CommandItem>
);
};
// Render Cursor model item (no thinking level needed)
const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => {
const modelValue = stripProviderPrefix(model.id);
@@ -242,7 +311,7 @@ export function PhaseModelSelector({
className="group flex items-center justify-between py-2"
>
<div className="flex items-center gap-3 overflow-hidden">
<Sparkles
<CursorIcon
className={cn(
'h-4 w-4 shrink-0',
isSelected ? 'text-primary' : 'text-muted-foreground'
@@ -311,7 +380,7 @@ export function PhaseModelSelector({
)}
>
<div className="flex items-center gap-3 overflow-hidden">
<Brain
<AnthropicIcon
className={cn(
'h-4 w-4 shrink-0',
isSelected ? 'text-primary' : 'text-muted-foreground'
@@ -358,10 +427,10 @@ export function PhaseModelSelector({
</PopoverTrigger>
<PopoverContent
side="right"
align="center"
avoidCollisions={false}
align="start"
className="w-[220px] p-1"
sideOffset={8}
collisionPadding={16}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="space-y-1">
@@ -445,7 +514,7 @@ export function PhaseModelSelector({
)}
>
<div className="flex items-center gap-3 overflow-hidden">
<Sparkles
<CursorIcon
className={cn(
'h-4 w-4 shrink-0',
groupIsSelected ? 'text-primary' : 'text-muted-foreground'
@@ -474,10 +543,10 @@ export function PhaseModelSelector({
</PopoverTrigger>
<PopoverContent
side="right"
align="center"
avoidCollisions={false}
align="start"
className="w-[220px] p-1"
sideOffset={8}
collisionPadding={16}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="space-y-1">
@@ -603,6 +672,10 @@ export function PhaseModelSelector({
// Standalone Cursor model
return renderCursorModelItem(model);
}
// Codex model
if (model.provider === 'codex') {
return renderCodexModelItem(model);
}
// Claude model
return renderClaudeModelItem(model);
});
@@ -626,6 +699,12 @@ export function PhaseModelSelector({
{standaloneCursorModels.map((model) => renderCursorModelItem(model))}
</CommandGroup>
)}
{codex.length > 0 && (
<CommandGroup heading="Codex Models">
{codex.map((model) => renderCodexModelItem(model))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { useCliStatus } from '../hooks/use-cli-status';

View File

@@ -0,0 +1,183 @@
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Cpu } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CodexModelId } from '@automaker/types';
import { CODEX_MODEL_MAP } from '@automaker/types';
import { OpenAIIcon } from '@/components/ui/provider-icon';
interface CodexModelConfigurationProps {
enabledCodexModels: CodexModelId[];
codexDefaultModel: CodexModelId;
isSaving: boolean;
onDefaultModelChange: (model: CodexModelId) => void;
onModelToggle: (model: CodexModelId, enabled: boolean) => void;
}
interface CodexModelInfo {
id: CodexModelId;
label: string;
description: string;
}
const CODEX_MODEL_INFO: Record<CodexModelId, CodexModelInfo> = {
'gpt-5.2-codex': {
id: 'gpt-5.2-codex',
label: 'GPT-5.2-Codex',
description: 'Most advanced agentic coding model for complex software engineering',
},
'gpt-5-codex': {
id: 'gpt-5-codex',
label: 'GPT-5-Codex',
description: 'Purpose-built for Codex CLI with versatile tool use',
},
'gpt-5-codex-mini': {
id: 'gpt-5-codex-mini',
label: 'GPT-5-Codex-Mini',
description: 'Faster workflows optimized for low-latency code Q&A and editing',
},
'codex-1': {
id: 'codex-1',
label: 'Codex-1',
description: 'Version of o3 optimized for software engineering',
},
'codex-mini-latest': {
id: 'codex-mini-latest',
label: 'Codex-Mini-Latest',
description: 'Version of o4-mini for Codex, optimized for faster workflows',
},
'gpt-5': {
id: 'gpt-5',
label: 'GPT-5',
description: 'GPT-5 base flagship model',
},
};
export function CodexModelConfiguration({
enabledCodexModels,
codexDefaultModel,
isSaving,
onDefaultModelChange,
onModelToggle,
}: CodexModelConfigurationProps) {
const availableModels = Object.values(CODEX_MODEL_INFO);
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<OpenAIIcon className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Model Configuration
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure which Codex models are available in the feature modal
</p>
</div>
<div className="p-6 space-y-6">
<div className="space-y-2">
<Label>Default Model</Label>
<Select
value={codexDefaultModel}
onValueChange={(v) => onDefaultModelChange(v as CodexModelId)}
disabled={isSaving}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2">
<span>{model.label}</span>
{supportsReasoningEffort(model.id) && (
<Badge variant="outline" className="text-xs">
Thinking
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<Label>Available Models</Label>
<div className="grid gap-3">
{availableModels.map((model) => {
const isEnabled = enabledCodexModels.includes(model.id);
const isDefault = model.id === codexDefaultModel;
return (
<div
key={model.id}
className="flex items-center justify-between p-3 rounded-xl border border-border/50 bg-card/50 hover:bg-accent/30 transition-colors"
>
<div className="flex items-center gap-3">
<Checkbox
checked={isEnabled}
onCheckedChange={(checked) => onModelToggle(model.id, !!checked)}
disabled={isSaving || isDefault}
/>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{model.label}</span>
{supportsReasoningEffort(model.id) && (
<Badge variant="outline" className="text-xs">
Thinking
</Badge>
)}
{isDefault && (
<Badge variant="secondary" className="text-xs">
Default
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{model.description}</p>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}
function getModelDisplayName(modelId: string): string {
const displayNames: Record<string, string> = {
'gpt-5.2-codex': 'GPT-5.2-Codex',
'gpt-5-codex': 'GPT-5-Codex',
'gpt-5-codex-mini': 'GPT-5-Codex-Mini',
'codex-1': 'Codex-1',
'codex-mini-latest': 'Codex-Mini-Latest',
'gpt-5': 'GPT-5',
};
return displayNames[modelId] || modelId;
}
function supportsReasoningEffort(modelId: string): boolean {
const reasoningModels = ['gpt-5.2-codex', 'gpt-5-codex', 'gpt-5', 'codex-1'];
return reasoningModels.includes(modelId);
}

View File

@@ -0,0 +1,198 @@
import { useState, useCallback, useEffect } from 'react';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { CodexCliStatus } from '../cli-status/codex-cli-status';
import { CodexSettings } from '../codex/codex-settings';
import { CodexUsageSection } from '../codex/codex-usage-section';
import { CodexModelConfiguration } from './codex-model-configuration';
import { getElectronAPI } from '@/lib/electron';
import { createLogger } from '@automaker/utils/logger';
import type { CliStatus as SharedCliStatus } from '../shared/types';
import type { CodexModelId } from '@automaker/types';
const logger = createLogger('CodexSettings');
export function CodexSettingsTab() {
const {
codexAutoLoadAgents,
codexSandboxMode,
codexApprovalPolicy,
codexEnableWebSearch,
codexEnableImages,
enabledCodexModels,
codexDefaultModel,
setCodexAutoLoadAgents,
setCodexSandboxMode,
setCodexApprovalPolicy,
setCodexEnableWebSearch,
setCodexEnableImages,
setEnabledCodexModels,
setCodexDefaultModel,
toggleCodexModel,
} = useAppStore();
const {
codexAuthStatus,
codexCliStatus: setupCliStatus,
setCodexCliStatus,
setCodexAuthStatus,
} = useSetupStore();
const [isCheckingCodexCli, setIsCheckingCodexCli] = useState(false);
const [displayCliStatus, setDisplayCliStatus] = useState<SharedCliStatus | null>(null);
const [isSaving, setIsSaving] = useState(false);
const codexCliStatus: SharedCliStatus | null =
displayCliStatus ||
(setupCliStatus
? {
success: true,
status: setupCliStatus.installed ? 'installed' : 'not_installed',
method: setupCliStatus.method,
version: setupCliStatus.version || undefined,
path: setupCliStatus.path || undefined,
}
: null);
// Load Codex CLI status and auth status on mount
useEffect(() => {
const checkCodexStatus = async () => {
const api = getElectronAPI();
if (api?.setup?.getCodexStatus) {
try {
const result = await api.setup.getCodexStatus();
setDisplayCliStatus({
success: result.success,
status: result.installed ? 'installed' : 'not_installed',
method: result.auth?.method,
version: result.version,
path: result.path,
recommendation: result.recommendation,
installCommands: result.installCommands,
});
setCodexCliStatus({
installed: result.installed,
version: result.version,
path: result.path,
method: result.auth?.method || 'none',
});
if (result.auth) {
setCodexAuthStatus({
authenticated: result.auth.authenticated,
method: result.auth.method as
| 'cli_authenticated'
| 'api_key'
| 'api_key_env'
| 'none',
hasAuthFile: result.auth.method === 'cli_authenticated',
hasApiKey: result.auth.hasApiKey,
});
}
} catch (error) {
logger.error('Failed to check Codex CLI status:', error);
}
}
};
checkCodexStatus();
}, [setCodexCliStatus, setCodexAuthStatus]);
const handleRefreshCodexCli = useCallback(async () => {
setIsCheckingCodexCli(true);
try {
const api = getElectronAPI();
if (api?.setup?.getCodexStatus) {
const result = await api.setup.getCodexStatus();
setDisplayCliStatus({
success: result.success,
status: result.installed ? 'installed' : 'not_installed',
method: result.auth?.method,
version: result.version,
path: result.path,
recommendation: result.recommendation,
installCommands: result.installCommands,
});
setCodexCliStatus({
installed: result.installed,
version: result.version,
path: result.path,
method: result.auth?.method || 'none',
});
if (result.auth) {
setCodexAuthStatus({
authenticated: result.auth.authenticated,
method: result.auth.method as 'cli_authenticated' | 'api_key' | 'api_key_env' | 'none',
hasAuthFile: result.auth.method === 'cli_authenticated',
hasApiKey: result.auth.hasApiKey,
});
}
}
} catch (error) {
logger.error('Failed to refresh Codex CLI status:', error);
} finally {
setIsCheckingCodexCli(false);
}
}, [setCodexCliStatus, setCodexAuthStatus]);
const handleDefaultModelChange = useCallback(
(model: CodexModelId) => {
setIsSaving(true);
try {
setCodexDefaultModel(model);
} finally {
setIsSaving(false);
}
},
[setCodexDefaultModel]
);
const handleModelToggle = useCallback(
(model: CodexModelId, enabled: boolean) => {
setIsSaving(true);
try {
toggleCodexModel(model, enabled);
} finally {
setIsSaving(false);
}
},
[toggleCodexModel]
);
const showUsageTracking = codexAuthStatus?.authenticated ?? false;
const authStatusToDisplay = codexAuthStatus;
return (
<div className="space-y-6">
<CodexCliStatus
status={codexCliStatus}
authStatus={authStatusToDisplay}
isChecking={isCheckingCodexCli}
onRefresh={handleRefreshCodexCli}
/>
{showUsageTracking && <CodexUsageSection />}
<CodexModelConfiguration
enabledCodexModels={enabledCodexModels}
codexDefaultModel={codexDefaultModel}
isSaving={isSaving}
onDefaultModelChange={handleDefaultModelChange}
onModelToggle={handleModelToggle}
/>
<CodexSettings
autoLoadCodexAgents={codexAutoLoadAgents}
codexSandboxMode={codexSandboxMode}
codexApprovalPolicy={codexApprovalPolicy}
codexEnableWebSearch={codexEnableWebSearch}
codexEnableImages={codexEnableImages}
onAutoLoadCodexAgentsChange={setCodexAutoLoadAgents}
onCodexSandboxModeChange={setCodexSandboxMode}
onCodexApprovalPolicyChange={setCodexApprovalPolicy}
onCodexEnableWebSearchChange={setCodexEnableWebSearch}
onCodexEnableImagesChange={setCodexEnableImages}
/>
</div>
);
}
export default CodexSettingsTab;

View File

@@ -1,3 +1,4 @@
export { ProviderTabs } from './provider-tabs';
export { ClaudeSettingsTab } from './claude-settings-tab';
export { CursorSettingsTab } from './cursor-settings-tab';
export { CodexSettingsTab } from './codex-settings-tab';

View File

@@ -1,25 +1,30 @@
import React from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Bot, Terminal } from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
import { CursorSettingsTab } from './cursor-settings-tab';
import { ClaudeSettingsTab } from './claude-settings-tab';
import { CodexSettingsTab } from './codex-settings-tab';
interface ProviderTabsProps {
defaultTab?: 'claude' | 'cursor';
defaultTab?: 'claude' | 'cursor' | 'codex';
}
export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
return (
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsList className="grid w-full grid-cols-3 mb-6">
<TabsTrigger value="claude" className="flex items-center gap-2">
<Bot className="w-4 h-4" />
<AnthropicIcon className="w-4 h-4" />
Claude
</TabsTrigger>
<TabsTrigger value="cursor" className="flex items-center gap-2">
<Terminal className="w-4 h-4" />
<CursorIcon className="w-4 h-4" />
Cursor
</TabsTrigger>
<TabsTrigger value="codex" className="flex items-center gap-2">
<OpenAIIcon className="w-4 h-4" />
Codex
</TabsTrigger>
</TabsList>
<TabsContent value="claude">
@@ -29,6 +34,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
<TabsContent value="cursor">
<CursorSettingsTab />
</TabsContent>
<TabsContent value="codex">
<CodexSettingsTab />
</TabsContent>
</Tabs>
);
}

View File

@@ -0,0 +1 @@
export { SecuritySection } from './security-section';

View File

@@ -0,0 +1,71 @@
import { Shield, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
interface SecuritySectionProps {
skipSandboxWarning: boolean;
onSkipSandboxWarningChange: (skip: boolean) => void;
}
export function SecuritySection({
skipSandboxWarning,
onSkipSandboxWarningChange,
}: SecuritySectionProps) {
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/80 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm'
)}
>
<div className="p-6 border-b border-border/30 bg-gradient-to-r from-primary/5 via-transparent to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center border border-primary/20">
<Shield className="w-5 h-5 text-primary" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Security</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure security warnings and protections.
</p>
</div>
<div className="p-6 space-y-4">
{/* Sandbox Warning Toggle */}
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-muted/30 border border-border/30">
<div className="flex items-center gap-3.5 min-w-0">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-amber-500/15 to-amber-600/10 border border-amber-500/20 flex items-center justify-center shrink-0">
<AlertTriangle className="w-5 h-5 text-amber-500" />
</div>
<div className="min-w-0 flex-1">
<Label
htmlFor="sandbox-warning-toggle"
className="font-medium text-foreground cursor-pointer"
>
Show Sandbox Warning on Startup
</Label>
<p className="text-xs text-muted-foreground/70 mt-0.5">
Display a security warning when not running in a sandboxed environment
</p>
</div>
</div>
<Switch
id="sandbox-warning-toggle"
checked={!skipSandboxWarning}
onCheckedChange={(checked) => onSkipSandboxWarningChange(!checked)}
data-testid="sandbox-warning-toggle"
/>
</div>
{/* Info text */}
<p className="text-xs text-muted-foreground/60 px-4">
When enabled, you&apos;ll see a warning on app startup if you&apos;re not running in a
containerized environment (like Docker). This helps remind you to use proper isolation
when running AI agents.
</p>
</div>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import {
CompleteStep,
ClaudeSetupStep,
CursorSetupStep,
CodexSetupStep,
GitHubSetupStep,
} from './setup-view/steps';
import { useNavigate } from '@tanstack/react-router';
@@ -18,13 +19,14 @@ export function SetupView() {
const { currentStep, setCurrentStep, completeSetup, setSkipClaudeSetup } = useSetupStore();
const navigate = useNavigate();
const steps = ['welcome', 'theme', 'claude', 'cursor', 'github', 'complete'] as const;
const steps = ['welcome', 'theme', 'claude', 'cursor', 'codex', 'github', 'complete'] as const;
type StepName = (typeof steps)[number];
const getStepName = (): StepName => {
if (currentStep === 'claude_detect' || currentStep === 'claude_auth') return 'claude';
if (currentStep === 'welcome') return 'welcome';
if (currentStep === 'theme') return 'theme';
if (currentStep === 'cursor') return 'cursor';
if (currentStep === 'codex') return 'codex';
if (currentStep === 'github') return 'github';
return 'complete';
};
@@ -46,6 +48,10 @@ export function SetupView() {
setCurrentStep('cursor');
break;
case 'cursor':
logger.debug('[Setup Flow] Moving to codex step');
setCurrentStep('codex');
break;
case 'codex':
logger.debug('[Setup Flow] Moving to github step');
setCurrentStep('github');
break;
@@ -68,9 +74,12 @@ export function SetupView() {
case 'cursor':
setCurrentStep('claude_detect');
break;
case 'github':
case 'codex':
setCurrentStep('cursor');
break;
case 'github':
setCurrentStep('codex');
break;
}
};
@@ -82,6 +91,11 @@ export function SetupView() {
const handleSkipCursor = () => {
logger.debug('[Setup Flow] Skipping Cursor setup');
setCurrentStep('codex');
};
const handleSkipCodex = () => {
logger.debug('[Setup Flow] Skipping Codex setup');
setCurrentStep('github');
};
@@ -139,6 +153,14 @@ export function SetupView() {
/>
)}
{currentStep === 'codex' && (
<CodexSetupStep
onNext={() => handleNext('codex')}
onBack={() => handleBack('codex')}
onSkip={handleSkipCodex}
/>
)}
{currentStep === 'github' && (
<GitHubSetupStep
onNext={() => handleNext('github')}

View File

@@ -2,12 +2,28 @@ import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
interface UseCliStatusOptions {
cliType: 'claude';
cliType: 'claude' | 'codex';
statusApi: () => Promise<any>;
setCliStatus: (status: any) => void;
setAuthStatus: (status: any) => void;
}
const VALID_AUTH_METHODS = {
claude: [
'oauth_token_env',
'oauth_token',
'api_key',
'api_key_env',
'credentials_file',
'cli_authenticated',
'none',
],
codex: ['cli_authenticated', 'api_key', 'api_key_env', 'none'],
} as const;
// Create logger outside of the hook to avoid re-creating it on every render
const logger = createLogger('CliStatus');
export function useCliStatus({
cliType,
statusApi,
@@ -15,7 +31,6 @@ export function useCliStatus({
setAuthStatus,
}: UseCliStatusOptions) {
const [isChecking, setIsChecking] = useState(false);
const logger = createLogger('CliStatus');
const checkStatus = useCallback(async () => {
logger.info(`Starting status check for ${cliType}...`);
@@ -25,8 +40,13 @@ export function useCliStatus({
logger.info(`Raw status result for ${cliType}:`, result);
if (result.success) {
// Handle both response formats:
// - Claude API returns {status: 'installed' | 'not_installed'}
// - Codex API returns {installed: boolean}
const isInstalled =
typeof result.installed === 'boolean' ? result.installed : result.status === 'installed';
const cliStatus = {
installed: result.status === 'installed',
installed: isInstalled,
path: result.path || null,
version: result.version || null,
method: result.method || 'none',
@@ -35,30 +55,43 @@ export function useCliStatus({
setCliStatus(cliStatus);
if (result.auth) {
// Validate method is one of the expected values, default to "none"
const validMethods = [
'oauth_token_env',
'oauth_token',
'api_key',
'api_key_env',
'credentials_file',
'cli_authenticated',
'none',
] as const;
type AuthMethod = (typeof validMethods)[number];
const method: AuthMethod = validMethods.includes(result.auth.method as AuthMethod)
? (result.auth.method as AuthMethod)
: 'none';
const authStatus = {
authenticated: result.auth.authenticated,
method,
hasCredentialsFile: false,
oauthTokenValid: result.auth.hasStoredOAuthToken || result.auth.hasEnvOAuthToken,
apiKeyValid: result.auth.hasStoredApiKey || result.auth.hasEnvApiKey,
hasEnvOAuthToken: result.auth.hasEnvOAuthToken,
hasEnvApiKey: result.auth.hasEnvApiKey,
};
setAuthStatus(authStatus);
if (cliType === 'claude') {
// Validate method is one of the expected Claude values, default to "none"
const validMethods = VALID_AUTH_METHODS.claude;
type ClaudeAuthMethod = (typeof validMethods)[number];
const method: ClaudeAuthMethod = validMethods.includes(
result.auth.method as ClaudeAuthMethod
)
? (result.auth.method as ClaudeAuthMethod)
: 'none';
setAuthStatus({
authenticated: result.auth.authenticated,
method,
hasCredentialsFile: false,
oauthTokenValid: result.auth.hasStoredOAuthToken || result.auth.hasEnvOAuthToken,
apiKeyValid: result.auth.hasStoredApiKey || result.auth.hasEnvApiKey,
hasEnvOAuthToken: result.auth.hasEnvOAuthToken,
hasEnvApiKey: result.auth.hasEnvApiKey,
});
} else {
// Validate method is one of the expected Codex values, default to "none"
const validMethods = VALID_AUTH_METHODS.codex;
type CodexAuthMethod = (typeof validMethods)[number];
const method: CodexAuthMethod = validMethods.includes(
result.auth.method as CodexAuthMethod
)
? (result.auth.method as CodexAuthMethod)
: 'none';
setAuthStatus({
authenticated: result.auth.authenticated,
method,
hasAuthFile: result.auth.hasAuthFile ?? false,
hasApiKey: result.auth.hasApiKey ?? false,
hasEnvApiKey: result.auth.hasEnvApiKey ?? false,
});
}
}
}
} catch (error) {
@@ -66,7 +99,7 @@ export function useCliStatus({
} finally {
setIsChecking(false);
}
}, [cliType, statusApi, setCliStatus, setAuthStatus, logger]);
}, [cliType, statusApi, setCliStatus, setAuthStatus]);
return { isChecking, checkStatus };
}

View File

@@ -15,7 +15,6 @@ import { getElectronAPI } from '@/lib/electron';
import {
CheckCircle2,
Loader2,
Terminal,
Key,
ArrowRight,
ArrowLeft,
@@ -31,6 +30,7 @@ import {
import { toast } from 'sonner';
import { StatusBadge, TerminalOutput } from '../components';
import { useCliStatus, useCliInstallation, useTokenSave } from '../hooks';
import { AnthropicIcon } from '@/components/ui/provider-icon';
interface ClaudeSetupStepProps {
onNext: () => void;
@@ -310,7 +310,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
<div className="space-y-6">
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-xl bg-brand-500/10 flex items-center justify-center mx-auto mb-4">
<Terminal className="w-8 h-8 text-brand-500" />
<AnthropicIcon className="w-8 h-8 text-brand-500" />
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">Claude Code Setup</h2>
<p className="text-muted-foreground">Configure for code generation</p>
@@ -339,7 +339,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center justify-between w-full pr-4">
<div className="flex items-center gap-3">
<Terminal
<AnthropicIcon
className={`w-5 h-5 ${
cliVerificationStatus === 'verified'
? 'text-green-500'

View File

@@ -0,0 +1,814 @@
// @ts-nocheck
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import {
CheckCircle2,
Loader2,
Key,
ArrowRight,
ArrowLeft,
ExternalLink,
Copy,
RefreshCw,
Download,
Info,
ShieldCheck,
XCircle,
Trash2,
} from 'lucide-react';
import { toast } from 'sonner';
import { StatusBadge, TerminalOutput } from '../components';
import { useCliStatus, useCliInstallation, useTokenSave } from '../hooks';
import type { ApiKeys } from '@/store/app-store';
import type { ModelProvider } from '@/store/app-store';
import type { ProviderKey } from '@/config/api-providers';
import type {
CliStatus,
InstallProgress,
ClaudeAuthStatus,
CodexAuthStatus,
} from '@/store/setup-store';
import { PROVIDER_ICON_COMPONENTS } from '@/components/ui/provider-icon';
type VerificationStatus = 'idle' | 'verifying' | 'verified' | 'error';
type CliSetupAuthStatus = ClaudeAuthStatus | CodexAuthStatus;
interface CliSetupConfig {
cliType: ModelProvider;
displayName: string;
cliLabel: string;
cliDescription: string;
apiKeyLabel: string;
apiKeyDescription: string;
apiKeyProvider: ProviderKey;
apiKeyPlaceholder: string;
apiKeyDocsUrl: string;
apiKeyDocsLabel: string;
installCommands: {
macos: string;
windows: string;
};
cliLoginCommand: string;
testIds: {
installButton: string;
verifyCliButton: string;
verifyApiKeyButton: string;
apiKeyInput: string;
saveApiKeyButton: string;
deleteApiKeyButton: string;
nextButton: string;
};
buildCliAuthStatus: (previous: CliSetupAuthStatus | null) => CliSetupAuthStatus;
buildApiKeyAuthStatus: (previous: CliSetupAuthStatus | null) => CliSetupAuthStatus;
buildClearedAuthStatus: (previous: CliSetupAuthStatus | null) => CliSetupAuthStatus;
statusApi: () => Promise<any>;
installApi: () => Promise<any>;
verifyAuthApi: (
method: 'cli' | 'api_key',
apiKey?: string
) => Promise<{
success: boolean;
authenticated: boolean;
error?: string;
details?: string;
}>;
apiKeyHelpText: string;
}
interface CliSetupStateHandlers {
cliStatus: CliStatus | null;
authStatus: CliSetupAuthStatus | null;
setCliStatus: (status: CliStatus | null) => void;
setAuthStatus: (status: CliSetupAuthStatus | null) => void;
setInstallProgress: (progress: Partial<InstallProgress>) => void;
getStoreState: () => CliStatus | null;
}
interface CliSetupStepProps {
config: CliSetupConfig;
state: CliSetupStateHandlers;
onNext: () => void;
onBack: () => void;
onSkip: () => void;
}
export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetupStepProps) {
const { apiKeys, setApiKeys } = useAppStore();
const { cliStatus, authStatus, setCliStatus, setAuthStatus, setInstallProgress, getStoreState } =
state;
const [apiKey, setApiKey] = useState('');
const [cliVerificationStatus, setCliVerificationStatus] = useState<VerificationStatus>('idle');
const [cliVerificationError, setCliVerificationError] = useState<string | null>(null);
const [apiKeyVerificationStatus, setApiKeyVerificationStatus] =
useState<VerificationStatus>('idle');
const [apiKeyVerificationError, setApiKeyVerificationError] = useState<string | null>(null);
const [isDeletingApiKey, setIsDeletingApiKey] = useState(false);
const statusApi = useCallback(() => config.statusApi(), [config]);
const installApi = useCallback(() => config.installApi(), [config]);
const { isChecking, checkStatus } = useCliStatus({
cliType: config.cliType,
statusApi,
setCliStatus,
setAuthStatus,
});
const onInstallSuccess = useCallback(() => {
checkStatus();
}, [checkStatus]);
const { isInstalling, installProgress, install } = useCliInstallation({
cliType: config.cliType,
installApi,
onProgressEvent: getElectronAPI().setup?.onInstallProgress,
onSuccess: onInstallSuccess,
getStoreState,
});
const { isSaving: isSavingApiKey, saveToken: saveApiKeyToken } = useTokenSave({
provider: config.apiKeyProvider,
onSuccess: () => {
setAuthStatus(config.buildApiKeyAuthStatus(authStatus));
setApiKeys({ ...apiKeys, [config.apiKeyProvider]: apiKey });
toast.success('API key saved successfully!');
},
});
const verifyCliAuth = useCallback(async () => {
setCliVerificationStatus('verifying');
setCliVerificationError(null);
try {
const result = await config.verifyAuthApi('cli');
const hasLimitOrBillingError =
result.error?.toLowerCase().includes('limit reached') ||
result.error?.toLowerCase().includes('rate limit') ||
result.error?.toLowerCase().includes('credit balance') ||
result.error?.toLowerCase().includes('billing');
if (result.authenticated) {
// Auth succeeded - even if rate limited or billing issue
setCliVerificationStatus('verified');
setAuthStatus(config.buildCliAuthStatus(authStatus));
if (hasLimitOrBillingError) {
// Show warning but keep auth verified
toast.warning(result.error || 'Rate limit or billing issue');
} else {
toast.success(`${config.displayName} CLI authentication verified!`);
}
} else {
// Actual auth failure
setCliVerificationStatus('error');
// Include detailed error if available
const errorDisplay = result.details
? `${result.error}\n\nDetails: ${result.details}`
: result.error || 'Authentication failed';
setCliVerificationError(errorDisplay);
setAuthStatus(config.buildClearedAuthStatus(authStatus));
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Verification failed';
setCliVerificationStatus('error');
setCliVerificationError(errorMessage);
}
}, [authStatus, config, setAuthStatus]);
const verifyApiKeyAuth = useCallback(async () => {
setApiKeyVerificationStatus('verifying');
setApiKeyVerificationError(null);
try {
const result = await config.verifyAuthApi('api_key', apiKey);
const hasLimitOrBillingError =
result.error?.toLowerCase().includes('limit reached') ||
result.error?.toLowerCase().includes('rate limit') ||
result.error?.toLowerCase().includes('credit balance') ||
result.error?.toLowerCase().includes('billing');
if (result.authenticated) {
// Auth succeeded - even if rate limited or billing issue
setApiKeyVerificationStatus('verified');
setAuthStatus(config.buildApiKeyAuthStatus(authStatus));
if (hasLimitOrBillingError) {
// Show warning but keep auth verified
toast.warning(result.error || 'Rate limit or billing issue');
} else {
toast.success('API key authentication verified!');
}
} else {
// Actual auth failure
setApiKeyVerificationStatus('error');
// Include detailed error if available
const errorDisplay = result.details
? `${result.error}\n\nDetails: ${result.details}`
: result.error || 'Authentication failed';
setApiKeyVerificationError(errorDisplay);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Verification failed';
setApiKeyVerificationStatus('error');
setApiKeyVerificationError(errorMessage);
}
}, [authStatus, config, setAuthStatus]);
const deleteApiKey = useCallback(async () => {
setIsDeletingApiKey(true);
try {
const api = getElectronAPI();
if (!api.setup?.deleteApiKey) {
toast.error('Delete API not available');
return;
}
const result = await api.setup.deleteApiKey(config.apiKeyProvider);
if (result.success) {
setApiKey('');
setApiKeys({ ...apiKeys, [config.apiKeyProvider]: '' });
setApiKeyVerificationStatus('idle');
setApiKeyVerificationError(null);
setAuthStatus(config.buildClearedAuthStatus(authStatus));
toast.success('API key deleted successfully');
} else {
toast.error(result.error || 'Failed to delete API key');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to delete API key';
toast.error(errorMessage);
} finally {
setIsDeletingApiKey(false);
}
}, [apiKeys, authStatus, config, setApiKeys, setAuthStatus]);
useEffect(() => {
setInstallProgress({
isInstalling,
output: installProgress.output,
});
}, [isInstalling, installProgress, setInstallProgress]);
useEffect(() => {
checkStatus();
}, [checkStatus]);
const copyCommand = (command: string) => {
navigator.clipboard.writeText(command);
toast.success('Command copied to clipboard');
};
const hasApiKey =
!!(apiKeys as ApiKeys)[config.apiKeyProvider] ||
authStatus?.method === 'api_key' ||
authStatus?.method === 'api_key_env';
const isCliVerified = cliVerificationStatus === 'verified';
const isApiKeyVerified = apiKeyVerificationStatus === 'verified';
const isReady = isCliVerified || isApiKeyVerified;
const ProviderIcon = PROVIDER_ICON_COMPONENTS[config.cliType];
const getCliStatusBadge = () => {
if (cliVerificationStatus === 'verified') {
return <StatusBadge status="authenticated" label="Verified" />;
}
if (cliVerificationStatus === 'error') {
return <StatusBadge status="error" label="Error" />;
}
if (isChecking) {
return <StatusBadge status="checking" label="Checking..." />;
}
if (cliStatus?.installed) {
return <StatusBadge status="unverified" label="Unverified" />;
}
return <StatusBadge status="not_installed" label="Not Installed" />;
};
const getApiKeyStatusBadge = () => {
if (apiKeyVerificationStatus === 'verified') {
return <StatusBadge status="authenticated" label="Verified" />;
}
if (apiKeyVerificationStatus === 'error') {
return <StatusBadge status="error" label="Error" />;
}
if (hasApiKey) {
return <StatusBadge status="unverified" label="Unverified" />;
}
return <StatusBadge status="not_authenticated" label="Not Set" />;
};
return (
<div className="space-y-6">
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-xl bg-brand-500/10 flex items-center justify-center mx-auto mb-4">
<ProviderIcon className="w-8 h-8 text-brand-500" />
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">{config.displayName} Setup</h2>
<p className="text-muted-foreground">Configure authentication for code generation</p>
</div>
<Card className="bg-card border-border">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Info className="w-5 h-5" />
Authentication Methods
</CardTitle>
<Button variant="ghost" size="sm" onClick={checkStatus} disabled={isChecking}>
<RefreshCw className={`w-4 h-4 ${isChecking ? 'animate-spin' : ''}`} />
</Button>
</div>
<CardDescription>Choose one of the following methods to authenticate:</CardDescription>
</CardHeader>
<CardContent>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="cli" className="border-border">
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center justify-between w-full pr-4">
<div className="flex items-center gap-3">
<ProviderIcon
className={`w-5 h-5 ${
cliVerificationStatus === 'verified'
? 'text-green-500'
: 'text-muted-foreground'
}`}
/>
<div className="text-left">
<p className="font-medium text-foreground">{config.cliLabel}</p>
<p className="text-sm text-muted-foreground">{config.cliDescription}</p>
</div>
</div>
{getCliStatusBadge()}
</div>
</AccordionTrigger>
<AccordionContent className="pt-4 space-y-4">
{!cliStatus?.installed && (
<div className="space-y-4 p-4 rounded-lg bg-muted/30 border border-border">
<div className="flex items-center gap-2">
<Download className="w-4 h-4 text-muted-foreground" />
<p className="font-medium text-foreground">Install {config.cliLabel}</p>
</div>
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">macOS / Linux</Label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
{config.installCommands.macos}
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand(config.installCommands.macos)}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">Windows</Label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
{config.installCommands.windows}
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand(config.installCommands.windows)}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
{isInstalling && <TerminalOutput lines={installProgress.output} />}
<Button
onClick={install}
disabled={isInstalling}
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
data-testid={config.testIds.installButton}
>
{isInstalling ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Installing...
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
Auto Install
</>
)}
</Button>
</div>
)}
{cliStatus?.installed && cliStatus?.version && (
<p className="text-sm text-muted-foreground">Version: {cliStatus.version}</p>
)}
{cliVerificationStatus === 'verifying' && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
<div>
<p className="font-medium text-foreground">Verifying CLI authentication...</p>
<p className="text-sm text-muted-foreground">Running a test query</p>
</div>
</div>
)}
{cliVerificationStatus === 'verified' && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-foreground">CLI Authentication verified!</p>
<p className="text-sm text-muted-foreground">
Your {config.displayName} CLI is working correctly.
</p>
</div>
</div>
)}
{cliVerificationStatus === 'error' && cliVerificationError && (
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
<XCircle className="w-5 h-5 text-red-500 shrink-0" />
<div className="flex-1 space-y-2">
<p className="font-medium text-foreground">Verification failed</p>
{(() => {
const parts = cliVerificationError.split('\n\nDetails: ');
const mainError = parts[0];
const details = parts[1];
const errorLower = cliVerificationError.toLowerCase();
// Check if this is actually a usage limit issue, not an auth problem
const isUsageLimitIssue =
errorLower.includes('usage limit') ||
errorLower.includes('rate limit') ||
errorLower.includes('limit reached') ||
errorLower.includes('too many requests') ||
errorLower.includes('credit balance') ||
errorLower.includes('billing') ||
errorLower.includes('insufficient credits') ||
errorLower.includes('upgrade to pro');
// Categorize error and provide helpful suggestions
// IMPORTANT: Don't suggest re-authentication for usage limits!
const getHelpfulSuggestion = () => {
// Usage limit issue - NOT an authentication problem
if (isUsageLimitIssue) {
return {
title: 'Usage limit issue (not authentication)',
message:
'Your login credentials are working fine. This is a rate limit or billing error.',
action: 'Wait a few minutes and try again, or check your billing',
};
}
// Token refresh failures
if (
errorLower.includes('tokenrefresh') ||
errorLower.includes('token refresh')
) {
return {
title: 'Token refresh failed',
message: 'Your OAuth token needs to be refreshed.',
action: 'Re-authenticate',
command: config.cliLoginCommand,
};
}
// Connection/transport issues
if (errorLower.includes('transport channel closed')) {
return {
title: 'Connection issue',
message:
'The connection to the authentication server was interrupted.',
action: 'Try again or re-authenticate',
command: config.cliLoginCommand,
};
}
// Invalid API key
if (errorLower.includes('invalid') && errorLower.includes('api key')) {
return {
title: 'Invalid API key',
message: 'Your API key is incorrect or has been revoked.',
action: 'Check your API key or get a new one',
};
}
// Expired token
if (errorLower.includes('expired')) {
return {
title: 'Token expired',
message: 'Your authentication token has expired.',
action: 'Re-authenticate',
command: config.cliLoginCommand,
};
}
// Authentication required
if (errorLower.includes('login') || errorLower.includes('authenticate')) {
return {
title: 'Authentication required',
message: 'You need to authenticate with your account.',
action: 'Run the login command',
command: config.cliLoginCommand,
};
}
return null;
};
const suggestion = getHelpfulSuggestion();
return (
<>
<p className="text-sm text-red-400">{mainError}</p>
{details && (
<div className="mt-2 p-3 rounded bg-black/20 border border-red-500/20">
<p className="text-xs font-medium text-muted-foreground mb-1">
Technical details:
</p>
<pre className="text-xs text-red-300 whitespace-pre-wrap font-mono">
{details}
</pre>
</div>
)}
{suggestion && (
<div className="mt-3 p-3 rounded bg-muted/50 border border-border">
<div className="flex items-start gap-2 mb-2">
<span className="text-sm font-medium text-foreground">
💡 {suggestion.title}
</span>
</div>
<p className="text-sm text-muted-foreground mb-2">
{suggestion.message}
</p>
{suggestion.command && (
<>
<p className="text-xs text-muted-foreground mb-2">
{suggestion.action}:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
{suggestion.command}
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand(suggestion.command)}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</>
)}
{!suggestion.command && (
<p className="text-xs font-medium text-brand-500">
{suggestion.action}
</p>
)}
</div>
)}
</>
);
})()}
</div>
</div>
)}
{cliVerificationStatus !== 'verified' && (
<Button
onClick={verifyCliAuth}
disabled={cliVerificationStatus === 'verifying' || !cliStatus?.installed}
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
data-testid={config.testIds.verifyCliButton}
>
{cliVerificationStatus === 'verifying' ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verifying...
</>
) : cliVerificationStatus === 'error' ? (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Retry Verification
</>
) : (
<>
<ShieldCheck className="w-4 h-4 mr-2" />
Verify CLI Authentication
</>
)}
</Button>
)}
</AccordionContent>
</AccordionItem>
<AccordionItem value="api-key" className="border-border">
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center justify-between w-full pr-4">
<div className="flex items-center gap-3">
<Key
className={`w-5 h-5 ${
apiKeyVerificationStatus === 'verified'
? 'text-green-500'
: 'text-muted-foreground'
}`}
/>
<div className="text-left">
<p className="font-medium text-foreground">{config.apiKeyLabel}</p>
<p className="text-sm text-muted-foreground">{config.apiKeyDescription}</p>
</div>
</div>
{getApiKeyStatusBadge()}
</div>
</AccordionTrigger>
<AccordionContent className="pt-4 space-y-4">
<div className="space-y-4 p-4 rounded-lg bg-muted/30 border border-border">
<div className="space-y-2">
<Label htmlFor={config.testIds.apiKeyInput} className="text-foreground">
{config.apiKeyLabel}
</Label>
<Input
id={config.testIds.apiKeyInput}
type="password"
placeholder={config.apiKeyPlaceholder}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="bg-input border-border text-foreground"
data-testid={config.testIds.apiKeyInput}
/>
<p className="text-xs text-muted-foreground">
{config.apiKeyHelpText}{' '}
<a
href={config.apiKeyDocsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-brand-500 hover:underline"
>
{config.apiKeyDocsLabel}
<ExternalLink className="w-3 h-3 inline ml-1" />
</a>
</p>
</div>
<div className="flex gap-2">
<Button
onClick={() => saveApiKeyToken(apiKey)}
disabled={isSavingApiKey || !apiKey.trim()}
className="flex-1 bg-brand-500 hover:bg-brand-600 text-white"
data-testid={config.testIds.saveApiKeyButton}
>
{isSavingApiKey ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
'Save API Key'
)}
</Button>
{hasApiKey && (
<Button
onClick={deleteApiKey}
disabled={isDeletingApiKey}
variant="outline"
className="border-red-500/50 text-red-500 hover:bg-red-500/10 hover:text-red-400"
data-testid={config.testIds.deleteApiKeyButton}
>
{isDeletingApiKey ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</Button>
)}
</div>
</div>
{apiKeyVerificationStatus === 'verifying' && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
<div>
<p className="font-medium text-foreground">Verifying API key...</p>
<p className="text-sm text-muted-foreground">Running a test query</p>
</div>
</div>
)}
{apiKeyVerificationStatus === 'verified' && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-foreground">API Key verified!</p>
<p className="text-sm text-muted-foreground">
Your API key is working correctly.
</p>
</div>
</div>
)}
{apiKeyVerificationStatus === 'error' && apiKeyVerificationError && (
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
<XCircle className="w-5 h-5 text-red-500 shrink-0" />
<div className="flex-1 space-y-2">
<p className="font-medium text-foreground">Verification failed</p>
{(() => {
const parts = apiKeyVerificationError.split('\n\nDetails: ');
const mainError = parts[0];
const details = parts[1];
return (
<>
<p className="text-sm text-red-400">{mainError}</p>
{details && (
<div className="mt-2 p-3 rounded bg-black/20 border border-red-500/20">
<p className="text-xs font-medium text-muted-foreground mb-1">
Technical details:
</p>
<pre className="text-xs text-red-300 whitespace-pre-wrap font-mono">
{details}
</pre>
</div>
)}
</>
);
})()}
</div>
</div>
)}
{apiKeyVerificationStatus !== 'verified' && (
<Button
onClick={verifyApiKeyAuth}
disabled={apiKeyVerificationStatus === 'verifying' || !hasApiKey}
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
data-testid={config.testIds.verifyApiKeyButton}
>
{apiKeyVerificationStatus === 'verifying' ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verifying...
</>
) : apiKeyVerificationStatus === 'error' ? (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Retry Verification
</>
) : (
<>
<ShieldCheck className="w-4 h-4 mr-2" />
Verify API Key
</>
)}
</Button>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
<div className="flex justify-between pt-4">
<Button variant="ghost" onClick={onBack} className="text-muted-foreground">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<div className="flex gap-2">
<Button variant="ghost" onClick={onSkip} className="text-muted-foreground">
Skip for now
</Button>
<Button
onClick={onNext}
disabled={!isReady}
className="bg-brand-500 hover:bg-brand-600 text-white disabled:opacity-50 disabled:cursor-not-allowed"
data-testid={config.testIds.nextButton}
>
Continue
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
// @ts-nocheck
import { useMemo, useCallback } from 'react';
import { useSetupStore } from '@/store/setup-store';
import { getElectronAPI } from '@/lib/electron';
import { CliSetupStep } from './cli-setup-step';
import type { CodexAuthStatus } from '@/store/setup-store';
interface CodexSetupStepProps {
onNext: () => void;
onBack: () => void;
onSkip: () => void;
}
export function CodexSetupStep({ onNext, onBack, onSkip }: CodexSetupStepProps) {
const {
codexCliStatus,
codexAuthStatus,
setCodexCliStatus,
setCodexAuthStatus,
setCodexInstallProgress,
} = useSetupStore();
const statusApi = useCallback(
() => getElectronAPI().setup?.getCodexStatus() || Promise.reject(),
[]
);
const installApi = useCallback(
() => getElectronAPI().setup?.installCodex() || Promise.reject(),
[]
);
const verifyAuthApi = useCallback(
(method: 'cli' | 'api_key', apiKey?: string) =>
getElectronAPI().setup?.verifyCodexAuth(method, apiKey) || Promise.reject(),
[]
);
const config = useMemo(
() => ({
cliType: 'codex' as const,
displayName: 'Codex',
cliLabel: 'Codex CLI',
cliDescription: 'Use Codex CLI login',
apiKeyLabel: 'OpenAI API Key',
apiKeyDescription: 'Optional API key for Codex',
apiKeyProvider: 'openai' as const,
apiKeyPlaceholder: 'sk-...',
apiKeyDocsUrl: 'https://platform.openai.com/api-keys',
apiKeyDocsLabel: 'Get one from OpenAI',
apiKeyHelpText: "Don't have an API key?",
installCommands: {
macos: 'npm install -g @openai/codex',
windows: 'npm install -g @openai/codex',
},
cliLoginCommand: 'codex login',
testIds: {
installButton: 'install-codex-button',
verifyCliButton: 'verify-codex-cli-button',
verifyApiKeyButton: 'verify-codex-api-key-button',
apiKeyInput: 'openai-api-key-input',
saveApiKeyButton: 'save-openai-key-button',
deleteApiKeyButton: 'delete-openai-key-button',
nextButton: 'codex-next-button',
},
buildCliAuthStatus: (_previous: CodexAuthStatus | null) => ({
authenticated: true,
method: 'cli_authenticated',
hasAuthFile: true,
}),
buildApiKeyAuthStatus: (_previous: CodexAuthStatus | null) => ({
authenticated: true,
method: 'api_key',
hasApiKey: true,
}),
buildClearedAuthStatus: (_previous: CodexAuthStatus | null) => ({
authenticated: false,
method: 'none',
}),
statusApi,
installApi,
verifyAuthApi,
}),
[installApi, statusApi, verifyAuthApi]
);
return (
<CliSetupStep
config={config}
state={{
cliStatus: codexCliStatus,
authStatus: codexAuthStatus,
setCliStatus: setCodexCliStatus,
setAuthStatus: setCodexAuthStatus,
setInstallProgress: setCodexInstallProgress,
getStoreState: () => useSetupStore.getState().codexCliStatus,
}}
onNext={onNext}
onBack={onBack}
onSkip={onSkip}
/>
);
}

View File

@@ -14,11 +14,11 @@ import {
Copy,
RefreshCw,
AlertTriangle,
Terminal,
XCircle,
} from 'lucide-react';
import { toast } from 'sonner';
import { StatusBadge } from '../components';
import { CursorIcon } from '@/components/ui/provider-icon';
const logger = createLogger('CursorSetupStep');
@@ -168,7 +168,7 @@ export function CursorSetupStep({ onNext, onBack, onSkip }: CursorSetupStepProps
<div className="space-y-6">
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-xl bg-cyan-500/10 flex items-center justify-center mx-auto mb-4">
<Terminal className="w-8 h-8 text-cyan-500" />
<CursorIcon className="w-8 h-8 text-cyan-500" />
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">Cursor CLI Setup</h2>
<p className="text-muted-foreground">Optional - Use Cursor as an AI provider</p>
@@ -195,7 +195,7 @@ export function CursorSetupStep({ onNext, onBack, onSkip }: CursorSetupStepProps
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Terminal className="w-5 h-5" />
<CursorIcon className="w-5 h-5" />
Cursor CLI Status
<Badge variant="outline" className="ml-2">
Optional

View File

@@ -4,4 +4,5 @@ export { ThemeStep } from './theme-step';
export { CompleteStep } from './complete-step';
export { ClaudeSetupStep } from './claude-setup-step';
export { CursorSetupStep } from './cursor-setup-step';
export { CodexSetupStep } from './codex-setup-step';
export { GitHubSetupStep } from './github-setup-step';

View File

@@ -11,7 +11,7 @@ interface ThemeStepProps {
}
export function ThemeStep({ onNext, onBack }: ThemeStepProps) {
const { theme, setTheme, setPreviewTheme } = useAppStore();
const { theme, setTheme, setPreviewTheme, currentProject, setProjectTheme } = useAppStore();
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
const handleThemeHover = (themeValue: string) => {
@@ -24,6 +24,11 @@ export function ThemeStep({ onNext, onBack }: ThemeStepProps) {
const handleThemeClick = (themeValue: string) => {
setTheme(themeValue as typeof theme);
// Also update the current project's theme if one exists
// This ensures the selected theme is visible since getEffectiveTheme() prioritizes project theme
if (currentProject) {
setProjectTheme(currentProject.id, themeValue as typeof theme);
}
setPreviewTheme(null);
};

View File

@@ -319,6 +319,9 @@ export function WelcomeView() {
projectPath: projectPath,
});
setShowInitDialog(true);
// Navigate to the board view (dialog shows as overlay)
navigate({ to: '/board' });
} catch (error) {
logger.error('Failed to create project:', error);
toast.error('Failed to create project', {
@@ -418,6 +421,9 @@ export function WelcomeView() {
});
setShowInitDialog(true);
// Navigate to the board view (dialog shows as overlay)
navigate({ to: '/board' });
// Kick off project analysis
analyzeProject(projectPath);
} catch (error) {
@@ -515,6 +521,9 @@ export function WelcomeView() {
});
setShowInitDialog(true);
// Navigate to the board view (dialog shows as overlay)
navigate({ to: '/board' });
// Kick off project analysis
analyzeProject(projectPath);
} catch (error) {