Files
automaker/apps/ui/src/components/views/project-settings-view/worktree-preferences-section.tsx
gsxdsm 0330c70261 Feature: worktree view customization and stability fixes (#805)
* Changes from feature/worktree-view-customization

* Feature: Git sync, set-tracking, and push divergence handling (#796)

* Add quick-add feature with improved workflows (#802)

* Changes from feature/quick-add

* feat: Clarify system prompt and improve error handling across services. Address PR Feedback

* feat: Improve PR description parsing and refactor event handling

* feat: Add context options to pipeline orchestrator initialization

* fix: Deduplicate React and handle CJS interop for use-sync-external-store

Resolve "Cannot read properties of null (reading 'useState')" errors by
deduplicating React/react-dom and ensuring use-sync-external-store is
bundled together with React to prevent CJS packages from resolving to
different React instances.

* Changes from feature/worktree-view-customization

* refactor: Remove unused worktree swap and highlight props

* refactor: Consolidate feature completion logic and improve thinking level defaults

* feat: Increase max turn limit to 10000

- Update DEFAULT_MAX_TURNS from 1000 to 10000 in settings-helpers.ts and agent-executor.ts
- Update MAX_ALLOWED_TURNS from 2000 to 10000 in settings-helpers.ts
- Update UI clamping logic from 2000 to 10000 in app-store.ts
- Update fallback values from 1000 to 10000 in use-settings-sync.ts
- Update default value from 1000 to 10000 in DEFAULT_GLOBAL_SETTINGS
- Update documentation to reflect new range: 1-10000

Allows agents to perform up to 10000 turns for complex feature execution.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* feat: Add model resolution, improve session handling, and enhance UI stability

* refactor: Remove unused sync and tracking branch props from worktree components

* feat: Add PR number update functionality to worktrees. Address pr feedback

* feat: Optimize Gemini CLI startup and add tool result tracking

* refactor: Improve error handling and simplify worktree task cleanup

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-23 20:31:25 -08:00

763 lines
28 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from 'react';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Slider } from '@/components/ui/slider';
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
import {
GitBranch,
Terminal,
FileCode,
Save,
RotateCcw,
Trash2,
PanelBottomClose,
Copy,
Plus,
FolderOpen,
LayoutGrid,
Pin,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { Project } from '@/lib/electron';
import { ProjectFileSelectorDialog } from '@/components/dialogs/project-file-selector-dialog';
// Stable empty array reference to prevent unnecessary re-renders when no copy files are set
const EMPTY_FILES: string[] = [];
interface WorktreePreferencesSectionProps {
project: Project;
}
interface InitScriptResponse {
success: boolean;
exists: boolean;
content: string;
path: string;
error?: string;
}
export function WorktreePreferencesSection({ project }: WorktreePreferencesSectionProps) {
// Use direct store subscriptions (not getter functions) so the component
// properly re-renders when these values change in the store.
const globalUseWorktrees = useAppStore((s) => s.useWorktrees);
const projectUseWorktrees = useAppStore((s) => s.useWorktreesByProject[project.path]);
const setProjectUseWorktrees = useAppStore((s) => s.setProjectUseWorktrees);
const showIndicator = useAppStore(
(s) => s.showInitScriptIndicatorByProject[project.path] ?? true
);
const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator);
const defaultDeleteBranch = useAppStore(
(s) => s.defaultDeleteBranchByProject[project.path] ?? false
);
const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
const autoDismiss = useAppStore(
(s) => s.autoDismissInitScriptIndicatorByProject[project.path] ?? true
);
const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator);
// Use a stable empty array reference to prevent new array on every render when
// worktreeCopyFilesByProject[project.path] is undefined (not yet loaded).
const copyFilesFromStore = useAppStore((s) => s.worktreeCopyFilesByProject[project.path]);
const copyFiles = copyFilesFromStore ?? EMPTY_FILES;
const setWorktreeCopyFiles = useAppStore((s) => s.setWorktreeCopyFiles);
// Worktree display settings
const pinnedWorktreesCount = useAppStore((s) => s.getPinnedWorktreesCount(project.path));
const setPinnedWorktreesCount = useAppStore((s) => s.setPinnedWorktreesCount);
// Get effective worktrees setting (project override or global fallback)
const effectiveUseWorktrees = projectUseWorktrees ?? globalUseWorktrees;
const [scriptContent, setScriptContent] = useState('');
const [originalContent, setOriginalContent] = useState('');
const [scriptExists, setScriptExists] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Copy files state
const [newCopyFilePath, setNewCopyFilePath] = useState('');
const [fileSelectorOpen, setFileSelectorOpen] = useState(false);
// Ref for storing previous slider value for rollback on error
const sliderPrevRef = useRef<number | null>(null);
// Check if there are unsaved changes
const hasChanges = scriptContent !== originalContent;
// Load project settings (including useWorktrees) when project changes
useEffect(() => {
let isCancelled = false;
const currentPath = project.path;
const loadProjectSettings = async () => {
try {
const httpClient = getHttpApiClient();
const response = await httpClient.settings.getProject(currentPath);
// Avoid updating state if component unmounted or project changed
if (isCancelled) return;
if (response.success && response.settings) {
// Sync useWorktrees to store if it has a value
if (response.settings.useWorktrees !== undefined) {
setProjectUseWorktrees(currentPath, response.settings.useWorktrees);
}
// Also sync other settings to store
if (response.settings.showInitScriptIndicator !== undefined) {
setShowInitScriptIndicator(currentPath, response.settings.showInitScriptIndicator);
}
if (response.settings.defaultDeleteBranchWithWorktree !== undefined) {
setDefaultDeleteBranch(currentPath, response.settings.defaultDeleteBranchWithWorktree);
}
if (response.settings.autoDismissInitScriptIndicator !== undefined) {
setAutoDismissInitScriptIndicator(
currentPath,
response.settings.autoDismissInitScriptIndicator
);
}
if (response.settings.worktreeCopyFiles !== undefined) {
setWorktreeCopyFiles(currentPath, response.settings.worktreeCopyFiles);
}
if (response.settings.pinnedWorktreesCount !== undefined) {
setPinnedWorktreesCount(currentPath, response.settings.pinnedWorktreesCount);
}
}
} catch (error) {
if (!isCancelled) {
console.error('Failed to load project settings:', error);
}
}
};
loadProjectSettings();
return () => {
isCancelled = true;
};
}, [
project.path,
setProjectUseWorktrees,
setShowInitScriptIndicator,
setDefaultDeleteBranch,
setAutoDismissInitScriptIndicator,
setWorktreeCopyFiles,
setPinnedWorktreesCount,
]);
// Load init script content when project changes
useEffect(() => {
let isCancelled = false;
const currentPath = project.path;
const loadInitScript = async () => {
setIsLoading(true);
try {
const response = await apiGet<InitScriptResponse>(
`/api/worktree/init-script?projectPath=${encodeURIComponent(currentPath)}`
);
// Avoid updating state if component unmounted or project changed
if (isCancelled) return;
if (response.success) {
const content = response.content || '';
setScriptContent(content);
setOriginalContent(content);
setScriptExists(response.exists);
}
} catch (error) {
if (!isCancelled) {
console.error('Failed to load init script:', error);
}
} finally {
if (!isCancelled) {
setIsLoading(false);
}
}
};
loadInitScript();
return () => {
isCancelled = true;
};
}, [project.path]);
// Save script
const handleSave = useCallback(async () => {
setIsSaving(true);
try {
const response = await apiPut<{ success: boolean; error?: string }>(
'/api/worktree/init-script',
{
projectPath: project.path,
content: scriptContent,
}
);
if (response.success) {
setOriginalContent(scriptContent);
setScriptExists(true);
toast.success('Init script saved');
} else {
toast.error('Failed to save init script', {
description: response.error,
});
}
} catch (error) {
console.error('Failed to save init script:', error);
toast.error('Failed to save init script');
} finally {
setIsSaving(false);
}
}, [project.path, scriptContent]);
// Reset to original content
const handleReset = useCallback(() => {
setScriptContent(originalContent);
}, [originalContent]);
// Delete script
const handleDelete = useCallback(async () => {
setIsDeleting(true);
try {
const response = await apiDelete<{ success: boolean; error?: string }>(
'/api/worktree/init-script',
{
body: { projectPath: project.path },
}
);
if (response.success) {
setScriptContent('');
setOriginalContent('');
setScriptExists(false);
toast.success('Init script deleted');
} else {
toast.error('Failed to delete init script', {
description: response.error,
});
}
} catch (error) {
console.error('Failed to delete init script:', error);
toast.error('Failed to delete init script');
} finally {
setIsDeleting(false);
}
}, [project.path]);
// Handle content change (no auto-save)
const handleContentChange = useCallback((value: string) => {
setScriptContent(value);
}, []);
// Add a new file path to copy list
const handleAddCopyFile = useCallback(async () => {
const trimmed = newCopyFilePath.trim();
if (!trimmed) return;
// Normalize: remove leading ./ or /
const normalized = trimmed.replace(/^\.\//, '').replace(/^\//, '');
if (!normalized) return;
// Check for duplicates
if (copyFiles.includes(normalized)) {
toast.error('File already in list', {
description: `"${normalized}" is already configured for copying.`,
});
return;
}
const prevFiles = copyFiles;
const updatedFiles = [...copyFiles, normalized];
setWorktreeCopyFiles(project.path, updatedFiles);
setNewCopyFilePath('');
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(project.path, {
worktreeCopyFiles: updatedFiles,
});
toast.success('Copy file added', {
description: `"${normalized}" will be copied to new worktrees.`,
});
} catch (error) {
// Rollback optimistic update on failure
setWorktreeCopyFiles(project.path, prevFiles);
setNewCopyFilePath(normalized);
console.error('Failed to persist worktreeCopyFiles:', error);
toast.error('Failed to save copy files setting');
}
}, [project.path, newCopyFilePath, copyFiles, setWorktreeCopyFiles]);
// Remove a file path from copy list
const handleRemoveCopyFile = useCallback(
async (filePath: string) => {
const prevFiles = copyFiles;
const updatedFiles = copyFiles.filter((f) => f !== filePath);
setWorktreeCopyFiles(project.path, updatedFiles);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(project.path, {
worktreeCopyFiles: updatedFiles,
});
toast.success('Copy file removed');
} catch (error) {
// Rollback optimistic update on failure
setWorktreeCopyFiles(project.path, prevFiles);
console.error('Failed to persist worktreeCopyFiles:', error);
toast.error('Failed to save copy files setting');
}
},
[project.path, copyFiles, setWorktreeCopyFiles]
);
// Handle files selected from the file selector dialog
const handleFileSelectorSelect = useCallback(
async (paths: string[]) => {
// Filter out duplicates
const newPaths = paths.filter((p) => !copyFiles.includes(p));
if (newPaths.length === 0) {
toast.info('All selected files are already in the list');
return;
}
const prevFiles = copyFiles;
const updatedFiles = [...copyFiles, ...newPaths];
setWorktreeCopyFiles(project.path, updatedFiles);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(project.path, {
worktreeCopyFiles: updatedFiles,
});
toast.success(`${newPaths.length} ${newPaths.length === 1 ? 'file' : 'files'} added`, {
description: newPaths.map((p) => `"${p}"`).join(', '),
});
} catch (error) {
// Rollback optimistic update on failure
setWorktreeCopyFiles(project.path, prevFiles);
console.error('Failed to persist worktreeCopyFiles:', error);
toast.error('Failed to save copy files setting');
}
},
[project.path, copyFiles, setWorktreeCopyFiles]
);
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<GitBranch className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Worktree Preferences
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure worktree behavior for this project.
</p>
</div>
<div className="p-6 space-y-5">
{/* Enable Git Worktree Isolation Toggle */}
<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="project-use-worktrees"
checked={effectiveUseWorktrees}
onCheckedChange={async (checked) => {
const value = checked === true;
setProjectUseWorktrees(project.path, value);
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(project.path, {
useWorktrees: value,
});
} catch (error) {
console.error('Failed to persist useWorktrees:', error);
}
}}
className="mt-1"
data-testid="project-use-worktrees-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="project-use-worktrees"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<GitBranch className="w-4 h-4 text-brand-500" />
Enable Git Worktree Isolation
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Creates isolated git branches for each feature in this project. When disabled, agents
work directly in the main project directory.
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Show Init Script Indicator Toggle */}
<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="show-init-script-indicator"
checked={showIndicator}
onCheckedChange={async (checked) => {
const value = checked === true;
setShowInitScriptIndicator(project.path, value);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(project.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 */}
{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) => {
const value = checked === true;
setAutoDismissInitScriptIndicator(project.path, value);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(project.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 */}
<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) => {
const value = checked === true;
setDefaultDeleteBranch(project.path, value);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(project.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" />
{/* Worktree Display Settings */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<LayoutGrid className="w-4 h-4 text-brand-500" />
<Label className="text-foreground font-medium">Display Settings</Label>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Control how worktrees are presented in the panel. Pinned worktrees appear as tabs, and
remaining worktrees are available in a combined overflow dropdown.
</p>
{/* Pinned Worktrees Count */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<div className="mt-0.5">
<Pin className="w-4 h-4 text-brand-500" />
</div>
<div className="space-y-2 flex-1">
<div className="flex items-center justify-between">
<Label
htmlFor="pinned-worktrees-count"
className="text-foreground cursor-pointer font-medium"
>
Pinned Worktree Tabs
</Label>
<span className="text-sm font-medium text-foreground tabular-nums">
{pinnedWorktreesCount}
</span>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Number of worktree tabs to pin (excluding the main worktree, which is always shown).
</p>
<Slider
id="pinned-worktrees-count"
min={0}
max={25}
step={1}
value={[pinnedWorktreesCount]}
onValueChange={(value) => {
// Capture previous value before mutation for potential rollback
const prevCount = pinnedWorktreesCount;
// Update local state immediately for visual feedback
const newValue = value[0] ?? pinnedWorktreesCount;
setPinnedWorktreesCount(project.path, newValue);
// Store prev for onValueCommit rollback
sliderPrevRef.current = prevCount;
}}
onValueCommit={async (value) => {
const newValue = value[0] ?? pinnedWorktreesCount;
const prev = sliderPrevRef.current ?? pinnedWorktreesCount;
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(project.path, {
pinnedWorktreesCount: newValue,
});
} catch (error) {
console.error('Failed to persist pinnedWorktreesCount:', error);
toast.error('Failed to save pinned worktrees setting');
// Rollback optimistic update using captured previous value
setPinnedWorktreesCount(project.path, prev);
}
}}
className="w-full"
/>
</div>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Copy Files Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Copy className="w-4 h-4 text-brand-500" />
<Label className="text-foreground font-medium">Copy Files to Worktrees</Label>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Specify files or directories (relative to project root) to automatically copy into new
worktrees. Useful for untracked files like{' '}
<code className="font-mono text-foreground/60">.env</code>,{' '}
<code className="font-mono text-foreground/60">.env.local</code>, or local config files
that aren&apos;t committed to git.
</p>
{/* Current file list */}
{copyFiles.length > 0 && (
<div className="space-y-1.5">
{copyFiles.map((filePath) => (
<div
key={filePath}
className="flex items-center gap-2 group/item px-3 py-1.5 rounded-lg bg-accent/20 hover:bg-accent/40 transition-colors"
>
<FileCode className="w-3.5 h-3.5 text-muted-foreground/60 flex-shrink-0" />
<code className="font-mono text-sm text-foreground/80 flex-1 truncate">
{filePath}
</code>
<button
onClick={() => handleRemoveCopyFile(filePath)}
className="p-0.5 rounded text-muted-foreground/50 hover:bg-destructive/10 hover:text-destructive transition-all flex-shrink-0"
title={`Remove ${filePath}`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
{/* Add new file input */}
<div className="flex items-center gap-2">
<Input
value={newCopyFilePath}
onChange={(e) => setNewCopyFilePath(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddCopyFile();
}
}}
placeholder=".env, config/local.json, etc."
className="flex-1 h-8 text-sm font-mono"
/>
<Button
variant="outline"
size="sm"
onClick={handleAddCopyFile}
disabled={!newCopyFilePath.trim()}
className="gap-1.5 h-8"
>
<Plus className="w-3.5 h-3.5" />
Add
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setFileSelectorOpen(true)}
className="gap-1.5 h-8"
>
<FolderOpen className="w-3.5 h-3.5" />
Browse
</Button>
</div>
{/* File selector dialog */}
<ProjectFileSelectorDialog
open={fileSelectorOpen}
onOpenChange={setFileSelectorOpen}
onSelect={handleFileSelectorSelect}
projectPath={project.path}
existingFiles={copyFiles}
/>
</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.
</p>
{/* 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">
<Spinner size="md" />
</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 ? <Spinner size="xs" /> : <Trash2 className="w-3.5 h-3.5" />}
Delete
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || isSaving || isDeleting}
className="gap-1.5"
>
{isSaving ? <Spinner size="xs" /> : <Save className="w-3.5 h-3.5" />}
Save
</Button>
</div>
</>
)}
</div>
</div>
</div>
);
}