mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { EventHooksSection } from './event-hooks-section';
|
||||
@@ -15,10 +15,12 @@ export type SettingsViewId =
|
||||
| 'terminal'
|
||||
| 'keyboard'
|
||||
| 'audio'
|
||||
| 'event-hooks'
|
||||
| 'defaults'
|
||||
| 'worktrees'
|
||||
| 'account'
|
||||
| 'security'
|
||||
| 'developer'
|
||||
| 'danger';
|
||||
|
||||
interface UseSettingsViewOptions {
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
51
apps/ui/src/components/views/settings-view/prompts/types.ts
Normal file
51
apps/ui/src/components/views/settings-view/prompts/types.ts
Normal 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[];
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -26,6 +26,8 @@ export interface Project {
|
||||
name: string;
|
||||
path: string;
|
||||
theme?: string;
|
||||
fontFamilySans?: string;
|
||||
fontFamilyMono?: string;
|
||||
icon?: string;
|
||||
customIconPath?: string;
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user