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

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

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

View File

@@ -11,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...
</>
) : (

View File

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

View File

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

View File

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

View File

@@ -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...
</>
) : (

View File

@@ -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'}
/>
)}

View File

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

View File

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

View File

@@ -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" />
)}

View File

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

View File

@@ -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?.()}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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...
</>
) : (

View File

@@ -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...
</>
) : (

View File

@@ -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...
</>
) : (

View File

@@ -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...
</>
) : (

View File

@@ -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...
</>
) : (

View File

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

View File

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

View File

@@ -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...
</>
) : (

View File

@@ -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" />
)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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...
</>
) : (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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...
</>
) : (

View File

@@ -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...
</>
) : (

View File

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

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

View File

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

View File

@@ -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 },
];

View File

@@ -0,0 +1 @@
export { useProjectSettingsView, type ProjectSettingsViewId } from './use-project-settings-view';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More