mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +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,12 +11,12 @@ import {
|
||||
Terminal,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Play,
|
||||
File,
|
||||
Pencil,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
@@ -236,7 +236,7 @@ export function AgentToolsView() {
|
||||
>
|
||||
{isReadingFile ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Reading...
|
||||
</>
|
||||
) : (
|
||||
@@ -315,7 +315,7 @@ export function AgentToolsView() {
|
||||
>
|
||||
{isWritingFile ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Writing...
|
||||
</>
|
||||
) : (
|
||||
@@ -383,7 +383,7 @@ export function AgentToolsView() {
|
||||
>
|
||||
{isRunningCommand ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Running...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -42,7 +42,7 @@ export function AgentView() {
|
||||
return () => window.removeEventListener('resize', updateVisibility);
|
||||
}, []);
|
||||
|
||||
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'sonnet' });
|
||||
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'claude-sonnet' });
|
||||
|
||||
// Input ref for auto-focus
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -27,18 +27,6 @@ export function AgentHeader({
|
||||
return (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggleSessionManager}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showSessionManager ? (
|
||||
<PanelLeftClose className="w-4 h-4" />
|
||||
) : (
|
||||
<PanelLeft className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<Bot className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
@@ -71,6 +59,19 @@ export function AgentHeader({
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggleSessionManager}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showSessionManager ? 'Hide sessions panel' : 'Show sessions panel'}
|
||||
>
|
||||
{showSessionManager ? (
|
||||
<PanelLeftClose className="w-4 h-4" />
|
||||
) : (
|
||||
<PanelLeft className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Bot } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
export function ThinkingIndicator() {
|
||||
return (
|
||||
@@ -8,20 +9,7 @@ export function ThinkingIndicator() {
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-2xl px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: '0ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: '150ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: '300ms' }}
|
||||
/>
|
||||
</div>
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm text-muted-foreground">Thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,13 +16,13 @@ import {
|
||||
RefreshCw,
|
||||
BarChart3,
|
||||
FileCode,
|
||||
Loader2,
|
||||
FileText,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
ListChecks,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn, generateUUID } from '@/lib/utils';
|
||||
|
||||
const logger = createLogger('AnalysisView');
|
||||
|
||||
@@ -641,7 +641,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
|
||||
for (const detectedFeature of detectedFeatures) {
|
||||
await api.features.create(currentProject.path, {
|
||||
id: crypto.randomUUID(),
|
||||
id: generateUUID(),
|
||||
category: detectedFeature.category,
|
||||
description: detectedFeature.description,
|
||||
status: 'backlog',
|
||||
@@ -750,7 +750,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
<Button onClick={runAnalysis} disabled={isAnalyzing} data-testid="analyze-project-button">
|
||||
{isAnalyzing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Analyzing...
|
||||
</>
|
||||
) : (
|
||||
@@ -779,7 +779,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
</div>
|
||||
) : isAnalyzing ? (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-primary mb-4" />
|
||||
<Spinner size="xl" className="mb-4" />
|
||||
<p className="text-muted-foreground">Scanning project files...</p>
|
||||
</div>
|
||||
) : projectAnalysis ? (
|
||||
@@ -858,7 +858,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
>
|
||||
{isGeneratingSpec ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
@@ -911,7 +911,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
>
|
||||
{isGeneratingFeatureList ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -34,7 +34,7 @@ import { pathsEqual } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||
import { useWindowState } from '@/hooks/use-window-state';
|
||||
@@ -195,6 +195,7 @@ export function BoardView() {
|
||||
// Selection mode hook for mass editing
|
||||
const {
|
||||
isSelectionMode,
|
||||
selectionTarget,
|
||||
selectedFeatureIds,
|
||||
selectedCount,
|
||||
toggleSelectionMode,
|
||||
@@ -509,9 +510,9 @@ export function BoardView() {
|
||||
// Empty string clears the branch assignment, moving features to main/current branch
|
||||
finalBranchName = '';
|
||||
} else if (workMode === 'auto') {
|
||||
// Auto-generate a branch name based on current branch and timestamp
|
||||
const baseBranch =
|
||||
currentWorktreeBranch || getPrimaryWorktreeBranch(currentProject.path) || 'main';
|
||||
// Auto-generate a branch name based on primary branch (main/master) and timestamp
|
||||
// Always use primary branch to avoid nested feature/feature/... paths
|
||||
const baseBranch = getPrimaryWorktreeBranch(currentProject.path) || 'main';
|
||||
const timestamp = Date.now();
|
||||
const randomSuffix = Math.random().toString(36).substring(2, 6);
|
||||
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
|
||||
@@ -591,7 +592,6 @@ export function BoardView() {
|
||||
selectedFeatureIds,
|
||||
updateFeature,
|
||||
exitSelectionMode,
|
||||
currentWorktreeBranch,
|
||||
getPrimaryWorktreeBranch,
|
||||
addAndSelectWorktree,
|
||||
setWorktreeRefreshKey,
|
||||
@@ -673,6 +673,67 @@ export function BoardView() {
|
||||
isPrimaryWorktreeBranch,
|
||||
]);
|
||||
|
||||
// Get waiting_approval feature IDs in current branch for "Select All"
|
||||
const allSelectableWaitingApprovalFeatureIds = useMemo(() => {
|
||||
return hookFeatures
|
||||
.filter((f) => {
|
||||
// Only waiting_approval features
|
||||
if (f.status !== 'waiting_approval') return false;
|
||||
|
||||
// Filter by current worktree branch
|
||||
const featureBranch = f.branchName;
|
||||
if (!featureBranch) {
|
||||
// No branch assigned - only selectable on primary worktree
|
||||
return currentWorktreePath === null;
|
||||
}
|
||||
if (currentWorktreeBranch === null) {
|
||||
// Viewing main but branch hasn't been initialized
|
||||
return currentProject?.path
|
||||
? isPrimaryWorktreeBranch(currentProject.path, featureBranch)
|
||||
: false;
|
||||
}
|
||||
// Match by branch name
|
||||
return featureBranch === currentWorktreeBranch;
|
||||
})
|
||||
.map((f) => f.id);
|
||||
}, [
|
||||
hookFeatures,
|
||||
currentWorktreePath,
|
||||
currentWorktreeBranch,
|
||||
currentProject?.path,
|
||||
isPrimaryWorktreeBranch,
|
||||
]);
|
||||
|
||||
// Handler for bulk verifying multiple features
|
||||
const handleBulkVerify = useCallback(async () => {
|
||||
if (!currentProject || selectedFeatureIds.size === 0) return;
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const featureIds = Array.from(selectedFeatureIds);
|
||||
const updates = { status: 'verified' as const };
|
||||
|
||||
// Use bulk update API for efficient batch processing
|
||||
const result = await api.features.bulkUpdate(currentProject.path, featureIds, updates);
|
||||
|
||||
if (result.success) {
|
||||
// Update local state for all features
|
||||
featureIds.forEach((featureId) => {
|
||||
updateFeature(featureId, updates);
|
||||
});
|
||||
toast.success(`Verified ${result.updatedCount} features`);
|
||||
exitSelectionMode();
|
||||
} else {
|
||||
toast.error('Failed to verify some features', {
|
||||
description: `${result.failedCount} features failed to verify`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Bulk verify failed:', error);
|
||||
toast.error('Failed to verify features');
|
||||
}
|
||||
}, [currentProject, selectedFeatureIds, updateFeature, exitSelectionMode]);
|
||||
|
||||
// Handler for addressing PR comments - creates a feature and starts it automatically
|
||||
const handleAddressPRComments = useCallback(
|
||||
async (worktree: WorktreeInfo, prInfo: PRInfo) => {
|
||||
@@ -784,68 +845,9 @@ export function BoardView() {
|
||||
[handleAddFeature, handleStartImplementation]
|
||||
);
|
||||
|
||||
// Client-side auto mode: periodically check for backlog items and move them to in-progress
|
||||
// Use a ref to track the latest auto mode state so async operations always check the current value
|
||||
const autoModeRunningRef = useRef(autoMode.isRunning);
|
||||
useEffect(() => {
|
||||
autoModeRunningRef.current = autoMode.isRunning;
|
||||
}, [autoMode.isRunning]);
|
||||
|
||||
// Use a ref to track the latest features to avoid effect re-runs when features change
|
||||
const hookFeaturesRef = useRef(hookFeatures);
|
||||
useEffect(() => {
|
||||
hookFeaturesRef.current = hookFeatures;
|
||||
}, [hookFeatures]);
|
||||
|
||||
// Use a ref to track running tasks to avoid effect re-runs that clear pendingFeaturesRef
|
||||
const runningAutoTasksRef = useRef(runningAutoTasks);
|
||||
useEffect(() => {
|
||||
runningAutoTasksRef.current = runningAutoTasks;
|
||||
}, [runningAutoTasks]);
|
||||
|
||||
// Keep latest start handler without retriggering the auto mode effect
|
||||
const handleStartImplementationRef = useRef(handleStartImplementation);
|
||||
useEffect(() => {
|
||||
handleStartImplementationRef.current = handleStartImplementation;
|
||||
}, [handleStartImplementation]);
|
||||
|
||||
// Track features that are pending (started but not yet confirmed running)
|
||||
const pendingFeaturesRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Listen to auto mode events to remove features from pending when they start running
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
// Only process events for the current project
|
||||
const eventProjectPath = 'projectPath' in event ? event.projectPath : undefined;
|
||||
if (eventProjectPath && eventProjectPath !== currentProject.path) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'auto_mode_feature_start':
|
||||
// Feature is now confirmed running - remove from pending
|
||||
if (event.featureId) {
|
||||
pendingFeaturesRef.current.delete(event.featureId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auto_mode_feature_complete':
|
||||
case 'auto_mode_error':
|
||||
// Feature completed or errored - remove from pending if still there
|
||||
if (event.featureId) {
|
||||
pendingFeaturesRef.current.delete(event.featureId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [currentProject]);
|
||||
// NOTE: Auto mode polling loop has been moved to the backend.
|
||||
// The frontend now just toggles the backend's auto loop via API calls.
|
||||
// See use-auto-mode.ts for the start/stop logic that calls the backend.
|
||||
|
||||
// Listen for backlog plan events (for background generation)
|
||||
useEffect(() => {
|
||||
@@ -878,218 +880,31 @@ export function BoardView() {
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Load any saved plan from disk when opening the board
|
||||
useEffect(() => {
|
||||
logger.info(
|
||||
'[AutoMode] Effect triggered - isRunning:',
|
||||
autoMode.isRunning,
|
||||
'hasProject:',
|
||||
!!currentProject
|
||||
);
|
||||
if (!autoMode.isRunning || !currentProject) {
|
||||
return;
|
||||
}
|
||||
if (!currentProject || pendingBacklogPlan) return;
|
||||
|
||||
logger.info('[AutoMode] Starting auto mode polling loop for project:', currentProject.path);
|
||||
let isChecking = false;
|
||||
let isActive = true; // Track if this effect is still active
|
||||
let isActive = true;
|
||||
const loadSavedPlan = async () => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.backlogPlan) return;
|
||||
|
||||
const checkAndStartFeatures = async () => {
|
||||
// Check if auto mode is still running and effect is still active
|
||||
// Use ref to get the latest value, not the closure value
|
||||
if (!isActive || !autoModeRunningRef.current || !currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent concurrent executions
|
||||
if (isChecking) {
|
||||
return;
|
||||
}
|
||||
|
||||
isChecking = true;
|
||||
try {
|
||||
// Double-check auto mode is still running before proceeding
|
||||
if (!isActive || !autoModeRunningRef.current || !currentProject) {
|
||||
logger.debug(
|
||||
'[AutoMode] Skipping check - isActive:',
|
||||
isActive,
|
||||
'autoModeRunning:',
|
||||
autoModeRunningRef.current,
|
||||
'hasProject:',
|
||||
!!currentProject
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Count currently running tasks + pending features
|
||||
// Use ref to get the latest running tasks without causing effect re-runs
|
||||
const currentRunning = runningAutoTasksRef.current.length + pendingFeaturesRef.current.size;
|
||||
const availableSlots = maxConcurrency - currentRunning;
|
||||
logger.debug(
|
||||
'[AutoMode] Checking features - running:',
|
||||
currentRunning,
|
||||
'available slots:',
|
||||
availableSlots
|
||||
);
|
||||
|
||||
// No available slots, skip check
|
||||
if (availableSlots <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter backlog features by the currently selected worktree branch
|
||||
// This logic mirrors use-board-column-features.ts for consistency.
|
||||
// HOWEVER: auto mode should still run even if the user is viewing a non-primary worktree,
|
||||
// so we fall back to "all backlog features" when none are visible in the current view.
|
||||
// Use ref to get the latest features without causing effect re-runs
|
||||
const currentFeatures = hookFeaturesRef.current;
|
||||
const backlogFeaturesInView = currentFeatures.filter((f) => {
|
||||
if (f.status !== 'backlog') return false;
|
||||
|
||||
const featureBranch = f.branchName;
|
||||
|
||||
// Features without branchName are considered unassigned (show only on primary worktree)
|
||||
if (!featureBranch) {
|
||||
// No branch assigned - show only when viewing primary worktree
|
||||
const isViewingPrimary = currentWorktreePath === null;
|
||||
return isViewingPrimary;
|
||||
}
|
||||
|
||||
if (currentWorktreeBranch === null) {
|
||||
// We're viewing main but branch hasn't been initialized yet
|
||||
// Show features assigned to primary worktree's branch
|
||||
return currentProject.path
|
||||
? isPrimaryWorktreeBranch(currentProject.path, featureBranch)
|
||||
: false;
|
||||
}
|
||||
|
||||
// Match by branch name
|
||||
return featureBranch === currentWorktreeBranch;
|
||||
});
|
||||
|
||||
const backlogFeatures =
|
||||
backlogFeaturesInView.length > 0
|
||||
? backlogFeaturesInView
|
||||
: currentFeatures.filter((f) => f.status === 'backlog');
|
||||
|
||||
logger.debug(
|
||||
'[AutoMode] Features - total:',
|
||||
currentFeatures.length,
|
||||
'backlog in view:',
|
||||
backlogFeaturesInView.length,
|
||||
'backlog total:',
|
||||
backlogFeatures.length
|
||||
);
|
||||
|
||||
if (backlogFeatures.length === 0) {
|
||||
logger.debug(
|
||||
'[AutoMode] No backlog features found, statuses:',
|
||||
currentFeatures.map((f) => f.status).join(', ')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by priority (lower number = higher priority, priority 1 is highest)
|
||||
const sortedBacklog = [...backlogFeatures].sort(
|
||||
(a, b) => (a.priority || 999) - (b.priority || 999)
|
||||
);
|
||||
|
||||
// Filter out features with blocking dependencies if dependency blocking is enabled
|
||||
// NOTE: skipVerificationInAutoMode means "ignore unmet dependency verification" so we
|
||||
// should NOT exclude blocked features in that mode.
|
||||
const eligibleFeatures =
|
||||
enableDependencyBlocking && !skipVerificationInAutoMode
|
||||
? sortedBacklog.filter((f) => {
|
||||
const blockingDeps = getBlockingDependencies(f, currentFeatures);
|
||||
if (blockingDeps.length > 0) {
|
||||
logger.debug('[AutoMode] Feature', f.id, 'blocked by deps:', blockingDeps);
|
||||
}
|
||||
return blockingDeps.length === 0;
|
||||
})
|
||||
: sortedBacklog;
|
||||
|
||||
logger.debug(
|
||||
'[AutoMode] Eligible features after dep check:',
|
||||
eligibleFeatures.length,
|
||||
'dependency blocking enabled:',
|
||||
enableDependencyBlocking
|
||||
);
|
||||
|
||||
// Start features up to available slots
|
||||
const featuresToStart = eligibleFeatures.slice(0, availableSlots);
|
||||
const startImplementation = handleStartImplementationRef.current;
|
||||
if (!startImplementation) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
'[AutoMode] Starting',
|
||||
featuresToStart.length,
|
||||
'features:',
|
||||
featuresToStart.map((f) => f.id).join(', ')
|
||||
);
|
||||
|
||||
for (const feature of featuresToStart) {
|
||||
// Check again before starting each feature
|
||||
if (!isActive || !autoModeRunningRef.current || !currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Simplified: No worktree creation on client - server derives workDir from feature.branchName
|
||||
// If feature has no branchName, assign it to the primary branch so it can run consistently
|
||||
// even when the user is viewing a non-primary worktree.
|
||||
if (!feature.branchName) {
|
||||
const primaryBranch =
|
||||
(currentProject.path ? getPrimaryWorktreeBranch(currentProject.path) : null) ||
|
||||
'main';
|
||||
await persistFeatureUpdate(feature.id, {
|
||||
branchName: primaryBranch,
|
||||
});
|
||||
}
|
||||
|
||||
// Final check before starting implementation
|
||||
if (!isActive || !autoModeRunningRef.current || !currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the implementation - server will derive workDir from feature.branchName
|
||||
const started = await startImplementation(feature);
|
||||
|
||||
// If successfully started, track it as pending until we receive the start event
|
||||
if (started) {
|
||||
pendingFeaturesRef.current.add(feature.id);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isChecking = false;
|
||||
const result = await api.backlogPlan.status(currentProject.path);
|
||||
if (
|
||||
isActive &&
|
||||
result.success &&
|
||||
result.savedPlan?.result &&
|
||||
result.savedPlan.result.changes?.length > 0
|
||||
) {
|
||||
setPendingBacklogPlan(result.savedPlan.result);
|
||||
}
|
||||
};
|
||||
|
||||
// Check immediately, then every 3 seconds
|
||||
checkAndStartFeatures();
|
||||
const interval = setInterval(checkAndStartFeatures, 3000);
|
||||
|
||||
loadSavedPlan();
|
||||
return () => {
|
||||
// Mark as inactive to prevent any pending async operations from continuing
|
||||
isActive = false;
|
||||
clearInterval(interval);
|
||||
// Clear pending features when effect unmounts or dependencies change
|
||||
pendingFeaturesRef.current.clear();
|
||||
};
|
||||
}, [
|
||||
autoMode.isRunning,
|
||||
currentProject,
|
||||
// runningAutoTasks is accessed via runningAutoTasksRef to prevent effect re-runs
|
||||
// that would clear pendingFeaturesRef and cause concurrency issues
|
||||
maxConcurrency,
|
||||
// hookFeatures is accessed via hookFeaturesRef to prevent effect re-runs
|
||||
currentWorktreeBranch,
|
||||
currentWorktreePath,
|
||||
getPrimaryWorktreeBranch,
|
||||
isPrimaryWorktreeBranch,
|
||||
enableDependencyBlocking,
|
||||
skipVerificationInAutoMode,
|
||||
persistFeatureUpdate,
|
||||
]);
|
||||
}, [currentProject, pendingBacklogPlan]);
|
||||
|
||||
// Use keyboard shortcuts hook (after actions hook)
|
||||
useBoardKeyboardShortcuts({
|
||||
@@ -1284,7 +1099,7 @@ export function BoardView() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="board-view-loading">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1303,12 +1118,18 @@ export function BoardView() {
|
||||
isAutoModeRunning={autoMode.isRunning}
|
||||
onAutoModeToggle={(enabled) => {
|
||||
if (enabled) {
|
||||
autoMode.start();
|
||||
autoMode.start().catch((error) => {
|
||||
logger.error('[AutoMode] Failed to start:', error);
|
||||
});
|
||||
} else {
|
||||
autoMode.stop();
|
||||
autoMode.stop().catch((error) => {
|
||||
logger.error('[AutoMode] Failed to stop:', error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
onOpenPlanDialog={() => setShowPlanDialog(true)}
|
||||
hasPendingPlan={Boolean(pendingBacklogPlan)}
|
||||
onOpenPendingPlan={() => setShowPlanDialog(true)}
|
||||
isMounted={isMounted}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
@@ -1435,6 +1256,7 @@ export function BoardView() {
|
||||
pipelineConfig={pipelineConfig}
|
||||
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectionTarget={selectionTarget}
|
||||
selectedFeatureIds={selectedFeatureIds}
|
||||
onToggleFeatureSelection={toggleFeatureSelection}
|
||||
onToggleSelectionMode={toggleSelectionMode}
|
||||
@@ -1450,11 +1272,23 @@ export function BoardView() {
|
||||
{isSelectionMode && (
|
||||
<SelectionActionBar
|
||||
selectedCount={selectedCount}
|
||||
totalCount={allSelectableFeatureIds.length}
|
||||
onEdit={() => setShowMassEditDialog(true)}
|
||||
onDelete={handleBulkDelete}
|
||||
totalCount={
|
||||
selectionTarget === 'waiting_approval'
|
||||
? allSelectableWaitingApprovalFeatureIds.length
|
||||
: allSelectableFeatureIds.length
|
||||
}
|
||||
onEdit={selectionTarget === 'backlog' ? () => setShowMassEditDialog(true) : undefined}
|
||||
onDelete={selectionTarget === 'backlog' ? handleBulkDelete : undefined}
|
||||
onVerify={selectionTarget === 'waiting_approval' ? handleBulkVerify : undefined}
|
||||
onClear={clearSelection}
|
||||
onSelectAll={() => selectAll(allSelectableFeatureIds)}
|
||||
onSelectAll={() =>
|
||||
selectAll(
|
||||
selectionTarget === 'waiting_approval'
|
||||
? allSelectableWaitingApprovalFeatureIds
|
||||
: allSelectableFeatureIds
|
||||
)
|
||||
}
|
||||
mode={selectionTarget === 'waiting_approval' ? 'waiting_approval' : 'backlog'}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Wand2, GitBranch } from 'lucide-react';
|
||||
import { Wand2, GitBranch, ClipboardCheck } from 'lucide-react';
|
||||
import { UsagePopover } from '@/components/usage-popover';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { useIsTablet } from '@/hooks/use-media-query';
|
||||
import { AutoModeSettingsPopover } from './dialogs/auto-mode-settings-popover';
|
||||
import { WorktreeSettingsPopover } from './dialogs/worktree-settings-popover';
|
||||
import { PlanSettingsPopover } from './dialogs/plan-settings-popover';
|
||||
@@ -25,6 +25,8 @@ interface BoardHeaderProps {
|
||||
isAutoModeRunning: boolean;
|
||||
onAutoModeToggle: (enabled: boolean) => void;
|
||||
onOpenPlanDialog: () => void;
|
||||
hasPendingPlan?: boolean;
|
||||
onOpenPendingPlan?: () => void;
|
||||
isMounted: boolean;
|
||||
// Search bar props
|
||||
searchQuery: string;
|
||||
@@ -50,6 +52,8 @@ export function BoardHeader({
|
||||
isAutoModeRunning,
|
||||
onAutoModeToggle,
|
||||
onOpenPlanDialog,
|
||||
hasPendingPlan,
|
||||
onOpenPendingPlan,
|
||||
isMounted,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
@@ -104,7 +108,10 @@ export function BoardHeader({
|
||||
// Show if Codex is authenticated (CLI or API key)
|
||||
const showCodexUsage = !!codexAuthStatus?.authenticated;
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
// State for mobile actions panel
|
||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||
|
||||
const isTablet = useIsTablet();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-5 p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
@@ -121,11 +128,13 @@ export function BoardHeader({
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
|
||||
{isMounted && !isMobile && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
||||
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
||||
|
||||
{/* Mobile view: show hamburger menu with all controls */}
|
||||
{isMounted && isMobile && (
|
||||
{/* Tablet/Mobile view: show hamburger menu with all controls */}
|
||||
{isMounted && isTablet && (
|
||||
<HeaderMobileMenu
|
||||
isOpen={showActionsPanel}
|
||||
onToggle={() => setShowActionsPanel(!showActionsPanel)}
|
||||
isWorktreePanelVisible={isWorktreePanelVisible}
|
||||
onWorktreePanelToggle={handleWorktreePanelToggle}
|
||||
maxConcurrency={maxConcurrency}
|
||||
@@ -142,7 +151,7 @@ export function BoardHeader({
|
||||
|
||||
{/* Desktop view: show full controls */}
|
||||
{/* Worktrees Toggle - only show after mount to prevent hydration issues */}
|
||||
{isMounted && !isMobile && (
|
||||
{isMounted && !isTablet && (
|
||||
<div className={controlContainerClass} data-testid="worktrees-toggle-container">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<Label
|
||||
@@ -165,7 +174,7 @@ export function BoardHeader({
|
||||
)}
|
||||
|
||||
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
||||
{isMounted && !isMobile && (
|
||||
{isMounted && !isTablet && (
|
||||
<div className={controlContainerClass} data-testid="auto-mode-toggle-container">
|
||||
<Label
|
||||
htmlFor="auto-mode-toggle"
|
||||
@@ -189,9 +198,18 @@ export function BoardHeader({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan Button with Settings - only show on desktop, mobile has it in the menu */}
|
||||
{isMounted && !isMobile && (
|
||||
{/* Plan Button with Settings - only show on desktop, tablet/mobile has it in the panel */}
|
||||
{isMounted && !isTablet && (
|
||||
<div className={controlContainerClass} data-testid="plan-button-container">
|
||||
{hasPendingPlan && (
|
||||
<button
|
||||
onClick={onOpenPendingPlan || onOpenPlanDialog}
|
||||
className="flex items-center gap-1.5 text-emerald-500 hover:text-emerald-400 transition-colors"
|
||||
data-testid="plan-review-button"
|
||||
>
|
||||
<ClipboardCheck className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onOpenPlanDialog}
|
||||
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Search, X, Loader2 } from 'lucide-react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
interface BoardSearchBarProps {
|
||||
searchQuery: string;
|
||||
@@ -75,7 +76,7 @@ export function BoardSearchBar({
|
||||
title="Creating App Specification"
|
||||
data-testid="spec-creation-badge"
|
||||
>
|
||||
<Loader2 className="w-3 h-3 animate-spin text-brand-500 shrink-0" />
|
||||
<Spinner size="xs" className="shrink-0" />
|
||||
<span className="text-xs font-medium text-brand-500 whitespace-nowrap">
|
||||
Creating spec
|
||||
</span>
|
||||
|
||||
@@ -10,16 +10,8 @@ import {
|
||||
} from '@/lib/agent-context-parser';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AutoModeEvent } from '@/types/electron';
|
||||
import {
|
||||
Brain,
|
||||
ListTodo,
|
||||
Sparkles,
|
||||
Expand,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Loader2,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { Brain, ListTodo, Sparkles, Expand, CheckCircle2, Circle, Wrench } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { SummaryDialog } from './summary-dialog';
|
||||
import { getProviderIconForModel } from '@/components/ui/provider-icon';
|
||||
@@ -303,7 +295,7 @@ export function AgentInfoPanel({
|
||||
{todo.status === 'completed' ? (
|
||||
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
|
||||
) : todo.status === 'in_progress' ? (
|
||||
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
|
||||
<Spinner size="xs" className="w-2.5 h-2.5 shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
|
||||
)}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import {
|
||||
GripVertical,
|
||||
Edit,
|
||||
Loader2,
|
||||
Trash2,
|
||||
FileText,
|
||||
MoreVertical,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
ChevronUp,
|
||||
GitFork,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { CountUpTimer } from '@/components/ui/count-up-timer';
|
||||
import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
|
||||
@@ -65,7 +65,7 @@ export function CardHeaderSection({
|
||||
{isCurrentAutoTask && !isSelectionMode && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
<div className="flex items-center justify-center gap-2 bg-[var(--status-in-progress)]/15 border border-[var(--status-in-progress)]/50 rounded-md px-2 py-0.5">
|
||||
<Loader2 className="w-3.5 h-3.5 text-[var(--status-in-progress)] animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
{feature.startedAt && (
|
||||
<CountUpTimer
|
||||
startedAt={feature.startedAt}
|
||||
@@ -324,7 +324,7 @@ export function CardHeaderSection({
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
{feature.titleGenerating ? (
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
|
||||
<Spinner size="xs" />
|
||||
<span className="text-xs text-muted-foreground italic">Generating title...</span>
|
||||
</div>
|
||||
) : feature.title ? (
|
||||
|
||||
@@ -65,6 +65,7 @@ interface KanbanCardProps {
|
||||
isSelectionMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
onToggleSelect?: () => void;
|
||||
selectionTarget?: 'backlog' | 'waiting_approval' | null;
|
||||
}
|
||||
|
||||
export const KanbanCard = memo(function KanbanCard({
|
||||
@@ -96,6 +97,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
isSelectionMode = false,
|
||||
isSelected = false,
|
||||
onToggleSelect,
|
||||
selectionTarget = null,
|
||||
}: KanbanCardProps) {
|
||||
const { useWorktrees, currentProject } = useAppStore();
|
||||
const [isLifted, setIsLifted] = useState(false);
|
||||
@@ -125,8 +127,8 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
|
||||
const cardStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity);
|
||||
|
||||
// Only allow selection for backlog features
|
||||
const isSelectable = isSelectionMode && feature.status === 'backlog';
|
||||
// Only allow selection for features matching the selection target
|
||||
const isSelectable = isSelectionMode && feature.status === selectionTarget;
|
||||
|
||||
const wrapperClasses = cn(
|
||||
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
|
||||
@@ -180,7 +182,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
|
||||
{/* Category row with selection checkbox */}
|
||||
<div className="px-3 pt-3 flex items-center gap-2">
|
||||
{isSelectionMode && !isOverlay && feature.status === 'backlog' && (
|
||||
{isSelectable && !isOverlay && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleSelect?.()}
|
||||
|
||||
@@ -411,7 +411,6 @@ export const ListView = memo(function ListView({
|
||||
feature={feature}
|
||||
handlers={createHandlers(feature)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||
pipelineConfig={pipelineConfig}
|
||||
isSelected={selectedFeatureIds.has(feature.id)}
|
||||
showCheckbox={isSelectionMode}
|
||||
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Pencil, X, CheckSquare, Trash2 } from 'lucide-react';
|
||||
import { Pencil, X, CheckSquare, Trash2, CheckCircle2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -11,13 +11,17 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
export type SelectionActionMode = 'backlog' | 'waiting_approval';
|
||||
|
||||
interface SelectionActionBarProps {
|
||||
selectedCount: number;
|
||||
totalCount: number;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
onVerify?: () => void;
|
||||
onClear: () => void;
|
||||
onSelectAll: () => void;
|
||||
mode?: SelectionActionMode;
|
||||
}
|
||||
|
||||
export function SelectionActionBar({
|
||||
@@ -25,10 +29,13 @@ export function SelectionActionBar({
|
||||
totalCount,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onVerify,
|
||||
onClear,
|
||||
onSelectAll,
|
||||
mode = 'backlog',
|
||||
}: SelectionActionBarProps) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showVerifyDialog, setShowVerifyDialog] = useState(false);
|
||||
|
||||
const allSelected = selectedCount === totalCount && totalCount > 0;
|
||||
|
||||
@@ -38,7 +45,16 @@ export function SelectionActionBar({
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
setShowDeleteDialog(false);
|
||||
onDelete();
|
||||
onDelete?.();
|
||||
};
|
||||
|
||||
const handleVerifyClick = () => {
|
||||
setShowVerifyDialog(true);
|
||||
};
|
||||
|
||||
const handleConfirmVerify = () => {
|
||||
setShowVerifyDialog(false);
|
||||
onVerify?.();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -54,36 +70,56 @@ export function SelectionActionBar({
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{selectedCount === 0
|
||||
? 'Select features to edit'
|
||||
? mode === 'waiting_approval'
|
||||
? 'Select features to verify'
|
||||
: 'Select features to edit'
|
||||
: `${selectedCount} feature${selectedCount !== 1 ? 's' : ''} selected`}
|
||||
</span>
|
||||
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
disabled={selectedCount === 0}
|
||||
className="h-8 bg-brand-500 hover:bg-brand-600 disabled:opacity-50"
|
||||
data-testid="selection-edit-button"
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-1.5" />
|
||||
Edit Selected
|
||||
</Button>
|
||||
{mode === 'backlog' && (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
disabled={selectedCount === 0}
|
||||
className="h-8 bg-brand-500 hover:bg-brand-600 disabled:opacity-50"
|
||||
data-testid="selection-edit-button"
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-1.5" />
|
||||
Edit Selected
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDeleteClick}
|
||||
disabled={selectedCount === 0}
|
||||
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
data-testid="selection-delete-button"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1.5" />
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDeleteClick}
|
||||
disabled={selectedCount === 0}
|
||||
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
data-testid="selection-delete-button"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'waiting_approval' && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleVerifyClick}
|
||||
disabled={selectedCount === 0}
|
||||
className="h-8 bg-green-600 hover:bg-green-700 disabled:opacity-50"
|
||||
data-testid="selection-verify-button"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-1.5" />
|
||||
Verify Selected
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!allSelected && (
|
||||
<Button
|
||||
@@ -146,6 +182,42 @@ export function SelectionActionBar({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Verify Confirmation Dialog */}
|
||||
<Dialog open={showVerifyDialog} onOpenChange={setShowVerifyDialog}>
|
||||
<DialogContent data-testid="bulk-verify-confirmation-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-green-600">
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
Verify Selected Features?
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to mark {selectedCount} feature
|
||||
{selectedCount !== 1 ? 's' : ''} as verified?
|
||||
<span className="block mt-2 text-muted-foreground">
|
||||
This will move them to the Verified column.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowVerifyDialog(false)}
|
||||
data-testid="cancel-bulk-verify-button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
onClick={handleConfirmVerify}
|
||||
data-testid="confirm-bulk-verify-button"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Verify
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
|
||||
import { DependencySelector } from '@/components/ui/dependency-selector';
|
||||
import {
|
||||
DescriptionImageDropZone,
|
||||
FeatureImagePath as DescriptionImagePath,
|
||||
@@ -99,6 +100,7 @@ type FeatureData = {
|
||||
planningMode: PlanningMode;
|
||||
requirePlanApproval: boolean;
|
||||
dependencies?: string[];
|
||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||
workMode: WorkMode;
|
||||
};
|
||||
|
||||
@@ -168,7 +170,7 @@ export function AddFeatureDialog({
|
||||
const [priority, setPriority] = useState(2);
|
||||
|
||||
// Model selection state
|
||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'opus' });
|
||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'claude-opus' });
|
||||
|
||||
// Check if current model supports planning mode (Claude/Anthropic only)
|
||||
const modelSupportsPlanningMode = isClaudeModel(modelEntry.model);
|
||||
@@ -188,6 +190,10 @@ export function AddFeatureDialog({
|
||||
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
|
||||
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Dependency selection state (not in spawn mode)
|
||||
const [parentDependencies, setParentDependencies] = useState<string[]>([]);
|
||||
const [childDependencies, setChildDependencies] = useState<string[]>([]);
|
||||
|
||||
// Get defaults from store
|
||||
const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees, defaultFeatureModel } =
|
||||
useAppStore();
|
||||
@@ -224,6 +230,10 @@ export function AddFeatureDialog({
|
||||
setAncestors([]);
|
||||
setSelectedAncestorIds(new Set());
|
||||
}
|
||||
|
||||
// Reset dependency selections
|
||||
setParentDependencies([]);
|
||||
setChildDependencies([]);
|
||||
}
|
||||
}, [
|
||||
open,
|
||||
@@ -291,6 +301,16 @@ export function AddFeatureDialog({
|
||||
}
|
||||
}
|
||||
|
||||
// Determine final dependencies
|
||||
// In spawn mode, use parent feature as dependency
|
||||
// Otherwise, use manually selected parent dependencies
|
||||
const finalDependencies =
|
||||
isSpawnMode && parentFeature
|
||||
? [parentFeature.id]
|
||||
: parentDependencies.length > 0
|
||||
? parentDependencies
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
title,
|
||||
category: finalCategory,
|
||||
@@ -306,7 +326,8 @@ export function AddFeatureDialog({
|
||||
priority,
|
||||
planningMode,
|
||||
requirePlanApproval,
|
||||
dependencies: isSpawnMode && parentFeature ? [parentFeature.id] : undefined,
|
||||
dependencies: finalDependencies,
|
||||
childDependencies: childDependencies.length > 0 ? childDependencies : undefined,
|
||||
workMode,
|
||||
};
|
||||
};
|
||||
@@ -331,6 +352,8 @@ export function AddFeatureDialog({
|
||||
setPreviewMap(new Map());
|
||||
setDescriptionError(false);
|
||||
setDescriptionHistory([]);
|
||||
setParentDependencies([]);
|
||||
setChildDependencies([]);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
@@ -641,6 +664,38 @@ export function AddFeatureDialog({
|
||||
testIdPrefix="feature-work-mode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dependencies - only show when not in spawn mode */}
|
||||
{!isSpawnMode && allFeatures.length > 0 && (
|
||||
<div className="pt-2 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Parent Dependencies (this feature depends on)
|
||||
</Label>
|
||||
<DependencySelector
|
||||
value={parentDependencies}
|
||||
onChange={setParentDependencies}
|
||||
features={allFeatures}
|
||||
type="parent"
|
||||
placeholder="Select features this depends on..."
|
||||
data-testid="add-feature-parent-deps"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Child Dependencies (features that depend on this)
|
||||
</Label>
|
||||
<DependencySelector
|
||||
value={childDependencies}
|
||||
onChange={setChildDependencies}
|
||||
features={allFeatures}
|
||||
type="child"
|
||||
placeholder="Select features that will depend on this..."
|
||||
data-testid="add-feature-child-deps"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Loader2, List, FileText, GitBranch, ClipboardList } from 'lucide-react';
|
||||
import { List, FileText, GitBranch, ClipboardList } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { LogViewer } from '@/components/ui/log-viewer';
|
||||
import { GitDiffPanel } from '@/components/ui/git-diff-panel';
|
||||
@@ -41,6 +42,8 @@ export function AgentOutputModal({
|
||||
onNumberKeyPress,
|
||||
projectPath: projectPathProp,
|
||||
}: AgentOutputModalProps) {
|
||||
const isBacklogPlan = featureId.startsWith('backlog-plan:');
|
||||
|
||||
// Resolve project path - prefer prop, fallback to window.__currentProject
|
||||
const resolvedProjectPath = projectPathProp || (window as any).__currentProject?.path || '';
|
||||
|
||||
@@ -86,7 +89,7 @@ export function AgentOutputModal({
|
||||
if (!open) return;
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
if (!api?.autoMode || isBacklogPlan) return;
|
||||
|
||||
console.log('[AgentOutputModal] Subscribing to events for featureId:', featureId);
|
||||
|
||||
@@ -247,7 +250,43 @@ export function AgentOutputModal({
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [open, featureId]);
|
||||
}, [open, featureId, isBacklogPlan]);
|
||||
|
||||
// Listen to backlog plan events and update output
|
||||
useEffect(() => {
|
||||
if (!open || !isBacklogPlan) return;
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api?.backlogPlan) return;
|
||||
|
||||
const unsubscribe = api.backlogPlan.onEvent((event: any) => {
|
||||
if (!event?.type) return;
|
||||
|
||||
let newContent = '';
|
||||
switch (event.type) {
|
||||
case 'backlog_plan_progress':
|
||||
newContent = `\n🧭 ${event.content || 'Backlog plan progress update'}\n`;
|
||||
break;
|
||||
case 'backlog_plan_error':
|
||||
newContent = `\n❌ Backlog plan error: ${event.error || 'Unknown error'}\n`;
|
||||
break;
|
||||
case 'backlog_plan_complete':
|
||||
newContent = `\n✅ Backlog plan completed\n`;
|
||||
break;
|
||||
default:
|
||||
newContent = `\nℹ️ ${event.type}\n`;
|
||||
break;
|
||||
}
|
||||
|
||||
if (newContent) {
|
||||
setOutput((prev) => `${prev}${newContent}`);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [open, isBacklogPlan]);
|
||||
|
||||
// Handle scroll to detect if user scrolled up
|
||||
const handleScroll = () => {
|
||||
@@ -286,7 +325,7 @@ export function AgentOutputModal({
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pr-8">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
|
||||
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
||||
<Spinner size="md" />
|
||||
)}
|
||||
Agent Output
|
||||
</DialogTitle>
|
||||
@@ -344,7 +383,7 @@ export function AgentOutputModal({
|
||||
</div>
|
||||
</div>
|
||||
<DialogDescription
|
||||
className="mt-1 max-h-24 overflow-y-auto break-words"
|
||||
className="mt-1 max-h-24 overflow-y-auto wrap-break-word"
|
||||
data-testid="agent-output-description"
|
||||
>
|
||||
{featureDescription}
|
||||
@@ -352,11 +391,13 @@ export function AgentOutputModal({
|
||||
</DialogHeader>
|
||||
|
||||
{/* Task Progress Panel - shows when tasks are being executed */}
|
||||
<TaskProgressPanel
|
||||
featureId={featureId}
|
||||
projectPath={resolvedProjectPath}
|
||||
className="flex-shrink-0 mx-3 my-2"
|
||||
/>
|
||||
{!isBacklogPlan && (
|
||||
<TaskProgressPanel
|
||||
featureId={featureId}
|
||||
projectPath={resolvedProjectPath}
|
||||
className="shrink-0 mx-3 my-2"
|
||||
/>
|
||||
)}
|
||||
|
||||
{effectiveViewMode === 'changes' ? (
|
||||
<div className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible">
|
||||
@@ -370,7 +411,7 @@ export function AgentOutputModal({
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||
<Spinner size="lg" className="mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
@@ -384,11 +425,11 @@ export function AgentOutputModal({
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs scrollbar-visible"
|
||||
className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto bg-popover border border-border/50 rounded-lg p-4 font-mono text-xs scrollbar-visible"
|
||||
>
|
||||
{isLoading && !output ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||
<Spinner size="lg" className="mr-2" />
|
||||
Loading output...
|
||||
</div>
|
||||
) : !output ? (
|
||||
@@ -398,11 +439,13 @@ export function AgentOutputModal({
|
||||
) : effectiveViewMode === 'parsed' ? (
|
||||
<LogViewer output={output} />
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap break-words text-zinc-300">{output}</div>
|
||||
<div className="whitespace-pre-wrap wrap-break-word text-foreground/80">
|
||||
{output}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground text-center flex-shrink-0">
|
||||
<div className="text-xs text-muted-foreground text-center shrink-0">
|
||||
{autoScrollRef.current
|
||||
? 'Auto-scrolling enabled'
|
||||
: 'Scroll to bottom to enable auto-scroll'}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,16 +11,8 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Loader2,
|
||||
Wand2,
|
||||
Check,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { Wand2, Check, Plus, Pencil, Trash2, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -43,16 +36,6 @@ function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract model string from PhaseModelEntry or string
|
||||
*/
|
||||
function extractModel(entry: PhaseModelEntry | string): ModelAlias | CursorModelId {
|
||||
if (typeof entry === 'string') {
|
||||
return entry as ModelAlias | CursorModelId;
|
||||
}
|
||||
return entry.model;
|
||||
}
|
||||
|
||||
interface BacklogPlanDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -80,6 +63,7 @@ export function BacklogPlanDialog({
|
||||
setIsGeneratingPlan,
|
||||
currentBranch,
|
||||
}: BacklogPlanDialogProps) {
|
||||
const logger = createLogger('BacklogPlanDialog');
|
||||
const [mode, setMode] = useState<DialogMode>('input');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [expandedChanges, setExpandedChanges] = useState<Set<number>>(new Set());
|
||||
@@ -110,11 +94,17 @@ export function BacklogPlanDialog({
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api?.backlogPlan) {
|
||||
logger.warn('Backlog plan API not available');
|
||||
toast.error('API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Start generation in background
|
||||
logger.debug('Starting backlog plan generation', {
|
||||
projectPath,
|
||||
promptLength: prompt.length,
|
||||
hasModelOverride: Boolean(modelOverride),
|
||||
});
|
||||
setIsGeneratingPlan(true);
|
||||
|
||||
// Use model override if set, otherwise use global default (extract model string from PhaseModelEntry)
|
||||
@@ -122,12 +112,20 @@ export function BacklogPlanDialog({
|
||||
const effectiveModel = effectiveModelEntry.model;
|
||||
const result = await api.backlogPlan.generate(projectPath, prompt, effectiveModel);
|
||||
if (!result.success) {
|
||||
logger.error('Backlog plan generation failed to start', {
|
||||
error: result.error,
|
||||
projectPath,
|
||||
});
|
||||
setIsGeneratingPlan(false);
|
||||
toast.error(result.error || 'Failed to start plan generation');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show toast and close dialog - generation runs in background
|
||||
logger.debug('Backlog plan generation started', {
|
||||
projectPath,
|
||||
model: effectiveModel,
|
||||
});
|
||||
toast.info('Generating plan... This will be ready soon!', {
|
||||
duration: 3000,
|
||||
});
|
||||
@@ -194,10 +192,15 @@ export function BacklogPlanDialog({
|
||||
currentBranch,
|
||||
]);
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
const handleDiscard = useCallback(async () => {
|
||||
setPendingPlanResult(null);
|
||||
setMode('input');
|
||||
}, [setPendingPlanResult]);
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (api?.backlogPlan) {
|
||||
await api.backlogPlan.clear(projectPath);
|
||||
}
|
||||
}, [setPendingPlanResult, projectPath]);
|
||||
|
||||
const toggleChangeExpanded = (index: number) => {
|
||||
setExpandedChanges((prev) => {
|
||||
@@ -260,11 +263,11 @@ export function BacklogPlanDialog({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Describe the changes you want to make to your backlog. The AI will analyze your
|
||||
current features and propose additions, updates, or deletions.
|
||||
Describe the changes you want to make across your features. The AI will analyze your
|
||||
current feature list and propose additions, updates, deletions, or restructuring.
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="e.g., Add authentication features with login, signup, and password reset. Also add a dashboard feature that depends on authentication."
|
||||
placeholder="e.g., Refactor onboarding into smaller features, add a dashboard feature that depends on authentication, and remove the legacy tour task."
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="min-h-[150px] resize-none"
|
||||
@@ -276,14 +279,13 @@ export function BacklogPlanDialog({
|
||||
</div>
|
||||
{isGeneratingPlan && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 rounded-lg p-3">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />A plan is currently being generated in
|
||||
the background...
|
||||
<Spinner size="sm" />A plan is currently being generated in the background...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'review':
|
||||
case 'review': {
|
||||
if (!pendingPlanResult) return null;
|
||||
|
||||
const additions = pendingPlanResult.changes.filter((c) => c.type === 'add');
|
||||
@@ -389,11 +391,12 @@ export function BacklogPlanDialog({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'applying':
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary mb-4" />
|
||||
<Spinner size="xl" className="mb-4" />
|
||||
<p className="text-muted-foreground">Applying changes...</p>
|
||||
</div>
|
||||
);
|
||||
@@ -402,7 +405,6 @@ export function BacklogPlanDialog({
|
||||
|
||||
// Get effective model entry (override or global default)
|
||||
const effectiveModelEntry = modelOverride || normalizeEntry(phaseModels.backlogPlanningModel);
|
||||
const effectiveModel = effectiveModelEntry.model;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
@@ -410,12 +412,12 @@ export function BacklogPlanDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Wand2 className="w-5 h-5 text-primary" />
|
||||
{mode === 'review' ? 'Review Plan' : 'Plan Backlog Changes'}
|
||||
{mode === 'review' ? 'Review Plan' : 'Plan Feature Changes'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === 'review'
|
||||
? 'Select which changes to apply to your backlog'
|
||||
: 'Use AI to add, update, or remove features from your backlog'}
|
||||
? 'Select which changes to apply to your features'
|
||||
: 'Use AI to add, update, remove, or restructure your features'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -441,13 +443,13 @@ export function BacklogPlanDialog({
|
||||
<Button onClick={handleGenerate} disabled={!prompt.trim() || isGeneratingPlan}>
|
||||
{isGeneratingPlan ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wand2 className="w-4 h-4 mr-2" />
|
||||
Generate Plan
|
||||
Apply Changes
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { GitCommit, Loader2, Sparkles } from 'lucide-react';
|
||||
import { GitCommit, Sparkles } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
@@ -209,7 +210,7 @@ export function CommitWorktreeDialog({
|
||||
<Button onClick={handleCommit} disabled={isLoading || isGenerating || !message.trim()}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Committing...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -13,7 +13,8 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { GitBranchPlus, Loader2 } from 'lucide-react';
|
||||
import { GitBranchPlus } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -133,7 +134,7 @@ export function CreateBranchDialog({
|
||||
<Button onClick={handleCreate} disabled={!branchName.trim() || isCreating}>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -13,7 +13,8 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
|
||||
import { GitPullRequest, Loader2, ExternalLink } from 'lucide-react';
|
||||
import { GitPullRequest, ExternalLink } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { useWorktreeBranches } from '@/hooks/queries';
|
||||
@@ -384,7 +385,7 @@ export function CreatePRDialog({
|
||||
<Button onClick={handleCreate} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { GitBranch, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { GitBranch, AlertCircle } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -216,7 +217,7 @@ export function CreateWorktreeDialog({
|
||||
<Button onClick={handleCreate} disabled={isLoading || !branchName.trim()}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2, Trash2, AlertTriangle, FileWarning } from 'lucide-react';
|
||||
import { Trash2, AlertTriangle, FileWarning } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -147,7 +148,7 @@ export function DeleteWorktreeDialog({
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
|
||||
import { DependencySelector } from '@/components/ui/dependency-selector';
|
||||
import {
|
||||
DescriptionImageDropZone,
|
||||
FeatureImagePath as DescriptionImagePath,
|
||||
@@ -27,6 +28,7 @@ import { toast } from 'sonner';
|
||||
import { cn, modelSupportsThinking } from '@/lib/utils';
|
||||
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
|
||||
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
|
||||
import { migrateModelId } from '@automaker/types';
|
||||
import {
|
||||
TestingTabContent,
|
||||
PrioritySelector,
|
||||
@@ -63,6 +65,8 @@ interface EditFeatureDialogProps {
|
||||
priority: number;
|
||||
planningMode: PlanningMode;
|
||||
requirePlanApproval: boolean;
|
||||
dependencies?: string[];
|
||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||
},
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: EnhancementMode,
|
||||
@@ -104,9 +108,9 @@ export function EditFeatureDialog({
|
||||
feature?.requirePlanApproval ?? false
|
||||
);
|
||||
|
||||
// Model selection state
|
||||
// Model selection state - migrate legacy model IDs to canonical format
|
||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>(() => ({
|
||||
model: (feature?.model as ModelAlias) || 'opus',
|
||||
model: migrateModelId(feature?.model) || 'claude-opus',
|
||||
thinkingLevel: feature?.thinkingLevel || 'none',
|
||||
reasoningEffort: feature?.reasoningEffort || 'none',
|
||||
}));
|
||||
@@ -127,6 +131,21 @@ export function EditFeatureDialog({
|
||||
feature?.descriptionHistory ?? []
|
||||
);
|
||||
|
||||
// Dependency state
|
||||
const [parentDependencies, setParentDependencies] = useState<string[]>(
|
||||
feature?.dependencies ?? []
|
||||
);
|
||||
// Child dependencies are features that have this feature in their dependencies
|
||||
const [childDependencies, setChildDependencies] = useState<string[]>(() => {
|
||||
if (!feature) return [];
|
||||
return allFeatures.filter((f) => f.dependencies?.includes(feature.id)).map((f) => f.id);
|
||||
});
|
||||
// Track original child dependencies to detect changes
|
||||
const [originalChildDependencies, setOriginalChildDependencies] = useState<string[]>(() => {
|
||||
if (!feature) return [];
|
||||
return allFeatures.filter((f) => f.dependencies?.includes(feature.id)).map((f) => f.id);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setEditingFeature(feature);
|
||||
if (feature) {
|
||||
@@ -139,19 +158,29 @@ export function EditFeatureDialog({
|
||||
setDescriptionChangeSource(null);
|
||||
setPreEnhancementDescription(null);
|
||||
setLocalHistory(feature.descriptionHistory ?? []);
|
||||
// Reset model entry
|
||||
// Reset model entry - migrate legacy model IDs
|
||||
setModelEntry({
|
||||
model: (feature.model as ModelAlias) || 'opus',
|
||||
model: migrateModelId(feature.model) || 'claude-opus',
|
||||
thinkingLevel: feature.thinkingLevel || 'none',
|
||||
reasoningEffort: feature.reasoningEffort || 'none',
|
||||
});
|
||||
// Reset dependency state
|
||||
setParentDependencies(feature.dependencies ?? []);
|
||||
const childDeps = allFeatures
|
||||
.filter((f) => f.dependencies?.includes(feature.id))
|
||||
.map((f) => f.id);
|
||||
setChildDependencies(childDeps);
|
||||
setOriginalChildDependencies(childDeps);
|
||||
} else {
|
||||
setEditFeaturePreviewMap(new Map());
|
||||
setDescriptionChangeSource(null);
|
||||
setPreEnhancementDescription(null);
|
||||
setLocalHistory([]);
|
||||
setParentDependencies([]);
|
||||
setChildDependencies([]);
|
||||
setOriginalChildDependencies([]);
|
||||
}
|
||||
}, [feature]);
|
||||
}, [feature, allFeatures]);
|
||||
|
||||
const handleModelChange = (entry: PhaseModelEntry) => {
|
||||
setModelEntry(entry);
|
||||
@@ -180,6 +209,12 @@ export function EditFeatureDialog({
|
||||
// For 'custom' mode, use the specified branch name
|
||||
const finalBranchName = workMode === 'custom' ? editingFeature.branchName || '' : '';
|
||||
|
||||
// Check if child dependencies changed
|
||||
const childDepsChanged =
|
||||
childDependencies.length !== originalChildDependencies.length ||
|
||||
childDependencies.some((id) => !originalChildDependencies.includes(id)) ||
|
||||
originalChildDependencies.some((id) => !childDependencies.includes(id));
|
||||
|
||||
const updates = {
|
||||
title: editingFeature.title ?? '',
|
||||
category: editingFeature.category,
|
||||
@@ -195,6 +230,8 @@ export function EditFeatureDialog({
|
||||
planningMode,
|
||||
requirePlanApproval,
|
||||
workMode,
|
||||
dependencies: parentDependencies,
|
||||
childDependencies: childDepsChanged ? childDependencies : undefined,
|
||||
};
|
||||
|
||||
// Determine if description changed and what source to use
|
||||
@@ -547,6 +584,40 @@ export function EditFeatureDialog({
|
||||
testIdPrefix="edit-feature-work-mode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
{allFeatures.length > 1 && (
|
||||
<div className="pt-2 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Parent Dependencies (this feature depends on)
|
||||
</Label>
|
||||
<DependencySelector
|
||||
currentFeatureId={editingFeature.id}
|
||||
value={parentDependencies}
|
||||
onChange={setParentDependencies}
|
||||
features={allFeatures}
|
||||
type="parent"
|
||||
placeholder="Select features this depends on..."
|
||||
data-testid="edit-feature-parent-deps"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Child Dependencies (features that depend on this)
|
||||
</Label>
|
||||
<DependencySelector
|
||||
currentFeatureId={editingFeature.id}
|
||||
value={childDependencies}
|
||||
onChange={setChildDependencies}
|
||||
features={allFeatures}
|
||||
type="child"
|
||||
placeholder="Select features that depend on this..."
|
||||
data-testid="edit-feature-child-deps"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ export function MassEditDialog({
|
||||
});
|
||||
|
||||
// Field values
|
||||
const [model, setModel] = useState<ModelAlias>('sonnet');
|
||||
const [model, setModel] = useState<ModelAlias>('claude-sonnet');
|
||||
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>('none');
|
||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||
@@ -160,7 +160,7 @@ export function MassEditDialog({
|
||||
skipTests: false,
|
||||
branchName: false,
|
||||
});
|
||||
setModel(getInitialValue(selectedFeatures, 'model', 'sonnet') as ModelAlias);
|
||||
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
|
||||
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
|
||||
setPlanningMode(getInitialValue(selectedFeatures, 'planningMode', 'skip') as PlanningMode);
|
||||
setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false));
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2, GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -217,7 +218,7 @@ export function MergeWorktreeDialog({
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Merging...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -14,7 +14,8 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { Check, RefreshCw, Edit2, Eye, Loader2 } from 'lucide-react';
|
||||
import { Check, RefreshCw, Edit2, Eye } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
interface PlanApprovalDialogProps {
|
||||
open: boolean;
|
||||
@@ -171,7 +172,7 @@ export function PlanApprovalDialog({
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleReject} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
@@ -190,7 +191,7 @@ export function PlanApprovalDialog({
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
) : (
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
|
||||
@@ -2,18 +2,17 @@ import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Menu, Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
|
||||
HeaderActionsPanel,
|
||||
HeaderActionsPanelTrigger,
|
||||
} from '@/components/ui/header-actions-panel';
|
||||
import { Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MobileUsageBar } from './mobile-usage-bar';
|
||||
|
||||
interface HeaderMobileMenuProps {
|
||||
// Panel visibility
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
// Worktree panel visibility
|
||||
isWorktreePanelVisible: boolean;
|
||||
onWorktreePanelToggle: (visible: boolean) => void;
|
||||
@@ -33,6 +32,8 @@ interface HeaderMobileMenuProps {
|
||||
}
|
||||
|
||||
export function HeaderMobileMenu({
|
||||
isOpen,
|
||||
onToggle,
|
||||
isWorktreePanelVisible,
|
||||
onWorktreePanelToggle,
|
||||
maxConcurrency,
|
||||
@@ -46,129 +47,122 @@ export function HeaderMobileMenu({
|
||||
showCodexUsage,
|
||||
}: HeaderMobileMenuProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
data-testid="header-mobile-menu-trigger"
|
||||
>
|
||||
<Menu className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<>
|
||||
<HeaderActionsPanelTrigger isOpen={isOpen} onToggle={onToggle} />
|
||||
<HeaderActionsPanel isOpen={isOpen} onClose={onToggle} title="Board Controls">
|
||||
{/* Usage Bar - show if either provider is authenticated */}
|
||||
{(showClaudeUsage || showCodexUsage) && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Usage
|
||||
</DropdownMenuLabel>
|
||||
</span>
|
||||
<MobileUsageBar showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} />
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
Controls
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{/* Controls Section */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Controls
|
||||
</span>
|
||||
|
||||
{/* Auto Mode Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between px-2 py-2 cursor-pointer hover:bg-accent rounded-sm"
|
||||
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
||||
data-testid="mobile-auto-mode-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={cn(
|
||||
'w-4 h-4',
|
||||
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium">Auto Mode</span>
|
||||
{/* Auto Mode Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 rounded-lg border border-border/50 transition-colors"
|
||||
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
||||
data-testid="mobile-auto-mode-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={cn(
|
||||
'w-4 h-4',
|
||||
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium">Auto Mode</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="mobile-auto-mode-toggle"
|
||||
checked={isAutoModeRunning}
|
||||
onCheckedChange={onAutoModeToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="mobile-auto-mode-toggle"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenAutoModeSettings();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Auto Mode Settings"
|
||||
data-testid="mobile-auto-mode-settings-button"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
{/* Worktrees Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 rounded-lg border border-border/50 transition-colors"
|
||||
onClick={() => onWorktreePanelToggle(!isWorktreePanelVisible)}
|
||||
data-testid="mobile-worktrees-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Worktree Bar</span>
|
||||
</div>
|
||||
<Switch
|
||||
id="mobile-auto-mode-toggle"
|
||||
checked={isAutoModeRunning}
|
||||
onCheckedChange={onAutoModeToggle}
|
||||
id="mobile-worktrees-toggle"
|
||||
checked={isWorktreePanelVisible}
|
||||
onCheckedChange={onWorktreePanelToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="mobile-auto-mode-toggle"
|
||||
data-testid="mobile-worktrees-toggle"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenAutoModeSettings();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Auto Mode Settings"
|
||||
data-testid="mobile-auto-mode-settings-button"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Worktrees Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between px-2 py-2 cursor-pointer hover:bg-accent rounded-sm"
|
||||
onClick={() => onWorktreePanelToggle(!isWorktreePanelVisible)}
|
||||
data-testid="mobile-worktrees-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Worktrees</span>
|
||||
{/* Concurrency Control */}
|
||||
<div
|
||||
className="p-3 rounded-lg border border-border/50"
|
||||
data-testid="mobile-concurrency-control"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Max Agents</span>
|
||||
<span
|
||||
className="text-sm text-muted-foreground ml-auto"
|
||||
data-testid="mobile-concurrency-value"
|
||||
>
|
||||
{runningAgentsCount}/{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="w-full"
|
||||
data-testid="mobile-concurrency-slider"
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
id="mobile-worktrees-toggle"
|
||||
checked={isWorktreePanelVisible}
|
||||
onCheckedChange={onWorktreePanelToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="mobile-worktrees-toggle"
|
||||
/>
|
||||
|
||||
{/* Plan Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
onOpenPlanDialog();
|
||||
onToggle();
|
||||
}}
|
||||
data-testid="mobile-plan-button"
|
||||
>
|
||||
<Wand2 className="w-4 h-4 mr-2" />
|
||||
Plan
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Concurrency Control */}
|
||||
<div className="px-2 py-2" data-testid="mobile-concurrency-control">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Max Agents</span>
|
||||
<span
|
||||
className="text-sm text-muted-foreground ml-auto"
|
||||
data-testid="mobile-concurrency-value"
|
||||
>
|
||||
{runningAgentsCount}/{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="w-full"
|
||||
data-testid="mobile-concurrency-slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Plan Button */}
|
||||
<DropdownMenuItem
|
||||
onClick={onOpenPlanDialog}
|
||||
className="flex items-center gap-2"
|
||||
data-testid="mobile-plan-button"
|
||||
>
|
||||
<Wand2 className="w-4 h-4" />
|
||||
<span>Plan</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</HeaderActionsPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@ export { useBoardEffects } from './use-board-effects';
|
||||
export { useBoardBackground } from './use-board-background';
|
||||
export { useBoardPersistence } from './use-board-persistence';
|
||||
export { useFollowUpState } from './use-follow-up-state';
|
||||
export { useSelectionMode } from './use-selection-mode';
|
||||
export { useSelectionMode, type SelectionTarget } from './use-selection-mode';
|
||||
export { useListViewState } from './use-list-view-state';
|
||||
|
||||
@@ -117,6 +117,7 @@ export function useBoardActions({
|
||||
planningMode: PlanningMode;
|
||||
requirePlanApproval: boolean;
|
||||
dependencies?: string[];
|
||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||
workMode?: 'current' | 'auto' | 'custom';
|
||||
}) => {
|
||||
const workMode = featureData.workMode || 'current';
|
||||
@@ -131,8 +132,10 @@ export function useBoardActions({
|
||||
// No worktree isolation - work directly on current branch
|
||||
finalBranchName = undefined;
|
||||
} else if (workMode === 'auto') {
|
||||
// Auto-generate a branch name based on current branch and timestamp
|
||||
const baseBranch = currentWorktreeBranch || 'main';
|
||||
// Auto-generate a branch name based on primary branch (main/master) and timestamp
|
||||
// Always use primary branch to avoid nested feature/feature/... paths
|
||||
const baseBranch =
|
||||
(currentProject?.path ? getPrimaryWorktreeBranch(currentProject.path) : null) || 'main';
|
||||
const timestamp = Date.now();
|
||||
const randomSuffix = Math.random().toString(36).substring(2, 6);
|
||||
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
|
||||
@@ -194,6 +197,21 @@ export function useBoardActions({
|
||||
await persistFeatureCreate(createdFeature);
|
||||
saveCategory(featureData.category);
|
||||
|
||||
// Handle child dependencies - update other features to depend on this new feature
|
||||
if (featureData.childDependencies && featureData.childDependencies.length > 0) {
|
||||
for (const childId of featureData.childDependencies) {
|
||||
const childFeature = features.find((f) => f.id === childId);
|
||||
if (childFeature) {
|
||||
const childDeps = childFeature.dependencies || [];
|
||||
if (!childDeps.includes(createdFeature.id)) {
|
||||
const newDeps = [...childDeps, createdFeature.id];
|
||||
updateFeature(childId, { dependencies: newDeps });
|
||||
persistFeatureUpdate(childId, { dependencies: newDeps });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate title in the background if needed (non-blocking)
|
||||
if (needsTitleGeneration) {
|
||||
const api = getElectronAPI();
|
||||
@@ -234,7 +252,8 @@ export function useBoardActions({
|
||||
currentProject,
|
||||
onWorktreeCreated,
|
||||
onWorktreeAutoSelect,
|
||||
currentWorktreeBranch,
|
||||
getPrimaryWorktreeBranch,
|
||||
features,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -255,6 +274,8 @@ export function useBoardActions({
|
||||
planningMode?: PlanningMode;
|
||||
requirePlanApproval?: boolean;
|
||||
workMode?: 'current' | 'auto' | 'custom';
|
||||
dependencies?: string[];
|
||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||
},
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||
@@ -268,7 +289,10 @@ export function useBoardActions({
|
||||
if (workMode === 'current') {
|
||||
finalBranchName = undefined;
|
||||
} else if (workMode === 'auto') {
|
||||
const baseBranch = currentWorktreeBranch || 'main';
|
||||
// Auto-generate a branch name based on primary branch (main/master) and timestamp
|
||||
// Always use primary branch to avoid nested feature/feature/... paths
|
||||
const baseBranch =
|
||||
(currentProject?.path ? getPrimaryWorktreeBranch(currentProject.path) : null) || 'main';
|
||||
const timestamp = Date.now();
|
||||
const randomSuffix = Math.random().toString(36).substring(2, 6);
|
||||
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
|
||||
@@ -308,8 +332,11 @@ export function useBoardActions({
|
||||
}
|
||||
}
|
||||
|
||||
// Separate child dependencies from the main updates (they affect other features)
|
||||
const { childDependencies, ...restUpdates } = updates;
|
||||
|
||||
const finalUpdates = {
|
||||
...updates,
|
||||
...restUpdates,
|
||||
title: updates.title,
|
||||
branchName: finalBranchName,
|
||||
};
|
||||
@@ -322,6 +349,45 @@ export function useBoardActions({
|
||||
enhancementMode,
|
||||
preEnhancementDescription
|
||||
);
|
||||
|
||||
// Handle child dependency changes
|
||||
// This updates other features' dependencies arrays
|
||||
if (childDependencies !== undefined) {
|
||||
// Find current child dependencies (features that have this feature in their dependencies)
|
||||
const currentChildDeps = features
|
||||
.filter((f) => f.dependencies?.includes(featureId))
|
||||
.map((f) => f.id);
|
||||
|
||||
// Find features to add this feature as a dependency (new child deps)
|
||||
const toAdd = childDependencies.filter((id) => !currentChildDeps.includes(id));
|
||||
// Find features to remove this feature as a dependency (removed child deps)
|
||||
const toRemove = currentChildDeps.filter((id) => !childDependencies.includes(id));
|
||||
|
||||
// Add this feature as a dependency to new child features
|
||||
for (const childId of toAdd) {
|
||||
const childFeature = features.find((f) => f.id === childId);
|
||||
if (childFeature) {
|
||||
const childDeps = childFeature.dependencies || [];
|
||||
if (!childDeps.includes(featureId)) {
|
||||
const newDeps = [...childDeps, featureId];
|
||||
updateFeature(childId, { dependencies: newDeps });
|
||||
persistFeatureUpdate(childId, { dependencies: newDeps });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove this feature as a dependency from removed child features
|
||||
for (const childId of toRemove) {
|
||||
const childFeature = features.find((f) => f.id === childId);
|
||||
if (childFeature) {
|
||||
const childDeps = childFeature.dependencies || [];
|
||||
const newDeps = childDeps.filter((depId) => depId !== featureId);
|
||||
updateFeature(childId, { dependencies: newDeps });
|
||||
persistFeatureUpdate(childId, { dependencies: newDeps });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.category) {
|
||||
saveCategory(updates.category);
|
||||
}
|
||||
@@ -334,7 +400,8 @@ export function useBoardActions({
|
||||
setEditingFeature,
|
||||
currentProject,
|
||||
onWorktreeCreated,
|
||||
currentWorktreeBranch,
|
||||
getPrimaryWorktreeBranch,
|
||||
features,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Calling API features.update', { featureId, updates });
|
||||
const result = await api.features.update(
|
||||
currentProject.path,
|
||||
featureId,
|
||||
@@ -42,12 +43,18 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
enhancementMode,
|
||||
preEnhancementDescription
|
||||
);
|
||||
logger.info('API features.update result', {
|
||||
success: result.success,
|
||||
feature: result.feature,
|
||||
});
|
||||
if (result.success && result.feature) {
|
||||
updateFeature(result.feature.id, result.feature);
|
||||
// Invalidate React Query cache to sync UI
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(currentProject.path),
|
||||
});
|
||||
} else if (!result.success) {
|
||||
logger.error('API features.update failed', result);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to persist feature update:', error);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
|
||||
export type SelectionTarget = 'backlog' | 'waiting_approval' | null;
|
||||
|
||||
interface UseSelectionModeReturn {
|
||||
isSelectionMode: boolean;
|
||||
selectionTarget: SelectionTarget;
|
||||
selectedFeatureIds: Set<string>;
|
||||
selectedCount: number;
|
||||
toggleSelectionMode: () => void;
|
||||
toggleSelectionMode: (target?: SelectionTarget) => void;
|
||||
toggleFeatureSelection: (featureId: string) => void;
|
||||
selectAll: (featureIds: string[]) => void;
|
||||
clearSelection: () => void;
|
||||
@@ -13,21 +16,26 @@ interface UseSelectionModeReturn {
|
||||
}
|
||||
|
||||
export function useSelectionMode(): UseSelectionModeReturn {
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [selectionTarget, setSelectionTarget] = useState<SelectionTarget>(null);
|
||||
const [selectedFeatureIds, setSelectedFeatureIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleSelectionMode = useCallback(() => {
|
||||
setIsSelectionMode((prev) => {
|
||||
if (prev) {
|
||||
const isSelectionMode = selectionTarget !== null;
|
||||
|
||||
const toggleSelectionMode = useCallback((target: SelectionTarget = 'backlog') => {
|
||||
setSelectionTarget((prev) => {
|
||||
if (prev === target) {
|
||||
// Exiting selection mode - clear selection
|
||||
setSelectedFeatureIds(new Set());
|
||||
return null;
|
||||
}
|
||||
return !prev;
|
||||
// Switching to a different target or entering selection mode
|
||||
setSelectedFeatureIds(new Set());
|
||||
return target;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const exitSelectionMode = useCallback(() => {
|
||||
setIsSelectionMode(false);
|
||||
setSelectionTarget(null);
|
||||
setSelectedFeatureIds(new Set());
|
||||
}, []);
|
||||
|
||||
@@ -70,6 +78,7 @@ export function useSelectionMode(): UseSelectionModeReturn {
|
||||
|
||||
return {
|
||||
isSelectionMode,
|
||||
selectionTarget,
|
||||
selectedFeatureIds,
|
||||
selectedCount: selectedFeatureIds.size,
|
||||
toggleSelectionMode,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Terminal, Check, X, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Terminal, Check, X, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore, type InitScriptState } from '@/store/app-store';
|
||||
import { AnsiOutput } from '@/components/ui/ansi-output';
|
||||
@@ -65,7 +66,7 @@ function SingleIndicator({
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
{status === 'running' && <Loader2 className="w-4 h-4 animate-spin text-blue-500" />}
|
||||
{status === 'running' && <Spinner size="sm" />}
|
||||
{status === 'success' && <Check className="w-4 h-4 text-green-500" />}
|
||||
{status === 'failed' && <X className="w-4 h-4 text-red-500" />}
|
||||
<span className="font-medium text-sm">
|
||||
|
||||
@@ -50,9 +50,10 @@ interface KanbanBoardProps {
|
||||
onOpenPipelineSettings?: () => void;
|
||||
// Selection mode props
|
||||
isSelectionMode?: boolean;
|
||||
selectionTarget?: 'backlog' | 'waiting_approval' | null;
|
||||
selectedFeatureIds?: Set<string>;
|
||||
onToggleFeatureSelection?: (featureId: string) => void;
|
||||
onToggleSelectionMode?: () => void;
|
||||
onToggleSelectionMode?: (target?: 'backlog' | 'waiting_approval') => void;
|
||||
// Empty state action props
|
||||
onAiSuggest?: () => void;
|
||||
/** Whether currently dragging (hides empty states during drag) */
|
||||
@@ -95,6 +96,7 @@ export function KanbanBoard({
|
||||
pipelineConfig,
|
||||
onOpenPipelineSettings,
|
||||
isSelectionMode = false,
|
||||
selectionTarget = null,
|
||||
selectedFeatureIds = new Set(),
|
||||
onToggleFeatureSelection,
|
||||
onToggleSelectionMode,
|
||||
@@ -189,12 +191,14 @@ export function KanbanBoard({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-xs ${isSelectionMode ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
onClick={onToggleSelectionMode}
|
||||
title={isSelectionMode ? 'Switch to Drag Mode' : 'Select Multiple'}
|
||||
className={`h-6 px-2 text-xs ${selectionTarget === 'backlog' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
onClick={() => onToggleSelectionMode?.('backlog')}
|
||||
title={
|
||||
selectionTarget === 'backlog' ? 'Switch to Drag Mode' : 'Select Multiple'
|
||||
}
|
||||
data-testid="selection-mode-button"
|
||||
>
|
||||
{isSelectionMode ? (
|
||||
{selectionTarget === 'backlog' ? (
|
||||
<>
|
||||
<GripVertical className="w-3.5 h-3.5 mr-1" />
|
||||
Drag
|
||||
@@ -207,6 +211,31 @@ export function KanbanBoard({
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : column.id === 'waiting_approval' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-xs ${selectionTarget === 'waiting_approval' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
onClick={() => onToggleSelectionMode?.('waiting_approval')}
|
||||
title={
|
||||
selectionTarget === 'waiting_approval'
|
||||
? 'Switch to Drag Mode'
|
||||
: 'Select Multiple'
|
||||
}
|
||||
data-testid="waiting-approval-selection-mode-button"
|
||||
>
|
||||
{selectionTarget === 'waiting_approval' ? (
|
||||
<>
|
||||
<GripVertical className="w-3.5 h-3.5 mr-1" />
|
||||
Drag
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckSquare className="w-3.5 h-3.5 mr-1" />
|
||||
Select
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : column.id === 'in_progress' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -305,6 +334,7 @@ export function KanbanBoard({
|
||||
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
||||
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectionTarget={selectionTarget}
|
||||
isSelected={selectedFeatureIds.has(feature.id)}
|
||||
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useCallback, useState, type ComponentType, type ReactNode } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||
@@ -90,9 +91,11 @@ function UsageItem({
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Refresh usage"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn('w-3.5 h-3.5 text-muted-foreground', isLoading && 'animate-spin')}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="pl-6 space-y-2">{children}</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
|
||||
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
|
||||
|
||||
export type ModelOption = {
|
||||
id: string; // Claude models use ModelAlias, Cursor models use "cursor-{id}"
|
||||
id: string; // All model IDs use canonical prefixed format (e.g., "claude-sonnet", "cursor-auto")
|
||||
label: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
@@ -17,23 +17,27 @@ export type ModelOption = {
|
||||
hasThinking?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Claude models with canonical prefixed IDs
|
||||
* UI displays short labels but stores full canonical IDs
|
||||
*/
|
||||
export const CLAUDE_MODELS: ModelOption[] = [
|
||||
{
|
||||
id: 'haiku',
|
||||
id: 'claude-haiku', // Canonical prefixed ID
|
||||
label: 'Claude Haiku',
|
||||
description: 'Fast and efficient for simple tasks.',
|
||||
badge: 'Speed',
|
||||
provider: 'claude',
|
||||
},
|
||||
{
|
||||
id: 'sonnet',
|
||||
id: 'claude-sonnet', // Canonical prefixed ID
|
||||
label: 'Claude Sonnet',
|
||||
description: 'Balanced performance with strong reasoning.',
|
||||
badge: 'Balanced',
|
||||
provider: 'claude',
|
||||
},
|
||||
{
|
||||
id: 'opus',
|
||||
id: 'claude-opus', // Canonical prefixed ID
|
||||
label: 'Claude Opus',
|
||||
description: 'Most capable model for complex work.',
|
||||
badge: 'Premium',
|
||||
@@ -43,11 +47,11 @@ export const CLAUDE_MODELS: ModelOption[] = [
|
||||
|
||||
/**
|
||||
* Cursor models derived from CURSOR_MODEL_MAP
|
||||
* ID is prefixed with "cursor-" for ProviderFactory routing
|
||||
* IDs already have 'cursor-' prefix in the canonical format
|
||||
*/
|
||||
export const CURSOR_MODELS: ModelOption[] = Object.entries(CURSOR_MODEL_MAP).map(
|
||||
([id, config]) => ({
|
||||
id: `cursor-${id}`,
|
||||
id, // Already prefixed in canonical format
|
||||
label: config.label,
|
||||
description: config.description,
|
||||
provider: 'cursor' as ModelProvider,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getModelProvider, PROVIDER_PREFIXES, stripProviderPrefix } from '@autom
|
||||
import type { ModelProvider } from '@automaker/types';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants';
|
||||
import { useEffect } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
interface ModelSelectorProps {
|
||||
selectedModel: string; // Can be ModelAlias or "cursor-{id}"
|
||||
@@ -31,6 +31,7 @@ export function ModelSelector({
|
||||
codexModelsLoading,
|
||||
codexModelsError,
|
||||
fetchCodexModels,
|
||||
disabledProviders,
|
||||
} = useAppStore();
|
||||
const { cursorCliStatus, codexCliStatus } = useSetupStore();
|
||||
|
||||
@@ -69,79 +70,106 @@ export function ModelSelector({
|
||||
|
||||
// Filter Cursor models based on enabled models from global settings
|
||||
const filteredCursorModels = CURSOR_MODELS.filter((model) => {
|
||||
// Extract the cursor model ID from the prefixed ID (e.g., "cursor-auto" -> "auto")
|
||||
const cursorModelId = stripProviderPrefix(model.id);
|
||||
return enabledCursorModels.includes(cursorModelId as any);
|
||||
// enabledCursorModels stores CursorModelIds which may or may not have "cursor-" prefix
|
||||
// (e.g., 'auto', 'sonnet-4.5' without prefix, but 'cursor-gpt-5.2' with prefix)
|
||||
// CURSOR_MODELS always has the "cursor-" prefix added in model-constants.ts
|
||||
// Check both the full ID (for GPT models) and the unprefixed version (for non-GPT models)
|
||||
const unprefixedId = model.id.startsWith('cursor-') ? model.id.slice(7) : model.id;
|
||||
return (
|
||||
enabledCursorModels.includes(model.id as any) ||
|
||||
enabledCursorModels.includes(unprefixedId as any)
|
||||
);
|
||||
});
|
||||
|
||||
const handleProviderChange = (provider: ModelProvider) => {
|
||||
if (provider === 'cursor' && selectedProvider !== 'cursor') {
|
||||
// Switch to Cursor's default model (from global settings)
|
||||
onModelSelect(`${PROVIDER_PREFIXES.cursor}${cursorDefaultModel}`);
|
||||
// cursorDefaultModel is now canonical (e.g., 'cursor-auto'), so use directly
|
||||
onModelSelect(cursorDefaultModel);
|
||||
} else if (provider === 'codex' && selectedProvider !== 'codex') {
|
||||
// Switch to Codex's default model (use isDefault flag from dynamic models)
|
||||
const defaultModel = codexModels.find((m) => m.isDefault);
|
||||
const defaultModelId = defaultModel?.id || codexModels[0]?.id || 'codex-gpt-5.2-codex';
|
||||
onModelSelect(defaultModelId);
|
||||
} else if (provider === 'claude' && selectedProvider !== 'claude') {
|
||||
// Switch to Claude's default model
|
||||
onModelSelect('sonnet');
|
||||
// Switch to Claude's default model (canonical format)
|
||||
onModelSelect('claude-sonnet');
|
||||
}
|
||||
};
|
||||
|
||||
// Check which providers are disabled
|
||||
const isClaudeDisabled = disabledProviders.includes('claude');
|
||||
const isCursorDisabled = disabledProviders.includes('cursor');
|
||||
const isCodexDisabled = disabledProviders.includes('codex');
|
||||
|
||||
// Count available providers
|
||||
const availableProviders = [
|
||||
!isClaudeDisabled && 'claude',
|
||||
!isCursorDisabled && 'cursor',
|
||||
!isCodexDisabled && 'codex',
|
||||
].filter(Boolean) as ModelProvider[];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Provider Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>AI Provider</Label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('claude')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
selectedProvider === 'claude'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
{availableProviders.length > 1 && (
|
||||
<div className="space-y-2">
|
||||
<Label>AI Provider</Label>
|
||||
<div className="flex gap-2">
|
||||
{!isClaudeDisabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('claude')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
selectedProvider === 'claude'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-claude`}
|
||||
>
|
||||
<AnthropicIcon className="w-4 h-4" />
|
||||
Claude
|
||||
</button>
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-claude`}
|
||||
>
|
||||
<AnthropicIcon className="w-4 h-4" />
|
||||
Claude
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('cursor')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
selectedProvider === 'cursor'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
{!isCursorDisabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('cursor')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
selectedProvider === 'cursor'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-cursor`}
|
||||
>
|
||||
<CursorIcon className="w-4 h-4" />
|
||||
Cursor CLI
|
||||
</button>
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-cursor`}
|
||||
>
|
||||
<CursorIcon className="w-4 h-4" />
|
||||
Cursor CLI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('codex')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
selectedProvider === 'codex'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
{!isCodexDisabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('codex')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
selectedProvider === 'codex'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-codex`}
|
||||
>
|
||||
<OpenAIIcon className="w-4 h-4" />
|
||||
Codex CLI
|
||||
</button>
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-codex`}
|
||||
>
|
||||
<OpenAIIcon className="w-4 h-4" />
|
||||
Codex CLI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Claude Models */}
|
||||
{selectedProvider === 'claude' && (
|
||||
{selectedProvider === 'claude' && !isClaudeDisabled && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="flex items-center gap-2">
|
||||
@@ -179,7 +207,7 @@ export function ModelSelector({
|
||||
)}
|
||||
|
||||
{/* Cursor Models */}
|
||||
{selectedProvider === 'cursor' && (
|
||||
{selectedProvider === 'cursor' && !isCursorDisabled && (
|
||||
<div className="space-y-3">
|
||||
{/* Warning when Cursor CLI is not available */}
|
||||
{!isCursorAvailable && (
|
||||
@@ -248,7 +276,7 @@ export function ModelSelector({
|
||||
)}
|
||||
|
||||
{/* Codex Models */}
|
||||
{selectedProvider === 'codex' && (
|
||||
{selectedProvider === 'codex' && !isCodexDisabled && (
|
||||
<div className="space-y-3">
|
||||
{/* Warning when Codex CLI is not available */}
|
||||
{!isCodexAvailable && (
|
||||
@@ -274,7 +302,7 @@ export function ModelSelector({
|
||||
{/* Loading state */}
|
||||
{codexModelsLoading && dynamicCodexModels.length === 0 && (
|
||||
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
<Spinner size="sm" />
|
||||
Loading models...
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,12 +6,12 @@ import {
|
||||
ClipboardList,
|
||||
FileText,
|
||||
ScrollText,
|
||||
Loader2,
|
||||
Check,
|
||||
Eye,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -236,7 +236,7 @@ export function PlanningModeSelector({
|
||||
<div className="flex items-center gap-2">
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Generating {mode === 'full' ? 'comprehensive spec' : 'spec'}...
|
||||
</span>
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { GitBranch, RefreshCw, GitBranchPlus, Check, Search } from 'lucide-react';
|
||||
import { GitBranch, GitBranchPlus, Check, Search } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WorktreeInfo, BranchInfo } from '../types';
|
||||
|
||||
@@ -81,7 +82,7 @@ export function BranchSwitchDropdown({
|
||||
<div className="max-h-[250px] overflow-y-auto">
|
||||
{isLoadingBranches ? (
|
||||
<DropdownMenuItem disabled className="text-xs">
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
|
||||
<Spinner size="xs" className="mr-2" />
|
||||
Loading branches...
|
||||
</DropdownMenuItem>
|
||||
) : filteredBranches.length === 0 ? (
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Loader2,
|
||||
Terminal,
|
||||
ArrowDown,
|
||||
ExternalLink,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
Clock,
|
||||
GitBranch,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { XtermLogViewer, type XtermLogViewerRef } from '@/components/ui/xterm-log-viewer';
|
||||
import { useDevServerLogs } from '../hooks/use-dev-server-logs';
|
||||
@@ -183,7 +183,7 @@ export function DevServerLogsPanel({
|
||||
onClick={() => fetchLogs()}
|
||||
title="Refresh logs"
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
|
||||
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,7 +234,7 @@ export function DevServerLogsPanel({
|
||||
>
|
||||
{isLoading && !logs ? (
|
||||
<div className="flex items-center justify-center h-full min-h-[300px] text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
<Spinner size="md" className="mr-2" />
|
||||
<span className="text-sm">Loading logs...</span>
|
||||
</div>
|
||||
) : !logs && !isRunning ? (
|
||||
@@ -245,7 +245,7 @@ export function DevServerLogsPanel({
|
||||
</div>
|
||||
) : !logs ? (
|
||||
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
|
||||
<div className="w-8 h-8 mb-3 rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground/60 animate-spin" />
|
||||
<Spinner size="xl" className="mb-3" />
|
||||
<p className="text-sm">Waiting for output...</p>
|
||||
<p className="text-xs mt-1 opacity-60">
|
||||
Logs will appear as the server generates output
|
||||
@@ -256,7 +256,6 @@ export function DevServerLogsPanel({
|
||||
ref={xtermRef}
|
||||
className="h-full"
|
||||
minHeight={280}
|
||||
fontSize={13}
|
||||
autoScroll={autoScrollEnabled}
|
||||
onScrollAwayFromBottom={() => setAutoScrollEnabled(false)}
|
||||
onScrollToBottom={() => setAutoScrollEnabled(true)}
|
||||
|
||||
@@ -26,13 +26,22 @@ import {
|
||||
RefreshCw,
|
||||
Copy,
|
||||
ScrollText,
|
||||
Terminal,
|
||||
SquarePlus,
|
||||
SplitSquareHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||
import { TooltipWrapper } from './tooltip-wrapper';
|
||||
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
|
||||
import {
|
||||
useAvailableTerminals,
|
||||
useEffectiveDefaultTerminal,
|
||||
} from '../hooks/use-available-terminals';
|
||||
import { getEditorIcon } from '@/components/icons/editor-icons';
|
||||
import { getTerminalIcon } from '@/components/icons/terminal-icons';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
interface WorktreeActionsDropdownProps {
|
||||
worktree: WorktreeInfo;
|
||||
@@ -51,6 +60,8 @@ interface WorktreeActionsDropdownProps {
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
||||
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
@@ -81,6 +92,8 @@ export function WorktreeActionsDropdown({
|
||||
onPull,
|
||||
onPush,
|
||||
onOpenInEditor,
|
||||
onOpenInIntegratedTerminal,
|
||||
onOpenInExternalTerminal,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onAddressPRComments,
|
||||
@@ -108,6 +121,20 @@ export function WorktreeActionsDropdown({
|
||||
? getEditorIcon(effectiveDefaultEditor.command)
|
||||
: null;
|
||||
|
||||
// Get available terminals for the "Open In Terminal" submenu
|
||||
const { terminals, hasExternalTerminals } = useAvailableTerminals();
|
||||
|
||||
// Use shared hook for effective default terminal (null = integrated terminal)
|
||||
const effectiveDefaultTerminal = useEffectiveDefaultTerminal(terminals);
|
||||
|
||||
// Get the user's preferred mode for opening terminals (new tab vs split)
|
||||
const openTerminalMode = useAppStore((s) => s.terminalState.openTerminalMode);
|
||||
|
||||
// Get icon component for the effective terminal
|
||||
const DefaultTerminalIcon = effectiveDefaultTerminal
|
||||
? getTerminalIcon(effectiveDefaultTerminal.id)
|
||||
: Terminal;
|
||||
|
||||
// Check if there's a PR associated with this worktree from stored metadata
|
||||
const hasPR = !!worktree.pr;
|
||||
|
||||
@@ -303,6 +330,77 @@ export function WorktreeActionsDropdown({
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{/* Open in terminal - always show with integrated + external options */}
|
||||
<DropdownMenuSub>
|
||||
<div className="flex items-center">
|
||||
{/* Main clickable area - opens in default terminal (integrated or external) */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (effectiveDefaultTerminal) {
|
||||
// External terminal is the default
|
||||
onOpenInExternalTerminal(worktree, effectiveDefaultTerminal.id);
|
||||
} else {
|
||||
// Integrated terminal is the default - use user's preferred mode
|
||||
const mode = openTerminalMode === 'newTab' ? 'tab' : 'split';
|
||||
onOpenInIntegratedTerminal(worktree, mode);
|
||||
}
|
||||
}}
|
||||
className="text-xs flex-1 pr-0 rounded-r-none"
|
||||
>
|
||||
<DefaultTerminalIcon className="w-3.5 h-3.5 mr-2" />
|
||||
Open in {effectiveDefaultTerminal?.name ?? 'Terminal'}
|
||||
</DropdownMenuItem>
|
||||
{/* Chevron trigger for submenu with all terminals */}
|
||||
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||
</div>
|
||||
<DropdownMenuSubContent>
|
||||
{/* Automaker Terminal - with submenu for new tab vs split */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="text-xs">
|
||||
<Terminal className="w-3.5 h-3.5 mr-2" />
|
||||
Terminal
|
||||
{!effectiveDefaultTerminal && (
|
||||
<span className="ml-auto mr-2 text-[10px] text-muted-foreground">(default)</span>
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenInIntegratedTerminal(worktree, 'tab')}
|
||||
className="text-xs"
|
||||
>
|
||||
<SquarePlus className="w-3.5 h-3.5 mr-2" />
|
||||
New Tab
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenInIntegratedTerminal(worktree, 'split')}
|
||||
className="text-xs"
|
||||
>
|
||||
<SplitSquareHorizontal className="w-3.5 h-3.5 mr-2" />
|
||||
Split
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
{/* External terminals */}
|
||||
{terminals.length > 0 && <DropdownMenuSeparator />}
|
||||
{terminals.map((terminal) => {
|
||||
const TerminalIcon = getTerminalIcon(terminal.id);
|
||||
const isDefault = terminal.id === effectiveDefaultTerminal?.id;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={terminal.id}
|
||||
onClick={() => onOpenInExternalTerminal(worktree, terminal.id)}
|
||||
className="text-xs"
|
||||
>
|
||||
<TerminalIcon className="w-3.5 h-3.5 mr-2" />
|
||||
{terminal.name}
|
||||
{isDefault && (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">(default)</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
{!worktree.isMain && hasInitScript && (
|
||||
<DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs">
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-2" />
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { GitBranch, ChevronDown, Loader2, CircleDot, Check } from 'lucide-react';
|
||||
import { GitBranch, ChevronDown, CircleDot, Check } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WorktreeInfo } from '../types';
|
||||
|
||||
@@ -44,7 +45,7 @@ export function WorktreeMobileDropdown({
|
||||
<GitBranch className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="truncate">{displayBranch}</span>
|
||||
{isActivating ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin shrink-0" />
|
||||
<Spinner size="xs" className="shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 shrink-0 ml-auto" />
|
||||
)}
|
||||
@@ -74,7 +75,7 @@ export function WorktreeMobileDropdown({
|
||||
) : (
|
||||
<div className="w-3.5 h-3.5 shrink-0" />
|
||||
)}
|
||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin shrink-0" />}
|
||||
{isRunning && <Spinner size="xs" className="shrink-0" />}
|
||||
<span className={cn('font-mono text-xs truncate', isSelected && 'font-medium')}>
|
||||
{worktree.branch}
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { JSX } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
|
||||
import { Globe, CircleDot, GitPullRequest } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||
@@ -37,6 +38,8 @@ interface WorktreeTabProps {
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
||||
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
@@ -81,6 +84,8 @@ export function WorktreeTab({
|
||||
onPull,
|
||||
onPush,
|
||||
onOpenInEditor,
|
||||
onOpenInIntegratedTerminal,
|
||||
onOpenInExternalTerminal,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onAddressPRComments,
|
||||
@@ -197,8 +202,8 @@ export function WorktreeTab({
|
||||
aria-label={worktree.branch}
|
||||
data-testid={`worktree-branch-${worktree.branch}`}
|
||||
>
|
||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
|
||||
{isRunning && <Spinner size="xs" />}
|
||||
{isActivating && !isRunning && <Spinner size="xs" />}
|
||||
{worktree.branch}
|
||||
{cardCount !== undefined && cardCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||
@@ -264,8 +269,8 @@ export function WorktreeTab({
|
||||
: 'Click to switch to this branch'
|
||||
}
|
||||
>
|
||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
|
||||
{isRunning && <Spinner size="xs" />}
|
||||
{isActivating && !isRunning && <Spinner size="xs" />}
|
||||
{worktree.branch}
|
||||
{cardCount !== undefined && cardCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||
@@ -342,6 +347,8 @@ export function WorktreeTab({
|
||||
onPull={onPull}
|
||||
onPush={onPush}
|
||||
onOpenInEditor={onOpenInEditor}
|
||||
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={onOpenInExternalTerminal}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { TerminalInfo } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('AvailableTerminals');
|
||||
|
||||
// Re-export TerminalInfo for convenience
|
||||
export type { TerminalInfo };
|
||||
|
||||
export function useAvailableTerminals() {
|
||||
const [terminals, setTerminals] = useState<TerminalInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const fetchAvailableTerminals = useCallback(async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.getAvailableTerminals) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.getAvailableTerminals();
|
||||
if (result.success && result.result?.terminals) {
|
||||
setTerminals(result.result.terminals);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch available terminals:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Refresh terminals by clearing the server cache and re-detecting
|
||||
* Use this when the user has installed/uninstalled terminals
|
||||
*/
|
||||
const refresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.refreshTerminals) {
|
||||
// Fallback to regular fetch if refresh not available
|
||||
await fetchAvailableTerminals();
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.refreshTerminals();
|
||||
if (result.success && result.result?.terminals) {
|
||||
setTerminals(result.result.terminals);
|
||||
logger.info(`Terminal cache refreshed, found ${result.result.terminals.length} terminals`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh terminals:', error);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [fetchAvailableTerminals]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAvailableTerminals();
|
||||
}, [fetchAvailableTerminals]);
|
||||
|
||||
return {
|
||||
terminals,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
refresh,
|
||||
// Convenience property: has external terminals available
|
||||
hasExternalTerminals: terminals.length > 0,
|
||||
// The first terminal is the "default" one (highest priority)
|
||||
defaultTerminal: terminals[0] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the effective default terminal based on user settings
|
||||
* Returns null if user prefers integrated terminal (defaultTerminalId is null)
|
||||
* Falls back to: user preference > first available external terminal
|
||||
*/
|
||||
export function useEffectiveDefaultTerminal(terminals: TerminalInfo[]): TerminalInfo | null {
|
||||
const defaultTerminalId = useAppStore((s) => s.defaultTerminalId);
|
||||
|
||||
return useMemo(() => {
|
||||
// If user hasn't set a preference (null/undefined), they prefer integrated terminal
|
||||
if (defaultTerminalId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If user has set a preference, find it in available terminals
|
||||
if (defaultTerminalId) {
|
||||
const found = terminals.find((t) => t.id === defaultTerminalId);
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
// If the saved preference doesn't exist anymore, fall back to first available
|
||||
return terminals[0] ?? null;
|
||||
}, [terminals, defaultTerminalId]);
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
|
||||
isRunning: true,
|
||||
isLoading: false,
|
||||
port: result.result!.port,
|
||||
url: `http://localhost:${result.result!.port}`,
|
||||
url: result.result!.url,
|
||||
startedAt: result.result!.startedAt,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
useSwitchBranch,
|
||||
usePullWorktree,
|
||||
@@ -7,7 +11,10 @@ import {
|
||||
} from '@/hooks/mutations';
|
||||
import type { WorktreeInfo } from '../types';
|
||||
|
||||
const logger = createLogger('WorktreeActions');
|
||||
|
||||
export function useWorktreeActions() {
|
||||
const navigate = useNavigate();
|
||||
const [isActivating, setIsActivating] = useState(false);
|
||||
|
||||
// Use React Query mutations
|
||||
@@ -45,6 +52,19 @@ export function useWorktreeActions() {
|
||||
[pushMutation]
|
||||
);
|
||||
|
||||
const handleOpenInIntegratedTerminal = useCallback(
|
||||
(worktree: WorktreeInfo, mode?: 'tab' | 'split') => {
|
||||
// Navigate to the terminal view with the worktree path and branch name
|
||||
// The terminal view will handle creating the terminal with the specified cwd
|
||||
// Include nonce to allow opening the same worktree multiple times
|
||||
navigate({
|
||||
to: '/terminal',
|
||||
search: { cwd: worktree.path, branch: worktree.branch, mode, nonce: Date.now() },
|
||||
});
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const handleOpenInEditor = useCallback(
|
||||
async (worktree: WorktreeInfo, editorCommand?: string) => {
|
||||
openInEditorMutation.mutate({
|
||||
@@ -55,6 +75,27 @@ export function useWorktreeActions() {
|
||||
[openInEditorMutation]
|
||||
);
|
||||
|
||||
const handleOpenInExternalTerminal = useCallback(
|
||||
async (worktree: WorktreeInfo, terminalId?: string) => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.openInExternalTerminal) {
|
||||
logger.warn('Open in external terminal API not available');
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.openInExternalTerminal(worktree.path, terminalId);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
} else if (result.error) {
|
||||
toast.error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Open in external terminal failed:', error);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
isPulling: pullMutation.isPending,
|
||||
isPushing: pushMutation.isPending,
|
||||
@@ -64,6 +105,8 @@ export function useWorktreeActions() {
|
||||
handleSwitchBranch,
|
||||
handlePull,
|
||||
handlePush,
|
||||
handleOpenInIntegratedTerminal,
|
||||
handleOpenInEditor,
|
||||
handleOpenInExternalTerminal,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
export interface WorktreePRInfo {
|
||||
number: number;
|
||||
url: string;
|
||||
title: string;
|
||||
state: string;
|
||||
createdAt: string;
|
||||
}
|
||||
// Re-export shared types from @automaker/types
|
||||
export type { PRState, WorktreePRInfo } from '@automaker/types';
|
||||
import type { PRState, WorktreePRInfo } from '@automaker/types';
|
||||
|
||||
export interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -43,7 +39,8 @@ export interface PRInfo {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
state: string;
|
||||
/** PR state: OPEN, MERGED, or CLOSED */
|
||||
state: PRState;
|
||||
author: string;
|
||||
body: string;
|
||||
comments: Array<{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn, pathsEqual } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
@@ -79,7 +80,9 @@ export function WorktreePanel({
|
||||
handleSwitchBranch,
|
||||
handlePull,
|
||||
handlePush,
|
||||
handleOpenInIntegratedTerminal,
|
||||
handleOpenInEditor,
|
||||
handleOpenInExternalTerminal,
|
||||
} = useWorktreeActions();
|
||||
|
||||
const { hasRunningFeatures } = useRunningFeatures({
|
||||
@@ -225,6 +228,8 @@ export function WorktreePanel({
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
@@ -265,7 +270,7 @@ export function WorktreePanel({
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
|
||||
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -312,6 +317,8 @@ export function WorktreePanel({
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
@@ -370,6 +377,8 @@ export function WorktreePanel({
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
@@ -409,7 +418,7 @@ export function WorktreePanel({
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
|
||||
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useAppStore } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { File, Folder, FolderOpen, ChevronRight, ChevronDown, RefreshCw, Code } from 'lucide-react';
|
||||
import { File, Folder, FolderOpen, ChevronRight, ChevronDown, Code } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const logger = createLogger('CodeView');
|
||||
@@ -206,7 +207,7 @@ export function CodeView() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="code-view-loading">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ import { Button } from '@/components/ui/button';
|
||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import {
|
||||
RefreshCw,
|
||||
HeaderActionsPanel,
|
||||
HeaderActionsPanelTrigger,
|
||||
} from '@/components/ui/header-actions-panel';
|
||||
import {
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Trash2,
|
||||
@@ -20,9 +23,9 @@ import {
|
||||
Pencil,
|
||||
FilePlus,
|
||||
FileUp,
|
||||
Loader2,
|
||||
MoreVertical,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
useKeyboardShortcuts,
|
||||
useKeyboardShortcutsConfig,
|
||||
@@ -94,6 +97,9 @@ export function ContextView() {
|
||||
const [editDescriptionValue, setEditDescriptionValue] = useState('');
|
||||
const [editDescriptionFileName, setEditDescriptionFileName] = useState('');
|
||||
|
||||
// Actions panel state (for tablet/mobile)
|
||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||
|
||||
// File input ref for import
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -663,7 +669,7 @@ export function ContextView() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="context-view-loading">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -691,30 +697,70 @@ export function ContextView() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleImportClick}
|
||||
disabled={isUploading}
|
||||
data-testid="import-file-button"
|
||||
>
|
||||
<FileUp className="w-4 h-4 mr-2" />
|
||||
Import File
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
size="sm"
|
||||
onClick={() => setIsCreateMarkdownOpen(true)}
|
||||
hotkey={shortcuts.addContextFile}
|
||||
hotkeyActive={false}
|
||||
data-testid="create-markdown-button"
|
||||
>
|
||||
<FilePlus className="w-4 h-4 mr-2" />
|
||||
Create Markdown
|
||||
</HotkeyButton>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Desktop: show actions inline */}
|
||||
<div className="hidden lg:flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleImportClick}
|
||||
disabled={isUploading}
|
||||
data-testid="import-file-button"
|
||||
>
|
||||
<FileUp className="w-4 h-4 mr-2" />
|
||||
Import File
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
size="sm"
|
||||
onClick={() => setIsCreateMarkdownOpen(true)}
|
||||
hotkey={shortcuts.addContextFile}
|
||||
hotkeyActive={false}
|
||||
data-testid="create-markdown-button"
|
||||
>
|
||||
<FilePlus className="w-4 h-4 mr-2" />
|
||||
Create Markdown
|
||||
</HotkeyButton>
|
||||
</div>
|
||||
{/* Tablet/Mobile: show trigger for actions panel */}
|
||||
<HeaderActionsPanelTrigger
|
||||
isOpen={showActionsPanel}
|
||||
onToggle={() => setShowActionsPanel(!showActionsPanel)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions Panel (tablet/mobile) */}
|
||||
<HeaderActionsPanel
|
||||
isOpen={showActionsPanel}
|
||||
onClose={() => setShowActionsPanel(false)}
|
||||
title="Context Actions"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
handleImportClick();
|
||||
setShowActionsPanel(false);
|
||||
}}
|
||||
disabled={isUploading}
|
||||
data-testid="import-file-button-mobile"
|
||||
>
|
||||
<FileUp className="w-4 h-4 mr-2" />
|
||||
Import File
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
setIsCreateMarkdownOpen(true);
|
||||
setShowActionsPanel(false);
|
||||
}}
|
||||
data-testid="create-markdown-button-mobile"
|
||||
>
|
||||
<FilePlus className="w-4 h-4 mr-2" />
|
||||
Create Markdown
|
||||
</Button>
|
||||
</HeaderActionsPanel>
|
||||
|
||||
{/* Main content area with file list and editor */}
|
||||
<div
|
||||
className={cn(
|
||||
@@ -743,7 +789,7 @@ export function ContextView() {
|
||||
{isUploading && (
|
||||
<div className="absolute inset-0 bg-background/80 z-50 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary mb-2" />
|
||||
<Spinner size="xl" className="mb-2" />
|
||||
<span className="text-sm font-medium">Uploading {uploadingFileName}...</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -791,7 +837,7 @@ export function ContextView() {
|
||||
<span className="truncate text-sm block">{file.name}</span>
|
||||
{isGenerating ? (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
Generating description...
|
||||
</span>
|
||||
) : file.description ? (
|
||||
@@ -908,7 +954,7 @@ export function ContextView() {
|
||||
</span>
|
||||
{generatingDescriptions.has(selectedFile.name) ? (
|
||||
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<Spinner size="sm" />
|
||||
<span>Generating description with AI...</span>
|
||||
</div>
|
||||
) : selectedFile.description ? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useAppStore, type ThemeMode } from '@/store/app-store';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useOSDetection } from '@/hooks/use-os-detection';
|
||||
import { getElectronAPI, isElectron } from '@/lib/electron';
|
||||
import { initializeProject } from '@/lib/project-init';
|
||||
@@ -18,13 +18,18 @@ import {
|
||||
Folder,
|
||||
Star,
|
||||
Clock,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
MoreVertical,
|
||||
Trash2,
|
||||
Search,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -55,6 +60,13 @@ function getOSAbbreviation(os: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function getIconComponent(iconName?: string): LucideIcon {
|
||||
if (iconName && iconName in LucideIcons) {
|
||||
return (LucideIcons as unknown as Record<string, LucideIcon>)[iconName];
|
||||
}
|
||||
return Folder;
|
||||
}
|
||||
|
||||
export function DashboardView() {
|
||||
const navigate = useNavigate();
|
||||
const { os } = useOSDetection();
|
||||
@@ -64,14 +76,11 @@ export function DashboardView() {
|
||||
|
||||
const {
|
||||
projects,
|
||||
trashedProjects,
|
||||
currentProject,
|
||||
upsertAndSetCurrentProject,
|
||||
addProject,
|
||||
setCurrentProject,
|
||||
toggleProjectFavorite,
|
||||
moveProjectToTrash,
|
||||
theme: globalTheme,
|
||||
} = useAppStore();
|
||||
|
||||
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
|
||||
@@ -79,6 +88,7 @@ export function DashboardView() {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isOpening, setIsOpening] = useState(false);
|
||||
const [projectToRemove, setProjectToRemove] = useState<{ id: string; name: string } | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Sort projects: favorites first, then by last opened
|
||||
const sortedProjects = [...projects].sort((a, b) => {
|
||||
@@ -91,8 +101,15 @@ export function DashboardView() {
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
const favoriteProjects = sortedProjects.filter((p) => p.isFavorite);
|
||||
const recentProjects = sortedProjects.filter((p) => !p.isFavorite);
|
||||
// Filter projects based on search query
|
||||
const filteredProjects = sortedProjects.filter((project) => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return project.name.toLowerCase().includes(query) || project.path.toLowerCase().includes(query);
|
||||
});
|
||||
|
||||
const favoriteProjects = filteredProjects.filter((p) => p.isFavorite);
|
||||
const recentProjects = filteredProjects.filter((p) => !p.isFavorite);
|
||||
|
||||
/**
|
||||
* Initialize project and navigate to board
|
||||
@@ -104,18 +121,27 @@ export function DashboardView() {
|
||||
const initResult = await initializeProject(path);
|
||||
|
||||
if (!initResult.success) {
|
||||
// If the project directory doesn't exist, automatically remove it from the project list
|
||||
if (initResult.error?.includes('does not exist')) {
|
||||
const projectToRemove = projects.find((p) => p.path === path);
|
||||
if (projectToRemove) {
|
||||
logger.warn(`[Dashboard] Removing project with non-existent path: ${path}`);
|
||||
moveProjectToTrash(projectToRemove.id);
|
||||
toast.error('Project directory not found', {
|
||||
description: `Removed ${name} from your projects list since the directory no longer exists.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
toast.error('Failed to initialize project', {
|
||||
description: initResult.error || 'Unknown error occurred',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const trashedProject = trashedProjects.find((p) => p.path === path);
|
||||
const effectiveTheme =
|
||||
(trashedProject?.theme as ThemeMode | undefined) ||
|
||||
(currentProject?.theme as ThemeMode | undefined) ||
|
||||
globalTheme;
|
||||
upsertAndSetCurrentProject(path, name, effectiveTheme);
|
||||
// Theme handling (trashed project recovery or undefined for global) is done by the store
|
||||
upsertAndSetCurrentProject(path, name);
|
||||
|
||||
toast.success('Project opened', {
|
||||
description: `Opened ${name}`,
|
||||
@@ -131,7 +157,7 @@ export function DashboardView() {
|
||||
setIsOpening(false);
|
||||
}
|
||||
},
|
||||
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject, navigate]
|
||||
[projects, upsertAndSetCurrentProject, navigate, moveProjectToTrash]
|
||||
);
|
||||
|
||||
const handleOpenProject = useCallback(async () => {
|
||||
@@ -529,14 +555,35 @@ export function DashboardView() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate({ to: '/settings' })}
|
||||
className="titlebar-no-drag"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
</Button>
|
||||
|
||||
{/* Mobile action buttons in header */}
|
||||
{hasProjects && (
|
||||
<div className="flex sm:hidden gap-2 titlebar-no-drag">
|
||||
<Button variant="outline" size="icon" onClick={handleOpenProject}>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
className="bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onClick={handleNewProject}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Quick Setup
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleInteractiveMode}>
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Interactive Mode
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -646,25 +693,42 @@ export function DashboardView() {
|
||||
{/* Has projects - show project list */}
|
||||
{hasProjects && (
|
||||
<div className="space-y-6 sm:space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
{/* Quick actions header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<h2 className="text-2xl font-bold text-foreground">Your Projects</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleOpenProject}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Open Folder</span>
|
||||
{/* Search and actions header */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-foreground">Your Projects</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Search input */}
|
||||
<div className="relative flex-1 sm:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search projects..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-8 w-full sm:w-64"
|
||||
data-testid="project-search-input"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-muted transition-colors"
|
||||
title="Clear search"
|
||||
>
|
||||
<X className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Desktop only buttons */}
|
||||
<Button variant="outline" onClick={handleOpenProject} className="hidden sm:flex">
|
||||
<FolderOpen className="w-4 h-4 mr-2" />
|
||||
Open Folder
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="flex-1 sm:flex-none bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white">
|
||||
<Plus className="w-4 h-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">New Project</span>
|
||||
<span className="sm:hidden">New</span>
|
||||
<ChevronDown className="w-4 h-4 ml-1 sm:ml-2" />
|
||||
<Button className="hidden sm:flex bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Project
|
||||
<ChevronDown className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
@@ -703,8 +767,24 @@ export function DashboardView() {
|
||||
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-yellow-500/5 to-amber-600/5 opacity-0 group-hover:opacity-100 transition-all duration-300" />
|
||||
<div className="relative p-3 sm:p-4">
|
||||
<div className="flex items-start gap-2.5 sm:gap-3">
|
||||
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-yellow-500/10 border border-yellow-500/30 flex items-center justify-center group-hover:bg-yellow-500/20 transition-all duration-300 shrink-0">
|
||||
<Folder className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-500" />
|
||||
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-yellow-500/10 border border-yellow-500/30 flex items-center justify-center group-hover:bg-yellow-500/20 transition-all duration-300 shrink-0 overflow-hidden">
|
||||
{project.customIconPath ? (
|
||||
<img
|
||||
src={getAuthenticatedImageUrl(
|
||||
project.customIconPath,
|
||||
project.path
|
||||
)}
|
||||
alt={project.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
(() => {
|
||||
const IconComponent = getIconComponent(project.icon);
|
||||
return (
|
||||
<IconComponent className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-500" />
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm sm:text-base font-medium text-foreground truncate group-hover:text-yellow-500 transition-colors duration-300">
|
||||
@@ -778,8 +858,24 @@ export function DashboardView() {
|
||||
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-brand-500/0 to-purple-600/0 group-hover:from-brand-500/5 group-hover:to-purple-600/5 transition-all duration-300" />
|
||||
<div className="relative p-3 sm:p-4">
|
||||
<div className="flex items-start gap-2.5 sm:gap-3">
|
||||
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-muted/80 border border-border flex items-center justify-center group-hover:bg-brand-500/10 group-hover:border-brand-500/30 transition-all duration-300 shrink-0">
|
||||
<Folder className="w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground group-hover:text-brand-500 transition-colors duration-300" />
|
||||
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-muted/80 border border-border flex items-center justify-center group-hover:bg-brand-500/10 group-hover:border-brand-500/30 transition-all duration-300 shrink-0 overflow-hidden">
|
||||
{project.customIconPath ? (
|
||||
<img
|
||||
src={getAuthenticatedImageUrl(
|
||||
project.customIconPath,
|
||||
project.path
|
||||
)}
|
||||
alt={project.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
(() => {
|
||||
const IconComponent = getIconComponent(project.icon);
|
||||
return (
|
||||
<IconComponent className="w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground group-hover:text-brand-500 transition-colors duration-300" />
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm sm:text-base font-medium text-foreground truncate group-hover:text-brand-500 transition-colors duration-300">
|
||||
@@ -797,10 +893,10 @@ export function DashboardView() {
|
||||
<div className="flex items-center gap-0.5 sm:gap-1">
|
||||
<button
|
||||
onClick={(e) => handleToggleFavorite(e, project.id)}
|
||||
className="p-1 sm:p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
|
||||
className="p-1 sm:p-1.5 rounded-lg hover:bg-muted transition-colors"
|
||||
title="Add to favorites"
|
||||
>
|
||||
<Star className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground hover:text-yellow-500" />
|
||||
<Star className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground/50 hover:text-yellow-500 transition-colors" />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -830,6 +926,22 @@ export function DashboardView() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No search results */}
|
||||
{searchQuery && favoriteProjects.length === 0 && recentProjects.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
|
||||
<Search className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">No projects found</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
No projects match "{searchQuery}"
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearchQuery('')}>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -886,7 +998,7 @@ export function DashboardView() {
|
||||
data-testid="project-opening-overlay"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 p-8 rounded-2xl bg-card border border-border shadow-2xl">
|
||||
<Loader2 className="w-10 h-10 text-brand-500 animate-spin" />
|
||||
<Spinner size="xl" />
|
||||
<p className="text-foreground font-medium">Opening project...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
// @ts-nocheck
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { CircleDot, RefreshCw } from 'lucide-react';
|
||||
import { CircleDot, RefreshCw, SearchX } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { LoadingState } from '@/components/ui/loading-state';
|
||||
import { ErrorState } from '@/components/ui/error-state';
|
||||
import { cn, pathsEqual } from '@/lib/utils';
|
||||
import { cn, pathsEqual, generateUUID } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { useGithubIssues, useIssueValidation } from './github-issues-view/hooks';
|
||||
import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks';
|
||||
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
|
||||
import { ValidationDialog } from './github-issues-view/dialogs';
|
||||
import { formatDate, getFeaturePriority } from './github-issues-view/utils';
|
||||
import { useModelOverride } from '@/components/shared';
|
||||
import type { ValidateIssueOptions } from './github-issues-view/types';
|
||||
import type {
|
||||
ValidateIssueOptions,
|
||||
IssuesFilterState,
|
||||
IssuesStateFilter,
|
||||
} from './github-issues-view/types';
|
||||
import { DEFAULT_ISSUES_FILTER_STATE } from './github-issues-view/types';
|
||||
|
||||
const logger = createLogger('GitHubIssuesView');
|
||||
|
||||
@@ -28,6 +34,9 @@ export function GitHubIssuesView() {
|
||||
const [pendingRevalidateOptions, setPendingRevalidateOptions] =
|
||||
useState<ValidateIssueOptions | null>(null);
|
||||
|
||||
// Filter state
|
||||
const [filterState, setFilterState] = useState<IssuesFilterState>(DEFAULT_ISSUES_FILTER_STATE);
|
||||
|
||||
const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -47,6 +56,41 @@ export function GitHubIssuesView() {
|
||||
onShowValidationDialogChange: setShowValidationDialog,
|
||||
});
|
||||
|
||||
// Combine all issues for filtering
|
||||
const allIssues = useMemo(() => [...openIssues, ...closedIssues], [openIssues, closedIssues]);
|
||||
|
||||
// Apply filter to issues - now returns matched issues directly for better performance
|
||||
const filterResult = useIssuesFilter(allIssues, filterState, cachedValidations);
|
||||
|
||||
// Separate filtered issues by state - this is O(n) but now only done once
|
||||
// since filterResult.matchedIssues already contains the filtered issues
|
||||
const { filteredOpenIssues, filteredClosedIssues } = useMemo(() => {
|
||||
const open: typeof openIssues = [];
|
||||
const closed: typeof closedIssues = [];
|
||||
for (const issue of filterResult.matchedIssues) {
|
||||
if (issue.state.toLowerCase() === 'open') {
|
||||
open.push(issue);
|
||||
} else {
|
||||
closed.push(issue);
|
||||
}
|
||||
}
|
||||
return { filteredOpenIssues: open, filteredClosedIssues: closed };
|
||||
}, [filterResult.matchedIssues]);
|
||||
|
||||
// Filter state change handlers
|
||||
const handleStateFilterChange = useCallback((stateFilter: IssuesStateFilter) => {
|
||||
setFilterState((prev) => ({ ...prev, stateFilter }));
|
||||
}, []);
|
||||
|
||||
const handleLabelsChange = useCallback((selectedLabels: string[]) => {
|
||||
setFilterState((prev) => ({ ...prev, selectedLabels }));
|
||||
}, []);
|
||||
|
||||
// Clear all filters to default state
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setFilterState(DEFAULT_ISSUES_FILTER_STATE);
|
||||
}, []);
|
||||
|
||||
// Get current branch from selected worktree
|
||||
const currentBranch = useMemo(() => {
|
||||
if (!currentProject?.path) return '';
|
||||
@@ -96,7 +140,7 @@ export function GitHubIssuesView() {
|
||||
.join('\n');
|
||||
|
||||
const feature = {
|
||||
id: `issue-${issue.number}-${crypto.randomUUID()}`,
|
||||
id: `issue-${issue.number}-${generateUUID()}`,
|
||||
title: issue.title,
|
||||
description,
|
||||
category: 'From GitHub',
|
||||
@@ -137,7 +181,10 @@ export function GitHubIssuesView() {
|
||||
return <ErrorState error={error} title="Failed to Load Issues" onRetry={refresh} />;
|
||||
}
|
||||
|
||||
const totalIssues = openIssues.length + closedIssues.length;
|
||||
const totalIssues = filteredOpenIssues.length + filteredClosedIssues.length;
|
||||
const totalUnfilteredIssues = openIssues.length + closedIssues.length;
|
||||
const isFilteredEmpty =
|
||||
totalIssues === 0 && totalUnfilteredIssues > 0 && filterResult.hasActiveFilter;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
@@ -150,10 +197,21 @@ export function GitHubIssuesView() {
|
||||
>
|
||||
{/* Header */}
|
||||
<IssuesListHeader
|
||||
openCount={openIssues.length}
|
||||
closedCount={closedIssues.length}
|
||||
openCount={filteredOpenIssues.length}
|
||||
closedCount={filteredClosedIssues.length}
|
||||
totalOpenCount={openIssues.length}
|
||||
totalClosedCount={closedIssues.length}
|
||||
hasActiveFilter={filterResult.hasActiveFilter}
|
||||
refreshing={refreshing}
|
||||
onRefresh={refresh}
|
||||
compact={!!selectedIssue}
|
||||
filterProps={{
|
||||
stateFilter: filterState.stateFilter,
|
||||
selectedLabels: filterState.selectedLabels,
|
||||
availableLabels: filterResult.availableLabels,
|
||||
onStateFilterChange: handleStateFilterChange,
|
||||
onLabelsChange: handleLabelsChange,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Issues List */}
|
||||
@@ -161,15 +219,35 @@ export function GitHubIssuesView() {
|
||||
{totalIssues === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-6">
|
||||
<div className="p-4 rounded-full bg-muted/50 mb-4">
|
||||
<CircleDot className="h-8 w-8 text-muted-foreground" />
|
||||
{isFilteredEmpty ? (
|
||||
<SearchX className="h-8 w-8 text-muted-foreground" />
|
||||
) : (
|
||||
<CircleDot className="h-8 w-8 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-base font-medium mb-2">No Issues</h2>
|
||||
<p className="text-sm text-muted-foreground">This repository has no issues yet.</p>
|
||||
<h2 className="text-base font-medium mb-2">
|
||||
{isFilteredEmpty ? 'No Matching Issues' : 'No Issues'}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{isFilteredEmpty
|
||||
? 'No issues match your current filters.'
|
||||
: 'This repository has no issues yet.'}
|
||||
</p>
|
||||
{isFilteredEmpty && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearFilters}
|
||||
className="text-xs"
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{/* Open Issues */}
|
||||
{openIssues.map((issue) => (
|
||||
{filteredOpenIssues.map((issue) => (
|
||||
<IssueRow
|
||||
key={issue.number}
|
||||
issue={issue}
|
||||
@@ -183,12 +261,12 @@ export function GitHubIssuesView() {
|
||||
))}
|
||||
|
||||
{/* Closed Issues Section */}
|
||||
{closedIssues.length > 0 && (
|
||||
{filteredClosedIssues.length > 0 && (
|
||||
<>
|
||||
<div className="px-4 py-2 bg-muted/30 text-xs font-medium text-muted-foreground">
|
||||
Closed Issues ({closedIssues.length})
|
||||
Closed Issues ({filteredClosedIssues.length})
|
||||
</div>
|
||||
{closedIssues.map((issue) => (
|
||||
{filteredClosedIssues.map((issue) => (
|
||||
<IssueRow
|
||||
key={issue.number}
|
||||
issue={issue}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { IssueRow } from './issue-row';
|
||||
export { IssueDetailPanel } from './issue-detail-panel';
|
||||
export { IssuesListHeader } from './issues-list-header';
|
||||
export { IssuesFilterControls } from './issues-filter-controls';
|
||||
export { CommentItem } from './comment-item';
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
X,
|
||||
Wand2,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
GitPullRequest,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -87,7 +87,7 @@ export function IssueDetailPanel({
|
||||
if (isValidating) {
|
||||
return (
|
||||
<Button variant="default" size="sm" disabled>
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
<Spinner size="sm" className="mr-1" />
|
||||
Validating...
|
||||
</Button>
|
||||
);
|
||||
@@ -297,9 +297,7 @@ export function IssueDetailPanel({
|
||||
<span className="text-sm font-medium">
|
||||
Comments {totalCount > 0 && `(${totalCount})`}
|
||||
</span>
|
||||
{commentsLoading && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{commentsLoading && <Spinner size="xs" />}
|
||||
{commentsExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
@@ -340,7 +338,7 @@ export function IssueDetailPanel({
|
||||
>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -2,12 +2,12 @@ import {
|
||||
Circle,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
Sparkles,
|
||||
GitPullRequest,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { IssueRowProps } from '../types';
|
||||
@@ -97,7 +97,7 @@ export function IssueRow({
|
||||
{/* Validating indicator */}
|
||||
{isValidating && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-primary/10 text-primary border border-primary/20 animate-in fade-in duration-200">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
Analyzing...
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { ChevronDown, Tag, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { IssuesStateFilter } from '../types';
|
||||
import { ISSUES_STATE_FILTER_OPTIONS } from '../types';
|
||||
|
||||
/** Maximum number of labels to display before showing "+N more" in normal layout */
|
||||
const VISIBLE_LABELS_LIMIT = 3;
|
||||
/** Maximum number of labels to display before showing "+N more" in compact layout */
|
||||
const VISIBLE_LABELS_LIMIT_COMPACT = 2;
|
||||
|
||||
interface IssuesFilterControlsProps {
|
||||
/** Current state filter value */
|
||||
stateFilter: IssuesStateFilter;
|
||||
/** Currently selected labels */
|
||||
selectedLabels: string[];
|
||||
/** Available labels to choose from (typically from useIssuesFilter result) */
|
||||
availableLabels: string[];
|
||||
/** Callback when state filter changes */
|
||||
onStateFilterChange: (filter: IssuesStateFilter) => void;
|
||||
/** Callback when labels selection changes */
|
||||
onLabelsChange: (labels: string[]) => void;
|
||||
/** Whether the controls are disabled (e.g., during loading) */
|
||||
disabled?: boolean;
|
||||
/** Whether to use compact layout (stacked vertically) */
|
||||
compact?: boolean;
|
||||
/** Additional class name for the container */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Human-readable labels for state filter options */
|
||||
const STATE_FILTER_LABELS: Record<IssuesStateFilter, string> = {
|
||||
open: 'Open',
|
||||
closed: 'Closed',
|
||||
all: 'All',
|
||||
};
|
||||
|
||||
export function IssuesFilterControls({
|
||||
stateFilter,
|
||||
selectedLabels,
|
||||
availableLabels,
|
||||
onStateFilterChange,
|
||||
onLabelsChange,
|
||||
disabled = false,
|
||||
compact = false,
|
||||
className,
|
||||
}: IssuesFilterControlsProps) {
|
||||
/**
|
||||
* Handles toggling a label in the selection.
|
||||
* If the label is already selected, it removes it; otherwise, it adds it.
|
||||
*/
|
||||
const handleLabelToggle = (label: string) => {
|
||||
const isSelected = selectedLabels.includes(label);
|
||||
if (isSelected) {
|
||||
onLabelsChange(selectedLabels.filter((l) => l !== label));
|
||||
} else {
|
||||
onLabelsChange([...selectedLabels, label]);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears all selected labels.
|
||||
*/
|
||||
const handleClearLabels = () => {
|
||||
onLabelsChange([]);
|
||||
};
|
||||
|
||||
const hasSelectedLabels = selectedLabels.length > 0;
|
||||
const hasAvailableLabels = availableLabels.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-2', className)}>
|
||||
{/* Filter Controls Row */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* State Filter Select */}
|
||||
<Select
|
||||
value={stateFilter}
|
||||
onValueChange={(value) => onStateFilterChange(value as IssuesStateFilter)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className={cn('h-8 text-sm', compact ? 'w-[90px]' : 'w-[110px]')}>
|
||||
<SelectValue placeholder="State" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ISSUES_STATE_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{STATE_FILTER_LABELS[option]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Labels Filter Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild disabled={disabled || !hasAvailableLabels}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn('h-8 gap-1.5', hasSelectedLabels && 'border-primary/50 bg-primary/5')}
|
||||
disabled={disabled || !hasAvailableLabels}
|
||||
>
|
||||
<Tag className="h-3.5 w-3.5" />
|
||||
<span>Labels</span>
|
||||
{hasSelectedLabels && (
|
||||
<Badge variant="secondary" size="sm" className="ml-1 px-1.5 py-0">
|
||||
{selectedLabels.length}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56 max-h-64 overflow-y-auto">
|
||||
<DropdownMenuLabel className="flex items-center justify-between">
|
||||
<span>Filter by label</span>
|
||||
{hasSelectedLabels && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={handleClearLabels}
|
||||
>
|
||||
<X className="h-3 w-3 mr-0.5" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{availableLabels.map((label) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={label}
|
||||
checked={selectedLabels.includes(label)}
|
||||
onCheckedChange={() => handleLabelToggle(label)}
|
||||
onSelect={(e) => e.preventDefault()} // Prevent dropdown from closing
|
||||
>
|
||||
{label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
{!hasAvailableLabels && (
|
||||
<div className="px-2 py-1.5 text-sm text-muted-foreground">No labels available</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Selected Labels Display - shown on separate row */}
|
||||
{hasSelectedLabels && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{selectedLabels
|
||||
.slice(0, compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT)
|
||||
.map((label) => (
|
||||
<Badge
|
||||
key={label}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1 cursor-pointer hover:bg-destructive/10 hover:border-destructive/50"
|
||||
onClick={() => handleLabelToggle(label)}
|
||||
>
|
||||
{label}
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</Badge>
|
||||
))}
|
||||
{selectedLabels.length >
|
||||
(compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT) && (
|
||||
<Badge variant="muted" size="sm">
|
||||
+
|
||||
{selectedLabels.length -
|
||||
(compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT)}{' '}
|
||||
more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +1,101 @@
|
||||
import { CircleDot, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { IssuesStateFilter } from '../types';
|
||||
import { IssuesFilterControls } from './issues-filter-controls';
|
||||
|
||||
interface IssuesListHeaderProps {
|
||||
openCount: number;
|
||||
closedCount: number;
|
||||
/** Total open issues count (unfiltered) - used to show "X of Y" when filtered */
|
||||
totalOpenCount?: number;
|
||||
/** Total closed issues count (unfiltered) - used to show "X of Y" when filtered */
|
||||
totalClosedCount?: number;
|
||||
/** Whether any filter is currently active */
|
||||
hasActiveFilter?: boolean;
|
||||
refreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
/** Whether the list is in compact mode (e.g., when detail panel is open) */
|
||||
compact?: boolean;
|
||||
/** Optional filter state and handlers - when provided, filter controls are rendered */
|
||||
filterProps?: {
|
||||
stateFilter: IssuesStateFilter;
|
||||
selectedLabels: string[];
|
||||
availableLabels: string[];
|
||||
onStateFilterChange: (filter: IssuesStateFilter) => void;
|
||||
onLabelsChange: (labels: string[]) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function IssuesListHeader({
|
||||
openCount,
|
||||
closedCount,
|
||||
totalOpenCount,
|
||||
totalClosedCount,
|
||||
hasActiveFilter = false,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
compact = false,
|
||||
filterProps,
|
||||
}: IssuesListHeaderProps) {
|
||||
const totalIssues = openCount + closedCount;
|
||||
|
||||
// Format the counts subtitle based on filter state
|
||||
const getCountsSubtitle = () => {
|
||||
if (totalIssues === 0) {
|
||||
return hasActiveFilter ? 'No matching issues' : 'No issues found';
|
||||
}
|
||||
|
||||
// When filters are active and we have total counts, show "X of Y" format
|
||||
if (hasActiveFilter && totalOpenCount !== undefined && totalClosedCount !== undefined) {
|
||||
const openText =
|
||||
openCount === totalOpenCount
|
||||
? `${openCount} open`
|
||||
: `${openCount} of ${totalOpenCount} open`;
|
||||
const closedText =
|
||||
closedCount === totalClosedCount
|
||||
? `${closedCount} closed`
|
||||
: `${closedCount} of ${totalClosedCount} closed`;
|
||||
return `${openText}, ${closedText}`;
|
||||
}
|
||||
|
||||
// Default format when no filters active
|
||||
return `${openCount} open, ${closedCount} closed`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-green-500/10">
|
||||
<CircleDot className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">Issues</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{totalIssues === 0 ? 'No issues found' : `${openCount} open, ${closedCount} closed`}
|
||||
</p>
|
||||
<div className="border-b border-border">
|
||||
{/* Top row: Title and refresh button */}
|
||||
<div className="flex items-center justify-between p-4 pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-green-500/10">
|
||||
<CircleDot className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">Issues</h1>
|
||||
<p className="text-xs text-muted-foreground">{getCountsSubtitle()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh} disabled={refreshing}>
|
||||
{refreshing ? <Spinner size="sm" /> : <RefreshCw className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
|
||||
{/* Filter controls row (optional) */}
|
||||
{filterProps && (
|
||||
<div className="px-4 pb-3 pt-1">
|
||||
<IssuesFilterControls
|
||||
stateFilter={filterProps.stateFilter}
|
||||
selectedLabels={filterProps.selectedLabels}
|
||||
availableLabels={filterProps.availableLabels}
|
||||
onStateFilterChange={filterProps.onStateFilterChange}
|
||||
onLabelsChange={filterProps.onLabelsChange}
|
||||
disabled={refreshing}
|
||||
compact={compact}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { useGithubIssues } from './use-github-issues';
|
||||
export { useIssueValidation } from './use-issue-validation';
|
||||
export { useIssueComments } from './use-issue-comments';
|
||||
export { useIssuesFilter } from './use-issues-filter';
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { GitHubIssue, StoredValidation } from '@/lib/electron';
|
||||
import type { IssuesFilterState, IssuesFilterResult, IssuesValidationStatus } from '../types';
|
||||
import { isValidationStale } from '../utils';
|
||||
|
||||
/**
|
||||
* Determines the validation status of an issue based on its cached validation.
|
||||
*/
|
||||
function getValidationStatus(
|
||||
issueNumber: number,
|
||||
cachedValidations: Map<number, StoredValidation>
|
||||
): IssuesValidationStatus | null {
|
||||
const validation = cachedValidations.get(issueNumber);
|
||||
if (!validation) {
|
||||
return 'not_validated';
|
||||
}
|
||||
if (isValidationStale(validation.validatedAt)) {
|
||||
return 'stale';
|
||||
}
|
||||
return 'validated';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a search query matches an issue's searchable content.
|
||||
* Searches through title and body (case-insensitive).
|
||||
*/
|
||||
function matchesSearchQuery(issue: GitHubIssue, normalizedQuery: string): boolean {
|
||||
if (!normalizedQuery) return true;
|
||||
|
||||
const titleMatch = issue.title?.toLowerCase().includes(normalizedQuery);
|
||||
const bodyMatch = issue.body?.toLowerCase().includes(normalizedQuery);
|
||||
|
||||
return titleMatch || bodyMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an issue matches the state filter (open/closed/all).
|
||||
* Note: GitHub CLI returns state in uppercase (OPEN/CLOSED), so we compare case-insensitively.
|
||||
*/
|
||||
function matchesStateFilter(
|
||||
issue: GitHubIssue,
|
||||
stateFilter: IssuesFilterState['stateFilter']
|
||||
): boolean {
|
||||
if (stateFilter === 'all') return true;
|
||||
return issue.state.toLowerCase() === stateFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an issue matches any of the selected labels.
|
||||
* Returns true if no labels are selected (no filter) or if any selected label matches.
|
||||
*/
|
||||
function matchesLabels(issue: GitHubIssue, selectedLabels: string[]): boolean {
|
||||
if (selectedLabels.length === 0) return true;
|
||||
|
||||
const issueLabels = issue.labels.map((l) => l.name);
|
||||
return selectedLabels.some((label) => issueLabels.includes(label));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an issue matches any of the selected assignees.
|
||||
* Returns true if no assignees are selected (no filter) or if any selected assignee matches.
|
||||
*/
|
||||
function matchesAssignees(issue: GitHubIssue, selectedAssignees: string[]): boolean {
|
||||
if (selectedAssignees.length === 0) return true;
|
||||
|
||||
const issueAssignees = issue.assignees?.map((a) => a.login) ?? [];
|
||||
return selectedAssignees.some((assignee) => issueAssignees.includes(assignee));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an issue matches any of the selected milestones.
|
||||
* Returns true if no milestones are selected (no filter) or if any selected milestone matches.
|
||||
* Note: GitHub issues may not have milestone data in the current schema, this is a placeholder.
|
||||
*/
|
||||
function matchesMilestones(issue: GitHubIssue, selectedMilestones: string[]): boolean {
|
||||
if (selectedMilestones.length === 0) return true;
|
||||
|
||||
// GitHub issues in the current schema don't have milestone field
|
||||
// This is a placeholder for future milestone support
|
||||
// For now, issues with no milestone won't match if a milestone filter is active
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an issue matches the validation status filter.
|
||||
*/
|
||||
function matchesValidationStatus(
|
||||
issue: GitHubIssue,
|
||||
validationStatusFilter: IssuesValidationStatus | null,
|
||||
cachedValidations: Map<number, StoredValidation>
|
||||
): boolean {
|
||||
if (!validationStatusFilter) return true;
|
||||
|
||||
const status = getValidationStatus(issue.number, cachedValidations);
|
||||
return status === validationStatusFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all unique labels from a list of issues.
|
||||
*/
|
||||
function extractAvailableLabels(issues: GitHubIssue[]): string[] {
|
||||
const labelsSet = new Set<string>();
|
||||
for (const issue of issues) {
|
||||
for (const label of issue.labels) {
|
||||
labelsSet.add(label.name);
|
||||
}
|
||||
}
|
||||
return Array.from(labelsSet).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all unique assignees from a list of issues.
|
||||
*/
|
||||
function extractAvailableAssignees(issues: GitHubIssue[]): string[] {
|
||||
const assigneesSet = new Set<string>();
|
||||
for (const issue of issues) {
|
||||
for (const assignee of issue.assignees ?? []) {
|
||||
assigneesSet.add(assignee.login);
|
||||
}
|
||||
}
|
||||
return Array.from(assigneesSet).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all unique milestones from a list of issues.
|
||||
* Note: Currently returns empty array as milestone is not in the GitHubIssue schema.
|
||||
*/
|
||||
function extractAvailableMilestones(_issues: GitHubIssue[]): string[] {
|
||||
// GitHub issues in the current schema don't have milestone field
|
||||
// This is a placeholder for future milestone support
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if any filter is currently active.
|
||||
*/
|
||||
function hasActiveFilterCheck(filterState: IssuesFilterState): boolean {
|
||||
const {
|
||||
searchQuery,
|
||||
stateFilter,
|
||||
selectedLabels,
|
||||
selectedAssignees,
|
||||
selectedMilestones,
|
||||
validationStatusFilter,
|
||||
} = filterState;
|
||||
|
||||
// Note: stateFilter 'open' is the default, so we consider it "not active" for UI purposes
|
||||
// Only 'closed' or 'all' are considered active filters
|
||||
const hasStateFilter = stateFilter !== 'open';
|
||||
const hasSearchQuery = searchQuery.trim().length > 0;
|
||||
const hasLabelFilter = selectedLabels.length > 0;
|
||||
const hasAssigneeFilter = selectedAssignees.length > 0;
|
||||
const hasMilestoneFilter = selectedMilestones.length > 0;
|
||||
const hasValidationFilter = validationStatusFilter !== null;
|
||||
|
||||
return (
|
||||
hasSearchQuery ||
|
||||
hasStateFilter ||
|
||||
hasLabelFilter ||
|
||||
hasAssigneeFilter ||
|
||||
hasMilestoneFilter ||
|
||||
hasValidationFilter
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to filter GitHub issues based on the current filter state.
|
||||
*
|
||||
* This hook follows the same pattern as useGraphFilter but is tailored for GitHub issues.
|
||||
* It computes matched issues and extracts available filter options from all issues.
|
||||
*
|
||||
* @param issues - Combined array of all issues (open + closed) to filter
|
||||
* @param filterState - Current filter state including search, labels, assignees, etc.
|
||||
* @param cachedValidations - Map of issue numbers to their cached validation results
|
||||
* @returns Filter result containing matched issue numbers and available filter options
|
||||
*/
|
||||
export function useIssuesFilter(
|
||||
issues: GitHubIssue[],
|
||||
filterState: IssuesFilterState,
|
||||
cachedValidations: Map<number, StoredValidation> = new Map()
|
||||
): IssuesFilterResult {
|
||||
const {
|
||||
searchQuery,
|
||||
stateFilter,
|
||||
selectedLabels,
|
||||
selectedAssignees,
|
||||
selectedMilestones,
|
||||
validationStatusFilter,
|
||||
} = filterState;
|
||||
|
||||
return useMemo(() => {
|
||||
// Extract available options from all issues (for filter dropdown population)
|
||||
const availableLabels = extractAvailableLabels(issues);
|
||||
const availableAssignees = extractAvailableAssignees(issues);
|
||||
const availableMilestones = extractAvailableMilestones(issues);
|
||||
|
||||
// Check if any filter is active
|
||||
const hasActiveFilter = hasActiveFilterCheck(filterState);
|
||||
|
||||
// Normalize search query for case-insensitive matching
|
||||
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||
|
||||
// Filter issues based on all criteria - return matched issues directly
|
||||
// This eliminates the redundant O(n) filtering operation in the consuming component
|
||||
const matchedIssues: GitHubIssue[] = [];
|
||||
|
||||
for (const issue of issues) {
|
||||
// All conditions must be true for a match
|
||||
const matchesAllFilters =
|
||||
matchesSearchQuery(issue, normalizedQuery) &&
|
||||
matchesStateFilter(issue, stateFilter) &&
|
||||
matchesLabels(issue, selectedLabels) &&
|
||||
matchesAssignees(issue, selectedAssignees) &&
|
||||
matchesMilestones(issue, selectedMilestones) &&
|
||||
matchesValidationStatus(issue, validationStatusFilter, cachedValidations);
|
||||
|
||||
if (matchesAllFilters) {
|
||||
matchedIssues.push(issue);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matchedIssues,
|
||||
availableLabels,
|
||||
availableAssignees,
|
||||
availableMilestones,
|
||||
hasActiveFilter,
|
||||
matchedCount: matchedIssues.length,
|
||||
};
|
||||
}, [
|
||||
issues,
|
||||
searchQuery,
|
||||
stateFilter,
|
||||
selectedLabels,
|
||||
selectedAssignees,
|
||||
selectedMilestones,
|
||||
validationStatusFilter,
|
||||
cachedValidations,
|
||||
]);
|
||||
}
|
||||
@@ -1,6 +1,111 @@
|
||||
import type { GitHubIssue, StoredValidation, GitHubComment } from '@/lib/electron';
|
||||
import type { ModelId, LinkedPRInfo, PhaseModelEntry } from '@automaker/types';
|
||||
|
||||
// ============================================================================
|
||||
// Issues Filter State Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Available sort columns for issues list
|
||||
*/
|
||||
export const ISSUES_SORT_COLUMNS = [
|
||||
'title',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'comments',
|
||||
'number',
|
||||
] as const;
|
||||
|
||||
export type IssuesSortColumn = (typeof ISSUES_SORT_COLUMNS)[number];
|
||||
|
||||
/**
|
||||
* Sort direction options
|
||||
*/
|
||||
export type IssuesSortDirection = 'asc' | 'desc';
|
||||
|
||||
/**
|
||||
* Available issue state filter values
|
||||
*/
|
||||
export const ISSUES_STATE_FILTER_OPTIONS = ['open', 'closed', 'all'] as const;
|
||||
|
||||
export type IssuesStateFilter = (typeof ISSUES_STATE_FILTER_OPTIONS)[number];
|
||||
|
||||
/**
|
||||
* Validation status filter values for filtering issues by validation state
|
||||
*/
|
||||
export const ISSUES_VALIDATION_STATUS_OPTIONS = ['validated', 'not_validated', 'stale'] as const;
|
||||
|
||||
export type IssuesValidationStatus = (typeof ISSUES_VALIDATION_STATUS_OPTIONS)[number];
|
||||
|
||||
/**
|
||||
* Sort configuration for issues list
|
||||
*/
|
||||
export interface IssuesSortConfig {
|
||||
column: IssuesSortColumn;
|
||||
direction: IssuesSortDirection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main filter state interface for the GitHub Issues view
|
||||
*
|
||||
* This interface defines all filterable/sortable state for the issues list.
|
||||
* It follows the same pattern as GraphFilterState but is tailored for GitHub issues.
|
||||
*/
|
||||
export interface IssuesFilterState {
|
||||
/** Search query for filtering by issue title or body */
|
||||
searchQuery: string;
|
||||
/** Filter by issue state (open/closed/all) */
|
||||
stateFilter: IssuesStateFilter;
|
||||
/** Filter by selected labels (matches any) */
|
||||
selectedLabels: string[];
|
||||
/** Filter by selected assignees (matches any) */
|
||||
selectedAssignees: string[];
|
||||
/** Filter by selected milestones (matches any) */
|
||||
selectedMilestones: string[];
|
||||
/** Filter by validation status */
|
||||
validationStatusFilter: IssuesValidationStatus | null;
|
||||
/** Current sort configuration */
|
||||
sortConfig: IssuesSortConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of applying filters to the issues list
|
||||
*/
|
||||
export interface IssuesFilterResult {
|
||||
/** Array of GitHubIssue objects that match the current filters */
|
||||
matchedIssues: GitHubIssue[];
|
||||
/** Available labels from all issues (for filter dropdown population) */
|
||||
availableLabels: string[];
|
||||
/** Available assignees from all issues (for filter dropdown population) */
|
||||
availableAssignees: string[];
|
||||
/** Available milestones from all issues (for filter dropdown population) */
|
||||
availableMilestones: string[];
|
||||
/** Whether any filter is currently active */
|
||||
hasActiveFilter: boolean;
|
||||
/** Total count of matched issues */
|
||||
matchedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default values for IssuesFilterState
|
||||
*/
|
||||
export const DEFAULT_ISSUES_FILTER_STATE: IssuesFilterState = {
|
||||
searchQuery: '',
|
||||
stateFilter: 'open',
|
||||
selectedLabels: [],
|
||||
selectedAssignees: [],
|
||||
selectedMilestones: [],
|
||||
validationStatusFilter: null,
|
||||
sortConfig: {
|
||||
column: 'updated_at',
|
||||
direction: 'desc',
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Component Props Types
|
||||
// ============================================================================
|
||||
|
||||
export interface IssueRowProps {
|
||||
issue: GitHubIssue;
|
||||
isSelected: boolean;
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { GitPullRequest, Loader2, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react';
|
||||
import { GitPullRequest, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI, type GitHubPR } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -63,7 +64,7 @@ export function GitHubPRsView() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<Spinner size="xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -113,7 +114,7 @@ export function GitHubPRsView() {
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
|
||||
{refreshing ? <Spinner size="sm" /> : <RefreshCw className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,18 +2,26 @@
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useAppStore, Feature } from '@/store/app-store';
|
||||
import { GraphView } from './graph-view';
|
||||
import { EditFeatureDialog, AddFeatureDialog, AgentOutputModal } from './board-view/dialogs';
|
||||
import {
|
||||
EditFeatureDialog,
|
||||
AddFeatureDialog,
|
||||
AgentOutputModal,
|
||||
BacklogPlanDialog,
|
||||
} from './board-view/dialogs';
|
||||
import {
|
||||
useBoardFeatures,
|
||||
useBoardActions,
|
||||
useBoardBackground,
|
||||
useBoardPersistence,
|
||||
} from './board-view/hooks';
|
||||
import { useWorktrees } from './board-view/worktree-panel/hooks';
|
||||
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||
import { pathsEqual } from '@/lib/utils';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { toast } from 'sonner';
|
||||
import type { BacklogPlanResult } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('GraphViewPage');
|
||||
|
||||
@@ -29,8 +37,14 @@ export function GraphViewPage() {
|
||||
setWorktrees,
|
||||
setCurrentWorktree,
|
||||
defaultSkipTests,
|
||||
addFeatureUseSelectedWorktreeBranch,
|
||||
planUseSelectedWorktreeBranch,
|
||||
setPlanUseSelectedWorktreeBranch,
|
||||
} = useAppStore();
|
||||
|
||||
// Ensure worktrees are loaded when landing directly on graph view
|
||||
useWorktrees({ projectPath: currentProject?.path ?? '' });
|
||||
|
||||
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
|
||||
const worktrees = useMemo(
|
||||
() =>
|
||||
@@ -62,6 +76,9 @@ export function GraphViewPage() {
|
||||
const [spawnParentFeature, setSpawnParentFeature] = useState<Feature | null>(null);
|
||||
const [showOutputModal, setShowOutputModal] = useState(false);
|
||||
const [outputFeature, setOutputFeature] = useState<Feature | null>(null);
|
||||
const [showPlanDialog, setShowPlanDialog] = useState(false);
|
||||
const [pendingBacklogPlan, setPendingBacklogPlan] = useState<BacklogPlanResult | null>(null);
|
||||
const [isGeneratingPlan, setIsGeneratingPlan] = useState(false);
|
||||
|
||||
// Worktree refresh key
|
||||
const [worktreeRefreshKey, setWorktreeRefreshKey] = useState(0);
|
||||
@@ -116,6 +133,71 @@ export function GraphViewPage() {
|
||||
fetchBranches();
|
||||
}, [currentProject, worktreeRefreshKey]);
|
||||
|
||||
// Listen for backlog plan events (for background generation)
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.backlogPlan) {
|
||||
logger.debug('Backlog plan API not available for event subscription');
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = api.backlogPlan.onEvent(
|
||||
(event: { type: string; result?: BacklogPlanResult; error?: string }) => {
|
||||
logger.debug('Backlog plan event received', {
|
||||
type: event.type,
|
||||
hasResult: Boolean(event.result),
|
||||
hasError: Boolean(event.error),
|
||||
});
|
||||
if (event.type === 'backlog_plan_complete') {
|
||||
setIsGeneratingPlan(false);
|
||||
if (event.result && event.result.changes?.length > 0) {
|
||||
setPendingBacklogPlan(event.result);
|
||||
toast.success('Plan ready! Click to review.', {
|
||||
duration: 10000,
|
||||
action: {
|
||||
label: 'Review',
|
||||
onClick: () => setShowPlanDialog(true),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
toast.info('No changes generated. Try again with a different prompt.');
|
||||
}
|
||||
} else if (event.type === 'backlog_plan_error') {
|
||||
setIsGeneratingPlan(false);
|
||||
toast.error(`Plan generation failed: ${event.error}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Load any saved plan from disk when opening the graph view
|
||||
useEffect(() => {
|
||||
if (!currentProject || pendingBacklogPlan) return;
|
||||
|
||||
let isActive = true;
|
||||
const loadSavedPlan = async () => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.backlogPlan) return;
|
||||
|
||||
const result = await api.backlogPlan.status(currentProject.path);
|
||||
if (
|
||||
isActive &&
|
||||
result.success &&
|
||||
result.savedPlan?.result &&
|
||||
result.savedPlan.result.changes?.length > 0
|
||||
) {
|
||||
setPendingBacklogPlan(result.savedPlan.result);
|
||||
}
|
||||
};
|
||||
|
||||
loadSavedPlan();
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [currentProject, pendingBacklogPlan]);
|
||||
|
||||
// Branch card counts
|
||||
const branchCardCounts = useMemo(() => {
|
||||
return hookFeatures.reduce(
|
||||
@@ -156,6 +238,17 @@ export function GraphViewPage() {
|
||||
});
|
||||
}, [hookFeatures, runningAutoTasks]);
|
||||
|
||||
// Simple feature update handler for graph view (dependencies, etc.)
|
||||
const handleGraphUpdateFeature = useCallback(
|
||||
async (featureId: string, updates: Partial<Feature>) => {
|
||||
logger.info('handleGraphUpdateFeature called', { featureId, updates });
|
||||
updateFeature(featureId, updates);
|
||||
await persistFeatureUpdate(featureId, updates);
|
||||
logger.info('handleGraphUpdateFeature completed');
|
||||
},
|
||||
[updateFeature, persistFeatureUpdate]
|
||||
);
|
||||
|
||||
// Board actions hook
|
||||
const {
|
||||
handleAddFeature,
|
||||
@@ -237,7 +330,7 @@ export function GraphViewPage() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="graph-view-loading">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -261,13 +354,17 @@ export function GraphViewPage() {
|
||||
onStartTask={handleStartImplementation}
|
||||
onStopTask={handleForceStopFeature}
|
||||
onResumeTask={handleResumeFeature}
|
||||
onUpdateFeature={updateFeature}
|
||||
onUpdateFeature={handleGraphUpdateFeature}
|
||||
onSpawnTask={(feature) => {
|
||||
setSpawnParentFeature(feature);
|
||||
setShowAddDialog(true);
|
||||
}}
|
||||
onDeleteTask={(feature) => handleDeleteFeature(feature.id)}
|
||||
onAddFeature={() => setShowAddDialog(true)}
|
||||
onOpenPlanDialog={() => setShowPlanDialog(true)}
|
||||
hasPendingPlan={Boolean(pendingBacklogPlan)}
|
||||
planUseSelectedWorktreeBranch={planUseSelectedWorktreeBranch}
|
||||
onPlanUseSelectedWorktreeBranchChange={setPlanUseSelectedWorktreeBranch}
|
||||
/>
|
||||
|
||||
{/* Edit Feature Dialog */}
|
||||
@@ -303,6 +400,14 @@ export function GraphViewPage() {
|
||||
isMaximized={false}
|
||||
parentFeature={spawnParentFeature}
|
||||
allFeatures={hookFeatures}
|
||||
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
|
||||
selectedNonMainWorktreeBranch={
|
||||
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
|
||||
? currentWorktreeBranch || undefined
|
||||
: undefined
|
||||
}
|
||||
// When the worktree setting is disabled, force 'current' branch mode
|
||||
forceCurrentBranchMode={!addFeatureUseSelectedWorktreeBranch}
|
||||
/>
|
||||
|
||||
{/* Agent Output Modal */}
|
||||
@@ -314,6 +419,19 @@ export function GraphViewPage() {
|
||||
featureStatus={outputFeature?.status}
|
||||
onNumberKeyPress={handleOutputModalNumberKeyPress}
|
||||
/>
|
||||
|
||||
{/* Backlog Plan Dialog */}
|
||||
<BacklogPlanDialog
|
||||
open={showPlanDialog}
|
||||
onClose={() => setShowPlanDialog(false)}
|
||||
projectPath={currentProject.path}
|
||||
onPlanApplied={loadFeatures}
|
||||
pendingPlanResult={pendingBacklogPlan}
|
||||
setPendingPlanResult={setPendingBacklogPlan}
|
||||
isGeneratingPlan={isGeneratingPlan}
|
||||
setIsGeneratingPlan={setIsGeneratingPlan}
|
||||
currentBranch={planUseSelectedWorktreeBranch ? selectedWorktreeBranch : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,16 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
edgeData?.onDeleteDependency?.(source, target);
|
||||
console.log('Edge delete button clicked', {
|
||||
source,
|
||||
target,
|
||||
hasCallback: !!edgeData?.onDeleteDependency,
|
||||
});
|
||||
if (edgeData?.onDeleteDependency) {
|
||||
edgeData.onDeleteDependency(source, target);
|
||||
} else {
|
||||
console.error('onDeleteDependency callback is not defined on edge data');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Panel } from '@xyflow/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
@@ -43,6 +45,8 @@ interface GraphFilterControlsProps {
|
||||
filterState: GraphFilterState;
|
||||
availableCategories: string[];
|
||||
hasActiveFilter: boolean;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (query: string) => void;
|
||||
onCategoriesChange: (categories: string[]) => void;
|
||||
onStatusesChange: (statuses: string[]) => void;
|
||||
onNegativeFilterChange: (isNegative: boolean) => void;
|
||||
@@ -53,6 +57,8 @@ export function GraphFilterControls({
|
||||
filterState,
|
||||
availableCategories,
|
||||
hasActiveFilter,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
onCategoriesChange,
|
||||
onStatusesChange,
|
||||
onNegativeFilterChange,
|
||||
@@ -114,6 +120,30 @@ export function GraphFilterControls({
|
||||
className="flex items-center gap-2 p-2 rounded-lg backdrop-blur-sm border border-border shadow-lg text-popover-foreground"
|
||||
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search tasks..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchQueryChange(e.target.value)}
|
||||
className="h-8 w-48 pl-8 pr-8 text-sm bg-background/50"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => onSearchQueryChange('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-6 w-px bg-border" />
|
||||
|
||||
{/* Category Filter Dropdown */}
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
|
||||
@@ -60,13 +60,6 @@ const statusConfig = {
|
||||
borderClass: 'border-[var(--status-success)]',
|
||||
bgClass: 'bg-[var(--status-success-bg)]',
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
label: 'Completed',
|
||||
colorClass: 'text-[var(--status-success)]',
|
||||
borderClass: 'border-[var(--status-success)]/50',
|
||||
bgClass: 'bg-[var(--status-success-bg)]/50',
|
||||
},
|
||||
};
|
||||
|
||||
const priorityConfig = {
|
||||
@@ -95,8 +88,13 @@ function getCardBorderStyle(
|
||||
|
||||
export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps) {
|
||||
// Handle pipeline statuses by treating them like in_progress
|
||||
// Treat completed (archived) as verified for display
|
||||
const status = data.status || 'backlog';
|
||||
const statusKey = status.startsWith('pipeline_') ? 'in_progress' : status;
|
||||
const statusKey = status.startsWith('pipeline_')
|
||||
? 'in_progress'
|
||||
: status === 'completed'
|
||||
? 'verified'
|
||||
: status;
|
||||
const config = statusConfig[statusKey as keyof typeof statusConfig] || statusConfig.backlog;
|
||||
const StatusIcon = config.icon;
|
||||
const priorityConf = data.priority ? priorityConfig[data.priority as 1 | 2 | 3] : null;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ConnectionMode,
|
||||
Node,
|
||||
Connection,
|
||||
Edge,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
@@ -35,8 +36,9 @@ import {
|
||||
} from './hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDebounceValue } from 'usehooks-ts';
|
||||
import { SearchX, Plus } from 'lucide-react';
|
||||
import { SearchX, Plus, Wand2, ClipboardCheck } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PlanSettingsPopover } from '../board-view/dialogs/plan-settings-popover';
|
||||
|
||||
// Define custom node and edge types - using any to avoid React Flow's strict typing
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -65,11 +67,46 @@ interface GraphCanvasProps {
|
||||
nodeActionCallbacks?: NodeActionCallbacks;
|
||||
onCreateDependency?: (sourceId: string, targetId: string) => Promise<boolean>;
|
||||
onAddFeature?: () => void;
|
||||
onOpenPlanDialog?: () => void;
|
||||
hasPendingPlan?: boolean;
|
||||
planUseSelectedWorktreeBranch?: boolean;
|
||||
onPlanUseSelectedWorktreeBranchChange?: (value: boolean) => void;
|
||||
backgroundStyle?: React.CSSProperties;
|
||||
backgroundSettings?: BackgroundSettings;
|
||||
className?: string;
|
||||
projectPath?: string | null;
|
||||
}
|
||||
|
||||
// Helper to get session storage key for viewport
|
||||
const getViewportStorageKey = (projectPath: string) => `graph-viewport:${projectPath}`;
|
||||
|
||||
// Helper to save viewport to session storage
|
||||
const saveViewportToStorage = (
|
||||
projectPath: string,
|
||||
viewport: { x: number; y: number; zoom: number }
|
||||
) => {
|
||||
try {
|
||||
sessionStorage.setItem(getViewportStorageKey(projectPath), JSON.stringify(viewport));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to load viewport from session storage
|
||||
const loadViewportFromStorage = (
|
||||
projectPath: string
|
||||
): { x: number; y: number; zoom: number } | null => {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(getViewportStorageKey(projectPath));
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
function GraphCanvasInner({
|
||||
features,
|
||||
runningAutoTasks,
|
||||
@@ -79,12 +116,38 @@ function GraphCanvasInner({
|
||||
nodeActionCallbacks,
|
||||
onCreateDependency,
|
||||
onAddFeature,
|
||||
onOpenPlanDialog,
|
||||
hasPendingPlan,
|
||||
planUseSelectedWorktreeBranch,
|
||||
onPlanUseSelectedWorktreeBranchChange,
|
||||
backgroundStyle,
|
||||
backgroundSettings,
|
||||
className,
|
||||
projectPath,
|
||||
}: GraphCanvasProps) {
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR');
|
||||
const { setViewport, getViewport, fitView } = useReactFlow();
|
||||
|
||||
// Refs for tracking layout and viewport state
|
||||
const hasRestoredViewport = useRef(false);
|
||||
const lastProjectPath = useRef(projectPath);
|
||||
const hasInitialLayout = useRef(false);
|
||||
const prevNodeIds = useRef<Set<string>>(new Set());
|
||||
const prevLayoutVersion = useRef<number>(0);
|
||||
const hasLayoutWithEdges = useRef(false);
|
||||
|
||||
// Reset flags when project changes
|
||||
useEffect(() => {
|
||||
if (projectPath !== lastProjectPath.current) {
|
||||
hasRestoredViewport.current = false;
|
||||
hasLayoutWithEdges.current = false;
|
||||
hasInitialLayout.current = false;
|
||||
prevNodeIds.current = new Set();
|
||||
prevLayoutVersion.current = 0;
|
||||
lastProjectPath.current = projectPath;
|
||||
}
|
||||
}, [projectPath]);
|
||||
|
||||
// Determine React Flow color mode based on current theme
|
||||
const effectiveTheme = useAppStore((state) => state.getEffectiveTheme());
|
||||
@@ -145,7 +208,7 @@ function GraphCanvasInner({
|
||||
});
|
||||
|
||||
// Apply layout
|
||||
const { layoutedNodes, layoutedEdges, runLayout } = useGraphLayout({
|
||||
const { layoutedNodes, layoutedEdges, layoutVersion, runLayout } = useGraphLayout({
|
||||
nodes: initialNodes,
|
||||
edges: initialEdges,
|
||||
});
|
||||
@@ -154,24 +217,22 @@ function GraphCanvasInner({
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
|
||||
|
||||
// Track if initial layout has been applied
|
||||
const hasInitialLayout = useRef(false);
|
||||
// Track the previous node IDs to detect new nodes
|
||||
const prevNodeIds = useRef<Set<string>>(new Set());
|
||||
|
||||
// Update nodes/edges when features change, but preserve user positions
|
||||
useEffect(() => {
|
||||
const currentNodeIds = new Set(layoutedNodes.map((n) => n.id));
|
||||
const isInitialRender = !hasInitialLayout.current;
|
||||
// Detect if a fresh layout was computed (structure changed)
|
||||
const layoutWasRecomputed = layoutVersion !== prevLayoutVersion.current;
|
||||
|
||||
// Check if there are new nodes that need layout
|
||||
const hasNewNodes = layoutedNodes.some((n) => !prevNodeIds.current.has(n.id));
|
||||
|
||||
if (isInitialRender) {
|
||||
// Apply full layout for initial render
|
||||
if (isInitialRender || layoutWasRecomputed) {
|
||||
// Apply full layout for initial render OR when layout was recomputed due to structure change
|
||||
setNodes(layoutedNodes);
|
||||
setEdges(layoutedEdges);
|
||||
hasInitialLayout.current = true;
|
||||
prevLayoutVersion.current = layoutVersion;
|
||||
} else if (hasNewNodes) {
|
||||
// New nodes added - need to re-layout but try to preserve existing positions
|
||||
setNodes((currentNodes) => {
|
||||
@@ -197,15 +258,55 @@ function GraphCanvasInner({
|
||||
|
||||
// Update prev node IDs for next comparison
|
||||
prevNodeIds.current = currentNodeIds;
|
||||
}, [layoutedNodes, layoutedEdges, setNodes, setEdges]);
|
||||
|
||||
// Restore viewport from session storage after initial layout
|
||||
if (isInitialRender && projectPath && !hasRestoredViewport.current) {
|
||||
const savedViewport = loadViewportFromStorage(projectPath);
|
||||
if (savedViewport) {
|
||||
// Use setTimeout to ensure React Flow has finished rendering
|
||||
setTimeout(() => {
|
||||
setViewport(savedViewport, { duration: 0 });
|
||||
}, 50);
|
||||
}
|
||||
hasRestoredViewport.current = true;
|
||||
}
|
||||
}, [layoutedNodes, layoutedEdges, layoutVersion, setNodes, setEdges, projectPath, setViewport]);
|
||||
|
||||
// Force layout recalculation on initial mount when edges are available
|
||||
// This fixes timing issues when navigating directly to the graph route
|
||||
useEffect(() => {
|
||||
// Only run once: when we have nodes and edges but haven't done a layout with edges yet
|
||||
if (!hasLayoutWithEdges.current && layoutedNodes.length > 0 && layoutedEdges.length > 0) {
|
||||
hasLayoutWithEdges.current = true;
|
||||
// Small delay to ensure React Flow is mounted and ready
|
||||
const timeoutId = setTimeout(() => {
|
||||
const { nodes: relayoutedNodes, edges: relayoutedEdges } = runLayout('LR');
|
||||
setNodes(relayoutedNodes);
|
||||
setEdges(relayoutedEdges);
|
||||
fitView({ padding: 0.2, duration: 300 });
|
||||
}, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [layoutedNodes.length, layoutedEdges.length, runLayout, setNodes, setEdges, fitView]);
|
||||
|
||||
// Save viewport when user pans or zooms
|
||||
const handleMoveEnd = useCallback(() => {
|
||||
if (projectPath) {
|
||||
const viewport = getViewport();
|
||||
saveViewportToStorage(projectPath, viewport);
|
||||
}
|
||||
}, [projectPath, getViewport]);
|
||||
|
||||
// Handle layout direction change
|
||||
const handleRunLayout = useCallback(
|
||||
(direction: 'LR' | 'TB') => {
|
||||
setLayoutDirection(direction);
|
||||
runLayout(direction);
|
||||
const { nodes: relayoutedNodes, edges: relayoutedEdges } = runLayout(direction);
|
||||
setNodes(relayoutedNodes);
|
||||
setEdges(relayoutedEdges);
|
||||
fitView({ padding: 0.2, duration: 300 });
|
||||
},
|
||||
[runLayout]
|
||||
[runLayout, setNodes, setEdges, fitView]
|
||||
);
|
||||
|
||||
// Handle clear all filters
|
||||
@@ -247,9 +348,6 @@ function GraphCanvasInner({
|
||||
[]
|
||||
);
|
||||
|
||||
// Get fitView from React Flow for orientation change handling
|
||||
const { fitView } = useReactFlow();
|
||||
|
||||
// Handle orientation changes on mobile devices
|
||||
// When rotating from landscape to portrait, the view may incorrectly zoom in
|
||||
// This effect listens for orientation changes and calls fitView to correct the viewport
|
||||
@@ -323,6 +421,23 @@ function GraphCanvasInner({
|
||||
};
|
||||
}, [fitView]);
|
||||
|
||||
// Handle edge deletion (when user presses delete key or uses other deletion methods)
|
||||
const handleEdgesDelete = useCallback(
|
||||
(deletedEdges: Edge[]) => {
|
||||
console.log('onEdgesDelete triggered', deletedEdges);
|
||||
deletedEdges.forEach((edge) => {
|
||||
if (nodeActionCallbacks?.onDeleteDependency) {
|
||||
console.log('Calling onDeleteDependency from onEdgesDelete', {
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
});
|
||||
nodeActionCallbacks.onDeleteDependency(edge.source, edge.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
[nodeActionCallbacks]
|
||||
);
|
||||
|
||||
// MiniMap node color based on status
|
||||
const minimapNodeColor = useCallback((node: Node<TaskNodeData>) => {
|
||||
const data = node.data as TaskNodeData | undefined;
|
||||
@@ -349,7 +464,9 @@ function GraphCanvasInner({
|
||||
edges={edges}
|
||||
onNodesChange={isLocked ? undefined : onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onEdgesDelete={handleEdgesDelete}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onMoveEnd={handleMoveEnd}
|
||||
onConnect={handleConnect}
|
||||
isValidConnection={isValidConnection}
|
||||
nodeTypes={nodeTypes}
|
||||
@@ -392,6 +509,8 @@ function GraphCanvasInner({
|
||||
filterState={filterState}
|
||||
availableCategories={filterResult.availableCategories}
|
||||
hasActiveFilter={filterResult.hasActiveFilter}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={onSearchQueryChange}
|
||||
onCategoriesChange={setSelectedCategories}
|
||||
onStatusesChange={setSelectedStatuses}
|
||||
onNegativeFilterChange={setIsNegativeFilter}
|
||||
@@ -402,10 +521,42 @@ function GraphCanvasInner({
|
||||
|
||||
{/* Add Feature Button */}
|
||||
<Panel position="top-right">
|
||||
<Button variant="default" size="sm" onClick={onAddFeature} className="gap-1.5">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Feature
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{onOpenPlanDialog && (
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-border bg-secondary/60 px-2 py-1 shadow-sm">
|
||||
{hasPendingPlan && (
|
||||
<button
|
||||
onClick={onOpenPlanDialog}
|
||||
className="flex items-center text-emerald-500 hover:text-emerald-400 transition-colors"
|
||||
data-testid="graph-plan-review-button"
|
||||
>
|
||||
<ClipboardCheck className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onOpenPlanDialog}
|
||||
className="gap-1.5"
|
||||
data-testid="graph-plan-button"
|
||||
>
|
||||
<Wand2 className="w-4 h-4" />
|
||||
Plan
|
||||
</Button>
|
||||
{onPlanUseSelectedWorktreeBranchChange &&
|
||||
planUseSelectedWorktreeBranch !== undefined && (
|
||||
<PlanSettingsPopover
|
||||
planUseSelectedWorktreeBranch={planUseSelectedWorktreeBranch}
|
||||
onPlanUseSelectedWorktreeBranchChange={onPlanUseSelectedWorktreeBranchChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Button variant="default" size="sm" onClick={onAddFeature} className="gap-1.5">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Feature
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Empty state when all nodes are filtered out */}
|
||||
|
||||
@@ -23,6 +23,10 @@ interface GraphViewProps {
|
||||
onSpawnTask?: (feature: Feature) => void;
|
||||
onDeleteTask?: (feature: Feature) => void;
|
||||
onAddFeature?: () => void;
|
||||
onOpenPlanDialog?: () => void;
|
||||
hasPendingPlan?: boolean;
|
||||
planUseSelectedWorktreeBranch?: boolean;
|
||||
onPlanUseSelectedWorktreeBranchChange?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function GraphView({
|
||||
@@ -42,6 +46,10 @@ export function GraphView({
|
||||
onSpawnTask,
|
||||
onDeleteTask,
|
||||
onAddFeature,
|
||||
onOpenPlanDialog,
|
||||
hasPendingPlan,
|
||||
planUseSelectedWorktreeBranch,
|
||||
onPlanUseSelectedWorktreeBranchChange,
|
||||
}: GraphViewProps) {
|
||||
const { currentProject } = useAppStore();
|
||||
|
||||
@@ -53,9 +61,6 @@ export function GraphView({
|
||||
const effectiveBranch = currentWorktreeBranch;
|
||||
|
||||
return features.filter((f) => {
|
||||
// Skip completed features (they're in archive)
|
||||
if (f.status === 'completed') return false;
|
||||
|
||||
const featureBranch = f.branchName as string | undefined;
|
||||
|
||||
if (!featureBranch) {
|
||||
@@ -178,15 +183,26 @@ export function GraphView({
|
||||
},
|
||||
onDeleteDependency: (sourceId: string, targetId: string) => {
|
||||
// Find the target feature and remove the source from its dependencies
|
||||
console.log('onDeleteDependency called', { sourceId, targetId });
|
||||
const targetFeature = features.find((f) => f.id === targetId);
|
||||
if (!targetFeature) return;
|
||||
if (!targetFeature) {
|
||||
console.error('Target feature not found:', targetId);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDeps = (targetFeature.dependencies as string[] | undefined) || [];
|
||||
console.log('Current dependencies:', currentDeps);
|
||||
const newDeps = currentDeps.filter((depId) => depId !== sourceId);
|
||||
console.log('New dependencies:', newDeps);
|
||||
|
||||
onUpdateFeature?.(targetId, {
|
||||
dependencies: newDeps,
|
||||
});
|
||||
if (onUpdateFeature) {
|
||||
console.log('Calling onUpdateFeature');
|
||||
onUpdateFeature(targetId, {
|
||||
dependencies: newDeps,
|
||||
});
|
||||
} else {
|
||||
console.error('onUpdateFeature is not defined!');
|
||||
}
|
||||
|
||||
toast.success('Dependency removed');
|
||||
},
|
||||
@@ -215,8 +231,13 @@ export function GraphView({
|
||||
nodeActionCallbacks={nodeActionCallbacks}
|
||||
onCreateDependency={handleCreateDependency}
|
||||
onAddFeature={onAddFeature}
|
||||
onOpenPlanDialog={onOpenPlanDialog}
|
||||
hasPendingPlan={hasPendingPlan}
|
||||
planUseSelectedWorktreeBranch={planUseSelectedWorktreeBranch}
|
||||
onPlanUseSelectedWorktreeBranchChange={onPlanUseSelectedWorktreeBranchChange}
|
||||
backgroundStyle={backgroundImageStyle}
|
||||
backgroundSettings={backgroundSettings}
|
||||
projectPath={projectPath}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -89,11 +89,16 @@ function getHighlightedEdges(highlightedNodeIds: Set<string>, features: Feature[
|
||||
|
||||
/**
|
||||
* Gets the effective status of a feature (accounting for running state)
|
||||
* Treats completed (archived) as verified
|
||||
*/
|
||||
function getEffectiveStatus(feature: Feature, runningAutoTasks: string[]): StatusFilterValue {
|
||||
if (feature.status === 'in_progress') {
|
||||
return runningAutoTasks.includes(feature.id) ? 'running' : 'paused';
|
||||
}
|
||||
// Treat completed (archived) as verified
|
||||
if (feature.status === 'completed') {
|
||||
return 'verified';
|
||||
}
|
||||
return feature.status as StatusFilterValue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import dagre from 'dagre';
|
||||
import { Node, Edge, useReactFlow } from '@xyflow/react';
|
||||
import { Node, Edge } from '@xyflow/react';
|
||||
import { TaskNode, DependencyEdge } from './use-graph-nodes';
|
||||
|
||||
const NODE_WIDTH = 280;
|
||||
@@ -16,11 +16,11 @@ interface UseGraphLayoutProps {
|
||||
* Dependencies flow left-to-right
|
||||
*/
|
||||
export function useGraphLayout({ nodes, edges }: UseGraphLayoutProps) {
|
||||
const { fitView, setNodes } = useReactFlow();
|
||||
|
||||
// Cache the last computed positions to avoid recalculating layout
|
||||
const positionCache = useRef<Map<string, { x: number; y: number }>>(new Map());
|
||||
const lastStructureKey = useRef<string>('');
|
||||
// Track layout version to signal when fresh layout was computed
|
||||
const layoutVersion = useRef<number>(0);
|
||||
|
||||
const getLayoutedElements = useCallback(
|
||||
(
|
||||
@@ -71,31 +71,39 @@ export function useGraphLayout({ nodes, edges }: UseGraphLayoutProps) {
|
||||
[]
|
||||
);
|
||||
|
||||
// Create a stable structure key based only on node IDs (not edge changes)
|
||||
// Edges changing shouldn't trigger re-layout
|
||||
// Create a stable structure key based on node IDs AND edge connections
|
||||
// Layout must recalculate when the dependency graph structure changes
|
||||
const structureKey = useMemo(() => {
|
||||
const nodeIds = nodes
|
||||
.map((n) => n.id)
|
||||
.sort()
|
||||
.join(',');
|
||||
return nodeIds;
|
||||
}, [nodes]);
|
||||
// Include edge structure (source->target pairs) to ensure layout recalculates
|
||||
// when dependencies change, not just when nodes are added/removed
|
||||
const edgeConnections = edges
|
||||
.map((e) => `${e.source}->${e.target}`)
|
||||
.sort()
|
||||
.join(',');
|
||||
return `${nodeIds}|${edgeConnections}`;
|
||||
}, [nodes, edges]);
|
||||
|
||||
// Initial layout - only recalculate when node structure changes (new nodes added/removed)
|
||||
// Initial layout - recalculate when graph structure changes (nodes added/removed OR edges/dependencies change)
|
||||
const layoutedElements = useMemo(() => {
|
||||
if (nodes.length === 0) {
|
||||
positionCache.current.clear();
|
||||
lastStructureKey.current = '';
|
||||
return { nodes: [], edges: [] };
|
||||
return { nodes: [], edges: [], didRelayout: false };
|
||||
}
|
||||
|
||||
// Check if structure changed (new nodes added or removed)
|
||||
// Check if structure changed (nodes added/removed OR dependencies changed)
|
||||
const structureChanged = structureKey !== lastStructureKey.current;
|
||||
|
||||
if (structureChanged) {
|
||||
// Structure changed - run full layout
|
||||
lastStructureKey.current = structureKey;
|
||||
return getLayoutedElements(nodes, edges, 'LR');
|
||||
layoutVersion.current += 1;
|
||||
const result = getLayoutedElements(nodes, edges, 'LR');
|
||||
return { ...result, didRelayout: true };
|
||||
} else {
|
||||
// Structure unchanged - preserve cached positions, just update node data
|
||||
const layoutedNodes = nodes.map((node) => {
|
||||
@@ -107,26 +115,22 @@ export function useGraphLayout({ nodes, edges }: UseGraphLayoutProps) {
|
||||
sourcePosition: 'right',
|
||||
} as TaskNode;
|
||||
});
|
||||
return { nodes: layoutedNodes, edges };
|
||||
return { nodes: layoutedNodes, edges, didRelayout: false };
|
||||
}
|
||||
}, [nodes, edges, structureKey, getLayoutedElements]);
|
||||
|
||||
// Manual re-layout function
|
||||
const runLayout = useCallback(
|
||||
(direction: 'LR' | 'TB' = 'LR') => {
|
||||
const { nodes: layoutedNodes } = getLayoutedElements(nodes, edges, direction);
|
||||
setNodes(layoutedNodes);
|
||||
// Fit view after layout with a small delay to allow DOM updates
|
||||
setTimeout(() => {
|
||||
fitView({ padding: 0.2, duration: 300 });
|
||||
}, 50);
|
||||
return getLayoutedElements(nodes, edges, direction);
|
||||
},
|
||||
[nodes, edges, getLayoutedElements, setNodes, fitView]
|
||||
[nodes, edges, getLayoutedElements]
|
||||
);
|
||||
|
||||
return {
|
||||
layoutedNodes: layoutedElements.nodes,
|
||||
layoutedEdges: layoutedElements.edges,
|
||||
layoutVersion: layoutVersion.current,
|
||||
runLayout,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { Loader2, AlertCircle, Plus, X, Sparkles, Lightbulb } from 'lucide-react';
|
||||
import { AlertCircle, Plus, X, Sparkles, Lightbulb, Trash2 } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { useIdeationStore, type GenerationJob } from '@/store/ideation-store';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
@@ -15,9 +17,37 @@ import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AnalysisSuggestion } from '@automaker/types';
|
||||
|
||||
// Helper for consistent pluralization of "idea/ideas"
|
||||
const pluralizeIdea = (count: number) => `idea${count !== 1 ? 's' : ''}`;
|
||||
|
||||
// Helper to map priority to Badge variant
|
||||
const getPriorityVariant = (
|
||||
priority: string
|
||||
):
|
||||
| 'default'
|
||||
| 'secondary'
|
||||
| 'destructive'
|
||||
| 'outline'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'error'
|
||||
| 'info' => {
|
||||
switch (priority.toLowerCase()) {
|
||||
case 'high':
|
||||
return 'error';
|
||||
case 'medium':
|
||||
return 'warning';
|
||||
case 'low':
|
||||
return 'info';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
interface IdeationDashboardProps {
|
||||
onGenerateIdeas: () => void;
|
||||
onAcceptAllReady?: (isReady: boolean, count: number, handler: () => Promise<void>) => void;
|
||||
onDiscardAllReady?: (isReady: boolean, count: number, handler: () => void) => void;
|
||||
}
|
||||
|
||||
function SuggestionCard({
|
||||
@@ -34,39 +64,53 @@ function SuggestionCard({
|
||||
isAdding: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card className="transition-all hover:border-primary/50">
|
||||
<CardContent className="p-4">
|
||||
<Card className="group transition-all hover:border-primary/50 hover:shadow-sm">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start gap-2 mb-1">
|
||||
<h4 className="font-medium shrink-0">{suggestion.title}</h4>
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h4 className="font-semibold text-base leading-tight">{suggestion.title}</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline" className="text-xs whitespace-nowrap">
|
||||
<Badge
|
||||
variant={getPriorityVariant(suggestion.priority)}
|
||||
className="text-xs font-medium capitalize"
|
||||
>
|
||||
{suggestion.priority}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs whitespace-nowrap">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs text-muted-foreground bg-secondary/40"
|
||||
>
|
||||
{job.prompt.title}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{suggestion.description}</p>
|
||||
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{suggestion.description}
|
||||
</p>
|
||||
|
||||
{suggestion.rationale && (
|
||||
<p className="text-xs text-muted-foreground mt-2 italic">{suggestion.rationale}</p>
|
||||
<div className="relative pl-3 border-l-2 border-primary/20 mt-3 py-1">
|
||||
<p className="text-xs text-muted-foreground/80 italic">{suggestion.rationale}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
|
||||
<div className="flex flex-col gap-2 shrink-0 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onRemove}
|
||||
onClick={onAccept}
|
||||
disabled={isAdding}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
className={cn(
|
||||
'w-full gap-1.5 shadow-none transition-all',
|
||||
isAdding ? 'opacity-80' : 'hover:ring-2 hover:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button size="sm" onClick={onAccept} disabled={isAdding} className="gap-1">
|
||||
{isAdding ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4" />
|
||||
@@ -74,6 +118,15 @@ function SuggestionCard({
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onRemove}
|
||||
disabled={isAdding}
|
||||
className="w-full text-muted-foreground hover:text-destructive hover:bg-destructive/10 h-8"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -86,19 +139,29 @@ function GeneratingCard({ job }: { job: GenerationJob }) {
|
||||
const isError = job.status === 'error';
|
||||
|
||||
return (
|
||||
<Card className={cn('transition-all', isError ? 'border-red-500/50' : 'border-blue-500/50')}>
|
||||
<CardContent className="p-4">
|
||||
<Card
|
||||
className={cn(
|
||||
'transition-all',
|
||||
isError ? 'border-destructive/50' : 'border-blue-500/30 bg-blue-50/5 dark:bg-blue-900/5'
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{isError ? (
|
||||
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||
) : (
|
||||
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-full flex items-center justify-center shrink-0',
|
||||
isError ? 'bg-destructive/10 text-destructive' : 'bg-blue-500/10 text-blue-500'
|
||||
)}
|
||||
>
|
||||
{isError ? <AlertCircle className="w-5 h-5" /> : <Spinner size="md" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{job.prompt.title}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isError ? job.error || 'Failed to generate' : 'Generating ideas...'}
|
||||
{isError
|
||||
? job.error || 'Failed to generate'
|
||||
: 'Analyzing codebase and generating ideas...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,7 +193,7 @@ function TagFilter({
|
||||
if (tags.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-2 py-2">
|
||||
{tags.map((tag) => {
|
||||
const isSelected = selectedTags.has(tag);
|
||||
const count = tagCounts[tag] || 0;
|
||||
@@ -139,28 +202,31 @@ function TagFilter({
|
||||
key={tag}
|
||||
onClick={() => onToggleTag(tag)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-sm rounded-full border transition-all flex items-center gap-1.5',
|
||||
'px-3.5 py-1.5 text-sm rounded-full border shadow-sm transition-all flex items-center gap-2',
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-secondary/50 text-muted-foreground border-border hover:border-primary/50 hover:text-foreground'
|
||||
? 'bg-primary text-primary-foreground border-primary ring-2 ring-primary/20'
|
||||
: 'bg-card text-muted-foreground border-border hover:border-primary/50 hover:text-foreground hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
<span className="font-medium">{tag}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs',
|
||||
isSelected ? 'text-primary-foreground/70' : 'text-muted-foreground/70'
|
||||
'text-xs py-0.5 px-1.5 rounded-full',
|
||||
isSelected
|
||||
? 'bg-primary-foreground/20 text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
({count})
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{selectedTags.size > 0 && <div className="h-8 w-px bg-border mx-1" />}
|
||||
{selectedTags.size > 0 && (
|
||||
<button
|
||||
onClick={() => selectedTags.forEach((tag) => onToggleTag(tag))}
|
||||
className="px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors font-medium"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
@@ -169,13 +235,18 @@ function TagFilter({
|
||||
);
|
||||
}
|
||||
|
||||
export function IdeationDashboard({ onGenerateIdeas, onAcceptAllReady }: IdeationDashboardProps) {
|
||||
export function IdeationDashboard({
|
||||
onGenerateIdeas,
|
||||
onAcceptAllReady,
|
||||
onDiscardAllReady,
|
||||
}: IdeationDashboardProps) {
|
||||
const currentProject = useAppStore((s) => s.currentProject);
|
||||
const generationJobs = useIdeationStore((s) => s.generationJobs);
|
||||
const removeSuggestionFromJob = useIdeationStore((s) => s.removeSuggestionFromJob);
|
||||
const [addingId, setAddingId] = useState<string | null>(null);
|
||||
const [isAcceptingAll, setIsAcceptingAll] = useState(false);
|
||||
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
|
||||
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false);
|
||||
|
||||
// Get jobs for current project only (memoized to prevent unnecessary re-renders)
|
||||
const projectJobs = useMemo(
|
||||
@@ -304,23 +375,40 @@ export function IdeationDashboard({ onGenerateIdeas, onAcceptAllReady }: Ideatio
|
||||
setIsAcceptingAll(false);
|
||||
|
||||
if (successCount > 0 && failCount === 0) {
|
||||
toast.success(`Added ${successCount} idea${successCount > 1 ? 's' : ''} to board`);
|
||||
toast.success(`Added ${successCount} ${pluralizeIdea(successCount)} to board`);
|
||||
} else if (successCount > 0 && failCount > 0) {
|
||||
toast.warning(
|
||||
`Added ${successCount} idea${successCount > 1 ? 's' : ''}, ${failCount} failed`
|
||||
);
|
||||
toast.warning(`Added ${successCount} ${pluralizeIdea(successCount)}, ${failCount} failed`);
|
||||
} else {
|
||||
toast.error('Failed to add ideas to board');
|
||||
}
|
||||
}, [currentProject?.path, filteredSuggestions, removeSuggestionFromJob]);
|
||||
|
||||
// Show discard confirmation dialog
|
||||
const handleDiscardAll = useCallback(() => {
|
||||
setShowDiscardConfirm(true);
|
||||
}, []);
|
||||
|
||||
// Actually discard all filtered suggestions
|
||||
const confirmDiscardAll = useCallback(() => {
|
||||
const count = filteredSuggestions.length;
|
||||
for (const { suggestion, job } of filteredSuggestions) {
|
||||
removeSuggestionFromJob(job.id, suggestion.id);
|
||||
}
|
||||
toast.info(`Discarded ${count} ${pluralizeIdea(count)}`);
|
||||
}, [filteredSuggestions, removeSuggestionFromJob]);
|
||||
|
||||
// Common readiness state for bulk operations
|
||||
const bulkActionsReady = filteredSuggestions.length > 0 && !isAcceptingAll && !addingId;
|
||||
|
||||
// Notify parent about accept all readiness
|
||||
useEffect(() => {
|
||||
if (onAcceptAllReady) {
|
||||
const isReady = filteredSuggestions.length > 0 && !isAcceptingAll && !addingId;
|
||||
onAcceptAllReady(isReady, filteredSuggestions.length, handleAcceptAll);
|
||||
}
|
||||
}, [filteredSuggestions.length, isAcceptingAll, addingId, handleAcceptAll, onAcceptAllReady]);
|
||||
onAcceptAllReady?.(bulkActionsReady, filteredSuggestions.length, handleAcceptAll);
|
||||
}, [bulkActionsReady, filteredSuggestions.length, handleAcceptAll, onAcceptAllReady]);
|
||||
|
||||
// Notify parent about discard all readiness
|
||||
useEffect(() => {
|
||||
onDiscardAllReady?.(bulkActionsReady, filteredSuggestions.length, handleDiscardAll);
|
||||
}, [bulkActionsReady, filteredSuggestions.length, handleDiscardAll, onDiscardAllReady]);
|
||||
|
||||
const isEmpty = allSuggestions.length === 0 && activeJobs.length === 0;
|
||||
|
||||
@@ -331,10 +419,10 @@ export function IdeationDashboard({ onGenerateIdeas, onAcceptAllReady }: Ideatio
|
||||
{(generatingCount > 0 || allSuggestions.length > 0) && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{generatingCount > 0
|
||||
? `Generating ${generatingCount} idea${generatingCount > 1 ? 's' : ''}...`
|
||||
? `Generating ${generatingCount} ${pluralizeIdea(generatingCount)}...`
|
||||
: selectedTags.size > 0
|
||||
? `Showing ${filteredSuggestions.length} of ${allSuggestions.length} ideas`
|
||||
: `${allSuggestions.length} idea${allSuggestions.length > 1 ? 's' : ''} ready for review`}
|
||||
? `Showing ${filteredSuggestions.length} of ${allSuggestions.length} ${pluralizeIdea(allSuggestions.length)}`
|
||||
: `${allSuggestions.length} ${pluralizeIdea(allSuggestions.length)} ready for review`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -419,6 +507,19 @@ export function IdeationDashboard({ onGenerateIdeas, onAcceptAllReady }: Ideatio
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Discard All Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
open={showDiscardConfirm}
|
||||
onOpenChange={setShowDiscardConfirm}
|
||||
onConfirm={confirmDiscardAll}
|
||||
title="Discard All Ideas"
|
||||
description={`Are you sure you want to discard ${filteredSuggestions.length} ${pluralizeIdea(filteredSuggestions.length)}? This cannot be undone.`}
|
||||
icon={Trash2}
|
||||
iconClassName="text-destructive"
|
||||
confirmText="Discard"
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
Gauge,
|
||||
Accessibility,
|
||||
BarChart3,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
|
||||
import type { IdeaCategory } from '@automaker/types';
|
||||
@@ -53,7 +53,7 @@ export function PromptCategoryGrid({ onSelect, onBack }: PromptCategoryGridProps
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<Spinner size="lg" />
|
||||
<span className="ml-2 text-muted-foreground">Loading categories...</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -69,17 +69,19 @@ export function PromptCategoryGrid({ onSelect, onBack }: PromptCategoryGridProps
|
||||
return (
|
||||
<Card
|
||||
key={category.id}
|
||||
className="cursor-pointer transition-all hover:border-primary hover:shadow-md"
|
||||
className="group cursor-pointer transition-all duration-300 hover:border-primary hover:shadow-lg hover:-translate-y-1"
|
||||
onClick={() => onSelect(category.id)}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col items-center text-center gap-3">
|
||||
<div className="p-4 rounded-full bg-primary/10">
|
||||
<Icon className="w-8 h-8 text-primary" />
|
||||
<div className="flex flex-col items-center text-center gap-4">
|
||||
<div className="p-4 rounded-2xl bg-primary/10 text-primary group-hover:bg-primary/20 group-hover:scale-110 transition-all duration-300">
|
||||
<Icon className="w-8 h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">{category.name}</h3>
|
||||
<p className="text-muted-foreground text-sm mt-1">{category.description}</p>
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-lg leading-tight group-hover:text-primary transition-colors">
|
||||
{category.name}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">{category.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { ArrowLeft, Lightbulb, Loader2, CheckCircle2 } from 'lucide-react';
|
||||
import { ArrowLeft, Lightbulb, CheckCircle2 } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
|
||||
import { useIdeationStore } from '@/store/ideation-store';
|
||||
@@ -113,7 +114,7 @@ export function PromptList({ category, onBack }: PromptListProps) {
|
||||
<div className="space-y-3">
|
||||
{isLoadingPrompts && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<Spinner size="lg" />
|
||||
<span className="ml-2 text-muted-foreground">Loading prompts...</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -133,43 +134,51 @@ export function PromptList({ category, onBack }: PromptListProps) {
|
||||
return (
|
||||
<Card
|
||||
key={prompt.id}
|
||||
className={`transition-all ${
|
||||
className={`group transition-all duration-300 ${
|
||||
isDisabled
|
||||
? 'opacity-60 cursor-not-allowed'
|
||||
: 'cursor-pointer hover:border-primary hover:shadow-md'
|
||||
} ${isLoading || isGenerating ? 'border-blue-500 ring-1 ring-blue-500' : ''} ${
|
||||
isStarted && !isGenerating ? 'border-green-500/50' : ''
|
||||
? 'opacity-60 cursor-not-allowed bg-muted/50'
|
||||
: 'cursor-pointer hover:border-primary hover:shadow-md hover:-translate-x-1'
|
||||
} ${isLoading || isGenerating ? 'border-blue-500/50 ring-1 ring-blue-500/20 bg-blue-50/10' : ''} ${
|
||||
isStarted && !isGenerating ? 'border-green-500/50 bg-green-50/10' : ''
|
||||
}`}
|
||||
onClick={() => !isDisabled && handleSelectPrompt(prompt)}
|
||||
>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-start gap-5">
|
||||
<div
|
||||
className={`p-2 rounded-lg mt-0.5 ${
|
||||
className={`p-3 rounded-xl shrink-0 transition-all duration-300 ${
|
||||
isLoading || isGenerating
|
||||
? 'bg-blue-500/10'
|
||||
? 'bg-blue-500/10 text-blue-500'
|
||||
: isStarted
|
||||
? 'bg-green-500/10'
|
||||
: 'bg-primary/10'
|
||||
? 'bg-green-500/10 text-green-500'
|
||||
: 'bg-primary/10 text-primary group-hover:bg-primary/20 group-hover:scale-110'
|
||||
}`}
|
||||
>
|
||||
{isLoading || isGenerating ? (
|
||||
<Loader2 className="w-4 h-4 text-blue-500 animate-spin" />
|
||||
<Spinner size="md" />
|
||||
) : isStarted ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
) : (
|
||||
<Lightbulb className="w-4 h-4 text-primary" />
|
||||
<Lightbulb className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold">{prompt.title}</h3>
|
||||
<p className="text-muted-foreground text-sm mt-1">{prompt.description}</p>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="font-semibold text-lg group-hover:text-primary transition-colors">
|
||||
{prompt.title}
|
||||
</h3>
|
||||
{isStarted && !isGenerating && (
|
||||
<span className="text-xs font-medium text-green-600 bg-green-100 dark:bg-green-900/30 dark:text-green-400 px-2 py-0.5 rounded-full">
|
||||
Generated
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{prompt.description}
|
||||
</p>
|
||||
{(isLoading || isGenerating) && (
|
||||
<p className="text-blue-500 text-sm mt-2">Generating in dashboard...</p>
|
||||
)}
|
||||
{isStarted && !isGenerating && (
|
||||
<p className="text-green-500 text-sm mt-2">
|
||||
Already generated - check dashboard
|
||||
<p className="text-blue-500 text-sm font-medium animate-pulse pt-1">
|
||||
Generating ideas...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,8 @@ import { PromptList } from './components/prompt-list';
|
||||
import { IdeationDashboard } from './components/ideation-dashboard';
|
||||
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeft, ChevronRight, Lightbulb, CheckCheck, Loader2 } from 'lucide-react';
|
||||
import { ArrowLeft, ChevronRight, Lightbulb, CheckCheck, Trash2 } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import type { IdeaCategory } from '@automaker/types';
|
||||
import type { IdeationMode } from '@/store/ideation-store';
|
||||
|
||||
@@ -71,6 +72,9 @@ function IdeationHeader({
|
||||
acceptAllCount,
|
||||
onAcceptAll,
|
||||
isAcceptingAll,
|
||||
discardAllReady,
|
||||
discardAllCount,
|
||||
onDiscardAll,
|
||||
}: {
|
||||
currentMode: IdeationMode;
|
||||
selectedCategory: IdeaCategory | null;
|
||||
@@ -81,6 +85,9 @@ function IdeationHeader({
|
||||
acceptAllCount: number;
|
||||
onAcceptAll: () => void;
|
||||
isAcceptingAll: boolean;
|
||||
discardAllReady: boolean;
|
||||
discardAllCount: number;
|
||||
onDiscardAll: () => void;
|
||||
}) {
|
||||
const { getCategoryById } = useGuidedPrompts();
|
||||
const showBackButton = currentMode === 'prompts';
|
||||
@@ -128,6 +135,17 @@ function IdeationHeader({
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
{currentMode === 'dashboard' && discardAllReady && (
|
||||
<Button
|
||||
onClick={onDiscardAll}
|
||||
variant="outline"
|
||||
className="gap-2 text-destructive hover:text-destructive"
|
||||
disabled={isAcceptingAll}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Discard All ({discardAllCount})
|
||||
</Button>
|
||||
)}
|
||||
{currentMode === 'dashboard' && acceptAllReady && (
|
||||
<Button
|
||||
onClick={onAcceptAll}
|
||||
@@ -135,11 +153,7 @@ function IdeationHeader({
|
||||
className="gap-2"
|
||||
disabled={isAcceptingAll}
|
||||
>
|
||||
{isAcceptingAll ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCheck className="w-4 h-4" />
|
||||
)}
|
||||
{isAcceptingAll ? <Spinner size="sm" /> : <CheckCheck className="w-4 h-4" />}
|
||||
Accept All ({acceptAllCount})
|
||||
</Button>
|
||||
)}
|
||||
@@ -162,6 +176,11 @@ export function IdeationView() {
|
||||
const [acceptAllHandler, setAcceptAllHandler] = useState<(() => Promise<void>) | null>(null);
|
||||
const [isAcceptingAll, setIsAcceptingAll] = useState(false);
|
||||
|
||||
// Discard all state
|
||||
const [discardAllReady, setDiscardAllReady] = useState(false);
|
||||
const [discardAllCount, setDiscardAllCount] = useState(0);
|
||||
const [discardAllHandler, setDiscardAllHandler] = useState<(() => void) | null>(null);
|
||||
|
||||
const handleAcceptAllReady = useCallback(
|
||||
(isReady: boolean, count: number, handler: () => Promise<void>) => {
|
||||
setAcceptAllReady(isReady);
|
||||
@@ -182,6 +201,21 @@ export function IdeationView() {
|
||||
}
|
||||
}, [acceptAllHandler]);
|
||||
|
||||
const handleDiscardAllReady = useCallback(
|
||||
(isReady: boolean, count: number, handler: () => void) => {
|
||||
setDiscardAllReady(isReady);
|
||||
setDiscardAllCount(count);
|
||||
setDiscardAllHandler(() => handler);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDiscardAll = useCallback(() => {
|
||||
if (discardAllHandler) {
|
||||
discardAllHandler();
|
||||
}
|
||||
}, [discardAllHandler]);
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(mode: IdeationMode, category?: IdeaCategory | null) => {
|
||||
setMode(mode);
|
||||
@@ -245,6 +279,9 @@ export function IdeationView() {
|
||||
acceptAllCount={acceptAllCount}
|
||||
onAcceptAll={handleAcceptAll}
|
||||
isAcceptingAll={isAcceptingAll}
|
||||
discardAllReady={discardAllReady}
|
||||
discardAllCount={discardAllCount}
|
||||
onDiscardAll={handleDiscardAll}
|
||||
/>
|
||||
|
||||
{/* Dashboard - main view */}
|
||||
@@ -252,6 +289,7 @@ export function IdeationView() {
|
||||
<IdeationDashboard
|
||||
onGenerateIdeas={handleGenerateIdeas}
|
||||
onAcceptAllReady={handleAcceptAllReady}
|
||||
onDiscardAllReady={handleDiscardAllReady}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ import { useAppStore, Feature } from '@/store/app-store';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Bot, Send, User, Loader2, Sparkles, FileText, ArrowLeft, CheckCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Bot, Send, User, Sparkles, FileText, ArrowLeft, CheckCircle } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn, generateUUID } from '@/lib/utils';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import { useFileBrowser } from '@/contexts/file-browser-context';
|
||||
@@ -345,7 +346,7 @@ export function InterviewView() {
|
||||
|
||||
// Create initial feature in the features folder
|
||||
const initialFeature: Feature = {
|
||||
id: crypto.randomUUID(),
|
||||
id: generateUUID(),
|
||||
category: 'Core',
|
||||
description: 'Initial project setup',
|
||||
status: 'backlog' as const,
|
||||
@@ -491,7 +492,7 @@ export function InterviewView() {
|
||||
<Card className="border border-primary/30 bg-card">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm text-primary">Generating specification...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -571,7 +572,7 @@ export function InterviewView() {
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
} from '@/lib/http-api-client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { KeyRound, AlertCircle, Loader2, RefreshCw, ServerCrash } from 'lucide-react';
|
||||
import { KeyRound, AlertCircle, RefreshCw, ServerCrash } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
|
||||
@@ -349,7 +350,7 @@ export function LoginView() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="text-center space-y-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
|
||||
<Spinner size="xl" className="mx-auto" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Connecting to server
|
||||
{state.attempt > 1 ? ` (attempt ${state.attempt}/${MAX_RETRIES})` : '...'}
|
||||
@@ -385,7 +386,7 @@ export function LoginView() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="text-center space-y-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
|
||||
<Spinner size="xl" className="mx-auto" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{state.phase === 'checking_setup' ? 'Loading settings...' : 'Redirecting...'}
|
||||
</p>
|
||||
@@ -447,7 +448,7 @@ export function LoginView() {
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Authenticating...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useAppStore } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import {
|
||||
HeaderActionsPanel,
|
||||
HeaderActionsPanelTrigger,
|
||||
} from '@/components/ui/header-actions-panel';
|
||||
import {
|
||||
RefreshCw,
|
||||
FileText,
|
||||
@@ -15,6 +19,7 @@ import {
|
||||
FilePlus,
|
||||
MoreVertical,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -60,6 +65,9 @@ export function MemoryView() {
|
||||
const [newMemoryName, setNewMemoryName] = useState('');
|
||||
const [newMemoryContent, setNewMemoryContent] = useState('');
|
||||
|
||||
// Actions panel state (for tablet/mobile)
|
||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||
|
||||
// Get memory directory path
|
||||
const getMemoryPath = useCallback(() => {
|
||||
if (!currentProject) return null;
|
||||
@@ -292,7 +300,7 @@ export function MemoryView() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="memory-view-loading">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -310,27 +318,66 @@ export function MemoryView() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadMemoryFiles}
|
||||
data-testid="refresh-memory-button"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setIsCreateMemoryOpen(true)}
|
||||
data-testid="create-memory-button"
|
||||
>
|
||||
<FilePlus className="w-4 h-4 mr-2" />
|
||||
Create Memory File
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Desktop: show actions inline */}
|
||||
<div className="hidden lg:flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadMemoryFiles}
|
||||
data-testid="refresh-memory-button"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setIsCreateMemoryOpen(true)}
|
||||
data-testid="create-memory-button"
|
||||
>
|
||||
<FilePlus className="w-4 h-4 mr-2" />
|
||||
Create Memory File
|
||||
</Button>
|
||||
</div>
|
||||
{/* Tablet/Mobile: show trigger for actions panel */}
|
||||
<HeaderActionsPanelTrigger
|
||||
isOpen={showActionsPanel}
|
||||
onToggle={() => setShowActionsPanel(!showActionsPanel)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions Panel (tablet/mobile) */}
|
||||
<HeaderActionsPanel
|
||||
isOpen={showActionsPanel}
|
||||
onClose={() => setShowActionsPanel(false)}
|
||||
title="Memory Actions"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
loadMemoryFiles();
|
||||
setShowActionsPanel(false);
|
||||
}}
|
||||
data-testid="refresh-memory-button-mobile"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
setIsCreateMemoryOpen(true);
|
||||
setShowActionsPanel(false);
|
||||
}}
|
||||
data-testid="create-memory-button-mobile"
|
||||
>
|
||||
<FilePlus className="w-4 h-4 mr-2" />
|
||||
Create Memory File
|
||||
</Button>
|
||||
</HeaderActionsPanel>
|
||||
|
||||
{/* Main content area with file list and editor */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left Panel - File List */}
|
||||
|
||||
273
apps/ui/src/components/views/notifications-view.tsx
Normal file
273
apps/ui/src/components/views/notifications-view.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Notifications View - Full page view for all notifications
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useNotificationsStore } from '@/store/notifications-store';
|
||||
import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Bell, Check, CheckCheck, Trash2, ExternalLink } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import type { Notification } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Format a date as relative time (e.g., "2 minutes ago", "3 hours ago")
|
||||
*/
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) return 'just now';
|
||||
if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
|
||||
if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
|
||||
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export function NotificationsView() {
|
||||
const { currentProject } = useAppStore();
|
||||
const projectPath = currentProject?.path ?? null;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
isLoading,
|
||||
error,
|
||||
setNotifications,
|
||||
setUnreadCount,
|
||||
markAsRead,
|
||||
dismissNotification,
|
||||
markAllAsRead,
|
||||
dismissAll,
|
||||
} = useNotificationsStore();
|
||||
|
||||
// Load notifications when project changes
|
||||
useLoadNotifications(projectPath);
|
||||
|
||||
// Subscribe to real-time notification events
|
||||
useNotificationEvents(projectPath);
|
||||
|
||||
const handleMarkAsRead = useCallback(
|
||||
async (notificationId: string) => {
|
||||
if (!projectPath) return;
|
||||
|
||||
// Optimistic update
|
||||
markAsRead(notificationId);
|
||||
|
||||
// Sync with server
|
||||
const api = getHttpApiClient();
|
||||
await api.notifications.markAsRead(projectPath, notificationId);
|
||||
},
|
||||
[projectPath, markAsRead]
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
async (notificationId: string) => {
|
||||
if (!projectPath) return;
|
||||
|
||||
// Optimistic update
|
||||
dismissNotification(notificationId);
|
||||
|
||||
// Sync with server
|
||||
const api = getHttpApiClient();
|
||||
await api.notifications.dismiss(projectPath, notificationId);
|
||||
},
|
||||
[projectPath, dismissNotification]
|
||||
);
|
||||
|
||||
const handleMarkAllAsRead = useCallback(async () => {
|
||||
if (!projectPath) return;
|
||||
|
||||
// Optimistic update
|
||||
markAllAsRead();
|
||||
|
||||
// Sync with server
|
||||
const api = getHttpApiClient();
|
||||
await api.notifications.markAsRead(projectPath);
|
||||
}, [projectPath, markAllAsRead]);
|
||||
|
||||
const handleDismissAll = useCallback(async () => {
|
||||
if (!projectPath) return;
|
||||
|
||||
// Optimistic update
|
||||
dismissAll();
|
||||
|
||||
// Sync with server
|
||||
const api = getHttpApiClient();
|
||||
await api.notifications.dismiss(projectPath);
|
||||
}, [projectPath, dismissAll]);
|
||||
|
||||
const handleNotificationClick = useCallback(
|
||||
(notification: Notification) => {
|
||||
// Mark as read
|
||||
handleMarkAsRead(notification.id);
|
||||
|
||||
// Navigate to the relevant view based on notification type
|
||||
if (notification.featureId) {
|
||||
// Navigate to board view - feature will be selected
|
||||
navigate({ to: '/board' });
|
||||
}
|
||||
},
|
||||
[handleMarkAsRead, navigate]
|
||||
);
|
||||
|
||||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'feature_waiting_approval':
|
||||
return <Bell className="h-5 w-5 text-yellow-500" />;
|
||||
case 'feature_verified':
|
||||
return <Check className="h-5 w-5 text-green-500" />;
|
||||
case 'spec_regeneration_complete':
|
||||
return <Check className="h-5 w-5 text-blue-500" />;
|
||||
case 'agent_complete':
|
||||
return <Check className="h-5 w-5 text-purple-500" />;
|
||||
default:
|
||||
return <Bell className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
||||
<Bell className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">Select a project to view notifications</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
||||
<Spinner size="xl" />
|
||||
<p className="text-muted-foreground mt-4">Loading notifications...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
||||
<p className="text-destructive">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col p-6 overflow-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Notifications</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{unreadCount > 0 ? `${unreadCount} unread` : 'All caught up!'}
|
||||
</p>
|
||||
</div>
|
||||
{notifications.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleMarkAllAsRead}
|
||||
disabled={unreadCount === 0}
|
||||
>
|
||||
<CheckCheck className="h-4 w-4 mr-2" />
|
||||
Mark all as read
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleDismissAll}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Dismiss all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<Card className="flex-1">
|
||||
<CardContent className="flex flex-col items-center justify-center h-full min-h-[300px]">
|
||||
<Bell className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground text-lg">No notifications</p>
|
||||
<p className="text-muted-foreground text-sm mt-2">
|
||||
Notifications will appear here when features are ready for review or operations
|
||||
complete.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{notifications.map((notification) => (
|
||||
<Card
|
||||
key={notification.id}
|
||||
className={`transition-colors cursor-pointer hover:bg-accent/50 ${
|
||||
!notification.read ? 'border-primary/50 bg-primary/5' : ''
|
||||
}`}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
>
|
||||
<CardContent className="flex items-start gap-4 p-4">
|
||||
<div className="flex-shrink-0 mt-1">{getNotificationIcon(notification.type)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-base">{notification.title}</CardTitle>
|
||||
{!notification.read && (
|
||||
<span className="h-2 w-2 rounded-full bg-primary flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="mt-1">{notification.message}</CardDescription>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{formatRelativeTime(new Date(notification.createdAt))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{!notification.read && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMarkAsRead(notification.id);
|
||||
}}
|
||||
title="Mark as read"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDismiss(notification.id);
|
||||
}}
|
||||
title="Dismiss"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{notification.featureId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleNotificationClick(notification);
|
||||
}}
|
||||
title="Go to feature"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PROJECT_SETTINGS_NAV_ITEMS } from '../config/navigation';
|
||||
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||
|
||||
interface ProjectSettingsNavigationProps {
|
||||
activeSection: ProjectSettingsViewId;
|
||||
onNavigate: (sectionId: ProjectSettingsViewId) => void;
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function ProjectSettingsNavigation({
|
||||
activeSection,
|
||||
onNavigate,
|
||||
isOpen = true,
|
||||
onClose,
|
||||
}: ProjectSettingsNavigationProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Mobile backdrop overlay - only shown when isOpen is true on mobile */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
|
||||
onClick={onClose}
|
||||
data-testid="project-settings-nav-backdrop"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Navigation sidebar */}
|
||||
<nav
|
||||
className={cn(
|
||||
// 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',
|
||||
// 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-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'
|
||||
)}
|
||||
>
|
||||
{/* Mobile close button */}
|
||||
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-border/50">
|
||||
<span className="text-sm font-semibold text-foreground">Navigation</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close navigation menu"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="sticky top-0 p-4 space-y-1">
|
||||
{PROJECT_SETTINGS_NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeSection === item.id;
|
||||
const isDanger = item.id === 'danger';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
className={cn(
|
||||
'group w-full flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ease-out text-left relative overflow-hidden',
|
||||
isActive
|
||||
? [
|
||||
isDanger
|
||||
? 'bg-gradient-to-r from-red-500/15 via-red-500/10 to-red-600/5'
|
||||
: 'bg-gradient-to-r from-brand-500/15 via-brand-500/10 to-brand-600/5',
|
||||
'text-foreground',
|
||||
isDanger ? 'border border-red-500/25' : 'border border-brand-500/25',
|
||||
isDanger ? 'shadow-sm shadow-red-500/5' : 'shadow-sm shadow-brand-500/5',
|
||||
]
|
||||
: [
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50',
|
||||
'border border-transparent hover:border-border/40',
|
||||
],
|
||||
'hover:scale-[1.01] active:scale-[0.98]'
|
||||
)}
|
||||
>
|
||||
{/* Active indicator bar */}
|
||||
{isActive && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-y-0 left-0 w-0.5 rounded-r-full',
|
||||
isDanger
|
||||
? 'bg-gradient-to-b from-red-400 via-red-500 to-red-600'
|
||||
: 'bg-gradient-to-b from-brand-400 via-brand-500 to-brand-600'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Icon
|
||||
className={cn(
|
||||
'w-4 h-4 shrink-0 transition-all duration-200',
|
||||
isActive
|
||||
? isDanger
|
||||
? 'text-red-500'
|
||||
: 'text-brand-500'
|
||||
: isDanger
|
||||
? 'group-hover:text-red-400 group-hover:scale-110'
|
||||
: 'group-hover:text-brand-400 group-hover:scale-110'
|
||||
)}
|
||||
/>
|
||||
<span className={cn(isDanger && !isActive && 'text-red-400/70')}>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { User, GitBranch, Palette, AlertTriangle } from 'lucide-react';
|
||||
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||
|
||||
export interface ProjectNavigationItem {
|
||||
id: ProjectSettingsViewId;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
||||
{ id: 'identity', label: 'Identity', icon: User },
|
||||
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
||||
{ id: 'theme', label: 'Theme', icon: Palette },
|
||||
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
export { useProjectSettingsView, type ProjectSettingsViewId } from './use-project-settings-view';
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export type ProjectSettingsViewId = 'identity' | 'theme' | 'worktrees' | 'danger';
|
||||
|
||||
interface UseProjectSettingsViewOptions {
|
||||
initialView?: ProjectSettingsViewId;
|
||||
}
|
||||
|
||||
export function useProjectSettingsView({
|
||||
initialView = 'identity',
|
||||
}: UseProjectSettingsViewOptions = {}) {
|
||||
const [activeView, setActiveView] = useState<ProjectSettingsViewId>(initialView);
|
||||
|
||||
const navigateTo = useCallback((viewId: ProjectSettingsViewId) => {
|
||||
setActiveView(viewId);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
activeView,
|
||||
navigateTo,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { ProjectSettingsView } from './project-settings-view';
|
||||
export { ProjectIdentitySection } from './project-identity-section';
|
||||
export { ProjectThemeSection } from './project-theme-section';
|
||||
export { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
|
||||
export { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Palette, Upload, X, ImageIcon } from 'lucide-react';
|
||||
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 { toast } from 'sonner';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
interface ProjectIdentitySectionProps {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
export function ProjectIdentitySection({ project }: ProjectIdentitySectionProps) {
|
||||
const { setProjectIcon, setProjectName, setProjectCustomIcon } = useAppStore();
|
||||
const [projectName, setProjectNameLocal] = useState(project.name || '');
|
||||
const [projectIcon, setProjectIconLocal] = useState<string | null>(project.icon || null);
|
||||
const [customIconPath, setCustomIconPathLocal] = useState<string | null>(
|
||||
project.customIconPath || null
|
||||
);
|
||||
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Sync local state when project changes
|
||||
useEffect(() => {
|
||||
setProjectNameLocal(project.name || '');
|
||||
setProjectIconLocal(project.icon || null);
|
||||
setCustomIconPathLocal(project.customIconPath || null);
|
||||
}, [project]);
|
||||
|
||||
// Auto-save when values change
|
||||
const handleNameChange = (name: string) => {
|
||||
setProjectNameLocal(name);
|
||||
if (name.trim() && name.trim() !== project.name) {
|
||||
setProjectName(project.id, name.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const handleIconChange = (icon: string | null) => {
|
||||
setProjectIconLocal(icon);
|
||||
setProjectIcon(project.id, icon);
|
||||
};
|
||||
|
||||
const handleCustomIconChange = (path: string | null) => {
|
||||
setCustomIconPathLocal(path);
|
||||
setProjectCustomIcon(project.id, path);
|
||||
// Clear Lucide icon when custom icon is set
|
||||
if (path) {
|
||||
setProjectIconLocal(null);
|
||||
setProjectIcon(project.id, null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomIconUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
toast.error('Invalid file type', {
|
||||
description: 'Please upload a PNG, JPG, GIF, or WebP image.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 2MB for icons)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error('File too large', {
|
||||
description: 'Please upload an image smaller than 2MB.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploadingIcon(true);
|
||||
try {
|
||||
// Convert to base64
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const base64Data = reader.result as string;
|
||||
const result = await getHttpApiClient().saveImageToTemp(
|
||||
base64Data,
|
||||
`project-icon-${file.name}`,
|
||||
file.type,
|
||||
project.path
|
||||
);
|
||||
if (result.success && result.path) {
|
||||
handleCustomIconChange(result.path);
|
||||
toast.success('Icon uploaded successfully');
|
||||
} else {
|
||||
toast.error('Failed to upload icon', {
|
||||
description: result.error || 'Please try again.',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to upload icon', {
|
||||
description: 'Network error. Please try again.',
|
||||
});
|
||||
} finally {
|
||||
setIsUploadingIcon(false);
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error('Failed to read file', {
|
||||
description: 'Please try again with a different file.',
|
||||
});
|
||||
setIsUploadingIcon(false);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch {
|
||||
toast.error('Failed to upload icon');
|
||||
setIsUploadingIcon(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCustomIcon = () => {
|
||||
handleCustomIconChange(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<Palette className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Project Identity</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Customize how your project appears in the sidebar and project switcher.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Project Name */}
|
||||
<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>
|
||||
|
||||
{/* Project Icon */}
|
||||
<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, project.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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Settings, FolderOpen, Menu, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ProjectIdentitySection } from './project-identity-section';
|
||||
import { ProjectThemeSection } from './project-theme-section';
|
||||
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
||||
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
|
||||
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||
import { useProjectSettingsView } from './hooks/use-project-settings-view';
|
||||
import type { Project as ElectronProject } from '@/lib/electron';
|
||||
|
||||
// Breakpoint constant for mobile (matches Tailwind lg breakpoint)
|
||||
const LG_BREAKPOINT = 1024;
|
||||
|
||||
// Convert to the shared types used by components
|
||||
interface SettingsProject {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
theme?: string;
|
||||
icon?: string;
|
||||
customIconPath?: string;
|
||||
}
|
||||
|
||||
export function ProjectSettingsView() {
|
||||
const { currentProject, moveProjectToTrash } = useAppStore();
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
// Use project settings view navigation hook
|
||||
const { activeView, navigateTo } = useProjectSettingsView();
|
||||
|
||||
// Mobile navigation state - default to showing on desktop, hidden on mobile
|
||||
const [showNavigation, setShowNavigation] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.innerWidth >= LG_BREAKPOINT;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Auto-close navigation on mobile when a section is selected
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && window.innerWidth < LG_BREAKPOINT) {
|
||||
setShowNavigation(false);
|
||||
}
|
||||
}, [activeView]);
|
||||
|
||||
// Handle window resize to show/hide navigation appropriately
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth >= LG_BREAKPOINT) {
|
||||
setShowNavigation(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Convert electron Project to settings-view Project type
|
||||
const convertProject = (project: ElectronProject | null): SettingsProject | null => {
|
||||
if (!project) return null;
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
path: project.path,
|
||||
theme: project.theme,
|
||||
icon: project.icon,
|
||||
customIconPath: project.customIconPath,
|
||||
};
|
||||
};
|
||||
|
||||
const settingsProject = convertProject(currentProject);
|
||||
|
||||
// Render the active section based on current view
|
||||
const renderActiveSection = () => {
|
||||
if (!currentProject) return null;
|
||||
|
||||
switch (activeView) {
|
||||
case 'identity':
|
||||
return <ProjectIdentitySection project={currentProject} />;
|
||||
case 'theme':
|
||||
return <ProjectThemeSection project={currentProject} />;
|
||||
case 'worktrees':
|
||||
return <WorktreePreferencesSection project={currentProject} />;
|
||||
case 'danger':
|
||||
return (
|
||||
<DangerZoneSection
|
||||
project={settingsProject}
|
||||
onDeleteClick={() => setShowDeleteDialog(true)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <ProjectIdentitySection project={currentProject} />;
|
||||
}
|
||||
};
|
||||
|
||||
// Show message if no project is selected
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
||||
data-testid="project-settings-view"
|
||||
>
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-muted/50 flex items-center justify-center">
|
||||
<FolderOpen className="w-8 h-8 text-muted-foreground/50" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">No Project Selected</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a project from the sidebar to configure project-specific settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
||||
data-testid="project-settings-view"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div className="flex items-center gap-3">
|
||||
<Settings className="w-5 h-5 text-muted-foreground" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Project Settings</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure settings for {currentProject.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile menu button - far right */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowNavigation(!showNavigation)}
|
||||
className="lg:hidden h-8 w-8 p-0"
|
||||
aria-label={showNavigation ? 'Close navigation menu' : 'Open navigation menu'}
|
||||
>
|
||||
{showNavigation ? <X className="w-4 h-4" /> : <Menu className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content Area with Sidebar */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Side Navigation */}
|
||||
<ProjectSettingsNavigation
|
||||
activeSection={activeView}
|
||||
onNavigate={navigateTo}
|
||||
isOpen={showNavigation}
|
||||
onClose={() => setShowNavigation(false)}
|
||||
/>
|
||||
|
||||
{/* Content Panel - Shows only the active section */}
|
||||
<div className="flex-1 overflow-y-auto p-4 lg:p-8">
|
||||
<div className="max-w-4xl mx-auto">{renderActiveSection()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Project Confirmation Dialog */}
|
||||
<DeleteProjectDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
project={currentProject}
|
||||
onConfirm={moveProjectToTrash}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Palette, Moon, Sun, Type } from 'lucide-react';
|
||||
import { darkThemes, lightThemes, type Theme } 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 { FontSelector } from '@/components/shared';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
interface ProjectThemeSectionProps {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
export function ProjectThemeSection({ project }: ProjectThemeSectionProps) {
|
||||
const {
|
||||
theme: globalTheme,
|
||||
fontFamilySans: globalFontSans,
|
||||
fontFamilyMono: globalFontMono,
|
||||
setProjectTheme,
|
||||
setProjectFontSans,
|
||||
setProjectFontMono,
|
||||
} = useAppStore();
|
||||
|
||||
// Theme state
|
||||
const projectTheme = project.theme as Theme | undefined;
|
||||
const hasCustomTheme = projectTheme !== undefined;
|
||||
const effectiveTheme = projectTheme || globalTheme;
|
||||
|
||||
// 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');
|
||||
|
||||
// Helper to validate fonts against available options
|
||||
const isValidSansFont = (font?: string): boolean =>
|
||||
!!font && UI_SANS_FONT_OPTIONS.some((opt) => opt.value === font);
|
||||
const isValidMonoFont = (font?: string): boolean =>
|
||||
!!font && UI_MONO_FONT_OPTIONS.some((opt) => opt.value === font);
|
||||
|
||||
// Helper to get initial font value with validation
|
||||
const getInitialFontValue = (font: string | undefined, validator: (f?: string) => boolean) =>
|
||||
font && validator(font) ? font : DEFAULT_FONT_VALUE;
|
||||
|
||||
// Font local state - tracks what's selected when using custom fonts
|
||||
// Falls back to default if stored font is not in available options
|
||||
const [fontSansLocal, setFontSansLocal] = useState<string>(() =>
|
||||
getInitialFontValue(project.fontFamilySans, isValidSansFont)
|
||||
);
|
||||
const [fontMonoLocal, setFontMonoLocal] = useState<string>(() =>
|
||||
getInitialFontValue(project.fontFamilyMono, isValidMonoFont)
|
||||
);
|
||||
|
||||
// Sync state when project changes
|
||||
useEffect(() => {
|
||||
setFontSansLocal(getInitialFontValue(project.fontFamilySans, isValidSansFont));
|
||||
setFontMonoLocal(getInitialFontValue(project.fontFamilyMono, isValidMonoFont));
|
||||
// Also sync the active tab based on current theme
|
||||
const currentIsLight = lightThemes.some((t) => t.value === (project.theme || globalTheme));
|
||||
setActiveTab(currentIsLight ? 'light' : 'dark');
|
||||
}, [project, globalTheme]);
|
||||
|
||||
// Font state - check if project has custom fonts set
|
||||
const hasCustomFontSans = project.fontFamilySans !== undefined;
|
||||
const hasCustomFontMono = project.fontFamilyMono !== undefined;
|
||||
|
||||
const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes;
|
||||
|
||||
// Theme handlers
|
||||
const handleThemeChange = (theme: Theme) => {
|
||||
setProjectTheme(project.id, theme);
|
||||
};
|
||||
|
||||
const handleUseGlobalTheme = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setProjectTheme(project.id, null);
|
||||
} else {
|
||||
setProjectTheme(project.id, globalTheme);
|
||||
}
|
||||
};
|
||||
|
||||
// Font handlers
|
||||
const handleUseGlobalFontSans = (checked: boolean) => {
|
||||
if (checked) {
|
||||
// Clear project font to use global
|
||||
setProjectFontSans(project.id, null);
|
||||
setFontSansLocal(DEFAULT_FONT_VALUE);
|
||||
} else {
|
||||
// Set explicit project override - use 'default' value to indicate explicit default choice
|
||||
const fontToSet = globalFontSans || DEFAULT_FONT_VALUE;
|
||||
setFontSansLocal(fontToSet);
|
||||
// Store the actual value (including 'default') so hasCustomFontSans stays true
|
||||
setProjectFontSans(project.id, fontToSet);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseGlobalFontMono = (checked: boolean) => {
|
||||
if (checked) {
|
||||
// Clear project font to use global
|
||||
setProjectFontMono(project.id, null);
|
||||
setFontMonoLocal(DEFAULT_FONT_VALUE);
|
||||
} else {
|
||||
// Set explicit project override - use 'default' value to indicate explicit default choice
|
||||
const fontToSet = globalFontMono || DEFAULT_FONT_VALUE;
|
||||
setFontMonoLocal(fontToSet);
|
||||
// Store the actual value (including 'default') so hasCustomFontMono stays true
|
||||
setProjectFontMono(project.id, fontToSet);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFontSansChange = (value: string) => {
|
||||
setFontSansLocal(value);
|
||||
// Store the actual value (including 'default') - only null clears to use global
|
||||
setProjectFontSans(project.id, value);
|
||||
};
|
||||
|
||||
const handleFontMonoChange = (value: string) => {
|
||||
setFontMonoLocal(value);
|
||||
// Store the actual value (including 'default') - only null clears to use global
|
||||
setProjectFontMono(project.id, value);
|
||||
};
|
||||
|
||||
// Get display label for global font
|
||||
const getGlobalFontSansLabel = () => {
|
||||
if (!globalFontSans) return 'Default (Geist Sans)';
|
||||
const option = UI_SANS_FONT_OPTIONS.find((o) => o.value === globalFontSans);
|
||||
return option?.label || globalFontSans;
|
||||
};
|
||||
|
||||
const getGlobalFontMonoLabel = () => {
|
||||
if (!globalFontMono) return 'Default (Geist Mono)';
|
||||
const option = UI_MONO_FONT_OPTIONS.find((o) => o.value === globalFontMono);
|
||||
return option?.label || globalFontMono;
|
||||
};
|
||||
|
||||
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">
|
||||
<Palette className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Theme & Fonts</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Customize the appearance for this project.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Use Global Theme 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="use-global-theme"
|
||||
checked={!hasCustomTheme}
|
||||
onCheckedChange={handleUseGlobalTheme}
|
||||
className="mt-1"
|
||||
data-testid="use-global-theme-checkbox"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="use-global-theme"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<Palette className="w-4 h-4 text-brand-500" />
|
||||
Use Global Theme
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
When enabled, this project will use the global theme setting. Disable to set a
|
||||
project-specific theme.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme Selection - only show if not using global theme */}
|
||||
{hasCustomTheme && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Project Theme</Label>
|
||||
{/* Dark/Light Tabs */}
|
||||
<div className="flex gap-1 p-1 rounded-lg bg-accent/30">
|
||||
<button
|
||||
onClick={() => setActiveTab('dark')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200',
|
||||
activeTab === 'dark'
|
||||
? 'bg-brand-500 text-white shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Moon className="w-3.5 h-3.5" />
|
||||
Dark
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('light')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200',
|
||||
activeTab === 'light'
|
||||
? 'bg-brand-500 text-white shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Sun className="w-3.5 h-3.5" />
|
||||
Light
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{themesToShow.map(({ value, label, Icon, testId, color }) => {
|
||||
const isActive = effectiveTheme === value;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => handleThemeChange(value)}
|
||||
className={cn(
|
||||
'group flex items-center justify-center gap-2.5 px-4 py-3.5 rounded-xl',
|
||||
'text-sm font-medium transition-all duration-200 ease-out',
|
||||
isActive
|
||||
? [
|
||||
'bg-gradient-to-br from-brand-500/15 to-brand-600/10',
|
||||
'border-2 border-brand-500/40',
|
||||
'text-foreground',
|
||||
'shadow-md shadow-brand-500/10',
|
||||
]
|
||||
: [
|
||||
'bg-accent/30 hover:bg-accent/50',
|
||||
'border border-border/50 hover:border-border',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:shadow-sm',
|
||||
],
|
||||
'hover:scale-[1.02] active:scale-[0.98]'
|
||||
)}
|
||||
data-testid={`project-${testId}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 transition-all duration-200" style={{ color }} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info when using global theme */}
|
||||
{!hasCustomTheme && (
|
||||
<div className="rounded-xl border border-border/30 bg-muted/30 p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This project is using the global theme:{' '}
|
||||
<span className="font-medium text-foreground">{globalTheme}</span>
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* UI Font */}
|
||||
<div className="space-y-3">
|
||||
<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="use-global-font-sans"
|
||||
checked={!hasCustomFontSans}
|
||||
onCheckedChange={handleUseGlobalFontSans}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label
|
||||
htmlFor="use-global-font-sans"
|
||||
className="text-foreground cursor-pointer font-medium"
|
||||
>
|
||||
Use Global UI Font
|
||||
</Label>
|
||||
{!hasCustomFontSans && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Currently using:{' '}
|
||||
<span className="font-medium">{getGlobalFontSansLabel()}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasCustomFontSans && (
|
||||
<div className="ml-6 space-y-2">
|
||||
<Label htmlFor="ui-font-select" className="text-sm">
|
||||
Project UI Font
|
||||
</Label>
|
||||
<FontSelector
|
||||
id="ui-font-select"
|
||||
value={fontSansLocal}
|
||||
options={UI_SANS_FONT_OPTIONS}
|
||||
placeholder="Default (Geist Sans)"
|
||||
onChange={handleFontSansChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Code Font */}
|
||||
<div className="space-y-3">
|
||||
<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="use-global-font-mono"
|
||||
checked={!hasCustomFontMono}
|
||||
onCheckedChange={handleUseGlobalFontMono}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label
|
||||
htmlFor="use-global-font-mono"
|
||||
className="text-foreground cursor-pointer font-medium"
|
||||
>
|
||||
Use Global Code Font
|
||||
</Label>
|
||||
{!hasCustomFontMono && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Currently using:{' '}
|
||||
<span className="font-medium">{getGlobalFontMonoLabel()}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasCustomFontMono && (
|
||||
<div className="ml-6 space-y-2">
|
||||
<Label htmlFor="code-font-select" className="text-sm">
|
||||
Project Code Font
|
||||
</Label>
|
||||
<FontSelector
|
||||
id="code-font-select"
|
||||
value={fontMonoLocal}
|
||||
options={UI_MONO_FONT_OPTIONS}
|
||||
placeholder="Default (Geist Mono)"
|
||||
onChange={handleFontMonoChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
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,
|
||||
PanelBottomClose,
|
||||
} 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';
|
||||
|
||||
interface WorktreePreferencesSectionProps {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
interface InitScriptResponse {
|
||||
success: boolean;
|
||||
exists: boolean;
|
||||
content: string;
|
||||
path: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function WorktreePreferencesSection({ project }: WorktreePreferencesSectionProps) {
|
||||
const globalUseWorktrees = useAppStore((s) => s.useWorktrees);
|
||||
const getProjectUseWorktrees = useAppStore((s) => s.getProjectUseWorktrees);
|
||||
const setProjectUseWorktrees = useAppStore((s) => s.setProjectUseWorktrees);
|
||||
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);
|
||||
|
||||
// Get effective worktrees setting (project override or global fallback)
|
||||
const projectUseWorktrees = getProjectUseWorktrees(project.path);
|
||||
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);
|
||||
|
||||
// Get the current settings for this project
|
||||
const showIndicator = getShowInitScriptIndicator(project.path);
|
||||
const defaultDeleteBranch = getDefaultDeleteBranch(project.path);
|
||||
const autoDismiss = getAutoDismissInitScriptIndicator(project.path);
|
||||
|
||||
// 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
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCancelled) {
|
||||
console.error('Failed to load project settings:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadProjectSettings();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [
|
||||
project.path,
|
||||
setProjectUseWorktrees,
|
||||
setShowInitScriptIndicator,
|
||||
setDefaultDeleteBranch,
|
||||
setAutoDismissInitScriptIndicator,
|
||||
]);
|
||||
|
||||
// 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);
|
||||
}, []);
|
||||
|
||||
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" />
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,10 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Bot, Folder, Loader2, RefreshCw, Square, Activity, FileText } from 'lucide-react';
|
||||
import type { RunningAgent } from '@/lib/electron';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { Bot, Folder, RefreshCw, Square, Activity, FileText } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI, type RunningAgent } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -21,6 +23,8 @@ export function RunningAgentsView() {
|
||||
const { setCurrentProject, projects } = useAppStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const logger = createLogger('RunningAgentsView');
|
||||
|
||||
// Use React Query for running agents with auto-refresh
|
||||
const { data, isLoading, isFetching, refetch } = useRunningAgents();
|
||||
|
||||
@@ -34,31 +38,54 @@ export function RunningAgentsView() {
|
||||
}, [refetch]);
|
||||
|
||||
const handleStopAgent = useCallback(
|
||||
(featureId: string, projectPath: string) => {
|
||||
stopFeature.mutate({ featureId, projectPath });
|
||||
async (agent: RunningAgent) => {
|
||||
const api = getElectronAPI();
|
||||
// Handle backlog plans separately - they use a different API
|
||||
const isBacklogPlan = agent.featureId.startsWith('backlog-plan:');
|
||||
if (isBacklogPlan && api.backlogPlan) {
|
||||
logger.debug('Stopping backlog plan agent', { featureId: agent.featureId });
|
||||
await api.backlogPlan.stop();
|
||||
refetch();
|
||||
return;
|
||||
}
|
||||
// Use mutation for regular features
|
||||
stopFeature.mutate({ featureId: agent.featureId, projectPath: agent.projectPath });
|
||||
},
|
||||
[stopFeature]
|
||||
[stopFeature, refetch, logger]
|
||||
);
|
||||
|
||||
const handleNavigateToProject = useCallback(
|
||||
(agent: RunningAgent) => {
|
||||
const project = projects.find((p) => p.path === agent.projectPath);
|
||||
if (project) {
|
||||
logger.debug('Navigating to running agent project', {
|
||||
projectPath: agent.projectPath,
|
||||
featureId: agent.featureId,
|
||||
});
|
||||
setCurrentProject(project);
|
||||
navigate({ to: '/board' });
|
||||
} else {
|
||||
logger.debug('Project not found for running agent', {
|
||||
projectPath: agent.projectPath,
|
||||
featureId: agent.featureId,
|
||||
});
|
||||
}
|
||||
},
|
||||
[projects, setCurrentProject, navigate]
|
||||
);
|
||||
|
||||
const handleViewLogs = useCallback((agent: RunningAgent) => {
|
||||
logger.debug('Opening running agent logs', {
|
||||
featureId: agent.featureId,
|
||||
projectPath: agent.projectPath,
|
||||
});
|
||||
setSelectedAgent(agent);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<Spinner size="xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -81,7 +108,11 @@ export function RunningAgentsView() {
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={isFetching}>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{isFetching ? (
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
@@ -147,7 +178,7 @@ export function RunningAgentsView() {
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -168,7 +199,7 @@ export function RunningAgentsView() {
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleStopAgent(agent.featureId, agent.projectPath)}
|
||||
onClick={() => handleStopAgent(agent)}
|
||||
disabled={stopFeature.isPending}
|
||||
>
|
||||
<Square className="h-3.5 w-3.5 mr-1.5" />
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useSettingsView, type SettingsViewId } from './settings-view/hooks';
|
||||
import { NAV_ITEMS } from './settings-view/config/navigation';
|
||||
import { SettingsHeader } from './settings-view/components/settings-header';
|
||||
import { KeyboardMapDialog } from './settings-view/components/keyboard-map-dialog';
|
||||
import { DeleteProjectDialog } from './settings-view/components/delete-project-dialog';
|
||||
import { SettingsNavigation } from './settings-view/components/settings-navigation';
|
||||
import { ApiKeysSection } from './settings-view/api-keys/api-keys-section';
|
||||
import { ModelDefaultsSection } from './settings-view/model-defaults';
|
||||
@@ -16,9 +15,9 @@ import { AudioSection } from './settings-view/audio/audio-section';
|
||||
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
|
||||
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
|
||||
import { WorktreesSection } from './settings-view/worktrees';
|
||||
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
|
||||
import { AccountSection } from './settings-view/account';
|
||||
import { SecuritySection } from './settings-view/security';
|
||||
import { DeveloperSection } from './settings-view/developer/developer-section';
|
||||
import {
|
||||
ClaudeSettingsTab,
|
||||
CursorSettingsTab,
|
||||
@@ -27,8 +26,9 @@ import {
|
||||
} from './settings-view/providers';
|
||||
import { MCPServersSection } from './settings-view/mcp-servers';
|
||||
import { PromptCustomizationSection } from './settings-view/prompts';
|
||||
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
|
||||
import type { Project as ElectronProject } from '@/lib/electron';
|
||||
import { EventHooksSection } from './settings-view/event-hooks';
|
||||
import { ImportExportDialog } from './settings-view/components/import-export-dialog';
|
||||
import type { Theme } from './settings-view/shared/types';
|
||||
|
||||
// Breakpoint constant for mobile (matches Tailwind lg breakpoint)
|
||||
const LG_BREAKPOINT = 1024;
|
||||
@@ -37,7 +37,6 @@ export function SettingsView() {
|
||||
const {
|
||||
theme,
|
||||
setTheme,
|
||||
setProjectTheme,
|
||||
defaultSkipTests,
|
||||
setDefaultSkipTests,
|
||||
enableDependencyBlocking,
|
||||
@@ -51,7 +50,6 @@ export function SettingsView() {
|
||||
muteDoneSound,
|
||||
setMuteDoneSound,
|
||||
currentProject,
|
||||
moveProjectToTrash,
|
||||
defaultPlanningMode,
|
||||
setDefaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
@@ -66,34 +64,8 @@ export function SettingsView() {
|
||||
setSkipSandboxWarning,
|
||||
} = useAppStore();
|
||||
|
||||
// Convert electron Project to settings-view Project type
|
||||
const convertProject = (project: ElectronProject | null): SettingsProject | null => {
|
||||
if (!project) return null;
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
path: project.path,
|
||||
theme: project.theme as Theme | undefined,
|
||||
icon: project.icon,
|
||||
customIconPath: project.customIconPath,
|
||||
};
|
||||
};
|
||||
|
||||
const settingsProject = convertProject(currentProject);
|
||||
|
||||
// Compute the effective theme for the current project
|
||||
const effectiveTheme = (settingsProject?.theme || theme) as Theme;
|
||||
|
||||
// Handler to set theme - always updates global theme (user's preference),
|
||||
// and also sets per-project theme if a project is selected
|
||||
const handleSetTheme = (newTheme: typeof theme) => {
|
||||
// Always update global theme so user's preference persists across all projects
|
||||
setTheme(newTheme);
|
||||
// Also set per-project theme if a project is selected
|
||||
if (currentProject) {
|
||||
setProjectTheme(currentProject.id, newTheme);
|
||||
}
|
||||
};
|
||||
// Global theme (project-specific themes are managed in Project Settings)
|
||||
const globalTheme = theme as Theme;
|
||||
|
||||
// Get initial view from URL search params
|
||||
const { view: initialView } = useSearch({ from: '/settings' });
|
||||
@@ -110,8 +82,8 @@ export function SettingsView() {
|
||||
}
|
||||
};
|
||||
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
||||
const [showImportExportDialog, setShowImportExportDialog] = useState(false);
|
||||
|
||||
// Mobile navigation state - default to showing on desktop, hidden on mobile
|
||||
const [showNavigation, setShowNavigation] = useState(() => {
|
||||
@@ -168,9 +140,8 @@ export function SettingsView() {
|
||||
case 'appearance':
|
||||
return (
|
||||
<AppearanceSection
|
||||
effectiveTheme={effectiveTheme as any}
|
||||
currentProject={settingsProject as any}
|
||||
onThemeChange={(theme) => handleSetTheme(theme as any)}
|
||||
effectiveTheme={globalTheme}
|
||||
onThemeChange={(newTheme) => setTheme(newTheme as typeof theme)}
|
||||
/>
|
||||
);
|
||||
case 'terminal':
|
||||
@@ -183,6 +154,8 @@ export function SettingsView() {
|
||||
return (
|
||||
<AudioSection muteDoneSound={muteDoneSound} onMuteDoneSoundChange={setMuteDoneSound} />
|
||||
);
|
||||
case 'event-hooks':
|
||||
return <EventHooksSection />;
|
||||
case 'defaults':
|
||||
return (
|
||||
<FeatureDefaultsSection
|
||||
@@ -215,13 +188,8 @@ export function SettingsView() {
|
||||
onSkipSandboxWarningChange={setSkipSandboxWarning}
|
||||
/>
|
||||
);
|
||||
case 'danger':
|
||||
return (
|
||||
<DangerZoneSection
|
||||
project={settingsProject}
|
||||
onDeleteClick={() => setShowDeleteDialog(true)}
|
||||
/>
|
||||
);
|
||||
case 'developer':
|
||||
return <DeveloperSection />;
|
||||
default:
|
||||
return <ApiKeysSection />;
|
||||
}
|
||||
@@ -233,6 +201,7 @@ export function SettingsView() {
|
||||
<SettingsHeader
|
||||
showNavigation={showNavigation}
|
||||
onToggleNavigation={() => setShowNavigation(!showNavigation)}
|
||||
onImportExportClick={() => setShowImportExportDialog(true)}
|
||||
/>
|
||||
|
||||
{/* Content Area with Sidebar */}
|
||||
@@ -256,13 +225,8 @@ export function SettingsView() {
|
||||
{/* Keyboard Map Dialog */}
|
||||
<KeyboardMapDialog open={showKeyboardMapDialog} onOpenChange={setShowKeyboardMapDialog} />
|
||||
|
||||
{/* Delete Project Confirmation Dialog */}
|
||||
<DeleteProjectDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
project={currentProject}
|
||||
onConfirm={moveProjectToTrash}
|
||||
/>
|
||||
{/* Import/Export Settings Dialog */}
|
||||
<ImportExportDialog open={showImportExportDialog} onOpenChange={setShowImportExportDialog} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user