Merge branch 'v0.13.0rc' into feat/react-query

Merged latest changes from v0.13.0rc into feat/react-query while preserving
React Query migration. Key merge decisions:

- Kept React Query hooks for data fetching (useRunningAgents, useStopFeature, etc.)
- Added backlog plan handling to running-agents-view stop functionality
- Imported both SkeletonPulse and Spinner for CLI status components
- Used Spinner for refresh buttons across all settings sections
- Preserved isBacklogPlan check in agent-output-modal TaskProgressPanel
- Added handleOpenInIntegratedTerminal to worktree actions while keeping React Query mutations
This commit is contained in:
Shirone
2026-01-19 13:28:43 +01:00
387 changed files with 28102 additions and 6881 deletions

View File

@@ -11,6 +11,7 @@ import {
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from 'sonner';
import { LogOut, User, Code2, RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { logout } from '@/lib/http-api-client';
import { useAuthStore } from '@/store/auth-store';
@@ -143,7 +144,7 @@ export function AccountSection() {
disabled={isRefreshing || isLoadingEditors}
className="shrink-0 h-9 w-9"
>
<RefreshCw className={cn('w-4 h-4', isRefreshing && 'animate-spin')} />
{isRefreshing ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>

View File

@@ -1,7 +1,8 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { AlertCircle, CheckCircle2, Eye, EyeOff, Loader2, Zap } from 'lucide-react';
import { AlertCircle, CheckCircle2, Eye, EyeOff, Zap } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import type { ProviderConfig } from '@/config/api-providers';
interface ApiKeyFieldProps {
@@ -70,7 +71,7 @@ export function ApiKeyField({ config }: ApiKeyFieldProps) {
>
{testButton.loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Testing...
</>
) : (

View File

@@ -1,7 +1,8 @@
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { Button } from '@/components/ui/button';
import { Key, CheckCircle2, Trash2, Loader2 } from 'lucide-react';
import { Key, CheckCircle2, Trash2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { ApiKeyField } from './api-key-field';
import { buildProviderConfigs } from '@/config/api-providers';
import { SecurityNotice } from './security-notice';
@@ -142,7 +143,7 @@ export function ApiKeysSection() {
data-testid="delete-anthropic-key"
>
{isDeletingAnthropicKey ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
) : (
<Trash2 className="w-4 h-4 mr-2" />
)}
@@ -159,7 +160,7 @@ export function ApiKeysSection() {
data-testid="delete-openai-key"
>
{isDeletingOpenaiKey ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
) : (
<Trash2 className="w-4 h-4 mr-2" />
)}

View File

@@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
import { useSetupStore } from '@/store/setup-store';
import { useClaudeUsage } from '@/hooks/queries';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { RefreshCw, AlertCircle } from 'lucide-react';
const CLAUDE_USAGE_TITLE = 'Claude Usage';
@@ -127,7 +128,7 @@ export function ClaudeUsageSection() {
data-testid="refresh-claude-usage"
title={CLAUDE_REFRESH_LABEL}
>
<RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} />
{isFetching ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">{CLAUDE_USAGE_SUBTITLE}</p>

View File

@@ -1,116 +1,58 @@
import { useState, useRef, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Palette, Moon, Sun, Upload, X, ImageIcon } from 'lucide-react';
import { Palette, Moon, Sun, Type } from 'lucide-react';
import { darkThemes, lightThemes } from '@/config/theme-options';
import {
UI_SANS_FONT_OPTIONS,
UI_MONO_FONT_OPTIONS,
DEFAULT_FONT_VALUE,
} from '@/config/ui-font-options';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { IconPicker } from '@/components/layout/project-switcher/components/icon-picker';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { Theme, Project } from '../shared/types';
import { FontSelector } from '@/components/shared';
import type { Theme } from '../shared/types';
interface AppearanceSectionProps {
effectiveTheme: Theme;
currentProject: Project | null;
onThemeChange: (theme: Theme) => void;
}
export function AppearanceSection({
effectiveTheme,
currentProject,
onThemeChange,
}: AppearanceSectionProps) {
const { setProjectIcon, setProjectName, setProjectCustomIcon } = useAppStore();
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
const [projectName, setProjectNameLocal] = useState(currentProject?.name || '');
const [projectIcon, setProjectIconLocal] = useState<string | null>(currentProject?.icon || null);
const [customIconPath, setCustomIconPathLocal] = useState<string | null>(
currentProject?.customIconPath || null
);
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceSectionProps) {
const { fontFamilySans, fontFamilyMono, setFontSans, setFontMono } = useAppStore();
// Sync local state when currentProject changes
// Determine if current theme is light or dark
const isLightTheme = lightThemes.some((t) => t.value === effectiveTheme);
const [activeTab, setActiveTab] = useState<'dark' | 'light'>(isLightTheme ? 'light' : 'dark');
// Sync active tab when theme changes
useEffect(() => {
setProjectNameLocal(currentProject?.name || '');
setProjectIconLocal(currentProject?.icon || null);
setCustomIconPathLocal(currentProject?.customIconPath || null);
}, [currentProject]);
const currentIsLight = lightThemes.some((t) => t.value === effectiveTheme);
setActiveTab(currentIsLight ? 'light' : 'dark');
}, [effectiveTheme]);
const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes;
// Auto-save when values change
const handleNameChange = (name: string) => {
setProjectNameLocal(name);
if (currentProject && name.trim() && name.trim() !== currentProject.name) {
setProjectName(currentProject.id, name.trim());
}
// Convert null to 'default' for Select component
// Also fallback to default if the stored font is not in the available options
const isValidSansFont = (font: string | null): boolean => {
if (!font) return false;
return UI_SANS_FONT_OPTIONS.some((opt) => opt.value === font);
};
const isValidMonoFont = (font: string | null): boolean => {
if (!font) return false;
return UI_MONO_FONT_OPTIONS.some((opt) => opt.value === font);
};
const fontSansValue =
fontFamilySans && isValidSansFont(fontFamilySans) ? fontFamilySans : DEFAULT_FONT_VALUE;
const fontMonoValue =
fontFamilyMono && isValidMonoFont(fontFamilyMono) ? fontFamilyMono : DEFAULT_FONT_VALUE;
const handleFontSansChange = (value: string) => {
setFontSans(value === DEFAULT_FONT_VALUE ? null : value);
};
const handleIconChange = (icon: string | null) => {
setProjectIconLocal(icon);
if (currentProject) {
setProjectIcon(currentProject.id, icon);
}
};
const handleCustomIconChange = (path: string | null) => {
setCustomIconPathLocal(path);
if (currentProject) {
setProjectCustomIcon(currentProject.id, path);
// Clear Lucide icon when custom icon is set
if (path) {
setProjectIconLocal(null);
setProjectIcon(currentProject.id, null);
}
}
};
const handleCustomIconUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !currentProject) return;
// Validate file type
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
return;
}
// Validate file size (max 2MB for icons)
if (file.size > 2 * 1024 * 1024) {
return;
}
setIsUploadingIcon(true);
try {
// Convert to base64
const reader = new FileReader();
reader.onload = async () => {
const base64Data = reader.result as string;
const result = await getHttpApiClient().saveImageToTemp(
base64Data,
`project-icon-${file.name}`,
file.type,
currentProject.path
);
if (result.success && result.path) {
handleCustomIconChange(result.path);
}
setIsUploadingIcon(false);
};
reader.readAsDataURL(file);
} catch {
setIsUploadingIcon(false);
}
};
const handleRemoveCustomIcon = () => {
handleCustomIconChange(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
const handleFontMonoChange = (value: string) => {
setFontMono(value === DEFAULT_FONT_VALUE ? null : value);
};
return (
@@ -134,94 +76,10 @@ export function AppearanceSection({
</p>
</div>
<div className="p-6 space-y-6">
{/* Project Details Section */}
{currentProject && (
<div className="space-y-4 pb-6 border-b border-border/50">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="project-name-settings">Project Name</Label>
<Input
id="project-name-settings"
value={projectName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Enter project name"
/>
</div>
<div className="space-y-2">
<Label>Project Icon</Label>
<p className="text-xs text-muted-foreground mb-2">
Choose a preset icon or upload a custom image
</p>
{/* Custom Icon Upload */}
<div className="mb-4">
<div className="flex items-center gap-3">
{customIconPath ? (
<div className="relative">
<img
src={getAuthenticatedImageUrl(customIconPath, currentProject.path)}
alt="Custom project icon"
className="w-12 h-12 rounded-lg object-cover border border-border"
/>
<button
type="button"
onClick={handleRemoveCustomIcon}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center hover:bg-destructive/90"
>
<X className="w-3 h-3" />
</button>
</div>
) : (
<div className="w-12 h-12 rounded-lg border border-dashed border-border flex items-center justify-center bg-accent/30">
<ImageIcon className="w-5 h-5 text-muted-foreground" />
</div>
)}
<div className="flex-1">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={handleCustomIconUpload}
className="hidden"
id="custom-icon-upload"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingIcon}
className="gap-1.5"
>
<Upload className="w-3.5 h-3.5" />
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
</Button>
<p className="text-xs text-muted-foreground mt-1">
PNG, JPG, GIF or WebP. Max 2MB.
</p>
</div>
</div>
</div>
{/* Preset Icon Picker - only show if no custom icon */}
{!customIconPath && (
<IconPicker selectedIcon={projectIcon} onSelectIcon={handleIconChange} />
)}
</div>
</div>
</div>
)}
{/* Theme Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">
Theme{' '}
<span className="text-muted-foreground font-normal">
{currentProject ? `(for ${currentProject.name})` : '(Global)'}
</span>
</Label>
<Label className="text-foreground font-medium">Theme</Label>
{/* Dark/Light Tabs */}
<div className="flex gap-1 p-1 rounded-lg bg-accent/30">
<button
@@ -284,6 +142,53 @@ export function AppearanceSection({
})}
</div>
</div>
{/* Fonts Section */}
<div className="space-y-4 pt-6 border-t border-border/50">
<div className="flex items-center gap-2 mb-4">
<Type className="w-4 h-4 text-muted-foreground" />
<Label className="text-foreground font-medium">Fonts</Label>
</div>
<p className="text-xs text-muted-foreground -mt-2 mb-4">
Set default fonts for all projects. Individual projects can override these settings.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* UI Font Selector */}
<div className="space-y-2">
<Label htmlFor="global-ui-font-select" className="text-sm">
UI Font
</Label>
<FontSelector
id="global-ui-font-select"
value={fontSansValue}
options={UI_SANS_FONT_OPTIONS}
placeholder="Default (Geist Sans)"
onChange={handleFontSansChange}
/>
<p className="text-xs text-muted-foreground">
Used for headings, labels, and UI text
</p>
</div>
{/* Code Font Selector */}
<div className="space-y-2">
<Label htmlFor="global-code-font-select" className="text-sm">
Code Font
</Label>
<FontSelector
id="global-code-font-select"
value={fontMonoValue}
options={UI_MONO_FONT_OPTIONS}
placeholder="Default (Geist Mono)"
onChange={handleFontMonoChange}
/>
<p className="text-xs text-muted-foreground">
Used for code blocks and monospaced text
</p>
</div>
</div>
</div>
</div>
</div>
);

View File

@@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { SkeletonPulse } from '@/components/ui/skeleton';
import { Spinner } from '@/components/ui/spinner';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
@@ -169,7 +170,7 @@ export function ClaudeCliStatus({ status, authStatus, isChecking, onRefresh }: C
'transition-all duration-200'
)}
>
<RefreshCw className={cn('w-4 h-4', isChecking && 'animate-spin')} />
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">

View File

@@ -1,5 +1,6 @@
import { Button } from '@/components/ui/button';
import { CheckCircle2, AlertCircle, RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
@@ -56,7 +57,7 @@ export function CliStatusCard({
'transition-all duration-200'
)}
>
<RefreshCw className={cn('w-4 h-4', isChecking && 'animate-spin')} />
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">{description}</p>

View File

@@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { SkeletonPulse } from '@/components/ui/skeleton';
import { Spinner } from '@/components/ui/spinner';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
@@ -162,7 +163,7 @@ export function CodexCliStatus({ status, authStatus, isChecking, onRefresh }: Cl
'transition-all duration-200'
)}
>
<RefreshCw className={cn('w-4 h-4', isChecking && 'animate-spin')} />
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">

View File

@@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { SkeletonPulse } from '@/components/ui/skeleton';
import { Spinner } from '@/components/ui/spinner';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { CursorIcon } from '@/components/ui/provider-icon';
@@ -287,7 +288,7 @@ export function CursorCliStatus({ status, isChecking, onRefresh }: CursorCliStat
'transition-all duration-200'
)}
>
<RefreshCw className={cn('w-4 h-4', isChecking && 'animate-spin')} />
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">

View File

@@ -1,6 +1,7 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { SkeletonPulse } from '@/components/ui/skeleton';
import { Spinner } from '@/components/ui/spinner';
import { CheckCircle2, AlertCircle, RefreshCw, Bot, Cloud } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
@@ -218,7 +219,7 @@ export function OpencodeCliStatus({
'transition-all duration-200'
)}
>
<RefreshCw className={cn('w-4 h-4', isChecking && 'animate-spin')} />
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">

View File

@@ -1,4 +1,5 @@
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { RefreshCw, AlertCircle } from 'lucide-react';
import { OpenAIIcon } from '@/components/ui/provider-icon';
import { cn } from '@/lib/utils';
@@ -124,7 +125,7 @@ export function CodexUsageSection() {
data-testid="refresh-codex-usage"
title={CODEX_REFRESH_LABEL}
>
<RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} />
{isFetching ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">{CODEX_USAGE_SUBTITLE}</p>

View File

@@ -0,0 +1,203 @@
import { useState, useEffect } from 'react';
import { Copy, Check, AlertCircle, Save } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { JsonSyntaxEditor } from '@/components/ui/json-syntax-editor';
import { apiGet, apiPut } from '@/lib/api-fetch';
import { toast } from 'sonner';
import type { GlobalSettings } from '@automaker/types';
interface ImportExportDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface SettingsResponse {
success: boolean;
settings: GlobalSettings;
}
export function ImportExportDialog({ open, onOpenChange }: ImportExportDialogProps) {
const [jsonValue, setJsonValue] = useState('');
const [originalValue, setOriginalValue] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [copied, setCopied] = useState(false);
const [parseError, setParseError] = useState<string | null>(null);
// Load current settings when dialog opens
useEffect(() => {
if (open) {
loadSettings();
}
}, [open]);
const loadSettings = async () => {
setIsLoading(true);
try {
const response = await apiGet<SettingsResponse>('/api/settings/global');
if (response.success) {
const formatted = JSON.stringify(response.settings, null, 2);
setJsonValue(formatted);
setOriginalValue(formatted);
setParseError(null);
}
} catch (error) {
toast.error('Failed to load settings');
console.error('Failed to load settings:', error);
} finally {
setIsLoading(false);
}
};
// Validate JSON on change
const handleJsonChange = (value: string) => {
setJsonValue(value);
try {
JSON.parse(value);
setParseError(null);
} catch {
setParseError('Invalid JSON syntax');
}
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(jsonValue);
setCopied(true);
toast.success('Settings copied to clipboard');
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error('Failed to copy to clipboard');
}
};
const handleSave = async () => {
if (parseError) {
toast.error('Please fix JSON syntax errors before saving');
return;
}
setIsSaving(true);
try {
const settings = JSON.parse(jsonValue);
const response = await apiPut<SettingsResponse>('/api/settings/global', settings);
if (response.success) {
const formatted = JSON.stringify(response.settings, null, 2);
setJsonValue(formatted);
setOriginalValue(formatted);
toast.success('Settings saved successfully', {
description: 'Your changes have been applied. Some settings may require a refresh.',
});
onOpenChange(false);
}
} catch (error) {
toast.error('Failed to save settings');
console.error('Failed to save settings:', error);
} finally {
setIsSaving(false);
}
};
const handleReset = () => {
setJsonValue(originalValue);
setParseError(null);
};
const hasChanges = jsonValue !== originalValue;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Import / Export Settings</DialogTitle>
<DialogDescription>
Copy your settings to transfer to another machine, or paste settings from another
installation.
</DialogDescription>
</DialogHeader>
<div className="flex-1 flex flex-col gap-4 min-h-0 mt-4">
{/* Action Buttons */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCopy}
disabled={isLoading || !!parseError}
className="gap-2"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
{copied ? 'Copied!' : 'Copy'}
</Button>
<Button
variant="outline"
size="sm"
onClick={loadSettings}
disabled={isLoading}
className="gap-2"
>
Refresh
</Button>
</div>
<div className="flex items-center gap-2">
{hasChanges && (
<Button variant="ghost" size="sm" onClick={handleReset} disabled={isSaving}>
Discard
</Button>
)}
<Button
variant="default"
size="sm"
onClick={handleSave}
disabled={isLoading || isSaving || !hasChanges || !!parseError}
className="gap-2"
>
<Save className="w-4 h-4" />
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
{/* Error Message */}
{parseError && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{parseError}</span>
</div>
)}
{/* JSON Editor */}
<div className="flex-1 min-h-0 overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading settings...
</div>
) : (
<JsonSyntaxEditor
value={jsonValue}
onChange={handleJsonChange}
placeholder="Loading settings..."
minHeight="350px"
maxHeight="450px"
data-testid="settings-json-editor"
/>
)}
</div>
{/* Help Text */}
<p className="text-xs text-muted-foreground">
To import settings, paste the JSON content into the editor and click "Save Changes".
</p>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,4 +1,4 @@
import { Settings, PanelLeft, PanelLeftClose } from 'lucide-react';
import { Cog, Menu, X, FileJson } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
@@ -7,13 +7,15 @@ interface SettingsHeaderProps {
description?: string;
showNavigation?: boolean;
onToggleNavigation?: () => void;
onImportExportClick?: () => void;
}
export function SettingsHeader({
title = 'Settings',
title = 'Global Settings',
description = 'Configure your API keys and preferences',
showNavigation,
onToggleNavigation,
onImportExportClick,
}: SettingsHeaderProps) {
return (
<div
@@ -24,38 +26,45 @@ export function SettingsHeader({
)}
>
<div className="px-4 py-4 lg:px-8 lg:py-6">
<div className="flex items-center gap-3 lg:gap-4">
{/* Mobile menu toggle button - only visible on mobile */}
{onToggleNavigation && (
<Button
variant="ghost"
size="sm"
onClick={onToggleNavigation}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground lg:hidden"
aria-label={showNavigation ? 'Close navigation menu' : 'Open navigation menu'}
>
{showNavigation ? (
<PanelLeftClose className="w-5 h-5" />
) : (
<PanelLeft className="w-5 h-5" />
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 lg:gap-4">
<div
className={cn(
'w-10 h-10 lg:w-12 lg:h-12 rounded-xl lg:rounded-2xl flex items-center justify-center',
'bg-gradient-to-br from-brand-500 to-brand-600',
'shadow-lg shadow-brand-500/25',
'ring-1 ring-white/10'
)}
</Button>
)}
<div
className={cn(
'w-10 h-10 lg:w-12 lg:h-12 rounded-xl lg:rounded-2xl flex items-center justify-center',
'bg-gradient-to-br from-brand-500 to-brand-600',
'shadow-lg shadow-brand-500/25',
'ring-1 ring-white/10'
)}
>
<Settings className="w-5 h-5 lg:w-6 lg:h-6 text-white" />
>
<Cog className="w-5 h-5 lg:w-6 lg:h-6 text-white" />
</div>
<div>
<h1 className="text-xl lg:text-2xl font-bold text-foreground tracking-tight">
{title}
</h1>
<p className="text-xs lg:text-sm text-muted-foreground/80 mt-0.5">{description}</p>
</div>
</div>
<div>
<h1 className="text-xl lg:text-2xl font-bold text-foreground tracking-tight">
{title}
</h1>
<p className="text-xs lg:text-sm text-muted-foreground/80 mt-0.5">{description}</p>
<div className="flex items-center gap-2">
{/* Import/Export button */}
{onImportExportClick && (
<Button variant="outline" size="sm" onClick={onImportExportClick} className="gap-2">
<FileJson className="w-4 h-4" />
<span className="hidden sm:inline">Import / Export</span>
</Button>
)}
{/* Mobile menu toggle button - only visible on mobile */}
{onToggleNavigation && (
<Button
variant="ghost"
size="sm"
onClick={onToggleNavigation}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground lg:hidden"
aria-label={showNavigation ? 'Close navigation menu' : 'Open navigation menu'}
>
{showNavigation ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</Button>
)}
</div>
</div>
</div>

View File

@@ -4,11 +4,21 @@ import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import type { Project } from '@/lib/electron';
import type { NavigationItem, NavigationGroup } from '../config/navigation';
import { GLOBAL_NAV_GROUPS, PROJECT_NAV_ITEMS } from '../config/navigation';
import { GLOBAL_NAV_GROUPS } from '../config/navigation';
import type { SettingsViewId } from '../hooks/use-settings-view';
import { useAppStore } from '@/store/app-store';
import type { ModelProvider } from '@automaker/types';
const PROVIDERS_DROPDOWN_KEY = 'settings-providers-dropdown-open';
// Map navigation item IDs to provider types for checking disabled state
const NAV_ID_TO_PROVIDER: Record<string, ModelProvider> = {
'claude-provider': 'claude',
'cursor-provider': 'cursor',
'codex-provider': 'codex',
'opencode-provider': 'opencode',
};
interface SettingsNavigationProps {
navItems: NavigationItem[];
activeSection: SettingsViewId;
@@ -73,6 +83,8 @@ function NavItemWithSubItems({
activeSection: SettingsViewId;
onNavigate: (sectionId: SettingsViewId) => void;
}) {
const disabledProviders = useAppStore((state) => state.disabledProviders);
const [isOpen, setIsOpen] = useState(() => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(PROVIDERS_DROPDOWN_KEY);
@@ -123,6 +135,9 @@ function NavItemWithSubItems({
{item.subItems.map((subItem) => {
const SubIcon = subItem.icon;
const isSubActive = subItem.id === activeSection;
// Check if this provider is disabled
const provider = NAV_ID_TO_PROVIDER[subItem.id];
const isDisabled = provider && disabledProviders.includes(provider);
return (
<button
key={subItem.id}
@@ -141,7 +156,9 @@ function NavItemWithSubItems({
'hover:bg-accent/50',
'border border-transparent hover:border-border/40',
],
'hover:scale-[1.01] active:scale-[0.98]'
'hover:scale-[1.01] active:scale-[0.98]',
// Gray out disabled providers
isDisabled && !isSubActive && 'opacity-40'
)}
>
{/* Active indicator bar */}
@@ -153,7 +170,9 @@ function NavItemWithSubItems({
'w-4 h-4 shrink-0 transition-all duration-200',
isSubActive
? 'text-brand-500'
: 'group-hover:text-brand-400 group-hover:scale-110'
: 'group-hover:text-brand-400 group-hover:scale-110',
// Gray out icon for disabled providers
isDisabled && !isSubActive && 'opacity-60'
)}
/>
<span className="truncate">{subItem.label}</span>
@@ -191,15 +210,15 @@ export function SettingsNavigation({
{/* Navigation sidebar */}
<nav
className={cn(
// Mobile: fixed position overlay with slide transition
'fixed inset-y-0 left-0 w-72 z-30',
// Mobile: fixed position overlay with slide transition from right
'fixed inset-y-0 right-0 w-72 z-30',
'transition-transform duration-200 ease-out',
// Hide on mobile when closed, show when open
isOpen ? 'translate-x-0' : '-translate-x-full',
isOpen ? 'translate-x-0' : 'translate-x-full',
// Desktop: relative position in layout, always visible
'lg:relative lg:w-64 lg:z-auto lg:translate-x-0',
'shrink-0 overflow-y-auto',
'border-r border-border/50',
'border-l border-border/50 lg:border-l-0 lg:border-r',
'bg-gradient-to-b from-card/95 via-card/90 to-card/85 backdrop-blur-xl',
// Desktop background
'lg:from-card/80 lg:via-card/60 lg:to-card/40'
@@ -253,31 +272,6 @@ export function SettingsNavigation({
</div>
</div>
))}
{/* Project Settings - only show when a project is selected */}
{currentProject && (
<>
{/* Divider */}
<div className="my-3 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

@@ -8,14 +8,14 @@ import {
Settings2,
Volume2,
FlaskConical,
Trash2,
Workflow,
Plug,
MessageSquareText,
User,
Shield,
Cpu,
GitBranch,
Code2,
Webhook,
} from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
import type { SettingsViewId } from '../hooks/use-settings-view';
@@ -63,6 +63,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
{ id: 'terminal', label: 'Terminal', icon: SquareTerminal },
{ id: 'keyboard', label: 'Keyboard Shortcuts', icon: Settings2 },
{ id: 'audio', label: 'Audio', icon: Volume2 },
{ id: 'event-hooks', label: 'Event Hooks', icon: Webhook },
],
},
{
@@ -72,15 +73,14 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
{ id: 'security', label: 'Security', icon: Shield },
],
},
{
label: 'Advanced',
items: [{ id: 'developer', label: 'Developer', icon: Code2 }],
},
];
// Flat list of all global nav items for backwards compatibility
export const GLOBAL_NAV_ITEMS: NavigationItem[] = GLOBAL_NAV_GROUPS.flatMap((group) => group.items);
// 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];
export const NAV_ITEMS: NavigationItem[] = GLOBAL_NAV_ITEMS;

View File

@@ -0,0 +1,91 @@
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Code2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore, type ServerLogLevel } from '@/store/app-store';
import { toast } from 'sonner';
const LOG_LEVEL_OPTIONS: { value: ServerLogLevel; label: string; description: string }[] = [
{ value: 'error', label: 'Error', description: 'Only show error messages' },
{ value: 'warn', label: 'Warning', description: 'Show warnings and errors' },
{ value: 'info', label: 'Info', description: 'Show general information (default)' },
{ value: 'debug', label: 'Debug', description: 'Show all messages including debug' },
];
export function DeveloperSection() {
const { serverLogLevel, setServerLogLevel, enableRequestLogging, setEnableRequestLogging } =
useAppStore();
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-purple-500/20 to-purple-600/10 flex items-center justify-center border border-purple-500/20">
<Code2 className="w-5 h-5 text-purple-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Developer</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Advanced settings for debugging and development.
</p>
</div>
<div className="p-6 space-y-6">
{/* Server Log Level */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Server Log Level</Label>
<p className="text-xs text-muted-foreground">
Control the verbosity of API server logs. Set to "Error" to only see error messages in
the server console.
</p>
<select
value={serverLogLevel}
onChange={(e) => {
setServerLogLevel(e.target.value as ServerLogLevel);
toast.success(`Log level changed to ${e.target.value}`, {
description: 'Server logging verbosity updated',
});
}}
className={cn(
'w-full px-3 py-2 rounded-lg',
'bg-accent/30 border border-border/50',
'text-foreground text-sm',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30'
)}
>
{LOG_LEVEL_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label} - {option.description}
</option>
))}
</select>
</div>
{/* HTTP Request Logging */}
<div className="flex items-center justify-between pt-4 border-t border-border/30">
<div className="space-y-1">
<Label className="text-foreground font-medium">HTTP Request Logging</Label>
<p className="text-xs text-muted-foreground">
Log all HTTP requests (method, URL, status) to the server console.
</p>
</div>
<Switch
checked={enableRequestLogging}
onCheckedChange={(checked) => {
setEnableRequestLogging(checked);
toast.success(checked ? 'Request logging enabled' : 'Request logging disabled', {
description: 'HTTP request logging updated',
});
}}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,346 @@
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import {
History,
RefreshCw,
Trash2,
Play,
ChevronDown,
ChevronRight,
CheckCircle,
XCircle,
Clock,
AlertCircle,
} from 'lucide-react';
import { useAppStore } from '@/store/app-store';
import type { StoredEventSummary, StoredEvent, EventHookTrigger } from '@automaker/types';
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
import { getHttpApiClient } from '@/lib/http-api-client';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
export function EventHistoryView() {
const currentProject = useAppStore((state) => state.currentProject);
const projectPath = currentProject?.path;
const [events, setEvents] = useState<StoredEventSummary[]>([]);
const [loading, setLoading] = useState(false);
const [expandedEvent, setExpandedEvent] = useState<string | null>(null);
const [expandedEventData, setExpandedEventData] = useState<StoredEvent | null>(null);
const [replayingEvent, setReplayingEvent] = useState<string | null>(null);
const [clearDialogOpen, setClearDialogOpen] = useState(false);
const loadEvents = useCallback(async () => {
if (!projectPath) return;
setLoading(true);
try {
const api = getHttpApiClient();
const result = await api.eventHistory.list(projectPath, { limit: 100 });
if (result.success && result.events) {
setEvents(result.events);
}
} catch (error) {
console.error('Failed to load events:', error);
} finally {
setLoading(false);
}
}, [projectPath]);
useEffect(() => {
loadEvents();
}, [loadEvents]);
const handleExpand = async (eventId: string) => {
if (expandedEvent === eventId) {
setExpandedEvent(null);
setExpandedEventData(null);
return;
}
if (!projectPath) return;
setExpandedEvent(eventId);
try {
const api = getHttpApiClient();
const result = await api.eventHistory.get(projectPath, eventId);
if (result.success && result.event) {
setExpandedEventData(result.event);
}
} catch (error) {
console.error('Failed to load event details:', error);
}
};
const handleReplay = async (eventId: string) => {
if (!projectPath) return;
setReplayingEvent(eventId);
try {
const api = getHttpApiClient();
const result = await api.eventHistory.replay(projectPath, eventId);
if (result.success && result.result) {
const { hooksTriggered, hookResults } = result.result;
const successCount = hookResults.filter((r) => r.success).length;
const failCount = hookResults.filter((r) => !r.success).length;
if (hooksTriggered === 0) {
alert('No matching hooks found for this event trigger.');
} else if (failCount === 0) {
alert(`Successfully ran ${successCount} hook(s).`);
} else {
alert(`Ran ${hooksTriggered} hook(s): ${successCount} succeeded, ${failCount} failed.`);
}
}
} catch (error) {
console.error('Failed to replay event:', error);
alert('Failed to replay event. Check console for details.');
} finally {
setReplayingEvent(null);
}
};
const handleDelete = async (eventId: string) => {
if (!projectPath) return;
try {
const api = getHttpApiClient();
const result = await api.eventHistory.delete(projectPath, eventId);
if (result.success) {
setEvents((prev) => prev.filter((e) => e.id !== eventId));
if (expandedEvent === eventId) {
setExpandedEvent(null);
setExpandedEventData(null);
}
}
} catch (error) {
console.error('Failed to delete event:', error);
}
};
const handleClearAll = async () => {
if (!projectPath) return;
try {
const api = getHttpApiClient();
const result = await api.eventHistory.clear(projectPath);
if (result.success) {
setEvents([]);
setExpandedEvent(null);
setExpandedEventData(null);
}
} catch (error) {
console.error('Failed to clear events:', error);
}
setClearDialogOpen(false);
};
const getTriggerIcon = (trigger: EventHookTrigger) => {
switch (trigger) {
case 'feature_created':
return <Clock className="w-4 h-4 text-blue-500" />;
case 'feature_success':
return <CheckCircle className="w-4 h-4 text-green-500" />;
case 'feature_error':
return <XCircle className="w-4 h-4 text-red-500" />;
case 'auto_mode_complete':
return <CheckCircle className="w-4 h-4 text-purple-500" />;
case 'auto_mode_error':
return <AlertCircle className="w-4 h-4 text-orange-500" />;
default:
return <History className="w-4 h-4 text-muted-foreground" />;
}
};
const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
};
if (!projectPath) {
return (
<div className="text-center py-8 text-muted-foreground">
<History className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">Select a project to view event history</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Header with actions */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{events.length} event{events.length !== 1 ? 's' : ''} recorded
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={loadEvents} disabled={loading}>
{loading ? (
<Spinner size="sm" className="mr-2" />
) : (
<RefreshCw className="w-4 h-4 mr-2" />
)}
Refresh
</Button>
{events.length > 0 && (
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setClearDialogOpen(true)}
>
<Trash2 className="w-4 h-4 mr-2" />
Clear All
</Button>
)}
</div>
</div>
{/* Events list */}
{events.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<History className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">No events recorded yet</p>
<p className="text-xs mt-1">
Events will appear here when features are created or completed
</p>
</div>
) : (
<div className="space-y-2">
{events.map((event) => (
<div
key={event.id}
className={cn(
'rounded-lg border bg-background/50',
expandedEvent === event.id && 'ring-1 ring-brand-500/30'
)}
>
{/* Event header */}
<div
className="flex items-center gap-3 p-3 cursor-pointer hover:bg-muted/30 transition-colors"
onClick={() => handleExpand(event.id)}
>
<button className="p-0.5">
{expandedEvent === event.id ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
</button>
{getTriggerIcon(event.trigger)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{EVENT_HOOK_TRIGGER_LABELS[event.trigger]}
</p>
{event.featureName && (
<p className="text-xs text-muted-foreground truncate">{event.featureName}</p>
)}
</div>
<span className="text-xs text-muted-foreground">
{formatTimestamp(event.timestamp)}
</span>
{/* Actions */}
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleReplay(event.id)}
disabled={replayingEvent === event.id}
title="Replay event (trigger matching hooks)"
>
<Play
className={cn('w-3.5 h-3.5', replayingEvent === event.id && 'animate-pulse')}
/>
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(event.id)}
title="Delete event"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
{/* Expanded details */}
{expandedEvent === event.id && expandedEventData && (
<div className="px-4 pb-4 pt-0 border-t border-border/50">
<div className="mt-3 space-y-2 text-xs">
<div className="grid grid-cols-2 gap-2">
<div>
<span className="text-muted-foreground">Event ID:</span>
<p className="font-mono text-[10px] truncate">{expandedEventData.id}</p>
</div>
<div>
<span className="text-muted-foreground">Timestamp:</span>
<p>{new Date(expandedEventData.timestamp).toLocaleString()}</p>
</div>
{expandedEventData.featureId && (
<div>
<span className="text-muted-foreground">Feature ID:</span>
<p className="font-mono text-[10px] truncate">
{expandedEventData.featureId}
</p>
</div>
)}
{expandedEventData.passes !== undefined && (
<div>
<span className="text-muted-foreground">Passed:</span>
<p>{expandedEventData.passes ? 'Yes' : 'No'}</p>
</div>
)}
</div>
{expandedEventData.error && (
<div>
<span className="text-muted-foreground">Error:</span>
<p className="text-red-400 mt-1 p-2 bg-red-500/10 rounded text-[10px] font-mono whitespace-pre-wrap">
{expandedEventData.error}
</p>
</div>
)}
<div>
<span className="text-muted-foreground">Project:</span>
<p className="font-mono text-[10px] truncate">
{expandedEventData.projectPath}
</p>
</div>
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Clear confirmation dialog */}
<ConfirmDialog
open={clearDialogOpen}
onOpenChange={setClearDialogOpen}
onConfirm={handleClearAll}
title="Clear Event History"
description={`This will permanently delete all ${events.length} recorded events. This action cannot be undone.`}
icon={Trash2}
iconClassName="text-destructive"
confirmText="Clear All"
confirmVariant="destructive"
/>
</div>
);
}

View File

@@ -0,0 +1,291 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Terminal, Globe } from 'lucide-react';
import type {
EventHook,
EventHookTrigger,
EventHookHttpMethod,
EventHookShellAction,
EventHookHttpAction,
} from '@automaker/types';
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
import { generateUUID } from '@/lib/utils';
interface EventHookDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
editingHook: EventHook | null;
onSave: (hook: EventHook) => void;
}
type ActionType = 'shell' | 'http';
const TRIGGER_OPTIONS: EventHookTrigger[] = [
'feature_created',
'feature_success',
'feature_error',
'auto_mode_complete',
'auto_mode_error',
];
const HTTP_METHODS: EventHookHttpMethod[] = ['POST', 'GET', 'PUT', 'PATCH'];
export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: EventHookDialogProps) {
// Form state
const [name, setName] = useState('');
const [trigger, setTrigger] = useState<EventHookTrigger>('feature_success');
const [actionType, setActionType] = useState<ActionType>('shell');
// Shell action state
const [command, setCommand] = useState('');
const [timeout, setTimeout] = useState('30000');
// HTTP action state
const [url, setUrl] = useState('');
const [method, setMethod] = useState<EventHookHttpMethod>('POST');
const [headers, setHeaders] = useState('');
const [body, setBody] = useState('');
// Reset form when dialog opens/closes or editingHook changes
useEffect(() => {
if (open) {
if (editingHook) {
// Populate form with existing hook data
setName(editingHook.name || '');
setTrigger(editingHook.trigger);
setActionType(editingHook.action.type);
if (editingHook.action.type === 'shell') {
const shellAction = editingHook.action as EventHookShellAction;
setCommand(shellAction.command);
setTimeout(String(shellAction.timeout || 30000));
// Reset HTTP fields
setUrl('');
setMethod('POST');
setHeaders('');
setBody('');
} else {
const httpAction = editingHook.action as EventHookHttpAction;
setUrl(httpAction.url);
setMethod(httpAction.method);
setHeaders(httpAction.headers ? JSON.stringify(httpAction.headers, null, 2) : '');
setBody(httpAction.body || '');
// Reset shell fields
setCommand('');
setTimeout('30000');
}
} else {
// Reset to defaults for new hook
setName('');
setTrigger('feature_success');
setActionType('shell');
setCommand('');
setTimeout('30000');
setUrl('');
setMethod('POST');
setHeaders('');
setBody('');
}
}
}, [open, editingHook]);
const handleSave = () => {
const hook: EventHook = {
id: editingHook?.id || generateUUID(),
name: name.trim() || undefined,
trigger,
enabled: editingHook?.enabled ?? true,
action:
actionType === 'shell'
? {
type: 'shell',
command,
timeout: parseInt(timeout, 10) || 30000,
}
: {
type: 'http',
url,
method,
headers: headers.trim() ? JSON.parse(headers) : undefined,
body: body.trim() || undefined,
},
};
onSave(hook);
};
const isValid = actionType === 'shell' ? command.trim().length > 0 : url.trim().length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingHook ? 'Edit Event Hook' : 'Add Event Hook'}</DialogTitle>
<DialogDescription>
Configure an action to run when a specific event occurs.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Name (optional) */}
<div className="space-y-2">
<Label htmlFor="hook-name">Name (optional)</Label>
<Input
id="hook-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My notification hook"
/>
</div>
{/* Trigger selection */}
<div className="space-y-2">
<Label htmlFor="hook-trigger">Trigger Event</Label>
<Select value={trigger} onValueChange={(v) => setTrigger(v as EventHookTrigger)}>
<SelectTrigger id="hook-trigger">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRIGGER_OPTIONS.map((t) => (
<SelectItem key={t} value={t}>
{EVENT_HOOK_TRIGGER_LABELS[t]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Action type tabs */}
<div className="space-y-2">
<Label>Action Type</Label>
<Tabs value={actionType} onValueChange={(v) => setActionType(v as ActionType)}>
<TabsList className="w-full">
<TabsTrigger value="shell" className="flex-1 gap-2">
<Terminal className="w-4 h-4" />
Shell Command
</TabsTrigger>
<TabsTrigger value="http" className="flex-1 gap-2">
<Globe className="w-4 h-4" />
HTTP Request
</TabsTrigger>
</TabsList>
{/* Shell command form */}
<TabsContent value="shell" className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="shell-command">Command</Label>
<Textarea
id="shell-command"
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder='echo "Feature {{featureId}} completed!"'
className="font-mono text-sm"
rows={3}
/>
<p className="text-xs text-muted-foreground">
Use {'{{variable}}'} syntax for dynamic values
</p>
</div>
<div className="space-y-2">
<Label htmlFor="shell-timeout">Timeout (ms)</Label>
<Input
id="shell-timeout"
type="number"
value={timeout}
onChange={(e) => setTimeout(e.target.value)}
placeholder="30000"
/>
</div>
</TabsContent>
{/* HTTP request form */}
<TabsContent value="http" className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="http-url">URL</Label>
<Input
id="http-url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://api.example.com/webhook"
/>
</div>
<div className="space-y-2">
<Label htmlFor="http-method">Method</Label>
<Select value={method} onValueChange={(v) => setMethod(v as EventHookHttpMethod)}>
<SelectTrigger id="http-method">
<SelectValue />
</SelectTrigger>
<SelectContent>
{HTTP_METHODS.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="http-headers">Headers (JSON, optional)</Label>
<Textarea
id="http-headers"
value={headers}
onChange={(e) => setHeaders(e.target.value)}
placeholder={'{\n "Authorization": "Bearer {{token}}"\n}'}
className="font-mono text-sm"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="http-body">Body (JSON, optional)</Label>
<Textarea
id="http-body"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={'{\n "feature": "{{featureId}}",\n "status": "{{eventType}}"\n}'}
className="font-mono text-sm"
rows={4}
/>
<p className="text-xs text-muted-foreground">
Leave empty for default body with all event context
</p>
</div>
</TabsContent>
</Tabs>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!isValid}>
{editingHook ? 'Save Changes' : 'Add Hook'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,232 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import { Webhook, Plus, Trash2, Pencil, Terminal, Globe, History } from 'lucide-react';
import { useAppStore } from '@/store/app-store';
import type { EventHook, EventHookTrigger } from '@automaker/types';
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
import { EventHookDialog } from './event-hook-dialog';
import { EventHistoryView } from './event-history-view';
export function EventHooksSection() {
const { eventHooks, setEventHooks } = useAppStore();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingHook, setEditingHook] = useState<EventHook | null>(null);
const [activeTab, setActiveTab] = useState<'hooks' | 'history'>('hooks');
const handleAddHook = () => {
setEditingHook(null);
setDialogOpen(true);
};
const handleEditHook = (hook: EventHook) => {
setEditingHook(hook);
setDialogOpen(true);
};
const handleDeleteHook = (hookId: string) => {
setEventHooks(eventHooks.filter((h) => h.id !== hookId));
};
const handleToggleHook = (hookId: string, enabled: boolean) => {
setEventHooks(eventHooks.map((h) => (h.id === hookId ? { ...h, enabled } : h)));
};
const handleSaveHook = (hook: EventHook) => {
if (editingHook) {
// Update existing
setEventHooks(eventHooks.map((h) => (h.id === hook.id ? hook : h)));
} else {
// Add new
setEventHooks([...eventHooks, hook]);
}
setDialogOpen(false);
setEditingHook(null);
};
// Group hooks by trigger type for better organization
const hooksByTrigger = eventHooks.reduce(
(acc, hook) => {
if (!acc[hook.trigger]) {
acc[hook.trigger] = [];
}
acc[hook.trigger].push(hook);
return acc;
},
{} as Record<EventHookTrigger, EventHook[]>
);
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'
)}
>
{/* Header */}
<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">
<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">
<Webhook className="w-5 h-5 text-brand-500" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Event Hooks</h2>
<p className="text-sm text-muted-foreground/80">
Run custom commands or webhooks when events occur
</p>
</div>
</div>
{activeTab === 'hooks' && (
<Button onClick={handleAddHook} size="sm" className="gap-2">
<Plus className="w-4 h-4" />
Add Hook
</Button>
)}
</div>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'hooks' | 'history')}>
<div className="px-6 pt-4">
<TabsList className="grid w-full max-w-xs grid-cols-2">
<TabsTrigger value="hooks" className="gap-2">
<Webhook className="w-4 h-4" />
Hooks
</TabsTrigger>
<TabsTrigger value="history" className="gap-2">
<History className="w-4 h-4" />
History
</TabsTrigger>
</TabsList>
</div>
{/* Hooks Tab */}
<TabsContent value="hooks" className="m-0">
<div className="p-6 pt-4">
{eventHooks.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Webhook className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">No event hooks configured</p>
<p className="text-xs mt-1">
Add hooks to run commands or send webhooks when features complete
</p>
</div>
) : (
<div className="space-y-6">
{/* Group by trigger type */}
{Object.entries(hooksByTrigger).map(([trigger, hooks]) => (
<div key={trigger} className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground">
{EVENT_HOOK_TRIGGER_LABELS[trigger as EventHookTrigger]}
</h3>
<div className="space-y-2">
{hooks.map((hook) => (
<HookCard
key={hook.id}
hook={hook}
onEdit={() => handleEditHook(hook)}
onDelete={() => handleDeleteHook(hook.id)}
onToggle={(enabled) => handleToggleHook(hook.id, enabled)}
/>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* Variable reference */}
<div className="px-6 pb-6">
<div className="rounded-lg bg-muted/30 p-4 text-xs text-muted-foreground">
<p className="font-medium mb-2">Available variables:</p>
<code className="text-[10px] leading-relaxed">
{'{{featureId}}'} {'{{featureName}}'} {'{{projectPath}}'} {'{{projectName}}'}{' '}
{'{{error}}'} {'{{timestamp}}'} {'{{eventType}}'}
</code>
</div>
</div>
</TabsContent>
{/* History Tab */}
<TabsContent value="history" className="m-0">
<div className="p-6 pt-4">
<EventHistoryView />
</div>
</TabsContent>
</Tabs>
{/* Dialog */}
<EventHookDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
editingHook={editingHook}
onSave={handleSaveHook}
/>
</div>
);
}
interface HookCardProps {
hook: EventHook;
onEdit: () => void;
onDelete: () => void;
onToggle: (enabled: boolean) => void;
}
function HookCard({ hook, onEdit, onDelete, onToggle }: HookCardProps) {
const isShell = hook.action.type === 'shell';
return (
<div
className={cn(
'flex items-center gap-3 p-3 rounded-lg border',
'bg-background/50 hover:bg-background/80 transition-colors',
!hook.enabled && 'opacity-60'
)}
>
{/* Type icon */}
<div
className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center',
isShell ? 'bg-amber-500/10 text-amber-500' : 'bg-blue-500/10 text-blue-500'
)}
>
{isShell ? <Terminal className="w-4 h-4" /> : <Globe className="w-4 h-4" />}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{hook.name || (isShell ? 'Shell Command' : 'HTTP Webhook')}
</p>
<p className="text-xs text-muted-foreground truncate">
{isShell
? (hook.action as { type: 'shell'; command: string }).command
: (hook.action as { type: 'http'; url: string }).url}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<Switch checked={hook.enabled} onCheckedChange={onToggle} />
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onEdit}>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={onDelete}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export { EventHooksSection } from './event-hooks-section';

View File

@@ -15,10 +15,12 @@ export type SettingsViewId =
| 'terminal'
| 'keyboard'
| 'audio'
| 'event-hooks'
| 'defaults'
| 'worktrees'
| 'account'
| 'security'
| 'developer'
| 'danger';
interface UseSettingsViewOptions {

View File

@@ -1,4 +1,5 @@
import { ChevronDown, ChevronRight, Code, Pencil, Trash2, PlayCircle, Loader2 } from 'lucide-react';
import { ChevronDown, ChevronRight, Code, Pencil, Trash2, PlayCircle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
@@ -111,7 +112,7 @@ export function MCPServerCard({
className="h-8 px-2"
>
{testState?.status === 'testing' ? (
<Loader2 className="w-4 h-4 animate-spin" />
<Spinner size="sm" />
) : (
<PlayCircle className="w-4 h-4" />
)}

View File

@@ -1,5 +1,6 @@
import { Plug, RefreshCw, Download, Code, FileJson, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
interface MCPServerHeaderProps {
@@ -43,7 +44,7 @@ export function MCPServerHeader({
disabled={isRefreshing}
data-testid="refresh-mcp-servers-button"
>
<RefreshCw className={cn('w-4 h-4', isRefreshing && 'animate-spin')} />
{isRefreshing ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
{hasServers && (
<>

View File

@@ -1,4 +1,5 @@
import { Terminal, Globe, Loader2, CheckCircle2, XCircle } from 'lucide-react';
import { Terminal, Globe, CheckCircle2, XCircle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import type { ServerType, ServerTestState } from './types';
import { SENSITIVE_PARAM_PATTERNS } from './constants';
@@ -40,7 +41,7 @@ export function getServerIcon(type: ServerType = 'stdio') {
export function getTestStatusIcon(status: ServerTestState['status']) {
switch (status) {
case 'testing':
return <Loader2 className="w-4 h-4 animate-spin text-brand-500" />;
return <Spinner size="sm" />;
case 'success':
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
case 'error':

View File

@@ -166,8 +166,10 @@ export function PhaseModelSelector({
codexModelsLoading,
fetchCodexModels,
dynamicOpencodeModels,
enabledDynamicModelIds,
opencodeModelsLoading,
fetchOpencodeModels,
disabledProviders,
} = useAppStore();
// Detect mobile devices to use inline expansion instead of nested popovers
@@ -277,9 +279,9 @@ export function PhaseModelSelector({
}, [codexModels]);
// Filter Cursor models to only show enabled ones
// With canonical IDs, both CURSOR_MODELS and enabledCursorModels use prefixed format
const availableCursorModels = CURSOR_MODELS.filter((model) => {
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
return enabledCursorModels.includes(cursorId);
return enabledCursorModels.includes(model.id as CursorModelId);
});
// Helper to find current selected model details
@@ -298,9 +300,8 @@ export function PhaseModelSelector({
};
}
const cursorModel = availableCursorModels.find(
(m) => stripProviderPrefix(m.id) === selectedModel
);
// With canonical IDs, direct comparison works
const cursorModel = availableCursorModels.find((m) => m.id === selectedModel);
if (cursorModel) return { ...cursorModel, icon: CursorIcon };
// Check if selectedModel is part of a grouped model
@@ -352,7 +353,7 @@ export function PhaseModelSelector({
const seenGroups = new Set<string>();
availableCursorModels.forEach((model) => {
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
const cursorId = model.id as CursorModelId;
// Check if this model is standalone
if (STANDALONE_CURSOR_MODELS.includes(cursorId)) {
@@ -384,13 +385,16 @@ export function PhaseModelSelector({
const staticModels = [...OPENCODE_MODELS];
// Add dynamic models (convert ModelDefinition to ModelOption)
const dynamicModelOptions: ModelOption[] = dynamicOpencodeModels.map((model) => ({
id: model.id,
label: model.name,
description: model.description,
badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Free' : undefined,
provider: 'opencode' as const,
}));
// Only include dynamic models that are enabled by the user
const dynamicModelOptions: ModelOption[] = dynamicOpencodeModels
.filter((model) => enabledDynamicModelIds.includes(model.id))
.map((model) => ({
id: model.id,
label: model.name,
description: model.description,
badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Free' : undefined,
provider: 'opencode' as const,
}));
// Merge, avoiding duplicates (static models take precedence for same ID)
// In practice, static and dynamic IDs don't overlap
@@ -398,9 +402,9 @@ export function PhaseModelSelector({
const uniqueDynamic = dynamicModelOptions.filter((m) => !staticIds.has(m.id));
return [...staticModels, ...uniqueDynamic];
}, [dynamicOpencodeModels]);
}, [dynamicOpencodeModels, enabledDynamicModelIds]);
// Group models
// Group models (filtering out disabled providers)
const { favorites, claude, cursor, codex, opencode } = useMemo(() => {
const favs: typeof CLAUDE_MODELS = [];
const cModels: typeof CLAUDE_MODELS = [];
@@ -408,41 +412,54 @@ export function PhaseModelSelector({
const codModels: typeof transformedCodexModels = [];
const ocModels: ModelOption[] = [];
// Process Claude Models
CLAUDE_MODELS.forEach((model) => {
if (favoriteModels.includes(model.id)) {
favs.push(model);
} else {
cModels.push(model);
}
});
const isClaudeDisabled = disabledProviders.includes('claude');
const isCursorDisabled = disabledProviders.includes('cursor');
const isCodexDisabled = disabledProviders.includes('codex');
const isOpencodeDisabled = disabledProviders.includes('opencode');
// Process Cursor Models
availableCursorModels.forEach((model) => {
if (favoriteModels.includes(model.id)) {
favs.push(model);
} else {
curModels.push(model);
}
});
// Process Claude Models (skip if provider is disabled)
if (!isClaudeDisabled) {
CLAUDE_MODELS.forEach((model) => {
if (favoriteModels.includes(model.id)) {
favs.push(model);
} else {
cModels.push(model);
}
});
}
// Process Codex Models
transformedCodexModels.forEach((model) => {
if (favoriteModels.includes(model.id)) {
favs.push(model);
} else {
codModels.push(model);
}
});
// Process Cursor Models (skip if provider is disabled)
if (!isCursorDisabled) {
availableCursorModels.forEach((model) => {
if (favoriteModels.includes(model.id)) {
favs.push(model);
} else {
curModels.push(model);
}
});
}
// Process OpenCode Models (including dynamic)
allOpencodeModels.forEach((model) => {
if (favoriteModels.includes(model.id)) {
favs.push(model);
} else {
ocModels.push(model);
}
});
// Process Codex Models (skip if provider is disabled)
if (!isCodexDisabled) {
transformedCodexModels.forEach((model) => {
if (favoriteModels.includes(model.id)) {
favs.push(model);
} else {
codModels.push(model);
}
});
}
// Process OpenCode Models (skip if provider is disabled)
if (!isOpencodeDisabled) {
allOpencodeModels.forEach((model) => {
if (favoriteModels.includes(model.id)) {
favs.push(model);
} else {
ocModels.push(model);
}
});
}
return {
favorites: favs,
@@ -451,7 +468,13 @@ export function PhaseModelSelector({
codex: codModels,
opencode: ocModels,
};
}, [favoriteModels, availableCursorModels, transformedCodexModels, allOpencodeModels]);
}, [
favoriteModels,
availableCursorModels,
transformedCodexModels,
allOpencodeModels,
disabledProviders,
]);
// Group OpenCode models by model type for better organization
const opencodeSections = useMemo(() => {
@@ -886,8 +909,8 @@ export function PhaseModelSelector({
// Render Cursor model item (no thinking level needed)
const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => {
const modelValue = stripProviderPrefix(model.id);
const isSelected = selectedModel === modelValue;
// With canonical IDs, store the full prefixed ID
const isSelected = selectedModel === model.id;
const isFavorite = favoriteModels.includes(model.id);
return (
@@ -895,7 +918,7 @@ export function PhaseModelSelector({
key={model.id}
value={model.label}
onSelect={() => {
onChange({ model: modelValue as CursorModelId });
onChange({ model: model.id as CursorModelId });
setOpen(false);
}}
className="group flex items-center justify-between py-2"
@@ -1436,7 +1459,7 @@ export function PhaseModelSelector({
return favorites.map((model) => {
// Check if this favorite is part of a grouped model
if (model.provider === 'cursor') {
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
const cursorId = model.id as CursorModelId;
const group = getModelGroup(cursorId);
if (group) {
// Skip if we already rendered this group

View File

@@ -0,0 +1,159 @@
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Info, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { PromptCustomization, CustomPrompt } from '@automaker/types';
import type { BannerConfig, PromptFieldConfig, PromptFieldProps } from './types';
/**
* Calculate dynamic minimum height based on content length.
* Ensures long prompts have adequate space.
*/
export function calculateMinHeight(text: string): string {
const lines = text.split('\n').length;
const estimatedLines = Math.max(lines, Math.ceil(text.length / 80));
const minHeight = Math.min(Math.max(120, estimatedLines * 20), 600);
return `${minHeight}px`;
}
/**
* Renders an info or warning banner.
*/
export function Banner({ config }: { config: BannerConfig }) {
const isWarning = config.type === 'warning';
const Icon = isWarning ? AlertTriangle : Info;
return (
<div
className={cn(
'flex items-start gap-3 p-4 rounded-xl',
isWarning
? 'bg-amber-500/10 border border-amber-500/20'
: 'bg-blue-500/10 border border-blue-500/20'
)}
>
<Icon
className={cn('w-5 h-5 mt-0.5 shrink-0', isWarning ? 'text-amber-500' : 'text-blue-500')}
/>
<div className="space-y-1">
<p className="text-sm text-foreground font-medium">{config.title}</p>
<p className="text-xs text-muted-foreground/80 leading-relaxed">{config.description}</p>
</div>
</div>
);
}
/**
* PromptField Component
*
* Shows a prompt with a toggle to switch between default and custom mode.
* - Toggle OFF: Shows default prompt in read-only mode
* - Toggle ON: Allows editing, custom value is used instead of default
*
* Custom value is always preserved, even when toggle is OFF.
*/
export function PromptField({
label,
description,
defaultValue,
customValue,
onCustomValueChange,
critical = false,
}: PromptFieldProps) {
const isEnabled = customValue?.enabled ?? false;
const displayValue = isEnabled ? (customValue?.value ?? defaultValue) : defaultValue;
const minHeight = calculateMinHeight(displayValue);
const handleToggle = (enabled: boolean) => {
const value = customValue?.value ?? defaultValue;
onCustomValueChange({ value, enabled });
};
const handleTextChange = (newValue: string) => {
if (isEnabled) {
onCustomValueChange({ value: newValue, enabled: true });
}
};
return (
<div className="space-y-2">
{critical && isEnabled && (
<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-500 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="text-xs font-medium text-amber-500">Critical Prompt</p>
<p className="text-xs text-muted-foreground mt-1">
This prompt requires a specific output format. Changing it incorrectly may break
functionality. Only modify if you understand the expected structure.
</p>
</div>
</div>
)}
<div className="flex items-center justify-between">
<Label htmlFor={label} className="text-sm font-medium">
{label}
</Label>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{isEnabled ? 'Custom' : 'Default'}</span>
<Switch
checked={isEnabled}
onCheckedChange={handleToggle}
className="data-[state=checked]:bg-brand-500"
/>
</div>
</div>
<Textarea
id={label}
value={displayValue}
onChange={(e) => handleTextChange(e.target.value)}
readOnly={!isEnabled}
style={{ minHeight }}
className={cn(
'font-mono text-xs resize-y',
!isEnabled && 'cursor-not-allowed bg-muted/50 text-muted-foreground'
)}
/>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
);
}
/**
* Renders a list of prompt fields from configuration.
*/
export function PromptFieldList({
fields,
category,
promptCustomization,
updatePrompt,
}: {
fields: PromptFieldConfig[];
category: keyof PromptCustomization;
promptCustomization?: PromptCustomization;
updatePrompt: (
category: keyof PromptCustomization,
field: string,
value: CustomPrompt | undefined
) => void;
}) {
return (
<>
{fields.map((field) => (
<PromptField
key={field.key}
label={field.label}
description={field.description}
defaultValue={field.defaultValue}
customValue={
(promptCustomization?.[category] as Record<string, CustomPrompt> | undefined)?.[
field.key
]
}
onCustomValueChange={(value) => updatePrompt(category, field.key, value)}
critical={field.critical}
/>
))}
</>
);
}

View File

@@ -1,135 +1,17 @@
import { useState } from 'react';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
MessageSquareText,
Bot,
KanbanSquare,
Sparkles,
RotateCcw,
Info,
AlertTriangle,
GitCommitHorizontal,
} from 'lucide-react';
import { MessageSquareText, RotateCcw, Info } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { PromptCustomization, CustomPrompt } from '@automaker/types';
import {
DEFAULT_AUTO_MODE_PROMPTS,
DEFAULT_AGENT_PROMPTS,
DEFAULT_BACKLOG_PLAN_PROMPTS,
DEFAULT_ENHANCEMENT_PROMPTS,
DEFAULT_COMMIT_MESSAGE_PROMPTS,
} from '@automaker/prompts';
import { TAB_CONFIGS } from './tab-configs';
import { Banner, PromptFieldList } from './components';
interface PromptCustomizationSectionProps {
promptCustomization?: PromptCustomization;
onPromptCustomizationChange: (customization: PromptCustomization) => void;
}
interface PromptFieldProps {
label: string;
description: string;
defaultValue: string;
customValue?: CustomPrompt;
onCustomValueChange: (value: CustomPrompt | undefined) => void;
critical?: boolean; // Whether this prompt requires strict output format
}
/**
* Calculate dynamic minimum height based on content length
* Ensures long prompts have adequate space
*/
function calculateMinHeight(text: string): string {
const lines = text.split('\n').length;
const estimatedLines = Math.max(lines, Math.ceil(text.length / 80));
// Min 120px, scales up for longer content, max 600px
const minHeight = Math.min(Math.max(120, estimatedLines * 20), 600);
return `${minHeight}px`;
}
/**
* PromptField Component
*
* Shows a prompt with a toggle to switch between default and custom mode.
* - Toggle OFF: Shows default prompt in read-only mode, custom value is preserved but not used
* - Toggle ON: Allows editing, custom value is used instead of default
*
* IMPORTANT: Custom value is ALWAYS preserved, even when toggle is OFF.
* This prevents users from losing their work when temporarily switching to default.
*/
function PromptField({
label,
description,
defaultValue,
customValue,
onCustomValueChange,
critical = false,
}: PromptFieldProps) {
const isEnabled = customValue?.enabled ?? false;
const displayValue = isEnabled ? (customValue?.value ?? defaultValue) : defaultValue;
const minHeight = calculateMinHeight(displayValue);
const handleToggle = (enabled: boolean) => {
// When toggling, preserve the existing custom value if it exists,
// otherwise initialize with the default value.
const value = customValue?.value ?? defaultValue;
onCustomValueChange({ value, enabled });
};
const handleTextChange = (newValue: string) => {
// Only allow editing when enabled
if (isEnabled) {
onCustomValueChange({ value: newValue, enabled: true });
}
};
return (
<div className="space-y-2">
{critical && isEnabled && (
<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-500 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-xs font-medium text-amber-500">Critical Prompt</p>
<p className="text-xs text-muted-foreground mt-1">
This prompt requires a specific output format. Changing it incorrectly may break
functionality. Only modify if you understand the expected structure.
</p>
</div>
</div>
)}
<div className="flex items-center justify-between">
<Label htmlFor={label} className="text-sm font-medium">
{label}
</Label>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{isEnabled ? 'Custom' : 'Default'}</span>
<Switch
checked={isEnabled}
onCheckedChange={handleToggle}
className="data-[state=checked]:bg-brand-500"
/>
</div>
</div>
<Textarea
id={label}
value={displayValue}
onChange={(e) => handleTextChange(e.target.value)}
readOnly={!isEnabled}
style={{ minHeight }}
className={cn(
'font-mono text-xs resize-y',
!isEnabled && 'cursor-not-allowed bg-muted/50 text-muted-foreground'
)}
/>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
);
}
/**
* PromptCustomizationSection Component
*
@@ -138,6 +20,7 @@ function PromptField({
* - Agent Runner (interactive chat)
* - Backlog Plan (Kanban planning)
* - Enhancement (feature description improvement)
* - And many more...
*/
export function PromptCustomizationSection({
promptCustomization = {},
@@ -145,9 +28,9 @@ export function PromptCustomizationSection({
}: PromptCustomizationSectionProps) {
const [activeTab, setActiveTab] = useState('auto-mode');
const updatePrompt = <T extends keyof PromptCustomization>(
category: T,
field: keyof NonNullable<PromptCustomization[T]>,
const updatePrompt = (
category: keyof PromptCustomization,
field: string,
value: CustomPrompt | undefined
) => {
const updated = {
@@ -206,7 +89,7 @@ export function PromptCustomizationSection({
{/* Info Banner */}
<div className="px-6 pt-6">
<div className="flex items-start gap-3 p-4 rounded-xl bg-blue-500/10 border border-blue-500/20">
<Info className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
<Info className="w-5 h-5 text-blue-500 mt-0.5 shrink-0" />
<div className="space-y-1">
<p className="text-sm text-foreground font-medium">How to Customize Prompts</p>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
@@ -221,262 +104,71 @@ export function PromptCustomizationSection({
{/* Tabs */}
<div className="p-6">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid grid-cols-5 w-full">
<TabsTrigger value="auto-mode" className="gap-2">
<Bot className="w-4 h-4" />
Auto Mode
</TabsTrigger>
<TabsTrigger value="agent" className="gap-2">
<MessageSquareText className="w-4 h-4" />
Agent
</TabsTrigger>
<TabsTrigger value="backlog-plan" className="gap-2">
<KanbanSquare className="w-4 h-4" />
Backlog Plan
</TabsTrigger>
<TabsTrigger value="enhancement" className="gap-2">
<Sparkles className="w-4 h-4" />
Enhancement
</TabsTrigger>
<TabsTrigger value="commit-message" className="gap-2">
<GitCommitHorizontal className="w-4 h-4" />
Commit
</TabsTrigger>
<TabsList className="grid grid-cols-4 gap-1 h-auto w-full bg-transparent p-0">
{TAB_CONFIGS.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id} className="gap-2">
<tab.icon className="w-4 h-4" />
{tab.label}
</TabsTrigger>
))}
</TabsList>
{/* Auto Mode Tab */}
<TabsContent value="auto-mode" className="space-y-6 mt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-foreground">Auto Mode Prompts</h3>
<Button
variant="ghost"
size="sm"
onClick={() => resetToDefaults('autoMode')}
className="gap-2"
>
<RotateCcw className="w-3 h-3" />
Reset Section
</Button>
</div>
{/* Info Banner for Auto Mode */}
<div className="flex items-start gap-3 p-4 rounded-xl bg-blue-500/10 border border-blue-500/20">
<Info className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm text-foreground font-medium">Planning Mode Markers</p>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Planning prompts use special markers like{' '}
<code className="px-1 py-0.5 rounded bg-muted text-xs">[PLAN_GENERATED]</code> and{' '}
<code className="px-1 py-0.5 rounded bg-muted text-xs">[SPEC_GENERATED]</code> to
control the Auto Mode workflow. These markers must be preserved for proper
functionality.
</p>
{TAB_CONFIGS.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="space-y-6 mt-6">
{/* Tab Header */}
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-foreground">{tab.title}</h3>
<Button
variant="ghost"
size="sm"
onClick={() => resetToDefaults(tab.category)}
className="gap-2"
>
<RotateCcw className="w-3 h-3" />
Reset Section
</Button>
</div>
</div>
<div className="space-y-4">
<PromptField
label="Planning: Lite Mode"
description="Quick planning outline without approval requirement"
defaultValue={DEFAULT_AUTO_MODE_PROMPTS.planningLite}
customValue={promptCustomization?.autoMode?.planningLite}
onCustomValueChange={(value) => updatePrompt('autoMode', 'planningLite', value)}
critical={true}
/>
{/* Tab Banner */}
{tab.banner && <Banner config={tab.banner} />}
<PromptField
label="Planning: Lite with Approval"
description="Planning outline that waits for user approval"
defaultValue={DEFAULT_AUTO_MODE_PROMPTS.planningLiteWithApproval}
customValue={promptCustomization?.autoMode?.planningLiteWithApproval}
onCustomValueChange={(value) =>
updatePrompt('autoMode', 'planningLiteWithApproval', value)
}
critical={true}
/>
{/* Main Fields */}
{tab.fields.length > 0 && (
<div className="space-y-4">
<PromptFieldList
fields={tab.fields}
category={tab.category}
promptCustomization={promptCustomization}
updatePrompt={updatePrompt}
/>
</div>
)}
<PromptField
label="Planning: Spec Mode"
description="Detailed specification with task breakdown"
defaultValue={DEFAULT_AUTO_MODE_PROMPTS.planningSpec}
customValue={promptCustomization?.autoMode?.planningSpec}
onCustomValueChange={(value) => updatePrompt('autoMode', 'planningSpec', value)}
critical={true}
/>
<PromptField
label="Planning: Full SDD Mode"
description="Comprehensive Software Design Document with phased implementation"
defaultValue={DEFAULT_AUTO_MODE_PROMPTS.planningFull}
customValue={promptCustomization?.autoMode?.planningFull}
onCustomValueChange={(value) => updatePrompt('autoMode', 'planningFull', value)}
critical={true}
/>
</div>
</TabsContent>
{/* Agent Tab */}
<TabsContent value="agent" className="space-y-6 mt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-foreground">Agent Runner Prompts</h3>
<Button
variant="ghost"
size="sm"
onClick={() => resetToDefaults('agent')}
className="gap-2"
>
<RotateCcw className="w-3 h-3" />
Reset Section
</Button>
</div>
<div className="space-y-4">
<PromptField
label="System Prompt"
description="Defines the AI's role and behavior in interactive chat sessions"
defaultValue={DEFAULT_AGENT_PROMPTS.systemPrompt}
customValue={promptCustomization?.agent?.systemPrompt}
onCustomValueChange={(value) => updatePrompt('agent', 'systemPrompt', value)}
/>
</div>
</TabsContent>
{/* Backlog Plan Tab */}
<TabsContent value="backlog-plan" className="space-y-6 mt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-foreground">Backlog Planning Prompts</h3>
<Button
variant="ghost"
size="sm"
onClick={() => resetToDefaults('backlogPlan')}
className="gap-2"
>
<RotateCcw className="w-3 h-3" />
Reset Section
</Button>
</div>
{/* Critical Warning for Backlog Plan */}
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="w-5 h-5 text-amber-500 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm text-foreground font-medium">Warning: Critical Prompts</p>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Backlog plan prompts require a strict JSON output format. Modifying these prompts
incorrectly can break the backlog planning feature and potentially corrupt your
feature data. Only customize if you fully understand the expected output
structure.
</p>
</div>
</div>
<div className="space-y-4">
<PromptField
label="System Prompt"
description="Defines how the AI modifies the feature backlog (Plan button on Kanban board)"
defaultValue={DEFAULT_BACKLOG_PLAN_PROMPTS.systemPrompt}
customValue={promptCustomization?.backlogPlan?.systemPrompt}
onCustomValueChange={(value) => updatePrompt('backlogPlan', 'systemPrompt', value)}
critical={true}
/>
</div>
</TabsContent>
{/* Enhancement Tab */}
<TabsContent value="enhancement" className="space-y-6 mt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-foreground">Enhancement Prompts</h3>
<Button
variant="ghost"
size="sm"
onClick={() => resetToDefaults('enhancement')}
className="gap-2"
>
<RotateCcw className="w-3 h-3" />
Reset Section
</Button>
</div>
<div className="space-y-4">
<PromptField
label="Improve Mode"
description="Transform vague requests into clear, actionable tasks"
defaultValue={DEFAULT_ENHANCEMENT_PROMPTS.improveSystemPrompt}
customValue={promptCustomization?.enhancement?.improveSystemPrompt}
onCustomValueChange={(value) =>
updatePrompt('enhancement', 'improveSystemPrompt', value)
}
/>
<PromptField
label="Technical Mode"
description="Add implementation details and technical specifications"
defaultValue={DEFAULT_ENHANCEMENT_PROMPTS.technicalSystemPrompt}
customValue={promptCustomization?.enhancement?.technicalSystemPrompt}
onCustomValueChange={(value) =>
updatePrompt('enhancement', 'technicalSystemPrompt', value)
}
/>
<PromptField
label="Simplify Mode"
description="Make verbose descriptions concise and focused"
defaultValue={DEFAULT_ENHANCEMENT_PROMPTS.simplifySystemPrompt}
customValue={promptCustomization?.enhancement?.simplifySystemPrompt}
onCustomValueChange={(value) =>
updatePrompt('enhancement', 'simplifySystemPrompt', value)
}
/>
<PromptField
label="Acceptance Criteria Mode"
description="Add testable acceptance criteria to descriptions"
defaultValue={DEFAULT_ENHANCEMENT_PROMPTS.acceptanceSystemPrompt}
customValue={promptCustomization?.enhancement?.acceptanceSystemPrompt}
onCustomValueChange={(value) =>
updatePrompt('enhancement', 'acceptanceSystemPrompt', value)
}
/>
<PromptField
label="User Experience Mode"
description="Review and enhance from a user experience and design perspective"
defaultValue={DEFAULT_ENHANCEMENT_PROMPTS.uxReviewerSystemPrompt}
customValue={promptCustomization?.enhancement?.uxReviewerSystemPrompt}
onCustomValueChange={(value) =>
updatePrompt('enhancement', 'uxReviewerSystemPrompt', value)
}
/>
</div>
</TabsContent>
{/* Commit Message Tab */}
<TabsContent value="commit-message" className="space-y-6 mt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-foreground">Commit Message Prompts</h3>
<Button
variant="ghost"
size="sm"
onClick={() => resetToDefaults('commitMessage')}
className="gap-2"
>
<RotateCcw className="w-3 h-3" />
Reset Section
</Button>
</div>
<div className="space-y-4">
<PromptField
label="System Prompt"
description="Instructions for generating git commit messages from diffs. The AI will receive the git diff and generate a conventional commit message."
defaultValue={DEFAULT_COMMIT_MESSAGE_PROMPTS.systemPrompt}
customValue={promptCustomization?.commitMessage?.systemPrompt}
onCustomValueChange={(value) =>
updatePrompt('commitMessage', 'systemPrompt', value)
}
/>
</div>
</TabsContent>
{/* Sections (for tabs like Auto Mode with grouped fields) */}
{tab.sections?.map((section, idx) => (
<div key={idx} className="pt-4 border-t border-border/50">
{section.title && (
<h4 className="text-sm font-medium text-muted-foreground mb-4">
{section.title}
</h4>
)}
{section.banner && (
<div className="mb-4">
<Banner config={section.banner} />
</div>
)}
<div className="space-y-4">
<PromptFieldList
fields={section.fields}
category={tab.category}
promptCustomization={promptCustomization}
updatePrompt={updatePrompt}
/>
</div>
</div>
))}
</TabsContent>
))}
</Tabs>
</div>
</div>

View File

@@ -0,0 +1,448 @@
import {
MessageSquareText,
Bot,
KanbanSquare,
Sparkles,
GitCommitHorizontal,
Type,
CheckCircle,
Lightbulb,
FileCode,
FileText,
Wand2,
Cog,
} from 'lucide-react';
import {
DEFAULT_AUTO_MODE_PROMPTS,
DEFAULT_AGENT_PROMPTS,
DEFAULT_BACKLOG_PLAN_PROMPTS,
DEFAULT_ENHANCEMENT_PROMPTS,
DEFAULT_COMMIT_MESSAGE_PROMPTS,
DEFAULT_TITLE_GENERATION_PROMPTS,
DEFAULT_ISSUE_VALIDATION_PROMPTS,
DEFAULT_IDEATION_PROMPTS,
DEFAULT_APP_SPEC_PROMPTS,
DEFAULT_CONTEXT_DESCRIPTION_PROMPTS,
DEFAULT_SUGGESTIONS_PROMPTS,
DEFAULT_TASK_EXECUTION_PROMPTS,
} from '@automaker/prompts';
import type { TabConfig } from './types';
/**
* Configuration for all prompt customization tabs.
* Each tab defines its fields, banners, and optional sections.
*/
export const TAB_CONFIGS: TabConfig[] = [
{
id: 'auto-mode',
label: 'Auto Mode',
icon: Bot,
title: 'Auto Mode Prompts',
category: 'autoMode',
banner: {
type: 'info',
title: 'Planning Mode Markers',
description:
'Planning prompts use special markers like [PLAN_GENERATED] and [SPEC_GENERATED] to control the Auto Mode workflow. These markers must be preserved for proper functionality.',
},
fields: [
{
key: 'planningLite',
label: 'Planning: Lite Mode',
description: 'Quick planning outline without approval requirement',
defaultValue: DEFAULT_AUTO_MODE_PROMPTS.planningLite,
critical: true,
},
{
key: 'planningLiteWithApproval',
label: 'Planning: Lite with Approval',
description: 'Planning outline that waits for user approval',
defaultValue: DEFAULT_AUTO_MODE_PROMPTS.planningLiteWithApproval,
critical: true,
},
{
key: 'planningSpec',
label: 'Planning: Spec Mode',
description: 'Detailed specification with task breakdown',
defaultValue: DEFAULT_AUTO_MODE_PROMPTS.planningSpec,
critical: true,
},
{
key: 'planningFull',
label: 'Planning: Full SDD Mode',
description: 'Comprehensive Software Design Document with phased implementation',
defaultValue: DEFAULT_AUTO_MODE_PROMPTS.planningFull,
critical: true,
},
],
sections: [
{
title: 'Template Prompts',
banner: {
type: 'info',
title: 'Template Variables',
description:
'Template prompts use Handlebars syntax for variable substitution. Available variables include {{featureId}}, {{title}}, {{description}}, etc.',
},
fields: [
{
key: 'featurePromptTemplate',
label: 'Feature Prompt Template',
description:
'Template for building feature implementation prompts. Variables: featureId, title, description, spec, imagePaths, dependencies, verificationInstructions',
defaultValue: DEFAULT_AUTO_MODE_PROMPTS.featurePromptTemplate,
},
{
key: 'followUpPromptTemplate',
label: 'Follow-up Prompt Template',
description:
'Template for follow-up prompts when resuming work. Variables: featurePrompt, previousContext, followUpInstructions',
defaultValue: DEFAULT_AUTO_MODE_PROMPTS.followUpPromptTemplate,
},
{
key: 'continuationPromptTemplate',
label: 'Continuation Prompt Template',
description:
'Template for continuation prompts. Variables: featurePrompt, previousContext',
defaultValue: DEFAULT_AUTO_MODE_PROMPTS.continuationPromptTemplate,
},
{
key: 'pipelineStepPromptTemplate',
label: 'Pipeline Step Prompt Template',
description:
'Template for pipeline step execution prompts. Variables: stepName, featurePrompt, previousContext, stepInstructions',
defaultValue: DEFAULT_AUTO_MODE_PROMPTS.pipelineStepPromptTemplate,
},
],
},
],
},
{
id: 'agent',
label: 'Agent',
icon: MessageSquareText,
title: 'Agent Runner Prompts',
category: 'agent',
fields: [
{
key: 'systemPrompt',
label: 'System Prompt',
description: "Defines the AI's role and behavior in interactive chat sessions",
defaultValue: DEFAULT_AGENT_PROMPTS.systemPrompt,
},
],
},
{
id: 'backlog-plan',
label: 'Backlog',
icon: KanbanSquare,
title: 'Backlog Planning Prompts',
category: 'backlogPlan',
banner: {
type: 'warning',
title: 'Warning: Critical Prompts',
description:
'Backlog plan prompts require a strict JSON output format. Modifying these prompts incorrectly can break the backlog planning feature and potentially corrupt your feature data. Only customize if you fully understand the expected output structure.',
},
fields: [
{
key: 'systemPrompt',
label: 'System Prompt',
description:
'Defines how the AI modifies the feature backlog (Plan button on Kanban board)',
defaultValue: DEFAULT_BACKLOG_PLAN_PROMPTS.systemPrompt,
critical: true,
},
{
key: 'userPromptTemplate',
label: 'User Prompt Template',
description:
'Template for the user prompt sent to the AI. Variables: currentFeatures, userRequest',
defaultValue: DEFAULT_BACKLOG_PLAN_PROMPTS.userPromptTemplate,
critical: true,
},
],
},
{
id: 'enhancement',
label: 'Enhancement',
icon: Sparkles,
title: 'Enhancement Prompts',
category: 'enhancement',
fields: [
{
key: 'improveSystemPrompt',
label: 'Improve Mode',
description: 'Transform vague requests into clear, actionable tasks',
defaultValue: DEFAULT_ENHANCEMENT_PROMPTS.improveSystemPrompt,
},
{
key: 'technicalSystemPrompt',
label: 'Technical Mode',
description: 'Add implementation details and technical specifications',
defaultValue: DEFAULT_ENHANCEMENT_PROMPTS.technicalSystemPrompt,
},
{
key: 'simplifySystemPrompt',
label: 'Simplify Mode',
description: 'Make verbose descriptions concise and focused',
defaultValue: DEFAULT_ENHANCEMENT_PROMPTS.simplifySystemPrompt,
},
{
key: 'acceptanceSystemPrompt',
label: 'Acceptance Criteria Mode',
description: 'Add testable acceptance criteria to descriptions',
defaultValue: DEFAULT_ENHANCEMENT_PROMPTS.acceptanceSystemPrompt,
},
{
key: 'uxReviewerSystemPrompt',
label: 'User Experience Mode',
description: 'Review and enhance from a user experience and design perspective',
defaultValue: DEFAULT_ENHANCEMENT_PROMPTS.uxReviewerSystemPrompt,
},
],
},
{
id: 'commit-message',
label: 'Commit',
icon: GitCommitHorizontal,
title: 'Commit Message Prompts',
category: 'commitMessage',
fields: [
{
key: 'systemPrompt',
label: 'System Prompt',
description:
'Instructions for generating git commit messages from diffs. The AI will receive the git diff and generate a conventional commit message.',
defaultValue: DEFAULT_COMMIT_MESSAGE_PROMPTS.systemPrompt,
},
],
},
{
id: 'title-generation',
label: 'Title',
icon: Type,
title: 'Title Generation Prompts',
category: 'titleGeneration',
fields: [
{
key: 'systemPrompt',
label: 'System Prompt',
description:
'Instructions for generating concise, descriptive feature titles from descriptions. Used when auto-generating titles for new features.',
defaultValue: DEFAULT_TITLE_GENERATION_PROMPTS.systemPrompt,
},
],
},
{
id: 'issue-validation',
label: 'Issues',
icon: CheckCircle,
title: 'Issue Validation Prompts',
category: 'issueValidation',
banner: {
type: 'warning',
title: 'Warning: Critical Prompt',
description:
'The issue validation prompt guides the AI through a structured validation process and expects specific output format. Modifying this prompt incorrectly may affect validation accuracy.',
},
fields: [
{
key: 'systemPrompt',
label: 'System Prompt',
description:
'Instructions for validating GitHub issues against the codebase. Guides the AI to determine if issues are valid, invalid, or need clarification.',
defaultValue: DEFAULT_ISSUE_VALIDATION_PROMPTS.systemPrompt,
critical: true,
},
],
},
{
id: 'ideation',
label: 'Ideation',
icon: Lightbulb,
title: 'Ideation Prompts',
category: 'ideation',
fields: [
{
key: 'ideationSystemPrompt',
label: 'Ideation Chat System Prompt',
description:
'System prompt for AI-powered ideation chat conversations. Guides the AI to brainstorm and suggest feature ideas.',
defaultValue: DEFAULT_IDEATION_PROMPTS.ideationSystemPrompt,
},
{
key: 'suggestionsSystemPrompt',
label: 'Suggestions System Prompt',
description:
'System prompt for generating structured feature suggestions. Used when generating batch suggestions from prompts.',
defaultValue: DEFAULT_IDEATION_PROMPTS.suggestionsSystemPrompt,
critical: true,
},
],
},
{
id: 'app-spec',
label: 'App Spec',
icon: FileCode,
title: 'App Specification Prompts',
category: 'appSpec',
fields: [
{
key: 'generateSpecSystemPrompt',
label: 'Generate Spec System Prompt',
description: 'System prompt for generating project specifications from overview',
defaultValue: DEFAULT_APP_SPEC_PROMPTS.generateSpecSystemPrompt,
},
{
key: 'structuredSpecInstructions',
label: 'Structured Spec Instructions',
description: 'Instructions for structured specification output format',
defaultValue: DEFAULT_APP_SPEC_PROMPTS.structuredSpecInstructions,
critical: true,
},
{
key: 'generateFeaturesFromSpecPrompt',
label: 'Generate Features from Spec',
description: 'Prompt for generating features from a project specification',
defaultValue: DEFAULT_APP_SPEC_PROMPTS.generateFeaturesFromSpecPrompt,
critical: true,
},
],
},
{
id: 'context-description',
label: 'Context',
icon: FileText,
title: 'Context Description Prompts',
category: 'contextDescription',
fields: [
{
key: 'describeFilePrompt',
label: 'Describe File Prompt',
description: 'Prompt for generating descriptions of text files added as context',
defaultValue: DEFAULT_CONTEXT_DESCRIPTION_PROMPTS.describeFilePrompt,
},
{
key: 'describeImagePrompt',
label: 'Describe Image Prompt',
description: 'Prompt for generating descriptions of images added as context',
defaultValue: DEFAULT_CONTEXT_DESCRIPTION_PROMPTS.describeImagePrompt,
},
],
},
{
id: 'suggestions',
label: 'Suggestions',
icon: Wand2,
title: 'Suggestions Prompts',
category: 'suggestions',
fields: [
{
key: 'featuresPrompt',
label: 'Features Suggestion Prompt',
description: 'Prompt for analyzing the project and suggesting new features',
defaultValue: DEFAULT_SUGGESTIONS_PROMPTS.featuresPrompt,
},
{
key: 'refactoringPrompt',
label: 'Refactoring Suggestion Prompt',
description: 'Prompt for identifying refactoring opportunities',
defaultValue: DEFAULT_SUGGESTIONS_PROMPTS.refactoringPrompt,
},
{
key: 'securityPrompt',
label: 'Security Suggestion Prompt',
description: 'Prompt for analyzing security vulnerabilities',
defaultValue: DEFAULT_SUGGESTIONS_PROMPTS.securityPrompt,
},
{
key: 'performancePrompt',
label: 'Performance Suggestion Prompt',
description: 'Prompt for identifying performance issues',
defaultValue: DEFAULT_SUGGESTIONS_PROMPTS.performancePrompt,
},
{
key: 'baseTemplate',
label: 'Base Template',
description: 'Base template applied to all suggestion types',
defaultValue: DEFAULT_SUGGESTIONS_PROMPTS.baseTemplate,
},
],
},
{
id: 'task-execution',
label: 'Tasks',
icon: Cog,
title: 'Task Execution Prompts',
category: 'taskExecution',
banner: {
type: 'info',
title: 'Template Variables',
description:
'Task execution prompts use Handlebars syntax for variable substitution. Variables include {{taskId}}, {{taskDescription}}, {{completedTasks}}, etc.',
},
fields: [
{
key: 'taskPromptTemplate',
label: 'Task Prompt Template',
description: 'Template for building individual task execution prompts',
defaultValue: DEFAULT_TASK_EXECUTION_PROMPTS.taskPromptTemplate,
},
{
key: 'implementationInstructions',
label: 'Implementation Instructions',
description: 'Instructions appended to feature implementation prompts',
defaultValue: DEFAULT_TASK_EXECUTION_PROMPTS.implementationInstructions,
},
{
key: 'playwrightVerificationInstructions',
label: 'Playwright Verification Instructions',
description: 'Instructions for automated Playwright verification (when enabled)',
defaultValue: DEFAULT_TASK_EXECUTION_PROMPTS.playwrightVerificationInstructions,
},
{
key: 'learningExtractionSystemPrompt',
label: 'Learning Extraction System Prompt',
description: 'System prompt for extracting learnings/ADRs from implementation output',
defaultValue: DEFAULT_TASK_EXECUTION_PROMPTS.learningExtractionSystemPrompt,
critical: true,
},
{
key: 'learningExtractionUserPromptTemplate',
label: 'Learning Extraction User Template',
description:
'User prompt template for learning extraction. Variables: featureTitle, implementationLog',
defaultValue: DEFAULT_TASK_EXECUTION_PROMPTS.learningExtractionUserPromptTemplate,
critical: true,
},
{
key: 'planRevisionTemplate',
label: 'Plan Revision Template',
description:
'Template for prompting plan revisions. Variables: planVersion, previousPlan, userFeedback',
defaultValue: DEFAULT_TASK_EXECUTION_PROMPTS.planRevisionTemplate,
},
{
key: 'continuationAfterApprovalTemplate',
label: 'Continuation After Approval Template',
description:
'Template for continuation after plan approval. Variables: userFeedback, approvedPlan',
defaultValue: DEFAULT_TASK_EXECUTION_PROMPTS.continuationAfterApprovalTemplate,
},
{
key: 'resumeFeatureTemplate',
label: 'Resume Feature Template',
description:
'Template for resuming interrupted features. Variables: featurePrompt, previousContext',
defaultValue: DEFAULT_TASK_EXECUTION_PROMPTS.resumeFeatureTemplate,
},
{
key: 'projectAnalysisPrompt',
label: 'Project Analysis Prompt',
description: 'Prompt for AI-powered project analysis',
defaultValue: DEFAULT_TASK_EXECUTION_PROMPTS.projectAnalysisPrompt,
},
],
},
];

View File

@@ -0,0 +1,51 @@
import type { LucideIcon } from 'lucide-react';
import type { PromptCustomization, CustomPrompt } from '@automaker/types';
/** Props for the PromptField component */
export interface PromptFieldProps {
label: string;
description: string;
defaultValue: string;
customValue?: CustomPrompt;
onCustomValueChange: (value: CustomPrompt | undefined) => void;
critical?: boolean;
}
/** Configuration for a single prompt field */
export interface PromptFieldConfig {
key: string;
label: string;
description: string;
defaultValue: string;
critical?: boolean;
}
/** Banner type for tabs */
export type BannerType = 'info' | 'warning';
/** Configuration for info/warning banners */
export interface BannerConfig {
type: BannerType;
title: string;
description: string;
}
/** Configuration for a section within a tab */
export interface TabSectionConfig {
title?: string;
banner?: BannerConfig;
fields: PromptFieldConfig[];
}
/** Configuration for a tab with prompt fields */
export interface TabConfig {
id: string;
label: string;
icon: LucideIcon;
title: string;
category: keyof PromptCustomization;
banner?: BannerConfig;
fields: PromptFieldConfig[];
/** For tabs with grouped sections (like Auto Mode) */
sections?: TabSectionConfig[];
}

View File

@@ -7,6 +7,7 @@ import { ClaudeMdSettings } from '../claude/claude-md-settings';
import { ClaudeUsageSection } from '../api-keys/claude-usage-section';
import { SkillsSection } from './claude-settings-tab/skills-section';
import { SubagentsSection } from './claude-settings-tab/subagents-section';
import { ProviderToggle } from './provider-toggle';
import { Info } from 'lucide-react';
export function ClaudeSettingsTab() {
@@ -24,6 +25,9 @@ export function ClaudeSettingsTab() {
return (
<div className="space-y-6">
{/* Provider Visibility Toggle */}
<ProviderToggle provider="claude" providerLabel="Claude" />
{/* Usage Info */}
<div className="flex items-start gap-3 p-4 rounded-xl bg-blue-500/10 border border-blue-500/20">
<Info className="w-5 h-5 text-blue-400 shrink-0 mt-0.5" />

View File

@@ -14,16 +14,8 @@ import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
import {
Bot,
RefreshCw,
Loader2,
Users,
ExternalLink,
Globe,
FolderOpen,
Sparkles,
} from 'lucide-react';
import { Bot, RefreshCw, Users, ExternalLink, Globe, FolderOpen, Sparkles } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { useSubagents } from './hooks/use-subagents';
import { useSubagentsSettings } from './hooks/use-subagents-settings';
import { SubagentCard } from './subagent-card';
@@ -178,11 +170,7 @@ export function SubagentsSection() {
title="Refresh agents from disk"
className="gap-1.5 h-7 px-2 text-xs"
>
{isLoadingAgents ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{isLoadingAgents ? <Spinner size="xs" /> : <RefreshCw className="h-3.5 w-3.5" />}
Refresh
</Button>
</div>

View File

@@ -5,6 +5,7 @@ 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 { ProviderToggle } from './provider-toggle';
import { getElectronAPI } from '@/lib/electron';
import { createLogger } from '@automaker/utils/logger';
import type { CliStatus as SharedCliStatus } from '../shared/types';
@@ -162,6 +163,9 @@ export function CodexSettingsTab() {
return (
<div className="space-y-6">
{/* Provider Visibility Toggle */}
<ProviderToggle provider="codex" providerLabel="Codex" />
<CodexCliStatus
status={codexCliStatus}
authStatus={authStatusToDisplay}

View File

@@ -92,7 +92,8 @@ export function CursorModelConfiguration({
<div className="grid gap-3">
{availableModels.map((model) => {
const isEnabled = enabledCursorModels.includes(model.id);
const isAuto = model.id === 'auto';
// With canonical IDs, 'auto' becomes 'cursor-auto'
const isAuto = model.id === 'cursor-auto';
return (
<div

View File

@@ -4,6 +4,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Shield, ShieldCheck, ShieldAlert, ChevronDown, Copy, Check } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type { CursorStatus } from '../hooks/use-cursor-status';
import type { PermissionsData } from '../hooks/use-cursor-permissions';
@@ -118,7 +119,7 @@ export function CursorPermissionsSection({
{isLoadingPermissions ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin w-6 h-6 border-2 border-brand-500 border-t-transparent rounded-full" />
<Spinner size="lg" />
</div>
) : (
<>

View File

@@ -12,6 +12,7 @@ import { useCursorStatus } from '../hooks/use-cursor-status';
import { useCursorPermissions } from '../hooks/use-cursor-permissions';
import { CursorPermissionsSection } from './cursor-permissions-section';
import { CursorModelConfiguration } from './cursor-model-configuration';
import { ProviderToggle } from './provider-toggle';
export function CursorSettingsTab() {
// Global settings from store
@@ -73,6 +74,9 @@ export function CursorSettingsTab() {
return (
<div className="space-y-6">
{/* Provider Visibility Toggle */}
<ProviderToggle provider="cursor" providerLabel="Cursor" />
{/* CLI Status */}
<CursorCliStatus status={status} isChecking={isLoading} onRefresh={loadData} />

View File

@@ -9,7 +9,8 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Terminal, Cloud, Cpu, Brain, Github, Loader2, KeyRound, ShieldCheck } from 'lucide-react';
import { Terminal, Cloud, Cpu, Brain, Github, KeyRound, ShieldCheck } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import type {
@@ -500,7 +501,7 @@ export function OpencodeModelConfiguration({
</p>
{isLoadingDynamicModels && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="w-3 h-3 animate-spin" />
<Spinner size="xs" />
<span>Discovering...</span>
</div>
)}
@@ -611,16 +612,16 @@ export function OpencodeModelConfiguration({
Dynamic
</Badge>
</div>
{models.length > 0 && (
{filteredModels.length > 0 && (
<div className={OPENCODE_SELECT_ALL_CONTAINER_CLASS}>
<Checkbox
checked={getSelectionState(
models.map((model) => model.id),
filteredModels.map((model) => model.id),
enabledDynamicModelIds
)}
onCheckedChange={(checked) =>
toggleProviderDynamicModels(
models.map((model) => model.id),
filteredModels.map((model) => model.id),
checked
)
}

View File

@@ -129,6 +129,9 @@ export function OpencodeSettingsTab() {
return (
<div className="space-y-6">
{/* Provider Visibility Toggle */}
<ProviderToggle provider="opencode" providerLabel="OpenCode" />
<OpencodeCliStatus
status={cliStatus}
authStatus={authStatus}

View File

@@ -0,0 +1,41 @@
import { useAppStore } from '@/store/app-store';
import type { ModelProvider } from '@automaker/types';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { EyeOff, Eye } from 'lucide-react';
interface ProviderToggleProps {
provider: ModelProvider;
providerLabel: string;
}
export function ProviderToggle({ provider, providerLabel }: ProviderToggleProps) {
const { disabledProviders, toggleProviderDisabled } = useAppStore();
const isDisabled = disabledProviders.includes(provider);
return (
<div className="flex items-center justify-between p-4 rounded-xl bg-accent/20 border border-border/30">
<div className="flex items-center gap-3">
{isDisabled ? (
<EyeOff className="w-4 h-4 text-muted-foreground" />
) : (
<Eye className="w-4 h-4 text-primary" />
)}
<div>
<Label className="text-sm font-medium">Show {providerLabel} in model dropdowns</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{isDisabled
? `${providerLabel} models are hidden from all model selectors`
: `${providerLabel} models appear in model selection dropdowns`}
</p>
</div>
</div>
<Switch
checked={!isDisabled}
onCheckedChange={(checked) => toggleProviderDisabled(provider, !checked)}
/>
</div>
);
}
export default ProviderToggle;

View File

@@ -26,6 +26,8 @@ export interface Project {
name: string;
path: string;
theme?: string;
fontFamilySans?: string;
fontFamilyMono?: string;
icon?: string;
customIconPath?: string;
}

View File

@@ -2,11 +2,28 @@ import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Slider } from '@/components/ui/slider';
import { Input } from '@/components/ui/input';
import { SquareTerminal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
SquareTerminal,
RefreshCw,
Terminal,
SquarePlus,
SplitSquareHorizontal,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import { TERMINAL_FONT_OPTIONS } from '@/config/terminal-themes';
import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
import { useAvailableTerminals } from '@/components/views/board-view/worktree-panel/hooks/use-available-terminals';
import { getTerminalIcon } from '@/components/icons/terminal-icons';
export function TerminalSection() {
const {
@@ -17,6 +34,9 @@ export function TerminalSection() {
setTerminalScrollbackLines,
setTerminalLineHeight,
setTerminalDefaultFontSize,
defaultTerminalId,
setDefaultTerminalId,
setOpenTerminalMode,
} = useAppStore();
const {
@@ -26,8 +46,12 @@ export function TerminalSection() {
scrollbackLines,
lineHeight,
defaultFontSize,
openTerminalMode,
} = terminalState;
// Get available external terminals
const { terminals, isRefreshing, refresh } = useAvailableTerminals();
return (
<div
className={cn(
@@ -50,30 +74,132 @@ export function TerminalSection() {
</p>
</div>
<div className="p-6 space-y-6">
{/* Default External Terminal */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Default External Terminal</Label>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={refresh}
disabled={isRefreshing}
title="Refresh available terminals"
aria-label="Refresh available terminals"
>
<RefreshCw className={cn('w-3.5 h-3.5', isRefreshing && 'animate-spin')} />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Terminal to use when selecting "Open in Terminal" from the worktree menu
</p>
<Select
value={defaultTerminalId ?? 'integrated'}
onValueChange={(value) => {
setDefaultTerminalId(value === 'integrated' ? null : value);
toast.success(
value === 'integrated'
? 'Integrated terminal set as default'
: 'Default terminal changed'
);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a terminal" />
</SelectTrigger>
<SelectContent>
<SelectItem value="integrated">
<span className="flex items-center gap-2">
<Terminal className="w-4 h-4" />
Integrated Terminal
</span>
</SelectItem>
{terminals.map((terminal) => {
const TerminalIcon = getTerminalIcon(terminal.id);
return (
<SelectItem key={terminal.id} value={terminal.id}>
<span className="flex items-center gap-2">
<TerminalIcon className="w-4 h-4" />
{terminal.name}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
{terminals.length === 0 && !isRefreshing && (
<p className="text-xs text-muted-foreground italic">
No external terminals detected. Click refresh to re-scan.
</p>
)}
</div>
{/* Default Open Mode */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Default Open Mode</Label>
<p className="text-xs text-muted-foreground">
How to open the integrated terminal when using "Open in Terminal" from the worktree menu
</p>
<Select
value={openTerminalMode}
onValueChange={(value: 'newTab' | 'split') => {
setOpenTerminalMode(value);
toast.success(
value === 'newTab'
? 'New terminals will open in new tabs'
: 'New terminals will split the current tab'
);
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="newTab">
<span className="flex items-center gap-2">
<SquarePlus className="w-4 h-4" />
New Tab
</span>
</SelectItem>
<SelectItem value="split">
<span className="flex items-center gap-2">
<SplitSquareHorizontal className="w-4 h-4" />
Split Current Tab
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Font Family */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Font Family</Label>
<select
value={fontFamily}
onChange={(e) => {
setTerminalFontFamily(e.target.value);
<Select
value={fontFamily || DEFAULT_FONT_VALUE}
onValueChange={(value) => {
setTerminalFontFamily(value);
toast.info('Font family changed', {
description: 'Restart terminal for changes to take effect',
});
}}
className={cn(
'w-full px-3 py-2 rounded-lg',
'bg-accent/30 border border-border/50',
'text-foreground text-sm',
'focus:outline-none focus:ring-2 focus:ring-green-500/30'
)}
>
{TERMINAL_FONT_OPTIONS.map((font) => (
<option key={font.value} value={font.value}>
{font.label}
</option>
))}
</select>
<SelectTrigger className="w-full">
<SelectValue placeholder="Default (Menlo / Monaco)" />
</SelectTrigger>
<SelectContent>
{TERMINAL_FONT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<span
style={{
fontFamily: option.value === DEFAULT_FONT_VALUE ? undefined : option.value,
}}
>
{option.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Default Font Size */}

View File

@@ -1,23 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
import {
GitBranch,
Terminal,
FileCode,
Save,
RotateCcw,
Trash2,
Loader2,
PanelBottomClose,
} from 'lucide-react';
import { GitBranch } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { getHttpApiClient } from '@/lib/http-api-client';
import { useWorktreeInitScript } from '@/hooks/queries';
import { useSetInitScript, useDeleteInitScript } from '@/hooks/mutations';
interface WorktreesSectionProps {
useWorktrees: boolean;
@@ -25,89 +9,6 @@ interface WorktreesSectionProps {
}
export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) {
const currentProject = useAppStore((s) => s.currentProject);
const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator);
const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator);
const getDefaultDeleteBranch = useAppStore((s) => s.getDefaultDeleteBranch);
const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator);
// Local state for script content editing
const [scriptContent, setScriptContent] = useState('');
const [originalContent, setOriginalContent] = useState('');
// React Query hooks for init script
const { data: initScriptData, isLoading } = useWorktreeInitScript(currentProject?.path);
const setInitScript = useSetInitScript(currentProject?.path ?? '');
const deleteInitScript = useDeleteInitScript(currentProject?.path ?? '');
// Derived state
const scriptExists = initScriptData?.exists ?? false;
const isSaving = setInitScript.isPending;
const isDeleting = deleteInitScript.isPending;
// Get the current show indicator setting
const showIndicator = currentProject?.path
? getShowInitScriptIndicator(currentProject.path)
: true;
// Get the default delete branch setting
const defaultDeleteBranch = currentProject?.path
? getDefaultDeleteBranch(currentProject.path)
: false;
// Get the auto-dismiss setting
const autoDismiss = currentProject?.path
? getAutoDismissInitScriptIndicator(currentProject.path)
: true;
// Check if there are unsaved changes
const hasChanges = scriptContent !== originalContent;
// Sync query data to local state when it changes
useEffect(() => {
if (initScriptData) {
const content = initScriptData.content || '';
setScriptContent(content);
setOriginalContent(content);
} else if (!currentProject?.path) {
setScriptContent('');
setOriginalContent('');
}
}, [initScriptData, currentProject?.path]);
// Save script using mutation
const handleSave = useCallback(() => {
if (!currentProject?.path) return;
setInitScript.mutate(scriptContent, {
onSuccess: () => {
setOriginalContent(scriptContent);
},
});
}, [currentProject?.path, scriptContent, setInitScript]);
// Reset to original content
const handleReset = useCallback(() => {
setScriptContent(originalContent);
}, [originalContent]);
// Delete script using mutation
const handleDelete = useCallback(() => {
if (!currentProject?.path) return;
deleteInitScript.mutate(undefined, {
onSuccess: () => {
setScriptContent('');
setOriginalContent('');
},
});
}, [currentProject?.path, deleteInitScript]);
// Handle content change (no auto-save)
const handleContentChange = useCallback((value: string) => {
setScriptContent(value);
}, []);
return (
<div
className={cn(
@@ -125,7 +26,7 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
<h2 className="text-lg font-semibold text-foreground tracking-tight">Worktrees</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure git worktree isolation and initialization scripts.
Configure git worktree isolation for feature development.
</p>
</div>
<div className="p-6 space-y-5">
@@ -153,217 +54,12 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
</div>
</div>
{/* Show Init Script Indicator Toggle */}
{currentProject && (
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 mt-4">
<Checkbox
id="show-init-script-indicator"
checked={showIndicator}
onCheckedChange={async (checked) => {
if (currentProject?.path) {
const value = checked === true;
setShowInitScriptIndicator(currentProject.path, value);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(currentProject.path, {
showInitScriptIndicator: value,
});
} catch (error) {
console.error('Failed to persist showInitScriptIndicator:', error);
}
}
}}
className="mt-1"
/>
<div className="space-y-1.5">
<Label
htmlFor="show-init-script-indicator"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<PanelBottomClose className="w-4 h-4 text-brand-500" />
Show Init Script Indicator
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Display a floating panel in the bottom-right corner showing init script execution
status and output when a worktree is created.
</p>
</div>
</div>
)}
{/* Auto-dismiss Init Script Indicator Toggle */}
{currentProject && showIndicator && (
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 ml-6">
<Checkbox
id="auto-dismiss-indicator"
checked={autoDismiss}
onCheckedChange={async (checked) => {
if (currentProject?.path) {
const value = checked === true;
setAutoDismissInitScriptIndicator(currentProject.path, value);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(currentProject.path, {
autoDismissInitScriptIndicator: value,
});
} catch (error) {
console.error('Failed to persist autoDismissInitScriptIndicator:', error);
}
}
}}
className="mt-1"
/>
<div className="space-y-1.5">
<Label
htmlFor="auto-dismiss-indicator"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
Auto-dismiss After Completion
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Automatically hide the indicator 5 seconds after the script completes.
</p>
</div>
</div>
)}
{/* Default Delete Branch Toggle */}
{currentProject && (
<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="default-delete-branch"
checked={defaultDeleteBranch}
onCheckedChange={async (checked) => {
if (currentProject?.path) {
const value = checked === true;
setDefaultDeleteBranch(currentProject.path, value);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(currentProject.path, {
defaultDeleteBranch: value,
});
} catch (error) {
console.error('Failed to persist defaultDeleteBranch:', error);
}
}
}}
className="mt-1"
/>
<div className="space-y-1.5">
<Label
htmlFor="default-delete-branch"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Trash2 className="w-4 h-4 text-brand-500" />
Delete Branch by Default
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When deleting a worktree, automatically check the "Also delete the branch" option.
</p>
</div>
</div>
)}
{/* Separator */}
<div className="border-t border-border/30" />
{/* Init Script Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Terminal className="w-4 h-4 text-brand-500" />
<Label className="text-foreground font-medium">Initialization Script</Label>
</div>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Shell commands to run after a worktree is created. Runs once per worktree. Uses Git Bash
on Windows for cross-platform compatibility.
{/* Info about project-specific settings */}
<div className="rounded-xl border border-border/30 bg-muted/30 p-4">
<p className="text-xs text-muted-foreground">
Project-specific worktree preferences (init script, delete branch behavior) can be
configured in each project's settings via the sidebar.
</p>
{currentProject ? (
<>
{/* File path indicator */}
<div className="flex items-center gap-2 text-xs text-muted-foreground/60">
<FileCode className="w-3.5 h-3.5" />
<code className="font-mono">.automaker/worktree-init.sh</code>
{hasChanges && (
<span className="text-amber-500 font-medium">(unsaved changes)</span>
)}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : (
<>
<ShellSyntaxEditor
value={scriptContent}
onChange={handleContentChange}
placeholder={`# Example initialization commands
npm install
# Or use pnpm
# pnpm install
# Copy environment file
# cp .env.example .env`}
minHeight="200px"
maxHeight="500px"
data-testid="init-script-editor"
/>
{/* Action buttons */}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={!hasChanges || isSaving || isDeleting}
className="gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
Reset
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDelete}
disabled={!scriptExists || isSaving || isDeleting}
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
>
{isDeleting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
Delete
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || isSaving || isDeleting}
className="gap-1.5"
>
{isSaving ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Save className="w-3.5 h-3.5" />
)}
Save
</Button>
</div>
</>
)}
</>
) : (
<div className="text-sm text-muted-foreground/60 py-4 text-center">
Select a project to configure the init script.
</div>
)}
</div>
</div>
</div>