Merge remote-tracking branch 'refs/remotes/origin/main'

# Conflicts:
#	apps/ui/src/routes/__root.tsx
This commit is contained in:
antdev
2026-01-07 01:48:34 +08:00
309 changed files with 24966 additions and 5040 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@automaker/ui",
"version": "0.7.3",
"version": "0.8.0",
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"homepage": "https://github.com/AutoMaker-Org/automaker",
"repository": {
@@ -31,6 +31,7 @@
"postinstall": "electron-builder install-app-deps",
"preview": "vite preview",
"lint": "npx eslint",
"typecheck": "tsc --noEmit",
"pretest": "node scripts/kill-test-servers.mjs && node scripts/setup-e2e-fixtures.mjs",
"test": "playwright test",
"test:headed": "playwright test --headed",

View File

@@ -1,11 +1,15 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { RouterProvider } from '@tanstack/react-router';
import { createLogger } from '@automaker/utils/logger';
import { router } from './utils/router';
import { SplashScreen } from './components/splash-screen';
import { useSettingsMigration } from './hooks/use-settings-migration';
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
import './styles/global.css';
import './styles/theme-imports';
const logger = createLogger('App');
export default function App() {
const [showSplash, setShowSplash] = useState(() => {
// Only show splash once per session
@@ -15,12 +19,28 @@ export default function App() {
return true;
});
// Clear accumulated PerformanceMeasure entries to prevent memory leak in dev mode
// React's internal scheduler creates performance marks/measures that accumulate without cleanup
useEffect(() => {
if (import.meta.env.DEV) {
const clearPerfEntries = () => {
performance.clearMarks();
performance.clearMeasures();
};
const interval = setInterval(clearPerfEntries, 5000);
return () => clearInterval(interval);
}
}, []);
// Run settings migration on startup (localStorage -> file storage)
const migrationState = useSettingsMigration();
if (migrationState.migrated) {
console.log('[App] Settings migrated to file storage');
logger.info('Settings migrated to file storage');
}
// Initialize Cursor CLI status at startup
useCursorStatusInit();
const handleSplashComplete = useCallback(() => {
sessionStorage.setItem('automaker-splash-shown', 'true');
setShowSplash(false);

View File

@@ -1,5 +1,8 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { ImageIcon, Upload, Loader2, Trash2 } from 'lucide-react';
const logger = createLogger('BoardBackgroundModal');
import {
Sheet,
SheetContent,
@@ -115,7 +118,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
setPreviewImage(null);
}
} catch (error) {
console.error('Failed to process image:', error);
logger.error('Failed to process image:', error);
toast.error('Failed to process image');
setPreviewImage(null);
} finally {
@@ -187,7 +190,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
toast.error(result.error || 'Failed to clear background image');
}
} catch (error) {
console.error('Failed to clear background:', error);
logger.error('Failed to clear background:', error);
toast.error('Failed to clear background');
} finally {
setIsProcessing(false);

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
DialogContent,
@@ -29,6 +30,8 @@ import { cn } from '@/lib/utils';
import { useFileBrowser } from '@/contexts/file-browser-context';
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
const logger = createLogger('NewProjectModal');
interface ValidationErrors {
projectName?: boolean;
workspaceDir?: boolean;
@@ -78,7 +81,7 @@ export function NewProjectModal({
}
})
.catch((error) => {
console.error('Failed to get default workspace directory:', error);
logger.error('Failed to get default workspace directory:', error);
})
.finally(() => {
setIsLoadingWorkspace(false);

View File

@@ -6,7 +6,10 @@
*/
import { useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { ShieldX, RefreshCw, Container, Copy, Check } from 'lucide-react';
const logger = createLogger('SandboxRejectionScreen');
import { Button } from '@/components/ui/button';
const DOCKER_COMMAND = 'npm run dev:docker';
@@ -26,7 +29,7 @@ export function SandboxRejectionScreen() {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
logger.error('Failed to copy:', err);
}
};

View File

@@ -6,7 +6,10 @@
*/
import { useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { ShieldAlert, Copy, Check } from 'lucide-react';
const logger = createLogger('SandboxRiskDialog');
import {
Dialog,
DialogContent,
@@ -43,7 +46,7 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
logger.error('Failed to copy:', err);
}
};

View File

@@ -1,5 +1,8 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useNavigate, useLocation } from '@tanstack/react-router';
const logger = createLogger('Sidebar');
import { cn } from '@/lib/utils';
import { useAppStore, type ThemeMode } from '@/store/app-store';
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
@@ -215,7 +218,7 @@ export function Sidebar() {
});
}
} catch (error) {
console.error('[Sidebar] Failed to open project:', error);
logger.error('Failed to open project:', error);
toast.error('Failed to open project', {
description: error instanceof Error ? error.message : 'Unknown error',
});

View File

@@ -10,6 +10,7 @@ import {
CircleDot,
GitPullRequest,
Zap,
Lightbulb,
} from 'lucide-react';
import type { NavSection, NavItem } from '../types';
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
@@ -30,6 +31,9 @@ interface UseNavigationProps {
agent: string;
terminal: string;
settings: string;
ideation: string;
githubIssues: string;
githubPrs: string;
};
hideSpecEditor: boolean;
hideContext: boolean;
@@ -92,6 +96,12 @@ export function useNavigation({
// Build navigation sections
const navSections: NavSection[] = useMemo(() => {
const allToolsItems: NavItem[] = [
{
id: 'ideation',
label: 'Ideation',
icon: Lightbulb,
shortcut: shortcuts.ideation,
},
{
id: 'spec',
label: 'Spec Editor',
@@ -172,12 +182,14 @@ export function useNavigation({
id: 'github-issues',
label: 'Issues',
icon: CircleDot,
shortcut: shortcuts.githubIssues,
count: unviewedValidationsCount,
},
{
id: 'github-prs',
label: 'Pull Requests',
icon: GitPullRequest,
shortcut: shortcuts.githubPrs,
},
],
});

View File

@@ -1,5 +1,8 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
const logger = createLogger('ProjectCreation');
import { initializeProject } from '@/lib/project-init';
import { toast } from 'sonner';
import type { StarterTemplate } from '@/lib/templates';
@@ -82,7 +85,7 @@ export function useProjectCreation({
toast.success('Project created successfully');
} catch (error) {
console.error('[ProjectCreation] Failed to finalize project:', error);
logger.error('Failed to finalize project:', error);
toast.error('Failed to initialize project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
@@ -108,7 +111,7 @@ export function useProjectCreation({
// Finalize project setup
await finalizeProjectCreation(projectPath, projectName);
} catch (error) {
console.error('[ProjectCreation] Failed to create blank project:', error);
logger.error('Failed to create blank project:', error);
toast.error('Failed to create project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
@@ -180,7 +183,7 @@ export function useProjectCreation({
description: `Created ${projectName} from ${template.name}`,
});
} catch (error) {
console.error('[ProjectCreation] Failed to create from template:', error);
logger.error('Failed to create from template:', error);
toast.error('Failed to create project from template', {
description: error instanceof Error ? error.message : 'Unknown error',
});
@@ -252,7 +255,7 @@ export function useProjectCreation({
description: `Created ${projectName} from ${repoUrl}`,
});
} catch (error) {
console.error('[ProjectCreation] Failed to create from custom URL:', error);
logger.error('Failed to create from custom URL:', error);
toast.error('Failed to create project from URL', {
description: error instanceof Error ? error.message : 'Unknown error',
});

View File

@@ -1,6 +1,9 @@
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
const logger = createLogger('RunningAgents');
export function useRunningAgents() {
const [runningAgentsCount, setRunningAgentsCount] = useState(0);
@@ -15,7 +18,7 @@ export function useRunningAgents() {
}
}
} catch (error) {
console.error('[Sidebar] Error fetching running agents count:', error);
logger.error('Error fetching running agents count:', error);
}
}, []);

View File

@@ -1,5 +1,8 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
const logger = createLogger('SetupDialog');
import { toast } from 'sonner';
import type { FeatureCount } from '@/components/views/spec-view/types';
@@ -53,7 +56,7 @@ export function useSetupDialog({
);
if (!result.success) {
console.error('[SetupDialog] Failed to start spec creation:', result.error);
logger.error('Failed to start spec creation:', result.error);
setSpecCreatingForProject(null);
toast.error('Failed to create specification', {
description: result.error,
@@ -66,7 +69,7 @@ export function useSetupDialog({
}
// If successful, we'll wait for the events to update the state
} catch (error) {
console.error('[SetupDialog] Failed to create spec:', error);
logger.error('Failed to create spec:', error);
setSpecCreatingForProject(null);
toast.error('Failed to create specification', {
description: error instanceof Error ? error.message : 'Unknown error',

View File

@@ -1,5 +1,8 @@
import { useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { toast } from 'sonner';
const logger = createLogger('SpecRegeneration');
import { getElectronAPI } from '@/lib/electron';
import type { SpecRegenerationEvent } from '@/types/electron';
@@ -30,20 +33,18 @@ export function useSpecRegeneration({
if (!api.specRegeneration) return;
const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => {
console.log(
'[Sidebar] Spec regeneration event:',
event.type,
'for project:',
event.projectPath
);
logger.debug('Spec regeneration event:', event.type, 'for project:', event.projectPath);
// Only handle events for the project we're currently setting up
if (event.projectPath !== creatingSpecProjectPath && event.projectPath !== setupProjectPath) {
console.log('[Sidebar] Ignoring event - not for project being set up');
logger.debug('Ignoring event - not for project being set up');
return;
}
if (event.type === 'spec_regeneration_complete') {
// Only show toast if we're in active creation flow (not regular regeneration)
const isCreationFlow = creatingSpecProjectPath !== null;
setSpecCreatingForProject(null);
setShowSetupDialog(false);
setProjectOverview('');
@@ -51,9 +52,12 @@ export function useSpecRegeneration({
// Clear onboarding state if we came from onboarding
setNewProjectName('');
setNewProjectPath('');
toast.success('App specification created', {
description: 'Your project is now set up and ready to go!',
});
if (isCreationFlow) {
toast.success('App specification created', {
description: 'Your project is now set up and ready to go!',
});
}
} else if (event.type === 'spec_regeneration_error') {
setSpecCreatingForProject(null);
toast.error('Failed to create specification', {

View File

@@ -1,5 +1,8 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { toast } from 'sonner';
const logger = createLogger('TrashOperations');
import { getElectronAPI, type TrashedProject } from '@/lib/electron';
interface UseTrashOperationsProps {
@@ -24,7 +27,7 @@ export function useTrashOperations({
description: 'Added back to your project list.',
});
} catch (error) {
console.error('[Sidebar] Failed to restore project:', error);
logger.error('Failed to restore project:', error);
toast.error('Failed to restore project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
@@ -52,7 +55,7 @@ export function useTrashOperations({
description: trashedProject.path,
});
} catch (error) {
console.error('[Sidebar] Failed to delete project from disk:', error);
logger.error('Failed to delete project from disk:', error);
toast.error('Failed to delete project folder', {
description: error instanceof Error ? error.message : 'Unknown error',
});
@@ -69,7 +72,7 @@ export function useTrashOperations({
emptyTrash();
toast.success('Recycle bin cleared');
} catch (error) {
console.error('[Sidebar] Failed to empty trash:', error);
logger.error('Failed to empty trash:', error);
toast.error('Failed to clear recycle bin', {
description: error instanceof Error ? error.message : 'Unknown error',
});

View File

@@ -1,5 +1,8 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
const logger = createLogger('UnviewedValidations');
import type { Project, StoredValidation } from '@/lib/electron';
/**
@@ -38,7 +41,7 @@ export function useUnviewedValidations(currentProject: Project | null) {
}
}
} catch (err) {
console.error('[useUnviewedValidations] Failed to load count:', err);
logger.error('Failed to load count:', err);
}
}, []);

View File

@@ -1,5 +1,8 @@
import { useState, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
const logger = createLogger('SessionManager');
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Input } from '@/components/ui/input';
@@ -126,7 +129,7 @@ export function SessionManager({
}
} catch (err) {
// Ignore errors for individual session checks
console.warn(`[SessionManager] Failed to check running state for ${session.id}:`, err);
logger.warn(`Failed to check running state for ${session.id}:`, err);
}
}
@@ -227,7 +230,7 @@ export function SessionManager({
const handleArchiveSession = async (sessionId: string) => {
const api = getElectronAPI();
if (!api?.sessions) {
console.error('[SessionManager] Sessions API not available');
logger.error('[SessionManager] Sessions API not available');
return;
}
@@ -240,10 +243,10 @@ export function SessionManager({
}
await loadSessions();
} else {
console.error('[SessionManager] Archive failed:', result.error);
logger.error('[SessionManager] Archive failed:', result.error);
}
} catch (error) {
console.error('[SessionManager] Archive error:', error);
logger.error('[SessionManager] Archive error:', error);
}
};
@@ -251,7 +254,7 @@ export function SessionManager({
const handleUnarchiveSession = async (sessionId: string) => {
const api = getElectronAPI();
if (!api?.sessions) {
console.error('[SessionManager] Sessions API not available');
logger.error('[SessionManager] Sessions API not available');
return;
}
@@ -260,10 +263,10 @@ export function SessionManager({
if (result.success) {
await loadSessions();
} else {
console.error('[SessionManager] Unarchive failed:', result.error);
logger.error('[SessionManager] Unarchive failed:', result.error);
}
} catch (error) {
console.error('[SessionManager] Unarchive error:', error);
logger.error('[SessionManager] Unarchive error:', error);
}
};

View File

@@ -0,0 +1,7 @@
// Model Override Components
export { ModelOverrideTrigger, type ModelOverrideTriggerProps } from './model-override-trigger';
export {
useModelOverride,
type UseModelOverrideOptions,
type UseModelOverrideResult,
} from './use-model-override';

View File

@@ -0,0 +1,126 @@
import * as React from 'react';
import { Settings2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useAppStore } from '@/store/app-store';
import type { ModelAlias, CursorModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
/**
* Normalize PhaseModelEntry or string to PhaseModelEntry
*/
function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
if (typeof entry === 'string') {
return { model: entry as ModelAlias | CursorModelId };
}
return entry;
}
export interface ModelOverrideTriggerProps {
/** Current effective model entry (from global settings or explicit override) */
currentModelEntry: PhaseModelEntry;
/** Callback when user selects override */
onModelChange: (entry: PhaseModelEntry | null) => void;
/** Optional: which phase this is for (shows global default) */
phase?: PhaseModelKey;
/** Size variants for different contexts */
size?: 'sm' | 'md' | 'lg';
/** Show as icon-only or with label */
variant?: 'icon' | 'button' | 'inline';
/** Whether the model is currently overridden from global */
isOverridden?: boolean;
/** Optional class name */
className?: string;
}
export function ModelOverrideTrigger({
currentModelEntry,
onModelChange,
phase,
size = 'sm',
variant = 'icon',
isOverridden = false,
className,
}: ModelOverrideTriggerProps) {
const { phaseModels } = useAppStore();
const handleChange = (entry: PhaseModelEntry) => {
// If the new entry matches the global default, clear the override
// Otherwise, set it as override
if (phase) {
const globalDefault = phaseModels[phase];
const normalizedGlobal = normalizeEntry(globalDefault);
// Compare models (and thinking levels if both have them)
const modelsMatch = entry.model === normalizedGlobal.model;
const thinkingMatch =
(entry.thinkingLevel || 'none') === (normalizedGlobal.thinkingLevel || 'none');
if (modelsMatch && thinkingMatch) {
onModelChange(null); // Clear override
} else {
onModelChange(entry); // Set override
}
} else {
onModelChange(entry);
}
};
// Size classes for icon variant
const sizeClasses = {
sm: 'h-6 w-6',
md: 'h-8 w-8',
lg: 'h-10 w-10',
};
const iconSizes = {
sm: 'w-3.5 h-3.5',
md: 'w-4 h-4',
lg: 'w-5 h-5',
};
// For icon variant, wrap PhaseModelSelector and hide text/chevron with CSS
if (variant === 'icon') {
return (
<div className={cn('relative inline-block', className)}>
<div className="relative [&_button>span]:hidden [&_button>svg:last-child]:hidden [&_button]:p-0 [&_button]:min-w-0 [&_button]:w-auto [&_button]:h-auto [&_button]:border-0 [&_button]:bg-transparent">
<PhaseModelSelector
value={currentModelEntry}
onChange={handleChange}
compact
triggerClassName={cn(
'relative rounded-md',
'transition-colors duration-150',
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50',
sizeClasses[size],
className
)}
disabled={false}
align="end"
/>
</div>
{isOverridden && (
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-brand-500 rounded-full z-10 pointer-events-none" />
)}
</div>
);
}
// For button and inline variants, use PhaseModelSelector in compact mode
return (
<div className={cn('relative', className)}>
<PhaseModelSelector
value={currentModelEntry}
onChange={handleChange}
compact
triggerClassName={variant === 'button' ? className : undefined}
disabled={false}
/>
{isOverridden && (
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-brand-500 rounded-full z-10" />
)}
</div>
);
}

View File

@@ -0,0 +1,114 @@
import { useState, useCallback, useMemo } from 'react';
import { useAppStore } from '@/store/app-store';
import type { ModelAlias, CursorModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
export interface UseModelOverrideOptions {
/** Which phase this override is for */
phase: PhaseModelKey;
/** Initial override value (optional) */
initialOverride?: PhaseModelEntry | null;
}
export interface UseModelOverrideResult {
/** The effective model entry (override or global default) */
effectiveModelEntry: PhaseModelEntry;
/** The effective model string (for backward compatibility with APIs that only accept strings) */
effectiveModel: ModelAlias | CursorModelId;
/** Whether the model is currently overridden */
isOverridden: boolean;
/** Set a model override */
setOverride: (entry: PhaseModelEntry | null) => void;
/** Clear the override and use global default */
clearOverride: () => void;
/** The global default for this phase */
globalDefault: PhaseModelEntry;
/** The current override value (null if not overridden) */
override: PhaseModelEntry | null;
}
/**
* Normalize PhaseModelEntry or string to PhaseModelEntry
*/
function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
if (typeof entry === 'string') {
return { model: entry as ModelAlias | CursorModelId };
}
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;
}
/**
* Hook for managing model overrides per phase
*
* Provides a simple way to allow users to override the global phase model
* for a specific run or context. Now supports PhaseModelEntry with thinking levels.
*
* @example
* ```tsx
* function EnhanceDialog() {
* const { effectiveModelEntry, isOverridden, setOverride, clearOverride } = useModelOverride({
* phase: 'enhancementModel',
* });
*
* return (
* <ModelOverrideTrigger
* currentModelEntry={effectiveModelEntry}
* onModelChange={setOverride}
* phase="enhancementModel"
* isOverridden={isOverridden}
* />
* );
* }
* ```
*/
export function useModelOverride({
phase,
initialOverride = null,
}: UseModelOverrideOptions): UseModelOverrideResult {
const { phaseModels } = useAppStore();
const [override, setOverrideState] = useState<PhaseModelEntry | null>(
initialOverride ? normalizeEntry(initialOverride) : null
);
// Normalize global default to PhaseModelEntry, with fallback to DEFAULT_PHASE_MODELS
// This handles cases where settings haven't been migrated to include new phase models
const globalDefault = normalizeEntry(phaseModels[phase] ?? DEFAULT_PHASE_MODELS[phase]);
const effectiveModelEntry = useMemo(() => {
return override ?? globalDefault;
}, [override, globalDefault]);
const effectiveModel = useMemo(() => {
return effectiveModelEntry.model;
}, [effectiveModelEntry]);
const isOverridden = override !== null;
const setOverride = useCallback((entry: PhaseModelEntry | null) => {
setOverrideState(entry ? normalizeEntry(entry) : null);
}, []);
const clearOverride = useCallback(() => {
setOverrideState(null);
}, []);
return {
effectiveModelEntry,
effectiveModel,
isOverridden,
setOverride,
clearOverride,
globalDefault,
override,
};
}

View File

@@ -1,5 +1,8 @@
import React, { useState, useRef, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('DescriptionImageDropZone');
import { ImageIcon, X, Loader2, FileText } from 'lucide-react';
import { Textarea } from '@/components/ui/textarea';
import { getElectronAPI } from '@/lib/electron';
@@ -107,7 +110,7 @@ export function DescriptionImageDropZone({
// Check if saveImageToTemp method exists
if (!api.saveImageToTemp) {
// Fallback path when saveImageToTemp is not available
console.log('[DescriptionImageDropZone] Using fallback path for image');
logger.info('Using fallback path for image');
return `.automaker/images/${Date.now()}_${filename}`;
}
@@ -117,10 +120,10 @@ export function DescriptionImageDropZone({
if (result.success && result.path) {
return result.path;
}
console.error('[DescriptionImageDropZone] Failed to save image:', result.error);
logger.error('Failed to save image:', result.error);
return null;
} catch (error) {
console.error('[DescriptionImageDropZone] Error saving image:', error);
logger.error('Error saving image:', error);
return null;
}
},
@@ -215,7 +218,7 @@ export function DescriptionImageDropZone({
}
if (errors.length > 0) {
console.warn('File upload errors:', errors);
logger.warn('File upload errors:', errors);
}
if (newImages.length > 0) {

View File

@@ -1,5 +1,8 @@
import React, { useState, useRef, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('FeatureImageUpload');
import { ImageIcon, X, Upload } from 'lucide-react';
import {
fileToBase64,
@@ -77,7 +80,7 @@ export function FeatureImageUpload({
}
if (errors.length > 0) {
console.warn('Image upload errors:', errors);
logger.warn('Image upload errors:', errors);
}
if (newImages.length > 0) {

View File

@@ -1,5 +1,8 @@
import React, { useState, useRef, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('ImageDropZone');
import { ImageIcon, X, Upload } from 'lucide-react';
import type { ImageAttachment } from '@/store/app-store';
import {
@@ -88,7 +91,7 @@ export function ImageDropZone({
}
if (errors.length > 0) {
console.warn('Image upload errors:', errors);
logger.warn('Image upload errors:', errors);
}
if (newImages.length > 0) {

View File

@@ -90,6 +90,9 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
settings: 'Settings',
profiles: 'AI Profiles',
terminal: 'Terminal',
ideation: 'Ideation',
githubIssues: 'GitHub Issues',
githubPrs: 'Pull Requests',
toggleSidebar: 'Toggle Sidebar',
addFeature: 'Add Feature',
addContextFile: 'Add Context File',
@@ -115,6 +118,9 @@ const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, 'navigation' | 'ui' |
settings: 'navigation',
profiles: 'navigation',
terminal: 'navigation',
ideation: 'navigation',
githubIssues: 'navigation',
githubPrs: 'navigation',
toggleSidebar: 'ui',
addFeature: 'action',
addContextFile: 'action',

View File

@@ -1,7 +1,10 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('TaskProgressPanel');
import { Check, Loader2, Circle, ChevronDown, ChevronRight, FileCode } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import type { AutoModeEvent } from '@/types/electron';
@@ -19,11 +22,18 @@ interface TaskProgressPanelProps {
featureId: string;
projectPath?: string;
className?: string;
/** Whether the panel starts expanded (default: true) */
defaultExpanded?: boolean;
}
export function TaskProgressPanel({ featureId, projectPath, className }: TaskProgressPanelProps) {
export function TaskProgressPanel({
featureId,
projectPath,
className,
defaultExpanded = true,
}: TaskProgressPanelProps) {
const [tasks, setTasks] = useState<TaskInfo[]>([]);
const [isExpanded, setIsExpanded] = useState(true);
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const [isLoading, setIsLoading] = useState(true);
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
@@ -65,7 +75,7 @@ export function TaskProgressPanel({ featureId, projectPath, className }: TaskPro
setCurrentTaskId(currentId || null);
}
} catch (error) {
console.error('Failed to load initial tasks:', error);
logger.error('Failed to load initial tasks:', error);
} finally {
setIsLoading(false);
}
@@ -151,13 +161,13 @@ export function TaskProgressPanel({ featureId, projectPath, className }: TaskPro
return (
<div
className={cn(
'group rounded-xl border bg-card/50 shadow-sm overflow-hidden transition-all duration-200',
'group rounded-lg border bg-card/50 shadow-sm overflow-hidden transition-all duration-200',
className
)}
>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between p-4 bg-muted/10 hover:bg-muted/20 transition-colors"
className="w-full flex items-center justify-between px-3 py-2.5 bg-muted/10 hover:bg-muted/20 transition-colors"
>
<div className="flex items-center gap-3">
<div
@@ -218,9 +228,9 @@ export function TaskProgressPanel({ featureId, projectPath, className }: TaskPro
)}
>
<div className="overflow-hidden">
<div className="p-5 pt-2 relative max-h-[300px] overflow-y-auto scrollbar-visible">
<div className="p-4 pt-2 relative max-h-[300px] overflow-y-auto scrollbar-visible">
{/* Vertical Connector Line */}
<div className="absolute left-[2.35rem] top-4 bottom-8 w-px bg-gradient-to-b from-border/80 via-border/40 to-transparent" />
<div className="absolute left-[2.35rem] top-4 bottom-8 w-px bg-linear-to-b from-border/80 via-border/40 to-transparent" />
<div className="space-y-5">
{tasks.map((task, index) => {

View File

@@ -1,4 +1,5 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -19,6 +20,8 @@ import {
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
const logger = createLogger('AgentToolsView');
interface ToolResult {
success: boolean;
output?: string;
@@ -62,7 +65,7 @@ export function AgentToolsView() {
try {
// Simulate agent requesting file read
console.log(`[Agent Tool] Requesting to read file: ${readFilePath}`);
logger.info(`[Agent Tool] Requesting to read file: ${readFilePath}`);
const result = await api.readFile(readFilePath);
@@ -72,14 +75,14 @@ export function AgentToolsView() {
output: result.content,
timestamp: new Date(),
});
console.log(`[Agent Tool] File read successful: ${readFilePath}`);
logger.info(`[Agent Tool] File read successful: ${readFilePath}`);
} else {
setReadFileResult({
success: false,
error: result.error || 'Failed to read file',
timestamp: new Date(),
});
console.log(`[Agent Tool] File read failed: ${result.error}`);
logger.info(`[Agent Tool] File read failed: ${result.error}`);
}
} catch (error) {
setReadFileResult({
@@ -101,7 +104,7 @@ export function AgentToolsView() {
try {
// Simulate agent requesting file write
console.log(`[Agent Tool] Requesting to write file: ${writeFilePath}`);
logger.info(`[Agent Tool] Requesting to write file: ${writeFilePath}`);
const result = await api.writeFile(writeFilePath, writeFileContent);
@@ -111,14 +114,14 @@ export function AgentToolsView() {
output: `File written successfully: ${writeFilePath}`,
timestamp: new Date(),
});
console.log(`[Agent Tool] File write successful: ${writeFilePath}`);
logger.info(`[Agent Tool] File write successful: ${writeFilePath}`);
} else {
setWriteFileResult({
success: false,
error: result.error || 'Failed to write file',
timestamp: new Date(),
});
console.log(`[Agent Tool] File write failed: ${result.error}`);
logger.info(`[Agent Tool] File write failed: ${result.error}`);
}
} catch (error) {
setWriteFileResult({
@@ -140,7 +143,7 @@ export function AgentToolsView() {
try {
// Terminal command simulation for demonstration purposes
console.log(`[Agent Tool] Simulating command: ${terminalCommand}`);
logger.info(`[Agent Tool] Simulating command: ${terminalCommand}`);
// Simulated outputs for common commands (preview mode)
// In production, the agent executes commands via Claude SDK
@@ -165,7 +168,7 @@ export function AgentToolsView() {
output: output,
timestamp: new Date(),
});
console.log(`[Agent Tool] Command executed successfully: ${terminalCommand}`);
logger.info(`[Agent Tool] Command executed successfully: ${terminalCommand}`);
} catch (error) {
setTerminalResult({
success: false,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,80 @@
import { Bot, PanelLeftClose, PanelLeft, Wrench, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface AgentHeaderProps {
projectName: string;
currentSessionId: string | null;
isConnected: boolean;
isProcessing: boolean;
currentTool: string | null;
agentError: string | null;
messagesCount: number;
showSessionManager: boolean;
onToggleSessionManager: () => void;
onClearChat: () => void;
}
export function AgentHeader({
projectName,
currentSessionId,
isConnected,
isProcessing,
currentTool,
agentError,
messagesCount,
showSessionManager,
onToggleSessionManager,
onClearChat,
}: AgentHeaderProps) {
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>
<div>
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
<p className="text-sm text-muted-foreground">
{projectName}
{currentSessionId && !isConnected && ' - Connecting...'}
</p>
</div>
</div>
{/* Status indicators & actions */}
<div className="flex items-center gap-3">
{currentTool && (
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-full border border-border">
<Wrench className="w-3 h-3 text-primary" />
<span className="font-medium">{currentTool}</span>
</div>
)}
{agentError && <span className="text-xs text-destructive font-medium">{agentError}</span>}
{currentSessionId && messagesCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={onClearChat}
disabled={isProcessing}
className="text-muted-foreground hover:text-foreground"
>
<Trash2 className="w-4 h-4 mr-2" />
Clear
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import type { ImageAttachment } from '@/store/app-store';
import { MessageList } from './message-list';
import { NoSessionState } from './empty-states';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: string;
images?: ImageAttachment[];
}
interface ChatAreaProps {
currentSessionId: string | null;
messages: Message[];
isProcessing: boolean;
showSessionManager: boolean;
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
onScroll: () => void;
onShowSessionManager: () => void;
}
export function ChatArea({
currentSessionId,
messages,
isProcessing,
showSessionManager,
messagesContainerRef,
onScroll,
onShowSessionManager,
}: ChatAreaProps) {
if (!currentSessionId) {
return (
<NoSessionState
showSessionManager={showSessionManager}
onShowSessionManager={onShowSessionManager}
/>
);
}
return (
<MessageList
messages={messages}
isProcessing={isProcessing}
messagesContainerRef={messagesContainerRef}
onScroll={onScroll}
/>
);
}

View File

@@ -0,0 +1,49 @@
import { Sparkles, Bot, PanelLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
export function NoProjectState() {
return (
<div
className="flex-1 flex items-center justify-center bg-background"
data-testid="agent-view-no-project"
>
<div className="text-center max-w-md">
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-6">
<Sparkles className="w-8 h-8 text-primary" />
</div>
<h2 className="text-xl font-semibold mb-3 text-foreground">No Project Selected</h2>
<p className="text-muted-foreground leading-relaxed">
Open or create a project to start working with the AI agent.
</p>
</div>
</div>
);
}
interface NoSessionStateProps {
showSessionManager: boolean;
onShowSessionManager: () => void;
}
export function NoSessionState({ showSessionManager, onShowSessionManager }: NoSessionStateProps) {
return (
<div
className="flex-1 flex items-center justify-center bg-background"
data-testid="no-session-placeholder"
>
<div className="text-center max-w-md">
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mx-auto mb-6">
<Bot className="w-8 h-8 text-muted-foreground" />
</div>
<h2 className="text-lg font-semibold mb-3 text-foreground">No Session Selected</h2>
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">
Create or select a session to start chatting with the AI agent
</p>
<Button onClick={onShowSessionManager} variant="outline" className="gap-2">
<PanelLeft className="w-4 h-4" />
{showSessionManager ? 'View' : 'Show'} Sessions
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,6 @@
export { ThinkingIndicator } from './thinking-indicator';
export { NoProjectState, NoSessionState } from './empty-states';
export { MessageBubble } from './message-bubble';
export { MessageList } from './message-list';
export { AgentHeader } from './agent-header';
export { ChatArea } from './chat-area';

View File

@@ -0,0 +1,109 @@
import { Bot, User, ImageIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Markdown } from '@/components/ui/markdown';
import type { ImageAttachment } from '@/store/app-store';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: string;
images?: ImageAttachment[];
}
interface MessageBubbleProps {
message: Message;
}
export function MessageBubble({ message }: MessageBubbleProps) {
return (
<div
className={cn(
'flex gap-4 max-w-4xl',
message.role === 'user' ? 'flex-row-reverse ml-auto' : ''
)}
>
{/* Avatar */}
<div
className={cn(
'w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm',
message.role === 'assistant'
? 'bg-primary/10 ring-1 ring-primary/20'
: 'bg-muted ring-1 ring-border'
)}
>
{message.role === 'assistant' ? (
<Bot className="w-4 h-4 text-primary" />
) : (
<User className="w-4 h-4 text-muted-foreground" />
)}
</div>
{/* Message Bubble */}
<div
className={cn(
'flex-1 max-w-[85%] rounded-2xl px-4 py-3 shadow-sm',
message.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-card border border-border'
)}
>
{message.role === 'assistant' ? (
<Markdown className="text-sm text-foreground prose-p:leading-relaxed prose-headings:text-foreground prose-strong:text-foreground prose-code:text-primary prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded">
{message.content}
</Markdown>
) : (
<p className="text-sm whitespace-pre-wrap leading-relaxed">{message.content}</p>
)}
{/* Display attached images for user messages */}
{message.role === 'user' && message.images && message.images.length > 0 && (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-1.5 text-xs text-primary-foreground/80">
<ImageIcon className="w-3 h-3" />
<span>
{message.images.length} image
{message.images.length > 1 ? 's' : ''} attached
</span>
</div>
<div className="flex flex-wrap gap-2">
{message.images.map((image, index) => {
// Construct proper data URL from base64 data and mime type
const dataUrl = image.data.startsWith('data:')
? image.data
: `data:${image.mimeType || 'image/png'};base64,${image.data}`;
return (
<div
key={image.id || `img-${index}`}
className="relative group rounded-lg overflow-hidden border border-primary-foreground/20 bg-primary-foreground/10"
>
<img
src={dataUrl}
alt={image.filename || `Attached image ${index + 1}`}
className="w-20 h-20 object-cover hover:opacity-90 transition-opacity"
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/50 px-1.5 py-0.5 text-[9px] text-white truncate">
{image.filename || `Image ${index + 1}`}
</div>
</div>
);
})}
</div>
</div>
)}
<p
className={cn(
'text-[11px] mt-2 font-medium',
message.role === 'user' ? 'text-primary-foreground/70' : 'text-muted-foreground'
)}
>
{new Date(message.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import type { ImageAttachment } from '@/store/app-store';
import { MessageBubble } from './message-bubble';
import { ThinkingIndicator } from './thinking-indicator';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: string;
images?: ImageAttachment[];
}
interface MessageListProps {
messages: Message[];
isProcessing: boolean;
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
onScroll: () => void;
}
export function MessageList({
messages,
isProcessing,
messagesContainerRef,
onScroll,
}: MessageListProps) {
return (
<div
ref={messagesContainerRef}
className="flex-1 overflow-y-auto px-6 py-6 space-y-6 scroll-smooth"
data-testid="message-list"
onScroll={onScroll}
>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{/* Thinking Indicator */}
{isProcessing && <ThinkingIndicator />}
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { Bot } from 'lucide-react';
export function ThinkingIndicator() {
return (
<div className="flex gap-4 max-w-4xl">
<div className="w-9 h-9 rounded-xl bg-primary/10 ring-1 ring-primary/20 flex items-center justify-center shrink-0 shadow-sm">
<Bot className="w-4 h-4 text-primary" />
</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>
<span className="text-sm text-muted-foreground">Thinking...</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { useAgentScroll } from './use-agent-scroll';
export { useFileAttachments } from './use-file-attachments';
export { useAgentShortcuts } from './use-agent-shortcuts';
export { useAgentSession } from './use-agent-session';

View File

@@ -0,0 +1,78 @@
import { useRef, useState, useCallback, useEffect } from 'react';
interface UseAgentScrollOptions {
messagesLength: number;
currentSessionId: string | null;
}
interface UseAgentScrollResult {
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
isUserAtBottom: boolean;
handleScroll: () => void;
scrollToBottom: (behavior?: ScrollBehavior) => void;
}
export function useAgentScroll({
messagesLength,
currentSessionId,
}: UseAgentScrollOptions): UseAgentScrollResult {
const messagesContainerRef = useRef<HTMLDivElement>(null);
const [isUserAtBottom, setIsUserAtBottom] = useState(true);
// Scroll position detection
const checkIfUserIsAtBottom = useCallback(() => {
const container = messagesContainerRef.current;
if (!container) return;
const threshold = 50; // 50px threshold for "near bottom"
const isAtBottom =
container.scrollHeight - container.scrollTop - container.clientHeight <= threshold;
setIsUserAtBottom(isAtBottom);
}, []);
// Scroll to bottom function
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
const container = messagesContainerRef.current;
if (!container) return;
container.scrollTo({
top: container.scrollHeight,
behavior: behavior,
});
}, []);
// Handle scroll events
const handleScroll = useCallback(() => {
checkIfUserIsAtBottom();
}, [checkIfUserIsAtBottom]);
// Auto-scroll effect when messages change
useEffect(() => {
// Only auto-scroll if user was already at bottom
if (isUserAtBottom && messagesLength > 0) {
// Use a small delay to ensure DOM is updated
setTimeout(() => {
scrollToBottom('smooth');
}, 100);
}
}, [messagesLength, isUserAtBottom, scrollToBottom]);
// Initial scroll to bottom when session changes
useEffect(() => {
if (currentSessionId && messagesLength > 0) {
// Scroll immediately without animation when switching sessions
setTimeout(() => {
scrollToBottom('auto');
setIsUserAtBottom(true);
}, 100);
}
}, [currentSessionId, scrollToBottom, messagesLength]);
return {
messagesContainerRef,
isUserAtBottom,
handleScroll,
scrollToBottom,
};
}

View File

@@ -0,0 +1,64 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
const logger = createLogger('AgentSession');
interface UseAgentSessionOptions {
projectPath: string | undefined;
}
interface UseAgentSessionResult {
currentSessionId: string | null;
handleSelectSession: (sessionId: string | null) => void;
}
export function useAgentSession({ projectPath }: UseAgentSessionOptions): UseAgentSessionResult {
const { setLastSelectedSession, getLastSelectedSession } = useAppStore();
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
// Track if initial session has been loaded
const initialSessionLoadedRef = useRef(false);
// Handle session selection with persistence
const handleSelectSession = useCallback(
(sessionId: string | null) => {
setCurrentSessionId(sessionId);
// Persist the selection for this project
if (projectPath) {
setLastSelectedSession(projectPath, sessionId);
}
},
[projectPath, setLastSelectedSession]
);
// Restore last selected session when switching to Agent view or when project changes
useEffect(() => {
if (!projectPath) {
// No project, reset
setCurrentSessionId(null);
initialSessionLoadedRef.current = false;
return;
}
// Only restore once per project
if (initialSessionLoadedRef.current) return;
initialSessionLoadedRef.current = true;
const lastSessionId = getLastSelectedSession(projectPath);
if (lastSessionId) {
logger.info('Restoring last selected session:', lastSessionId);
setCurrentSessionId(lastSessionId);
}
}, [projectPath, getLastSelectedSession]);
// Reset initialSessionLoadedRef when project changes
useEffect(() => {
initialSessionLoadedRef.current = false;
}, [projectPath]);
return {
currentSessionId,
handleSelectSession,
};
}

View File

@@ -0,0 +1,41 @@
import { useMemo } from 'react';
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
type KeyboardShortcut,
} from '@/hooks/use-keyboard-shortcuts';
interface UseAgentShortcutsOptions {
currentProject: { path: string; name: string } | null;
quickCreateSessionRef: React.MutableRefObject<(() => Promise<void>) | null>;
}
export function useAgentShortcuts({
currentProject,
quickCreateSessionRef,
}: UseAgentShortcutsOptions): void {
const shortcuts = useKeyboardShortcutsConfig();
// Keyboard shortcuts for agent view
const agentShortcuts: KeyboardShortcut[] = useMemo(() => {
const shortcutsList: KeyboardShortcut[] = [];
// New session shortcut - only when in agent view with a project
if (currentProject) {
shortcutsList.push({
key: shortcuts.newSession,
action: () => {
if (quickCreateSessionRef.current) {
quickCreateSessionRef.current();
}
},
description: 'Create new session',
});
}
return shortcutsList;
}, [currentProject, shortcuts, quickCreateSessionRef]);
// Register keyboard shortcuts
useKeyboardShortcuts(agentShortcuts);
}

View File

@@ -0,0 +1,291 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
const logger = createLogger('FileAttachments');
import {
fileToBase64,
generateImageId,
generateFileId,
validateImageFile,
validateTextFile,
isTextFile,
isImageFile,
fileToText,
getTextFileMimeType,
DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_FILES,
} from '@/lib/image-utils';
interface UseFileAttachmentsOptions {
isProcessing: boolean;
isConnected: boolean;
}
interface UseFileAttachmentsResult {
selectedImages: ImageAttachment[];
selectedTextFiles: TextFileAttachment[];
showImageDropZone: boolean;
isDragOver: boolean;
handleImagesSelected: (images: ImageAttachment[]) => void;
toggleImageDropZone: () => void;
processDroppedFiles: (files: FileList) => Promise<void>;
removeImage: (imageId: string) => void;
removeTextFile: (fileId: string) => void;
handleDragEnter: (e: React.DragEvent) => void;
handleDragLeave: (e: React.DragEvent) => void;
handleDragOver: (e: React.DragEvent) => void;
handleDrop: (e: React.DragEvent) => Promise<void>;
handlePaste: (e: React.ClipboardEvent) => Promise<void>;
clearAllFiles: () => void;
setSelectedImages: React.Dispatch<React.SetStateAction<ImageAttachment[]>>;
setSelectedTextFiles: React.Dispatch<React.SetStateAction<TextFileAttachment[]>>;
setShowImageDropZone: React.Dispatch<React.SetStateAction<boolean>>;
}
export function useFileAttachments({
isProcessing,
isConnected,
}: UseFileAttachmentsOptions): UseFileAttachmentsResult {
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
const [selectedTextFiles, setSelectedTextFiles] = useState<TextFileAttachment[]>([]);
const [showImageDropZone, setShowImageDropZone] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const handleImagesSelected = useCallback((images: ImageAttachment[]) => {
setSelectedImages(images);
}, []);
const toggleImageDropZone = useCallback(() => {
setShowImageDropZone((prev) => !prev);
}, []);
// Process dropped files (images and text files)
const processDroppedFiles = useCallback(
async (files: FileList) => {
if (isProcessing) return;
const newImages: ImageAttachment[] = [];
const newTextFiles: TextFileAttachment[] = [];
const errors: string[] = [];
for (const file of Array.from(files)) {
// Check if it's a text file
if (isTextFile(file)) {
const validation = validateTextFile(file);
if (!validation.isValid) {
errors.push(validation.error!);
continue;
}
// Check if we've reached max files
const totalFiles =
newImages.length +
selectedImages.length +
newTextFiles.length +
selectedTextFiles.length;
if (totalFiles >= DEFAULT_MAX_FILES) {
errors.push(`Maximum ${DEFAULT_MAX_FILES} files allowed.`);
break;
}
try {
const content = await fileToText(file);
const textFileAttachment: TextFileAttachment = {
id: generateFileId(),
content,
mimeType: getTextFileMimeType(file.name),
filename: file.name,
size: file.size,
};
newTextFiles.push(textFileAttachment);
} catch {
errors.push(`${file.name}: Failed to read text file.`);
}
}
// Check if it's an image file
else if (isImageFile(file)) {
const validation = validateImageFile(file, DEFAULT_MAX_FILE_SIZE);
if (!validation.isValid) {
errors.push(validation.error!);
continue;
}
// Check if we've reached max files
const totalFiles =
newImages.length +
selectedImages.length +
newTextFiles.length +
selectedTextFiles.length;
if (totalFiles >= DEFAULT_MAX_FILES) {
errors.push(`Maximum ${DEFAULT_MAX_FILES} files allowed.`);
break;
}
try {
const base64 = await fileToBase64(file);
const imageAttachment: ImageAttachment = {
id: generateImageId(),
data: base64,
mimeType: file.type,
filename: file.name,
size: file.size,
};
newImages.push(imageAttachment);
} catch {
errors.push(`${file.name}: Failed to process image.`);
}
} else {
errors.push(`${file.name}: Unsupported file type. Use images, .txt, or .md files.`);
}
}
if (errors.length > 0) {
logger.warn('File upload errors:', errors);
}
if (newImages.length > 0) {
setSelectedImages((prev) => [...prev, ...newImages]);
}
if (newTextFiles.length > 0) {
setSelectedTextFiles((prev) => [...prev, ...newTextFiles]);
}
},
[isProcessing, selectedImages, selectedTextFiles]
);
// Remove individual image
const removeImage = useCallback((imageId: string) => {
setSelectedImages((prev) => prev.filter((img) => img.id !== imageId));
}, []);
// Remove individual text file
const removeTextFile = useCallback((fileId: string) => {
setSelectedTextFiles((prev) => prev.filter((file) => file.id !== fileId));
}, []);
// Clear all files
const clearAllFiles = useCallback(() => {
setSelectedImages([]);
setSelectedTextFiles([]);
}, []);
// Drag and drop handlers
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (isProcessing || !isConnected) return;
// Check if dragged items contain files
if (e.dataTransfer.types.includes('Files')) {
setIsDragOver(true);
}
},
[isProcessing, isConnected]
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Only set dragOver to false if we're leaving the input container
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
setIsDragOver(false);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (isProcessing || !isConnected) return;
// Check if we have files
const files = e.dataTransfer.files;
if (files && files.length > 0) {
processDroppedFiles(files);
return;
}
// Handle file paths (from screenshots or other sources)
const items = e.dataTransfer.items;
if (items && items.length > 0) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
processDroppedFiles(dataTransfer.files);
}
}
}
}
},
[isProcessing, isConnected, processDroppedFiles]
);
const handlePaste = useCallback(
async (e: React.ClipboardEvent) => {
// Check if clipboard contains files
const items = e.clipboardData?.items;
if (items) {
const files: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const file = item.getAsFile();
if (file && file.type.startsWith('image/')) {
e.preventDefault(); // Prevent default paste of file path
files.push(file);
}
}
}
if (files.length > 0) {
const dataTransfer = new DataTransfer();
files.forEach((file) => dataTransfer.items.add(file));
await processDroppedFiles(dataTransfer.files);
}
}
},
[processDroppedFiles]
);
return {
selectedImages,
selectedTextFiles,
showImageDropZone,
isDragOver,
handleImagesSelected,
toggleImageDropZone,
processDroppedFiles,
removeImage,
removeTextFile,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
handlePaste,
clearAllFiles,
setSelectedImages,
setSelectedTextFiles,
setShowImageDropZone,
};
}

View File

@@ -0,0 +1,133 @@
import { ImageDropZone } from '@/components/ui/image-drop-zone';
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
import type { PhaseModelEntry } from '@automaker/types';
import { FilePreview } from './file-preview';
import { QueueDisplay } from './queue-display';
import { InputControls } from './input-controls';
interface QueueItem {
id: string;
message: string;
imagePaths?: string[];
}
interface AgentInputAreaProps {
input: string;
onInputChange: (value: string) => void;
onSend: () => void;
onStop: () => void;
/** Current model selection (model + optional thinking level) */
modelSelection: PhaseModelEntry;
/** Callback when model is selected */
onModelSelect: (entry: PhaseModelEntry) => void;
isProcessing: boolean;
isConnected: boolean;
// File attachments
selectedImages: ImageAttachment[];
selectedTextFiles: TextFileAttachment[];
showImageDropZone: boolean;
isDragOver: boolean;
onImagesSelected: (images: ImageAttachment[]) => void;
onToggleImageDropZone: () => void;
onRemoveImage: (imageId: string) => void;
onRemoveTextFile: (fileId: string) => void;
onClearAllFiles: () => void;
onDragEnter: (e: React.DragEvent) => void;
onDragLeave: (e: React.DragEvent) => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => Promise<void>;
onPaste: (e: React.ClipboardEvent) => Promise<void>;
// Queue
serverQueue: QueueItem[];
onRemoveFromQueue: (id: string) => void;
onClearQueue: () => void;
// Refs
inputRef?: React.RefObject<HTMLTextAreaElement | null>;
}
export function AgentInputArea({
input,
onInputChange,
onSend,
onStop,
modelSelection,
onModelSelect,
isProcessing,
isConnected,
selectedImages,
selectedTextFiles,
showImageDropZone,
isDragOver,
onImagesSelected,
onToggleImageDropZone,
onRemoveImage,
onRemoveTextFile,
onClearAllFiles,
onDragEnter,
onDragLeave,
onDragOver,
onDrop,
onPaste,
serverQueue,
onRemoveFromQueue,
onClearQueue,
inputRef,
}: AgentInputAreaProps) {
const hasFiles = selectedImages.length > 0 || selectedTextFiles.length > 0;
return (
<div className="border-t border-border p-4 bg-card/50 backdrop-blur-sm">
{/* Image Drop Zone (when visible) */}
{showImageDropZone && (
<ImageDropZone
onImagesSelected={onImagesSelected}
images={selectedImages}
maxFiles={5}
className="mb-4"
disabled={!isConnected}
/>
)}
{/* Queued Prompts List */}
<QueueDisplay
serverQueue={serverQueue}
onRemoveFromQueue={onRemoveFromQueue}
onClearQueue={onClearQueue}
/>
{/* Selected Files Preview - only show when ImageDropZone is hidden */}
{!showImageDropZone && (
<FilePreview
selectedImages={selectedImages}
selectedTextFiles={selectedTextFiles}
isProcessing={isProcessing}
onRemoveImage={onRemoveImage}
onRemoveTextFile={onRemoveTextFile}
onClearAll={onClearAllFiles}
/>
)}
{/* Input Controls */}
<InputControls
input={input}
onInputChange={onInputChange}
onSend={onSend}
onStop={onStop}
onToggleImageDropZone={onToggleImageDropZone}
onPaste={onPaste}
modelSelection={modelSelection}
onModelSelect={onModelSelect}
isProcessing={isProcessing}
isConnected={isConnected}
hasFiles={hasFiles}
isDragOver={isDragOver}
showImageDropZone={showImageDropZone}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
inputRef={inputRef}
/>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { X, FileText } from 'lucide-react';
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
import { formatFileSize } from '@/lib/image-utils';
interface FilePreviewProps {
selectedImages: ImageAttachment[];
selectedTextFiles: TextFileAttachment[];
isProcessing: boolean;
onRemoveImage: (imageId: string) => void;
onRemoveTextFile: (fileId: string) => void;
onClearAll: () => void;
}
export function FilePreview({
selectedImages,
selectedTextFiles,
isProcessing,
onRemoveImage,
onRemoveTextFile,
onClearAll,
}: FilePreviewProps) {
const totalFiles = selectedImages.length + selectedTextFiles.length;
if (totalFiles === 0) {
return null;
}
return (
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{totalFiles} file{totalFiles > 1 ? 's' : ''} attached
</p>
<button
onClick={onClearAll}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear all
</button>
</div>
<div className="flex flex-wrap gap-2">
{/* Image attachments */}
{selectedImages.map((image) => (
<div
key={image.id}
className="group relative rounded-lg border border-border bg-muted/30 p-2 flex items-center gap-2 hover:border-primary/30 transition-colors"
>
{/* Image thumbnail */}
<div className="w-8 h-8 rounded-md overflow-hidden bg-muted flex-shrink-0">
<img src={image.data} alt={image.filename} className="w-full h-full object-cover" />
</div>
{/* Image info */}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate max-w-24">
{image.filename}
</p>
{image.size !== undefined && (
<p className="text-[10px] text-muted-foreground">{formatFileSize(image.size)}</p>
)}
</div>
{/* Remove button */}
{image.id && (
<button
onClick={() => onRemoveImage(image.id!)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
disabled={isProcessing}
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
{/* Text file attachments */}
{selectedTextFiles.map((file) => (
<div
key={file.id}
className="group relative rounded-lg border border-border bg-muted/30 p-2 flex items-center gap-2 hover:border-primary/30 transition-colors"
>
{/* File icon */}
<div className="w-8 h-8 rounded-md bg-muted flex-shrink-0 flex items-center justify-center">
<FileText className="w-4 h-4 text-muted-foreground" />
</div>
{/* File info */}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate max-w-24">
{file.filename}
</p>
<p className="text-[10px] text-muted-foreground">{formatFileSize(file.size)}</p>
</div>
{/* Remove button */}
<button
onClick={() => onRemoveTextFile(file.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
disabled={isProcessing}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { AgentInputArea } from './agent-input-area';
export { FilePreview } from './file-preview';
export { QueueDisplay } from './queue-display';
export { InputControls } from './input-controls';

View File

@@ -0,0 +1,186 @@
import { useRef, useCallback, useEffect } from 'react';
import { Send, Paperclip, Square, ListOrdered } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { AgentModelSelector } from '../shared/agent-model-selector';
import type { PhaseModelEntry } from '@automaker/types';
interface InputControlsProps {
input: string;
onInputChange: (value: string) => void;
onSend: () => void;
onStop: () => void;
onToggleImageDropZone: () => void;
onPaste: (e: React.ClipboardEvent) => Promise<void>;
/** Current model selection (model + optional thinking level) */
modelSelection: PhaseModelEntry;
/** Callback when model is selected */
onModelSelect: (entry: PhaseModelEntry) => void;
isProcessing: boolean;
isConnected: boolean;
hasFiles: boolean;
isDragOver: boolean;
showImageDropZone: boolean;
// Drag handlers
onDragEnter: (e: React.DragEvent) => void;
onDragLeave: (e: React.DragEvent) => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => Promise<void>;
// Refs
inputRef?: React.RefObject<HTMLTextAreaElement | null>;
}
export function InputControls({
input,
onInputChange,
onSend,
onStop,
onToggleImageDropZone,
onPaste,
modelSelection,
onModelSelect,
isProcessing,
isConnected,
hasFiles,
isDragOver,
showImageDropZone,
onDragEnter,
onDragLeave,
onDragOver,
onDrop,
inputRef: externalInputRef,
}: InputControlsProps) {
const internalInputRef = useRef<HTMLTextAreaElement>(null);
const inputRef = externalInputRef || internalInputRef;
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSend();
}
};
const adjustTextareaHeight = useCallback(() => {
const textarea = inputRef.current;
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
}, [inputRef]);
useEffect(() => {
adjustTextareaHeight();
}, [input, adjustTextareaHeight]);
const canSend = (input.trim() || hasFiles) && isConnected;
return (
<>
{/* Text Input and Controls */}
<div
className={cn(
'flex gap-2 transition-all duration-200 rounded-xl p-1',
isDragOver && 'bg-primary/5 ring-2 ring-primary/30'
)}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
>
<div className="flex-1 relative">
<Textarea
ref={inputRef}
placeholder={
isDragOver
? 'Drop your files here...'
: isProcessing
? 'Type to queue another prompt...'
: 'Describe what you want to build...'
}
value={input}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={onPaste}
disabled={!isConnected}
data-testid="agent-input"
rows={1}
className={cn(
'min-h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all resize-none max-h-36 overflow-y-auto py-2.5',
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
hasFiles && 'border-primary/30',
isDragOver && 'border-primary bg-primary/5'
)}
/>
{hasFiles && !isDragOver && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
files attached
</div>
)}
{isDragOver && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-xs text-primary font-medium">
<Paperclip className="w-3 h-3" />
Drop here
</div>
)}
</div>
{/* Model Selector */}
<AgentModelSelector
value={modelSelection}
onChange={onModelSelect}
disabled={!isConnected}
/>
{/* File Attachment Button */}
<Button
variant="outline"
size="icon"
onClick={onToggleImageDropZone}
disabled={!isConnected}
className={cn(
'h-11 w-11 rounded-xl border-border',
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
hasFiles && 'border-primary/30 text-primary'
)}
title="Attach files (images, .txt, .md)"
>
<Paperclip className="w-4 h-4" />
</Button>
{/* Stop Button (only when processing) */}
{isProcessing && (
<Button
onClick={onStop}
disabled={!isConnected}
className="h-11 px-4 rounded-xl"
variant="destructive"
data-testid="stop-agent"
title="Stop generation"
>
<Square className="w-4 h-4 fill-current" />
</Button>
)}
{/* Send / Queue Button */}
<Button
onClick={onSend}
disabled={!canSend}
className="h-11 px-4 rounded-xl"
variant={isProcessing ? 'outline' : 'default'}
data-testid="send-message"
title={isProcessing ? 'Add to queue' : 'Send message'}
>
{isProcessing ? <ListOrdered className="w-4 h-4" /> : <Send className="w-4 h-4" />}
</Button>
</div>
{/* Keyboard hint */}
<p className="text-[11px] text-muted-foreground mt-2 text-center">
Press <kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to
send,{' '}
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Shift+Enter</kbd>{' '}
for new line
</p>
</>
);
}

View File

@@ -0,0 +1,60 @@
import { X } from 'lucide-react';
interface QueueItem {
id: string;
message: string;
imagePaths?: string[];
}
interface QueueDisplayProps {
serverQueue: QueueItem[];
onRemoveFromQueue: (id: string) => void;
onClearQueue: () => void;
}
export function QueueDisplay({ serverQueue, onRemoveFromQueue, onClearQueue }: QueueDisplayProps) {
if (serverQueue.length === 0) {
return null;
}
return (
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">
{serverQueue.length} prompt{serverQueue.length > 1 ? 's' : ''} queued
</p>
<button
onClick={onClearQueue}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear all
</button>
</div>
<div className="space-y-1.5">
{serverQueue.map((item, index) => (
<div
key={item.id}
className="group flex items-center gap-2 text-sm bg-muted/50 rounded-lg px-3 py-2 border border-border"
>
<span className="text-xs text-muted-foreground font-medium min-w-[1.5rem]">
{index + 1}.
</span>
<span className="flex-1 truncate text-foreground">{item.message}</span>
{item.imagePaths && item.imagePaths.length > 0 && (
<span className="text-xs text-muted-foreground">
+{item.imagePaths.length} file{item.imagePaths.length > 1 ? 's' : ''}
</span>
)}
<button
onClick={() => onRemoveFromQueue(item.id)}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-destructive/10 hover:text-destructive rounded transition-all"
title="Remove from queue"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
/**
* Re-export PhaseModelSelector in compact mode for use in agent chat view.
* This ensures we have a single source of truth for model selection logic.
*/
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import type { PhaseModelEntry } from '@automaker/types';
// Re-export types for convenience
export type { PhaseModelEntry };
interface AgentModelSelectorProps {
/** Current model selection (model + optional thinking level) */
value: PhaseModelEntry;
/** Callback when model is selected */
onChange: (entry: PhaseModelEntry) => void;
/** Disabled state */
disabled?: boolean;
}
export function AgentModelSelector({ value, onChange, disabled }: AgentModelSelectorProps) {
return (
<PhaseModelSelector value={value} onChange={onChange} disabled={disabled} compact align="end" />
);
}

View File

@@ -0,0 +1,9 @@
// Agent view constants
export const WELCOME_MESSAGE = {
id: 'welcome',
role: 'assistant' as const,
content:
"Hello! I'm the Automaker Agent. I can help you build software autonomously. I can read and modify files in this project, run commands, and execute tests. What would you like to create today?",
timestamp: new Date().toISOString(),
};

View File

@@ -0,0 +1,2 @@
export { AgentModelSelector } from './agent-model-selector';
export * from './constants';

View File

@@ -1,4 +1,5 @@
import { useCallback, useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore, FileTreeNode, ProjectAnalysis } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -21,6 +22,8 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
const logger = createLogger('AnalysisView');
const IGNORE_PATTERNS = [
'node_modules',
'.git',
@@ -109,7 +112,7 @@ export function AnalysisView() {
return nodes;
} catch (error) {
console.error('Failed to scan directory:', path, error);
logger.error('Failed to scan directory:', path, error);
return [];
}
},
@@ -165,7 +168,7 @@ export function AnalysisView() {
setProjectAnalysis(analysis);
} catch (error) {
console.error('Analysis failed:', error);
logger.error('Analysis failed:', error);
} finally {
setIsAnalyzing(false);
}
@@ -373,7 +376,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
setSpecError(writeResult.error || 'Failed to write spec file');
}
} catch (error) {
console.error('Failed to generate spec:', error);
logger.error('Failed to generate spec:', error);
setSpecError(error instanceof Error ? error.message : 'Failed to generate spec');
} finally {
setIsGeneratingSpec(false);
@@ -644,7 +647,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
setFeatureListGenerated(true);
} catch (error) {
console.error('Failed to generate feature list:', error);
logger.error('Failed to generate feature list:', error);
setFeatureListError(
error instanceof Error ? error.message : 'Failed to generate feature list'
);

View File

@@ -1,4 +1,5 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
PointerSensor,
useSensor,
@@ -10,7 +11,7 @@ import { useAppStore, Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { AutoModeEvent } from '@/types/electron';
import type { BacklogPlanResult } from '@automaker/types';
import type { ModelAlias, CursorModelId, BacklogPlanResult } from '@automaker/types';
import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
@@ -33,7 +34,6 @@ import {
ArchiveAllVerifiedDialog,
DeleteCompletedFeatureDialog,
EditFeatureDialog,
FeatureSuggestionsDialog,
FollowUpDialog,
PlanApprovalDialog,
} from './board-view/dialogs';
@@ -56,12 +56,13 @@ import {
useBoardBackground,
useBoardPersistence,
useFollowUpState,
useSuggestionsState,
} from './board-view/hooks';
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
const logger = createLogger('Board');
export function BoardView() {
const {
currentProject,
@@ -153,19 +154,6 @@ export function BoardView() {
handleFollowUpDialogChange,
} = useFollowUpState();
// Suggestions state hook
const {
showSuggestionsDialog,
suggestionsCount,
featureSuggestions,
isGeneratingSuggestions,
setShowSuggestionsDialog,
setSuggestionsCount,
setFeatureSuggestions,
setIsGeneratingSuggestions,
updateSuggestions,
closeSuggestionsDialog,
} = useSuggestionsState();
// Search filter for Kanban cards
const [searchQuery, setSearchQuery] = useState('');
// Plan approval loading state
@@ -188,7 +176,7 @@ export function BoardView() {
return result.success && result.exists === true;
} catch (error) {
console.error('[Board] Error checking context:', error);
logger.error('Error checking context:', error);
return false;
}
},
@@ -200,9 +188,6 @@ export function BoardView() {
currentProject,
specCreatingForProject,
setSpecCreatingForProject,
setSuggestionsCount,
setFeatureSuggestions,
setIsGeneratingSuggestions,
checkContextExists,
features: hookFeatures,
isLoading,
@@ -222,7 +207,7 @@ export function BoardView() {
setPipelineConfig(currentProject.path, result.config);
}
} catch (error) {
console.error('[Board] Failed to load pipeline config:', error);
logger.error('Failed to load pipeline config:', error);
}
};
@@ -288,7 +273,7 @@ export function BoardView() {
setBranchSuggestions(localBranches);
}
} catch (error) {
console.error('[BoardView] Error fetching branches:', error);
logger.error('Error fetching branches:', error);
setBranchSuggestions([]);
}
};
@@ -497,7 +482,7 @@ export function BoardView() {
if (newFeature) {
await handleStartImplementation(newFeature);
} else {
console.error('Could not find newly created feature to start it automatically.');
logger.error('Could not find newly created feature to start it automatically.');
toast.error('Failed to auto-start feature', {
description: 'The feature was created but could not be started automatically.',
});
@@ -538,7 +523,7 @@ export function BoardView() {
if (newFeature) {
await handleStartImplementation(newFeature);
} else {
console.error('Could not find newly created feature to start it automatically.');
logger.error('Could not find newly created feature to start it automatically.');
toast.error('Failed to auto-start feature', {
description: 'The feature was created but could not be started automatically.',
});
@@ -561,7 +546,7 @@ export function BoardView() {
if (newFeature) {
await handleStartImplementation(newFeature);
} else {
console.error('Could not find newly created feature to start it automatically.');
logger.error('Could not find newly created feature to start it automatically.');
toast.error('Failed to auto-start feature', {
description: 'The feature was created but could not be started automatically.',
});
@@ -889,10 +874,10 @@ export function BoardView() {
// Reload features from server to ensure sync
loadFeatures();
} else {
console.error('[Board] Failed to approve plan:', result.error);
logger.error('Failed to approve plan:', result.error);
}
} catch (error) {
console.error('[Board] Error approving plan:', error);
logger.error('Error approving plan:', error);
} finally {
setIsPlanApprovalLoading(false);
setPendingPlanApproval(null);
@@ -945,10 +930,10 @@ export function BoardView() {
// Reload features from server to ensure sync
loadFeatures();
} else {
console.error('[Board] Failed to reject plan:', result.error);
logger.error('Failed to reject plan:', result.error);
}
} catch (error) {
console.error('[Board] Error rejecting plan:', error);
logger.error('Error rejecting plan:', error);
} finally {
setIsPlanApprovalLoading(false);
setPendingPlanApproval(null);
@@ -1119,8 +1104,6 @@ export function BoardView() {
runningAutoTasks={runningAutoTasks}
shortcuts={shortcuts}
onStartNextFeatures={handleStartNextFeatures}
onShowSuggestions={() => setShowSuggestionsDialog(true)}
suggestionsCount={suggestionsCount}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
pipelineConfig={
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
@@ -1269,17 +1252,6 @@ export function BoardView() {
isMaximized={isMaximized}
/>
{/* Feature Suggestions Dialog */}
<FeatureSuggestionsDialog
open={showSuggestionsDialog}
onClose={closeSuggestionsDialog}
projectPath={currentProject.path}
suggestions={featureSuggestions}
setSuggestions={updateSuggestions}
isGenerating={isGeneratingSuggestions}
setIsGenerating={setIsGeneratingSuggestions}
/>
{/* Backlog Plan Dialog */}
<BacklogPlanDialog
open={showPlanDialog}
@@ -1407,7 +1379,7 @@ export function BoardView() {
// Persist changes asynchronously and in parallel
Promise.all(
featuresToUpdate.map((feature) => persistFeatureUpdate(feature.id, { prUrl }))
).catch(console.error);
).catch((err) => logger.error('Error in handleMove:', err));
}
setWorktreeRefreshKey((k) => k + 1);
setSelectedWorktreeForAction(null);

View File

@@ -22,6 +22,10 @@ interface BoardHeaderProps {
isMounted: boolean;
}
// Shared styles for header control containers
const controlContainerClass =
'flex items-center gap-1.5 px-3 h-8 rounded-md bg-secondary border border-border';
export function BoardHeader({
projectName,
maxConcurrency,
@@ -60,10 +64,7 @@ export function BoardHeader({
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
{isMounted && (
<div
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border"
data-testid="concurrency-slider-container"
>
<div className={controlContainerClass} data-testid="concurrency-slider-container">
<Bot className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Agents</span>
<Slider
@@ -86,7 +87,7 @@ export function BoardHeader({
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
{isMounted && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border">
<div className={controlContainerClass} data-testid="auto-mode-toggle-container">
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
Auto Mode
</Label>

View File

@@ -255,6 +255,45 @@ export function AgentInfoPanel({
);
}
// Show just the todo list for non-backlog features when showAgentInfo is false
// This ensures users always see what the agent is working on
if (!showAgentInfo && feature.status !== 'backlog' && agentInfo && agentInfo.todos.length > 0) {
return (
<div className="mb-3 space-y-1 overflow-hidden">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
<ListTodo className="w-3 h-3" />
<span>
{agentInfo.todos.filter((t) => t.status === 'completed').length}/
{agentInfo.todos.length} tasks
</span>
</div>
<div className="space-y-0.5 max-h-24 overflow-y-auto">
{agentInfo.todos.map((todo, idx) => (
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
{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" />
) : (
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
)}
<span
className={cn(
'break-words hyphens-auto line-clamp-2 leading-relaxed',
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
todo.status === 'pending' && 'text-muted-foreground/80'
)}
>
{todo.content}
</span>
</div>
))}
</div>
</div>
);
}
// Always render SummaryDialog if showAgentInfo is true (even if no agentInfo yet)
// This ensures the dialog can be opened from the expand button
return (

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
DialogContent,
@@ -32,7 +33,7 @@ import { getElectronAPI } from '@/lib/electron';
import { modelSupportsThinking } from '@/lib/utils';
import {
useAppStore,
AgentModel,
ModelAlias,
ThinkingLevel,
FeatureImage,
AIProfile,
@@ -49,6 +50,7 @@ import {
PlanningModeSelector,
AncestorContextSection,
} from '../shared';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import {
DropdownMenu,
DropdownMenuContent,
@@ -61,6 +63,9 @@ import {
formatAncestorContextForPrompt,
type AncestorContext,
} from '@automaker/dependency-resolver';
import { isCursorModel, PROVIDER_PREFIXES } from '@automaker/types';
const logger = createLogger('AddFeatureDialog');
type FeatureData = {
title: string;
@@ -126,7 +131,7 @@ export function AddFeatureDialog({
imagePaths: [] as DescriptionImagePath[],
textFilePaths: [] as DescriptionTextFilePath[],
skipTests: false,
model: 'opus' as AgentModel,
model: 'opus' as ModelAlias,
thinkingLevel: 'none' as ThinkingLevel,
branchName: '',
priority: 2 as number, // Default to medium priority
@@ -147,14 +152,12 @@ export function AddFeatureDialog({
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
// Get enhancement model, planning mode defaults, and worktrees setting from store
const {
enhancementModel,
defaultPlanningMode,
defaultRequirePlanApproval,
defaultAIProfileId,
useWorktrees,
} = useAppStore();
// Get planning mode defaults and worktrees setting from store
const { defaultPlanningMode, defaultRequirePlanApproval, defaultAIProfileId, useWorktrees } =
useAppStore();
// Enhancement model override
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
// Sync defaults when dialog opens
useEffect(() => {
@@ -320,7 +323,8 @@ export function AddFeatureDialog({
const result = await api.enhancePrompt?.enhance(
newFeature.description,
enhancementMode,
enhancementModel
enhancementOverride.effectiveModel, // API accepts string, extract from PhaseModelEntry
enhancementOverride.effectiveModelEntry.thinkingLevel // Pass thinking level
);
if (result?.success && result.enhancedText) {
@@ -331,30 +335,51 @@ export function AddFeatureDialog({
toast.error(result?.error || 'Failed to enhance description');
}
} catch (error) {
console.error('Enhancement failed:', error);
logger.error('Enhancement failed:', error);
toast.error('Failed to enhance description');
} finally {
setIsEnhancing(false);
}
};
const handleModelSelect = (model: AgentModel) => {
const handleModelSelect = (model: string) => {
// For Cursor models, thinking is handled by the model itself
// For Claude models, check if it supports extended thinking
const isCursor = isCursorModel(model);
setNewFeature({
...newFeature,
model,
thinkingLevel: modelSupportsThinking(model) ? newFeature.thinkingLevel : 'none',
model: model as ModelAlias,
thinkingLevel: isCursor
? 'none'
: modelSupportsThinking(model)
? newFeature.thinkingLevel
: 'none',
});
};
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
setNewFeature({
...newFeature,
model,
thinkingLevel,
});
const handleProfileSelect = (profile: AIProfile) => {
if (profile.provider === 'cursor') {
// Cursor profile - set cursor model
const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
setNewFeature({
...newFeature,
model: cursorModel as ModelAlias,
thinkingLevel: 'none', // Cursor handles thinking internally
});
} else {
// Claude profile
setNewFeature({
...newFeature,
model: profile.model || 'sonnet',
thinkingLevel: profile.thinkingLevel || 'none',
});
}
};
const newModelAllowsThinking = modelSupportsThinking(newFeature.model);
// Cursor models handle thinking internally, so only show thinking selector for Claude models
const isCurrentModelCursor = isCursorModel(newFeature.model);
const newModelAllowsThinking =
!isCurrentModelCursor && modelSupportsThinking(newFeature.model || 'sonnet');
return (
<Dialog open={open} onOpenChange={handleDialogClose}>
@@ -486,6 +511,15 @@ export function AddFeatureDialog({
<Sparkles className="w-4 h-4 mr-2" />
Enhance with AI
</Button>
<ModelOverrideTrigger
currentModelEntry={enhancementOverride.effectiveModelEntry}
onModelChange={enhancementOverride.setOverride}
phase="enhancementModel"
isOverridden={enhancementOverride.isOverridden}
size="sm"
variant="icon"
/>
</div>
<div className="space-y-2">
<Label htmlFor="category">Category (optional)</Label>
@@ -546,6 +580,7 @@ export function AddFeatureDialog({
profiles={aiProfiles}
selectedModel={newFeature.model}
selectedThinkingLevel={newFeature.thinkingLevel}
selectedCursorModel={isCurrentModelCursor ? newFeature.model : undefined}
onSelect={handleProfileSelect}
showManageLink
onManageLinkClick={() => {

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, useMemo } from 'react';
import {
Dialog,
DialogContent,
@@ -6,12 +6,14 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Loader2, List, FileText, GitBranch } from 'lucide-react';
import { Loader2, List, FileText, GitBranch, ClipboardList } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { LogViewer } from '@/components/ui/log-viewer';
import { GitDiffPanel } from '@/components/ui/git-diff-panel';
import { TaskProgressPanel } from '@/components/ui/task-progress-panel';
import { Markdown } from '@/components/ui/markdown';
import { useAppStore } from '@/store/app-store';
import { extractSummary } from '@/lib/log-parser';
import type { AutoModeEvent } from '@/types/electron';
interface AgentOutputModalProps {
@@ -27,7 +29,7 @@ interface AgentOutputModalProps {
projectPath?: string;
}
type ViewMode = 'parsed' | 'raw' | 'changes';
type ViewMode = 'summary' | 'parsed' | 'raw' | 'changes';
export function AgentOutputModal({
open,
@@ -40,8 +42,14 @@ export function AgentOutputModal({
}: AgentOutputModalProps) {
const [output, setOutput] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>('parsed');
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
const [projectPath, setProjectPath] = useState<string>('');
// Extract summary from output
const summary = useMemo(() => extractSummary(output), [output]);
// Determine the effective view mode - default to summary if available, otherwise parsed
const effectiveViewMode = viewMode ?? (summary ? 'summary' : 'parsed');
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const projectPathRef = useRef<string>('');
@@ -299,8 +307,8 @@ export function AgentOutputModal({
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col"
data-testid="agent-output-modal"
>
<DialogHeader className="flex-shrink-0">
<div className="flex items-center justify-between">
<DialogHeader className="shrink-0">
<div className="flex items-center justify-between pr-8">
<DialogTitle className="flex items-center gap-2">
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
@@ -308,10 +316,24 @@ export function AgentOutputModal({
Agent Output
</DialogTitle>
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
{summary && (
<button
onClick={() => setViewMode('summary')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
effectiveViewMode === 'summary'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-summary"
>
<ClipboardList className="w-3.5 h-3.5" />
Summary
</button>
)}
<button
onClick={() => setViewMode('parsed')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === 'parsed'
effectiveViewMode === 'parsed'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
@@ -323,7 +345,7 @@ export function AgentOutputModal({
<button
onClick={() => setViewMode('changes')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === 'changes'
effectiveViewMode === 'changes'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
@@ -335,7 +357,7 @@ export function AgentOutputModal({
<button
onClick={() => setViewMode('raw')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === 'raw'
effectiveViewMode === 'raw'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
@@ -361,7 +383,7 @@ export function AgentOutputModal({
className="flex-shrink-0 mx-1"
/>
{viewMode === 'changes' ? (
{effectiveViewMode === 'changes' ? (
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
{projectPath ? (
<GitDiffPanel
@@ -378,6 +400,10 @@ export function AgentOutputModal({
</div>
)}
</div>
) : effectiveViewMode === 'summary' && summary ? (
<div className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 min-h-[400px] max-h-[60vh] scrollbar-visible">
<Markdown>{summary}</Markdown>
</div>
) : (
<>
<div
@@ -394,7 +420,7 @@ export function AgentOutputModal({
<div className="flex items-center justify-center h-full text-muted-foreground">
No output yet. The agent will stream output here as it works.
</div>
) : viewMode === 'parsed' ? (
) : effectiveViewMode === 'parsed' ? (
<LogViewer output={output} />
) : (
<div className="whitespace-pre-wrap break-words text-zinc-300">{output}</div>

View File

@@ -23,7 +23,35 @@ import {
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { BacklogPlanResult, BacklogChange } from '@automaker/types';
import type {
BacklogPlanResult,
BacklogChange,
ModelAlias,
CursorModelId,
PhaseModelEntry,
} from '@automaker/types';
import { ModelOverrideTrigger } from '@/components/shared/model-override-trigger';
import { useAppStore } from '@/store/app-store';
/**
* Normalize PhaseModelEntry or string to PhaseModelEntry
*/
function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
if (typeof entry === 'string') {
return { model: entry as ModelAlias | CursorModelId };
}
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;
@@ -53,6 +81,9 @@ export function BacklogPlanDialog({
const [prompt, setPrompt] = useState('');
const [expandedChanges, setExpandedChanges] = useState<Set<number>>(new Set());
const [selectedChanges, setSelectedChanges] = useState<Set<number>>(new Set());
const [modelOverride, setModelOverride] = useState<PhaseModelEntry | null>(null);
const { phaseModels } = useAppStore();
// Set mode based on whether we have a pending result
useEffect(() => {
@@ -83,7 +114,10 @@ export function BacklogPlanDialog({
// Start generation in background
setIsGeneratingPlan(true);
const result = await api.backlogPlan.generate(projectPath, prompt);
// Use model override if set, otherwise use global default (extract model string from PhaseModelEntry)
const effectiveModelEntry = modelOverride || normalizeEntry(phaseModels.backlogPlanningModel);
const effectiveModel = effectiveModelEntry.model;
const result = await api.backlogPlan.generate(projectPath, prompt, effectiveModel);
if (!result.success) {
setIsGeneratingPlan(false);
toast.error(result.error || 'Failed to start plan generation');
@@ -96,7 +130,7 @@ export function BacklogPlanDialog({
});
setPrompt('');
onClose();
}, [projectPath, prompt, setIsGeneratingPlan, onClose]);
}, [projectPath, prompt, modelOverride, phaseModels, setIsGeneratingPlan, onClose]);
const handleApply = useCallback(async () => {
if (!pendingPlanResult) return;
@@ -358,6 +392,10 @@ 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()}>
<DialogContent className="max-w-2xl">
@@ -378,6 +416,17 @@ export function BacklogPlanDialog({
<DialogFooter>
{mode === 'input' && (
<>
<div className="flex items-center gap-2 mr-auto">
<span className="text-xs text-muted-foreground">Model:</span>
<ModelOverrideTrigger
currentModelEntry={effectiveModelEntry}
onModelChange={setModelOverride}
phase="backlogPlanningModel"
size="sm"
variant="button"
isOverridden={modelOverride !== null}
/>
</div>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
DialogContent,
@@ -22,6 +23,8 @@ interface WorktreeInfo {
changedFilesCount?: number;
}
const logger = createLogger('CreateBranchDialog');
interface CreateBranchDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -77,7 +80,7 @@ export function CreateBranchDialog({
setError(result.error || 'Failed to create branch');
}
} catch (err) {
console.error('Create branch failed:', err);
logger.error('Create branch failed:', err);
setError('Failed to create branch');
} finally {
setIsCreating(false);

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
DialogContent,
@@ -32,7 +33,7 @@ import { getElectronAPI } from '@/lib/electron';
import { modelSupportsThinking } from '@/lib/utils';
import {
Feature,
AgentModel,
ModelAlias,
ThinkingLevel,
AIProfile,
useAppStore,
@@ -47,6 +48,7 @@ import {
BranchSelector,
PlanningModeSelector,
} from '../shared';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import {
DropdownMenu,
DropdownMenuContent,
@@ -54,6 +56,9 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { DependencyTreeDialog } from './dependency-tree-dialog';
import { isCursorModel, PROVIDER_PREFIXES } from '@automaker/types';
const logger = createLogger('EditFeatureDialog');
interface EditFeatureDialogProps {
feature: Feature | null;
@@ -65,7 +70,7 @@ interface EditFeatureDialogProps {
category: string;
description: string;
skipTests: boolean;
model: AgentModel;
model: ModelAlias;
thinkingLevel: ThinkingLevel;
imagePaths: DescriptionImagePath[];
textFilePaths: DescriptionTextFilePath[];
@@ -117,8 +122,11 @@ export function EditFeatureDialog({
feature?.requirePlanApproval ?? false
);
// Get enhancement model and worktrees setting from store
const { enhancementModel, useWorktrees } = useAppStore();
// Get worktrees setting from store
const { useWorktrees } = useAppStore();
// Enhancement model override
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
useEffect(() => {
setEditingFeature(feature);
@@ -148,7 +156,7 @@ export function EditFeatureDialog({
return;
}
const selectedModel = (editingFeature.model ?? 'opus') as AgentModel;
const selectedModel = (editingFeature.model ?? 'opus') as ModelAlias;
const normalizedThinking: ThinkingLevel = modelSupportsThinking(selectedModel)
? (editingFeature.thinkingLevel ?? 'none')
: 'none';
@@ -187,22 +195,40 @@ export function EditFeatureDialog({
}
};
const handleModelSelect = (model: AgentModel) => {
const handleModelSelect = (model: string) => {
if (!editingFeature) return;
// For Cursor models, thinking is handled by the model itself
// For Claude models, check if it supports extended thinking
const isCursor = isCursorModel(model);
setEditingFeature({
...editingFeature,
model,
thinkingLevel: modelSupportsThinking(model) ? editingFeature.thinkingLevel : 'none',
model: model as ModelAlias,
thinkingLevel: isCursor
? 'none'
: modelSupportsThinking(model)
? editingFeature.thinkingLevel
: 'none',
});
};
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
const handleProfileSelect = (profile: AIProfile) => {
if (!editingFeature) return;
setEditingFeature({
...editingFeature,
model,
thinkingLevel,
});
if (profile.provider === 'cursor') {
// Cursor profile - set cursor model
const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
setEditingFeature({
...editingFeature,
model: cursorModel as ModelAlias,
thinkingLevel: 'none', // Cursor handles thinking internally
});
} else {
// Claude profile
setEditingFeature({
...editingFeature,
model: profile.model || 'sonnet',
thinkingLevel: profile.thinkingLevel || 'none',
});
}
};
const handleEnhanceDescription = async () => {
@@ -214,7 +240,8 @@ export function EditFeatureDialog({
const result = await api.enhancePrompt?.enhance(
editingFeature.description,
enhancementMode,
enhancementModel
enhancementOverride.effectiveModel, // API accepts string, extract from PhaseModelEntry
enhancementOverride.effectiveModelEntry.thinkingLevel // Pass thinking level
);
if (result?.success && result.enhancedText) {
@@ -225,14 +252,17 @@ export function EditFeatureDialog({
toast.error(result?.error || 'Failed to enhance description');
}
} catch (error) {
console.error('Enhancement failed:', error);
logger.error('Enhancement failed:', error);
toast.error('Failed to enhance description');
} finally {
setIsEnhancing(false);
}
};
const editModelAllowsThinking = modelSupportsThinking(editingFeature?.model);
// Cursor models handle thinking internally, so only show thinking selector for Claude models
const isCurrentModelCursor = isCursorModel(editingFeature?.model as string);
const editModelAllowsThinking =
!isCurrentModelCursor && modelSupportsThinking(editingFeature?.model);
if (!editingFeature) {
return null;
@@ -361,6 +391,15 @@ export function EditFeatureDialog({
<Sparkles className="w-4 h-4 mr-2" />
Enhance with AI
</Button>
<ModelOverrideTrigger
currentModelEntry={enhancementOverride.effectiveModelEntry}
onModelChange={enhancementOverride.setOverride}
phase="enhancementModel"
isOverridden={enhancementOverride.isOverridden}
size="sm"
variant="icon"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-category">Category (optional)</Label>
@@ -437,6 +476,9 @@ export function EditFeatureDialog({
profiles={aiProfiles}
selectedModel={editingFeature.model ?? 'opus'}
selectedThinkingLevel={editingFeature.thinkingLevel ?? 'none'}
selectedCursorModel={
isCurrentModelCursor ? (editingFeature.model as string) : undefined
}
onSelect={handleProfileSelect}
testIdPrefix="edit-profile-quick-select"
/>
@@ -450,7 +492,7 @@ export function EditFeatureDialog({
{(!showProfilesOnly || showEditAdvancedOptions) && (
<>
<ModelSelector
selectedModel={(editingFeature.model ?? 'opus') as AgentModel}
selectedModel={(editingFeature.model ?? 'opus') as ModelAlias}
onModelSelect={handleModelSelect}
testIdPrefix="edit-model-select"
/>

View File

@@ -1,575 +0,0 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
Loader2,
Lightbulb,
Download,
StopCircle,
ChevronDown,
ChevronRight,
RefreshCw,
Shield,
Zap,
List,
FileText,
} from 'lucide-react';
import {
getElectronAPI,
FeatureSuggestion,
SuggestionsEvent,
SuggestionType,
} from '@/lib/electron';
import { useAppStore, Feature } from '@/store/app-store';
import { toast } from 'sonner';
import { LogViewer } from '@/components/ui/log-viewer';
interface FeatureSuggestionsDialogProps {
open: boolean;
onClose: () => void;
projectPath: string;
// Props to persist state across dialog open/close
suggestions: FeatureSuggestion[];
setSuggestions: (suggestions: FeatureSuggestion[]) => void;
isGenerating: boolean;
setIsGenerating: (generating: boolean) => void;
}
// Configuration for each suggestion type
const suggestionTypeConfig: Record<
SuggestionType,
{
label: string;
icon: React.ComponentType<{ className?: string }>;
description: string;
color: string;
}
> = {
features: {
label: 'Feature Suggestions',
icon: Lightbulb,
description: 'Discover missing features and improvements',
color: 'text-yellow-500',
},
refactoring: {
label: 'Refactoring Suggestions',
icon: RefreshCw,
description: 'Find code smells and refactoring opportunities',
color: 'text-blue-500',
},
security: {
label: 'Security Suggestions',
icon: Shield,
description: 'Identify security vulnerabilities and issues',
color: 'text-red-500',
},
performance: {
label: 'Performance Suggestions',
icon: Zap,
description: 'Discover performance bottlenecks and optimizations',
color: 'text-green-500',
},
};
export function FeatureSuggestionsDialog({
open,
onClose,
projectPath,
suggestions,
setSuggestions,
isGenerating,
setIsGenerating,
}: FeatureSuggestionsDialogProps) {
const [progress, setProgress] = useState<string[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [isImporting, setIsImporting] = useState(false);
const [currentSuggestionType, setCurrentSuggestionType] = useState<SuggestionType | null>(null);
const [viewMode, setViewMode] = useState<'parsed' | 'raw'>('parsed');
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const { features, setFeatures } = useAppStore();
// Initialize selectedIds when suggestions change
useEffect(() => {
if (suggestions.length > 0 && selectedIds.size === 0) {
setSelectedIds(new Set(suggestions.map((s) => s.id)));
}
}, [suggestions, selectedIds.size]);
// Auto-scroll progress when new content arrives
useEffect(() => {
if (autoScrollRef.current && scrollRef.current && isGenerating) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [progress, isGenerating]);
// Listen for suggestion events when dialog is open
useEffect(() => {
if (!open) return;
const api = getElectronAPI();
if (!api?.suggestions) return;
const unsubscribe = api.suggestions.onEvent((event: SuggestionsEvent) => {
if (event.type === 'suggestions_progress') {
setProgress((prev) => [...prev, event.content || '']);
} else if (event.type === 'suggestions_tool') {
const toolName = event.tool || 'Unknown Tool';
const toolInput = event.input ? JSON.stringify(event.input, null, 2) : '';
const formattedTool = `\n🔧 Tool: ${toolName}\n${toolInput ? `Input: ${toolInput}\n` : ''}`;
setProgress((prev) => [...prev, formattedTool]);
} else if (event.type === 'suggestions_complete') {
setIsGenerating(false);
if (event.suggestions && event.suggestions.length > 0) {
setSuggestions(event.suggestions);
// Select all by default
setSelectedIds(new Set(event.suggestions.map((s) => s.id)));
const typeLabel = currentSuggestionType
? suggestionTypeConfig[currentSuggestionType].label.toLowerCase()
: 'suggestions';
toast.success(`Generated ${event.suggestions.length} ${typeLabel}!`);
} else {
toast.info('No suggestions generated. Try again.');
}
} else if (event.type === 'suggestions_error') {
setIsGenerating(false);
toast.error(`Error: ${event.error}`);
}
});
return () => {
unsubscribe();
};
}, [open, setSuggestions, setIsGenerating, currentSuggestionType]);
// Start generating suggestions for a specific type
const handleGenerate = useCallback(
async (suggestionType: SuggestionType) => {
const api = getElectronAPI();
if (!api?.suggestions) {
toast.error('Suggestions API not available');
return;
}
setIsGenerating(true);
setProgress([]);
setSuggestions([]);
setSelectedIds(new Set());
setCurrentSuggestionType(suggestionType);
try {
const result = await api.suggestions.generate(projectPath, suggestionType);
if (!result.success) {
toast.error(result.error || 'Failed to start generation');
setIsGenerating(false);
}
} catch (error) {
console.error('Failed to generate suggestions:', error);
toast.error('Failed to start generation');
setIsGenerating(false);
}
},
[projectPath, setIsGenerating, setSuggestions]
);
// Stop generating
const handleStop = useCallback(async () => {
const api = getElectronAPI();
if (!api?.suggestions) return;
try {
await api.suggestions.stop();
setIsGenerating(false);
toast.info('Generation stopped');
} catch (error) {
console.error('Failed to stop generation:', error);
}
}, [setIsGenerating]);
// Toggle suggestion selection
const toggleSelection = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
// Toggle expand/collapse for a suggestion
const toggleExpanded = useCallback((id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
// Select/deselect all
const toggleSelectAll = useCallback(() => {
if (selectedIds.size === suggestions.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(suggestions.map((s) => s.id)));
}
}, [selectedIds.size, suggestions]);
// Import selected suggestions as features
const handleImport = useCallback(async () => {
if (selectedIds.size === 0) {
toast.warning('No suggestions selected');
return;
}
setIsImporting(true);
try {
const api = getElectronAPI();
const selectedSuggestions = suggestions.filter((s) => selectedIds.has(s.id));
// Create new features from selected suggestions
const newFeatures: Feature[] = selectedSuggestions.map((s) => ({
id: `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
category: s.category,
description: s.description,
steps: [], // Required empty steps array for new features
status: 'backlog' as const,
skipTests: true, // As specified, testing mode true
priority: s.priority, // Preserve priority from suggestion
}));
// Create each new feature using the features API
if (api.features) {
for (const feature of newFeatures) {
await api.features.create(projectPath, feature);
}
}
// Merge with existing features for store update
const updatedFeatures = [...features, ...newFeatures];
// Update store
setFeatures(updatedFeatures);
toast.success(`Imported ${newFeatures.length} features to backlog!`);
// Clear suggestions after importing
setSuggestions([]);
setSelectedIds(new Set());
setProgress([]);
setCurrentSuggestionType(null);
onClose();
} catch (error) {
console.error('Failed to import features:', error);
toast.error('Failed to import features');
} finally {
setIsImporting(false);
}
}, [selectedIds, suggestions, features, setFeatures, setSuggestions, projectPath, onClose]);
// Handle scroll to detect if user scrolled up
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
autoScrollRef.current = isAtBottom;
};
// Go back to type selection
const handleBackToSelection = useCallback(() => {
setSuggestions([]);
setSelectedIds(new Set());
setProgress([]);
setCurrentSuggestionType(null);
}, [setSuggestions]);
const hasStarted = isGenerating || progress.length > 0 || suggestions.length > 0;
const hasSuggestions = suggestions.length > 0;
const currentConfig = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType] : null;
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent
className="w-[70vw] max-w-[70vw] max-h-[85vh] flex flex-col"
data-testid="feature-suggestions-dialog"
>
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
{currentConfig ? (
<>
<currentConfig.icon className={`w-5 h-5 ${currentConfig.color}`} />
{currentConfig.label}
</>
) : (
<>
<Lightbulb className="w-5 h-5 text-yellow-500" />
AI Suggestions
</>
)}
</DialogTitle>
<DialogDescription>
{currentConfig
? currentConfig.description
: 'Analyze your project to discover improvements. Choose a suggestion type below.'}
</DialogDescription>
</DialogHeader>
{!hasStarted ? (
// Initial state - show suggestion type buttons
<div className="flex-1 flex flex-col items-center justify-center py-8">
<p className="text-muted-foreground text-center max-w-lg mb-8">
Our AI will analyze your project and generate actionable suggestions. Choose what type
of analysis you want to perform:
</p>
<div className="grid grid-cols-2 gap-4 w-full max-w-2xl">
{(
Object.entries(suggestionTypeConfig) as [
SuggestionType,
(typeof suggestionTypeConfig)[SuggestionType],
][]
).map(([type, config]) => {
const Icon = config.icon;
return (
<Button
key={type}
variant="outline"
className="h-auto py-6 px-6 flex flex-col items-center gap-3 hover:border-primary/50 transition-colors"
onClick={() => handleGenerate(type)}
data-testid={`generate-${type}-btn`}
>
<Icon className={`w-8 h-8 ${config.color}`} />
<div className="text-center">
<div className="font-semibold">
{config.label.replace(' Suggestions', '')}
</div>
<div className="text-xs text-muted-foreground mt-1">{config.description}</div>
</div>
</Button>
);
})}
</div>
</div>
) : isGenerating ? (
// Generating state - show progress
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
Analyzing project...
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
<button
onClick={() => setViewMode('parsed')}
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium transition-all ${
viewMode === 'parsed'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-parsed"
>
<List className="w-3 h-3" />
Logs
</button>
<button
onClick={() => setViewMode('raw')}
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium transition-all ${
viewMode === 'raw'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-raw"
>
<FileText className="w-3 h-3" />
Raw
</button>
</div>
<Button variant="destructive" size="sm" onClick={handleStop}>
<StopCircle className="w-4 h-4 mr-2" />
Stop
</Button>
</div>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[200px] max-h-[400px]"
>
{progress.length === 0 ? (
<div className="flex items-center justify-center min-h-[168px] text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
Waiting for AI response...
</div>
) : viewMode === 'parsed' ? (
<LogViewer output={progress.join('')} />
) : (
<div className="whitespace-pre-wrap break-words text-zinc-300">
{progress.join('')}
</div>
)}
</div>
</div>
) : hasSuggestions ? (
// Results state - show suggestions list
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
{suggestions.length} suggestions generated
</span>
<Button variant="ghost" size="sm" onClick={toggleSelectAll}>
{selectedIds.size === suggestions.length ? 'Deselect All' : 'Select All'}
</Button>
</div>
<span className="text-sm font-medium">{selectedIds.size} selected</span>
</div>
<div
ref={scrollRef}
className="flex-1 overflow-y-auto space-y-2 min-h-[200px] max-h-[400px] pr-2"
>
{suggestions.map((suggestion) => {
const isSelected = selectedIds.has(suggestion.id);
const isExpanded = expandedIds.has(suggestion.id);
return (
<div
key={suggestion.id}
className={`border rounded-lg p-3 transition-colors ${
isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
data-testid={`suggestion-${suggestion.id}`}
>
<div className="flex items-start gap-3">
<Checkbox
id={suggestion.id}
checked={isSelected}
onCheckedChange={() => toggleSelection(suggestion.id)}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<button
onClick={() => toggleExpanded(suggestion.id)}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/20 text-primary font-medium">
#{suggestion.priority}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-secondary text-secondary-foreground">
{suggestion.category}
</span>
</div>
<Label
htmlFor={suggestion.id}
className="text-sm font-medium cursor-pointer"
>
{suggestion.description}
</Label>
{isExpanded && suggestion.reasoning && (
<div className="mt-3 text-sm">
<p className="text-muted-foreground italic">{suggestion.reasoning}</p>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
) : (
// No results state
<div className="flex-1 flex flex-col items-center justify-center py-8 text-center">
<p className="text-muted-foreground mb-4">
No suggestions were generated. Try running the analysis again.
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={handleBackToSelection}>
Back to Selection
</Button>
{currentSuggestionType && (
<Button onClick={() => handleGenerate(currentSuggestionType)}>
<Lightbulb className="w-4 h-4 mr-2" />
Try Again
</Button>
)}
</div>
</div>
)}
<DialogFooter className="flex-shrink-0">
{hasSuggestions && (
<div className="flex gap-2 w-full justify-between">
<div className="flex gap-2">
<Button variant="outline" onClick={handleBackToSelection}>
Back
</Button>
{currentSuggestionType && (
<Button variant="outline" onClick={() => handleGenerate(currentSuggestionType)}>
{currentConfig && <currentConfig.icon className="w-4 h-4 mr-2" />}
Regenerate
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<HotkeyButton
onClick={handleImport}
disabled={selectedIds.size === 0 || isImporting}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open && hasSuggestions}
>
{isImporting ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Download className="w-4 h-4 mr-2" />
)}
Import {selectedIds.size} Feature
{selectedIds.size !== 1 ? 's' : ''}
</HotkeyButton>
</div>
</div>
)}
{!hasSuggestions && !isGenerating && hasStarted && (
<Button variant="ghost" onClick={onClose}>
Close
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -5,6 +5,5 @@ export { CompletedFeaturesModal } from './completed-features-modal';
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
export { EditFeatureDialog } from './edit-feature-dialog';
export { FeatureSuggestionsDialog } from './feature-suggestions-dialog';
export { FollowUpDialog } from './follow-up-dialog';
export { PlanApprovalDialog } from './plan-approval-dialog';

View File

@@ -7,4 +7,3 @@ 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 { useSuggestionsState } from './use-suggestions-state';

View File

@@ -2,7 +2,7 @@ import { useCallback } from 'react';
import {
Feature,
FeatureImage,
AgentModel,
ModelAlias,
ThinkingLevel,
PlanningMode,
useAppStore,
@@ -13,6 +13,9 @@ import { toast } from 'sonner';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { truncateDescription } from '@/lib/utils';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('BoardActions');
interface UseBoardActionsProps {
currentProject: { path: string; id: string } | null;
@@ -92,7 +95,7 @@ export function useBoardActions({
images: FeatureImage[];
imagePaths: DescriptionImagePath[];
skipTests: boolean;
model: AgentModel;
model: ModelAlias;
thinkingLevel: ThinkingLevel;
branchName: string;
priority: number;
@@ -112,8 +115,8 @@ export function useBoardActions({
if (api?.worktree?.create) {
const result = await api.worktree.create(currentProject.path, finalBranchName);
if (result.success && result.worktree) {
console.log(
`[Board] Worktree for branch "${finalBranchName}" ${
logger.info(
`Worktree for branch "${finalBranchName}" ${
result.worktree?.isNew ? 'created' : 'already exists'
}`
);
@@ -125,8 +128,8 @@ export function useBoardActions({
// Refresh worktree list in UI
onWorktreeCreated?.();
} else if (!result.success) {
console.error(
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
logger.error(
`Failed to create worktree for branch "${finalBranchName}":`,
result.error
);
toast.error('Failed to create worktree', {
@@ -135,7 +138,7 @@ export function useBoardActions({
}
}
} catch (error) {
console.error('[Board] Error creating worktree:', error);
logger.error('Error creating worktree:', error);
toast.error('Failed to create worktree', {
description: error instanceof Error ? error.message : 'An error occurred',
});
@@ -180,7 +183,7 @@ export function useBoardActions({
}
})
.catch((error) => {
console.error('[Board] Error generating title:', error);
logger.error('Error generating title:', error);
// Clear generating flag on error
const titleUpdates = { titleGenerating: false };
updateFeature(createdFeature.id, titleUpdates);
@@ -210,7 +213,7 @@ export function useBoardActions({
category: string;
description: string;
skipTests: boolean;
model: AgentModel;
model: ModelAlias;
thinkingLevel: ThinkingLevel;
imagePaths: DescriptionImagePath[];
branchName: string;
@@ -229,16 +232,16 @@ export function useBoardActions({
if (api?.worktree?.create) {
const result = await api.worktree.create(currentProject.path, finalBranchName);
if (result.success) {
console.log(
`[Board] Worktree for branch "${finalBranchName}" ${
logger.info(
`Worktree for branch "${finalBranchName}" ${
result.worktree?.isNew ? 'created' : 'already exists'
}`
);
// Refresh worktree list in UI
onWorktreeCreated?.();
} else {
console.error(
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
logger.error(
`Failed to create worktree for branch "${finalBranchName}":`,
result.error
);
toast.error('Failed to create worktree', {
@@ -247,7 +250,7 @@ export function useBoardActions({
}
}
} catch (error) {
console.error('[Board] Error creating worktree:', error);
logger.error('Error creating worktree:', error);
toast.error('Failed to create worktree', {
description: error instanceof Error ? error.message : 'An error occurred',
});
@@ -292,7 +295,7 @@ export function useBoardActions({
description: `Stopped and deleted: ${truncateDescription(feature.description)}`,
});
} catch (error) {
console.error('[Board] Error stopping feature before delete:', error);
logger.error('Error stopping feature before delete:', error);
toast.error('Failed to stop agent', {
description: 'The feature will still be deleted.',
});
@@ -305,13 +308,13 @@ export function useBoardActions({
for (const imagePathObj of feature.imagePaths) {
try {
await api.deleteFile(imagePathObj.path);
console.log(`[Board] Deleted image: ${imagePathObj.path}`);
logger.info(`Deleted image: ${imagePathObj.path}`);
} catch (error) {
console.error(`[Board] Failed to delete image ${imagePathObj.path}:`, error);
logger.error(`Failed to delete image ${imagePathObj.path}:`, error);
}
}
} catch (error) {
console.error(`[Board] Error deleting images for feature ${featureId}:`, error);
logger.error(`Error deleting images for feature ${featureId}:`, error);
}
}
@@ -328,7 +331,7 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (!api?.autoMode) {
console.error('Auto mode API not available');
logger.error('Auto mode API not available');
return;
}
@@ -341,16 +344,13 @@ export function useBoardActions({
);
if (result.success) {
console.log(
'[Board] Feature run started successfully, branch:',
feature.branchName || 'default'
);
logger.info('Feature run started successfully, branch:', feature.branchName || 'default');
} else {
console.error('[Board] Failed to run feature:', result.error);
logger.error('Failed to run feature:', result.error);
await loadFeatures();
}
} catch (error) {
console.error('[Board] Error running feature:', error);
logger.error('Error running feature:', error);
await loadFeatures();
}
},
@@ -392,7 +392,7 @@ export function useBoardActions({
updateFeature(feature.id, updates);
// Must await to ensure feature status is persisted before starting agent
await persistFeatureUpdate(feature.id, updates);
console.log('[Board] Feature moved to in_progress, starting agent...');
logger.info('Feature moved to in_progress, starting agent...');
await handleRunFeature(feature);
return true;
},
@@ -413,20 +413,20 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (!api?.autoMode) {
console.error('Auto mode API not available');
logger.error('Auto mode API not available');
return;
}
const result = await api.autoMode.verifyFeature(currentProject.path, feature.id);
if (result.success) {
console.log('[Board] Feature verification started successfully');
logger.info('Feature verification started successfully');
} else {
console.error('[Board] Failed to verify feature:', result.error);
logger.error('Failed to verify feature:', result.error);
await loadFeatures();
}
} catch (error) {
console.error('[Board] Error verifying feature:', error);
logger.error('Error verifying feature:', error);
await loadFeatures();
}
},
@@ -435,20 +435,20 @@ export function useBoardActions({
const handleResumeFeature = useCallback(
async (feature: Feature) => {
console.log('[Board] handleResumeFeature called for feature:', feature.id);
logger.info('handleResumeFeature called for feature:', feature.id);
if (!currentProject) {
console.error('[Board] No current project');
logger.error('No current project');
return;
}
try {
const api = getElectronAPI();
if (!api?.autoMode) {
console.error('[Board] Auto mode API not available');
logger.error('Auto mode API not available');
return;
}
console.log('[Board] Calling resumeFeature API...', {
logger.info('Calling resumeFeature API...', {
projectPath: currentProject.path,
featureId: feature.id,
useWorktrees,
@@ -460,16 +460,16 @@ export function useBoardActions({
useWorktrees
);
console.log('[Board] resumeFeature result:', result);
logger.info('resumeFeature result:', result);
if (result.success) {
console.log('[Board] Feature resume started successfully');
logger.info('Feature resume started successfully');
} else {
console.error('[Board] Failed to resume feature:', result.error);
logger.error('Failed to resume feature:', result.error);
await loadFeatures();
}
} catch (error) {
console.error('[Board] Error resuming feature:', error);
logger.error('Error resuming feature:', error);
await loadFeatures();
}
},
@@ -523,7 +523,7 @@ export function useBoardActions({
const api = getElectronAPI();
if (!api?.autoMode?.followUpFeature) {
console.error('Follow-up feature API not available');
logger.error('Follow-up feature API not available');
toast.error('Follow-up not available', {
description: 'This feature is not available in the current version.',
});
@@ -559,7 +559,7 @@ export function useBoardActions({
// No worktreePath - server derives from feature.branchName
)
.catch((error) => {
console.error('[Board] Error sending follow-up:', error);
logger.error('Error sending follow-up:', error);
toast.error('Failed to send follow-up', {
description: error instanceof Error ? error.message : 'An error occurred',
});
@@ -587,7 +587,7 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (!api?.autoMode?.commitFeature) {
console.error('Commit feature API not available');
logger.error('Commit feature API not available');
toast.error('Commit not available', {
description: 'This feature is not available in the current version.',
});
@@ -610,14 +610,14 @@ export function useBoardActions({
// Refresh worktree selector to update commit counts
onWorktreeCreated?.();
} else {
console.error('[Board] Failed to commit feature:', result.error);
logger.error('Failed to commit feature:', result.error);
toast.error('Failed to commit feature', {
description: result.error || 'An error occurred',
});
await loadFeatures();
}
} catch (error) {
console.error('[Board] Error committing feature:', error);
logger.error('Error committing feature:', error);
toast.error('Failed to commit feature', {
description: error instanceof Error ? error.message : 'An error occurred',
});
@@ -634,7 +634,7 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (!api?.worktree?.mergeFeature) {
console.error('Worktree API not available');
logger.error('Worktree API not available');
toast.error('Merge not available', {
description: 'This feature is not available in the current version.',
});
@@ -651,13 +651,13 @@ export function useBoardActions({
)}`,
});
} else {
console.error('[Board] Failed to merge feature:', result.error);
logger.error('Failed to merge feature:', result.error);
toast.error('Failed to merge feature', {
description: result.error || 'An error occurred',
});
}
} catch (error) {
console.error('[Board] Error merging feature:', error);
logger.error('Error merging feature:', error);
toast.error('Failed to merge feature', {
description: error instanceof Error ? error.message : 'An error occurred',
});
@@ -747,7 +747,7 @@ export function useBoardActions({
: `Stopped working on: ${truncateDescription(feature.description)}`,
});
} catch (error) {
console.error('[Board] Error stopping feature:', error);
logger.error('Error stopping feature:', error);
toast.error('Failed to stop agent', {
description: error instanceof Error ? error.message : 'An error occurred',
});
@@ -857,7 +857,7 @@ export function useBoardActions({
try {
await autoMode.stopFeature(feature.id);
} catch (error) {
console.error('[Board] Error stopping feature before archive:', error);
logger.error('Error stopping feature before archive:', error);
}
}
// Archive the feature by setting status to completed

View File

@@ -1,10 +1,13 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { DragStartEvent, DragEndEvent } from '@dnd-kit/core';
import { Feature } from '@/store/app-store';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import { COLUMNS, ColumnId } from '../constants';
const logger = createLogger('BoardDragDrop');
interface UseBoardDragDropProps {
features: Feature[];
currentProject: { path: string; id: string } | null;
@@ -63,7 +66,7 @@ export function useBoardDragDrop({
if (draggedFeature.status === 'in_progress') {
// Only allow dragging in_progress if it's not currently running
if (isRunningTask) {
console.log('[Board] Cannot drag feature - currently running');
logger.debug('Cannot drag feature - currently running');
return;
}
}

View File

@@ -1,14 +1,14 @@
import { useEffect, useRef } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('BoardEffects');
interface UseBoardEffectsProps {
currentProject: { path: string; id: string } | null;
specCreatingForProject: string | null;
setSpecCreatingForProject: (path: string | null) => void;
setSuggestionsCount: (count: number) => void;
setFeatureSuggestions: (suggestions: any[]) => void;
setIsGeneratingSuggestions: (generating: boolean) => void;
checkContextExists: (featureId: string) => Promise<boolean>;
features: any[];
isLoading: boolean;
@@ -20,9 +20,6 @@ export function useBoardEffects({
currentProject,
specCreatingForProject,
setSpecCreatingForProject,
setSuggestionsCount,
setFeatureSuggestions,
setIsGeneratingSuggestions,
checkContextExists,
features,
isLoading,
@@ -44,38 +41,13 @@ export function useBoardEffects({
};
}, [currentProject]);
// Listen for suggestions events to update count (persists even when dialog is closed)
useEffect(() => {
const api = getElectronAPI();
if (!api?.suggestions) return;
const unsubscribe = api.suggestions.onEvent((event) => {
if (event.type === 'suggestions_complete' && event.suggestions) {
setSuggestionsCount(event.suggestions.length);
setFeatureSuggestions(event.suggestions);
setIsGeneratingSuggestions(false);
} else if (event.type === 'suggestions_error') {
setIsGeneratingSuggestions(false);
}
});
return () => {
unsubscribe();
};
}, [setSuggestionsCount, setFeatureSuggestions, setIsGeneratingSuggestions]);
// Subscribe to spec regeneration events to clear creating state on completion
useEffect(() => {
const api = getElectronAPI();
if (!api.specRegeneration) return;
const unsubscribe = api.specRegeneration.onEvent((event) => {
console.log(
'[BoardView] Spec regeneration event:',
event.type,
'for project:',
event.projectPath
);
logger.info('Spec regeneration event:', event.type, 'for project:', event.projectPath);
if (event.projectPath !== specCreatingForProject) {
return;
@@ -108,7 +80,7 @@ export function useBoardEffects({
const { clearRunningTasks, addRunningTask } = useAppStore.getState();
if (status.runningFeatures) {
console.log('[Board] Syncing running tasks from backend:', status.runningFeatures);
logger.info('Syncing running tasks from backend:', status.runningFeatures);
clearRunningTasks(projectId);
@@ -118,7 +90,7 @@ export function useBoardEffects({
}
}
} catch (error) {
console.error('[Board] Failed to sync running tasks:', error);
logger.error('Failed to sync running tasks:', error);
}
};

View File

@@ -2,6 +2,9 @@ import { useState, useCallback, useEffect, useRef } from 'react';
import { useAppStore, Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('BoardFeatures');
interface UseBoardFeaturesProps {
currentProject: { path: string; id: string } | null;
@@ -32,7 +35,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
// If project switched, mark it but don't clear features yet
// We'll clear after successful API load to prevent data loss
if (isProjectSwitch) {
console.log(`[BoardView] Project switch detected: ${previousPath} -> ${currentPath}`);
logger.info(`Project switch detected: ${previousPath} -> ${currentPath}`);
isSwitchingProjectRef.current = true;
isInitialLoadRef.current = true;
}
@@ -48,7 +51,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
try {
const api = getElectronAPI();
if (!api.features) {
console.error('[BoardView] Features API not available');
logger.error('Features API not available');
// Keep cached features if API is unavailable
return;
}
@@ -73,7 +76,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
setPersistedCategories([]);
}
} else if (!result.success && result.error) {
console.error('[BoardView] API returned error:', result.error);
logger.error('API returned error:', result.error);
// If it's a new project or the error indicates no features found,
// that's expected - start with empty array
if (isProjectSwitch) {
@@ -83,7 +86,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
// Otherwise keep cached features
}
} catch (error) {
console.error('Failed to load features:', error);
logger.error('Failed to load features:', error);
// On error, keep existing cached features for the current project
// Only clear on project switch if we have no features from server
if (isProjectSwitch && cachedFeatures.length === 0) {
@@ -115,7 +118,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
setPersistedCategories([]);
}
} catch (error) {
console.error('Failed to load categories:', error);
logger.error('Failed to load categories:', error);
// If file doesn't exist, ensure categories are cleared
setPersistedCategories([]);
}
@@ -147,7 +150,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
setPersistedCategories(categories);
}
} catch (error) {
console.error('Failed to save category:', error);
logger.error('Failed to save category:', error);
}
},
[currentProject, persistedCategories]
@@ -165,7 +168,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
currentProject &&
event.projectPath === currentProject.path
) {
console.log('[BoardView] Spec regeneration complete, refreshing features');
logger.info('Spec regeneration complete, refreshing features');
loadFeatures();
}
});
@@ -190,27 +193,27 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
if (event.type === 'auto_mode_feature_complete') {
// Reload features when a feature is completed
console.log('[Board] Feature completed, reloading features...');
logger.info('Feature completed, reloading features...');
loadFeatures();
// Play ding sound when feature is done (unless muted)
const { muteDoneSound } = useAppStore.getState();
if (!muteDoneSound) {
const audio = new Audio('/sounds/ding.mp3');
audio.play().catch((err) => console.warn('Could not play ding sound:', err));
audio.play().catch((err) => logger.warn('Could not play ding sound:', err));
}
} else if (event.type === 'plan_approval_required') {
// Reload features when plan is generated and requires approval
// This ensures the feature card shows the "Approve Plan" button
console.log('[Board] Plan approval required, reloading features...');
logger.info('Plan approval required, reloading features...');
loadFeatures();
} else if (event.type === 'pipeline_step_started') {
// Pipeline steps update the feature status to `pipeline_*` before the step runs.
// Reload so the card moves into the correct pipeline column immediately.
console.log('[Board] Pipeline step started, reloading features...');
logger.info('Pipeline step started, reloading features...');
loadFeatures();
} else if (event.type === 'auto_mode_error') {
// Reload features when an error occurs (feature moved to waiting_approval)
console.log('[Board] Feature error, reloading features...', event.error);
logger.info('Feature error, reloading features...', event.error);
// Remove from running tasks so it moves to the correct column
if (event.featureId) {

View File

@@ -2,6 +2,9 @@ import { useCallback } from 'react';
import { Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('BoardPersistence');
interface UseBoardPersistenceProps {
currentProject: { path: string; id: string } | null;
@@ -18,7 +21,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
try {
const api = getElectronAPI();
if (!api.features) {
console.error('[BoardView] Features API not available');
logger.error('Features API not available');
return;
}
@@ -27,7 +30,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
updateFeature(result.feature.id, result.feature);
}
} catch (error) {
console.error('Failed to persist feature update:', error);
logger.error('Failed to persist feature update:', error);
}
},
[currentProject, updateFeature]
@@ -41,7 +44,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
try {
const api = getElectronAPI();
if (!api.features) {
console.error('[BoardView] Features API not available');
logger.error('Features API not available');
return;
}
@@ -50,7 +53,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
updateFeature(result.feature.id, result.feature);
}
} catch (error) {
console.error('Failed to persist feature creation:', error);
logger.error('Failed to persist feature creation:', error);
}
},
[currentProject, updateFeature]
@@ -64,13 +67,13 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
try {
const api = getElectronAPI();
if (!api.features) {
console.error('[BoardView] Features API not available');
logger.error('Features API not available');
return;
}
await api.features.delete(currentProject.path, featureId);
} catch (error) {
console.error('Failed to persist feature deletion:', error);
logger.error('Failed to persist feature deletion:', error);
}
},
[currentProject]

View File

@@ -1,34 +0,0 @@
import { useState, useCallback } from 'react';
import type { FeatureSuggestion } from '@/lib/electron';
export function useSuggestionsState() {
const [showSuggestionsDialog, setShowSuggestionsDialog] = useState(false);
const [suggestionsCount, setSuggestionsCount] = useState(0);
const [featureSuggestions, setFeatureSuggestions] = useState<FeatureSuggestion[]>([]);
const [isGeneratingSuggestions, setIsGeneratingSuggestions] = useState(false);
const updateSuggestions = useCallback((suggestions: FeatureSuggestion[]) => {
setFeatureSuggestions(suggestions);
setSuggestionsCount(suggestions.length);
}, []);
const closeSuggestionsDialog = useCallback(() => {
setShowSuggestionsDialog(false);
}, []);
return {
// State
showSuggestionsDialog,
suggestionsCount,
featureSuggestions,
isGeneratingSuggestions,
// Setters
setShowSuggestionsDialog,
setSuggestionsCount,
setFeatureSuggestions,
setIsGeneratingSuggestions,
// Helpers
updateSuggestions,
closeSuggestionsDialog,
};
}

View File

@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { KanbanColumn, KanbanCard } from './components';
import { Feature } from '@/store/app-store';
import { FastForward, Lightbulb, Archive, Plus, Settings2 } from 'lucide-react';
import { FastForward, Archive, Plus, Settings2 } from 'lucide-react';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
import { getColumnsWithPipeline, type Column, type ColumnId } from './constants';
@@ -47,8 +47,6 @@ interface KanbanBoardProps {
runningAutoTasks: string[];
shortcuts: ReturnType<typeof useKeyboardShortcutsConfig>;
onStartNextFeatures: () => void;
onShowSuggestions: () => void;
suggestionsCount: number;
onArchiveAllVerified: () => void;
pipelineConfig: PipelineConfig | null;
onOpenPipelineSettings?: () => void;
@@ -82,8 +80,6 @@ export function KanbanBoard({
runningAutoTasks,
shortcuts,
onStartNextFeatures,
onShowSuggestions,
suggestionsCount,
onArchiveAllVerified,
pipelineConfig,
onOpenPipelineSettings,
@@ -130,40 +126,20 @@ export function KanbanBoard({
Complete All
</Button>
) : column.id === 'backlog' ? (
<div className="flex items-center gap-1">
<Button
columnFeatures.length > 0 && (
<HotkeyButton
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 relative"
onClick={onShowSuggestions}
title="Feature Suggestions"
data-testid="feature-suggestions-button"
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
onClick={onStartNextFeatures}
hotkey={shortcuts.startNext}
hotkeyActive={false}
data-testid="start-next-button"
>
<Lightbulb className="w-3.5 h-3.5" />
{suggestionsCount > 0 && (
<span
className="absolute -top-1 -right-1 w-4 h-4 text-[9px] font-mono rounded-full bg-yellow-500 text-black flex items-center justify-center"
data-testid="suggestions-count"
>
{suggestionsCount}
</span>
)}
</Button>
{columnFeatures.length > 0 && (
<HotkeyButton
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
onClick={onStartNextFeatures}
hotkey={shortcuts.startNext}
hotkeyActive={false}
data-testid="start-next-button"
>
<FastForward className="w-3 h-3 mr-1" />
Make
</HotkeyButton>
)}
</div>
<FastForward className="w-3 h-3 mr-1" />
Make
</HotkeyButton>
)
) : column.id === 'in_progress' ? (
<Button
variant="ghost"

View File

@@ -1,12 +1,15 @@
import type { AgentModel, ThinkingLevel } from '@/store/app-store';
import type { ModelAlias, ThinkingLevel } from '@/store/app-store';
import type { ModelProvider } from '@automaker/types';
import { CURSOR_MODEL_MAP } from '@automaker/types';
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
export type ModelOption = {
id: AgentModel;
id: string; // Claude models use ModelAlias, Cursor models use "cursor-{id}"
label: string;
description: string;
badge?: string;
provider: 'claude';
provider: ModelProvider;
hasThinking?: boolean;
};
export const CLAUDE_MODELS: ModelOption[] = [
@@ -33,6 +36,25 @@ export const CLAUDE_MODELS: ModelOption[] = [
},
];
/**
* Cursor models derived from CURSOR_MODEL_MAP
* ID is prefixed with "cursor-" for ProviderFactory routing
*/
export const CURSOR_MODELS: ModelOption[] = Object.entries(CURSOR_MODEL_MAP).map(
([id, config]) => ({
id: `cursor-${id}`,
label: config.label,
description: config.description,
provider: 'cursor' as ModelProvider,
hasThinking: config.hasThinking,
})
);
/**
* All available models (Claude + Cursor)
*/
export const ALL_MODELS: ModelOption[] = [...CLAUDE_MODELS, ...CURSOR_MODELS];
export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {

View File

@@ -1,12 +1,17 @@
import { Label } from '@/components/ui/label';
import { Brain } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Brain, Bot, Terminal, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { AgentModel } from '@/store/app-store';
import { CLAUDE_MODELS, ModelOption } from './model-constants';
import type { ModelAlias } from '@/store/app-store';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { getModelProvider, PROVIDER_PREFIXES, stripProviderPrefix } from '@automaker/types';
import type { ModelProvider } from '@automaker/types';
import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants';
interface ModelSelectorProps {
selectedModel: AgentModel;
onModelSelect: (model: AgentModel) => void;
selectedModel: string; // Can be ModelAlias or "cursor-{id}"
onModelSelect: (model: string) => void;
testIdPrefix?: string;
}
@@ -15,40 +20,174 @@ export function ModelSelector({
onModelSelect,
testIdPrefix = 'model-select',
}: ModelSelectorProps) {
const { enabledCursorModels, cursorDefaultModel } = useAppStore();
const { cursorCliStatus } = useSetupStore();
const selectedProvider = getModelProvider(selectedModel);
// Check if Cursor CLI is available
const isCursorAvailable = cursorCliStatus?.installed && cursorCliStatus?.auth?.authenticated;
// 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);
});
const handleProviderChange = (provider: ModelProvider) => {
if (provider === 'cursor' && selectedProvider !== 'cursor') {
// Switch to Cursor's default model (from global settings)
onModelSelect(`${PROVIDER_PREFIXES.cursor}${cursorDefaultModel}`);
} else if (provider === 'claude' && selectedProvider !== 'claude') {
// Switch to Claude's default model
onModelSelect('sonnet');
}
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-primary" />
Claude (SDK)
</Label>
<span className="text-[11px] px-2 py-0.5 rounded-full border border-primary/40 text-primary">
Native
</span>
</div>
<div className="flex gap-2 flex-wrap">
{CLAUDE_MODELS.map((option) => {
const isSelected = selectedModel === option.id;
const shortName = option.label.replace('Claude ', '');
return (
<button
key={option.id}
type="button"
onClick={() => onModelSelect(option.id)}
title={option.description}
className={cn(
'flex-1 min-w-[80px] px-3 py-2 rounded-md border text-sm font-medium transition-colors',
isSelected
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-input'
)}
data-testid={`${testIdPrefix}-${option.id}`}
>
{shortName}
</button>
);
})}
<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'
)}
data-testid={`${testIdPrefix}-provider-claude`}
>
<Bot 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'
)}
data-testid={`${testIdPrefix}-provider-cursor`}
>
<Terminal className="w-4 h-4" />
Cursor CLI
</button>
</div>
</div>
{/* Claude Models */}
{selectedProvider === 'claude' && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-primary" />
Claude Model
</Label>
<span className="text-[11px] px-2 py-0.5 rounded-full border border-primary/40 text-primary">
Native SDK
</span>
</div>
<div className="flex gap-2 flex-wrap">
{CLAUDE_MODELS.map((option) => {
const isSelected = selectedModel === option.id;
const shortName = option.label.replace('Claude ', '');
return (
<button
key={option.id}
type="button"
onClick={() => onModelSelect(option.id)}
title={option.description}
className={cn(
'flex-1 min-w-[80px] px-3 py-2 rounded-md border text-sm font-medium transition-colors',
isSelected
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-input'
)}
data-testid={`${testIdPrefix}-${option.id}`}
>
{shortName}
</button>
);
})}
</div>
</div>
)}
{/* Cursor Models */}
{selectedProvider === 'cursor' && (
<div className="space-y-3">
{/* Warning when Cursor CLI is not available */}
{!isCursorAvailable && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="w-4 h-4 text-amber-400 mt-0.5 shrink-0" />
<div className="text-sm text-amber-400">
Cursor CLI is not installed or authenticated. Configure it in Settings AI
Providers.
</div>
</div>
)}
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<Terminal className="w-4 h-4 text-primary" />
Cursor Model
</Label>
<span className="text-[11px] px-2 py-0.5 rounded-full border border-amber-500/40 text-amber-600 dark:text-amber-400">
CLI
</span>
</div>
<div className="flex flex-col gap-2">
{filteredCursorModels.length === 0 ? (
<div className="text-sm text-muted-foreground p-3 border border-dashed rounded-md text-center">
No Cursor models enabled. Enable models in Settings AI Providers.
</div>
) : (
filteredCursorModels.map((option) => {
const isSelected = selectedModel === option.id;
return (
<button
key={option.id}
type="button"
onClick={() => onModelSelect(option.id)}
title={option.description}
className={cn(
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
isSelected
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`${testIdPrefix}-${option.id}`}
>
<span>{option.label}</span>
<div className="flex gap-1">
{option.hasThinking && (
<Badge
variant="outline"
className={cn(
'text-xs',
isSelected
? 'border-primary-foreground/50 text-primary-foreground'
: 'border-amber-500/50 text-amber-600 dark:text-amber-400'
)}
>
Thinking
</Badge>
)}
</div>
</button>
);
})
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,14 +1,41 @@
import { Label } from '@/components/ui/label';
import { Brain, UserCircle } from 'lucide-react';
import { Brain, UserCircle, Terminal } from 'lucide-react';
import { cn } from '@/lib/utils';
import { AgentModel, ThinkingLevel, AIProfile } from '@/store/app-store';
import type { ModelAlias, ThinkingLevel, AIProfile, CursorModelId } from '@automaker/types';
import { CURSOR_MODEL_MAP, profileHasThinking, PROVIDER_PREFIXES } from '@automaker/types';
import { PROFILE_ICONS } from './model-constants';
/**
* Get display string for a profile's model configuration
*/
function getProfileModelDisplay(profile: AIProfile): string {
if (profile.provider === 'cursor') {
const cursorModel = profile.cursorModel || 'auto';
const modelConfig = CURSOR_MODEL_MAP[cursorModel];
return modelConfig?.label || cursorModel;
}
// Claude
return profile.model || 'sonnet';
}
/**
* Get display string for a profile's thinking configuration
*/
function getProfileThinkingDisplay(profile: AIProfile): string | null {
if (profile.provider === 'cursor') {
// For Cursor, thinking is embedded in the model
return profileHasThinking(profile) ? 'thinking' : null;
}
// Claude
return profile.thinkingLevel && profile.thinkingLevel !== 'none' ? profile.thinkingLevel : null;
}
interface ProfileQuickSelectProps {
profiles: AIProfile[];
selectedModel: AgentModel;
selectedModel: ModelAlias | CursorModelId;
selectedThinkingLevel: ThinkingLevel;
onSelect: (model: AgentModel, thinkingLevel: ThinkingLevel) => void;
selectedCursorModel?: string; // For detecting cursor profile selection
onSelect: (profile: AIProfile) => void; // Changed to pass full profile
testIdPrefix?: string;
showManageLink?: boolean;
onManageLinkClick?: () => void;
@@ -18,15 +45,30 @@ export function ProfileQuickSelect({
profiles,
selectedModel,
selectedThinkingLevel,
selectedCursorModel,
onSelect,
testIdPrefix = 'profile-quick-select',
showManageLink = false,
onManageLinkClick,
}: ProfileQuickSelectProps) {
if (profiles.length === 0) {
// Show both Claude and Cursor profiles
const allProfiles = profiles;
if (allProfiles.length === 0) {
return null;
}
// Check if a profile is selected
const isProfileSelected = (profile: AIProfile): boolean => {
if (profile.provider === 'cursor') {
// For cursor profiles, check if cursor model matches
const profileCursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
return selectedCursorModel === profileCursorModel;
}
// For Claude profiles
return selectedModel === profile.model && selectedThinkingLevel === profile.thinkingLevel;
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
@@ -39,15 +81,16 @@ export function ProfileQuickSelect({
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{profiles.slice(0, 6).map((profile) => {
{allProfiles.slice(0, 6).map((profile) => {
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
const isSelected =
selectedModel === profile.model && selectedThinkingLevel === profile.thinkingLevel;
const isSelected = isProfileSelected(profile);
const isCursorProfile = profile.provider === 'cursor';
return (
<button
key={profile.id}
type="button"
onClick={() => onSelect(profile.model, profile.thinkingLevel)}
onClick={() => onSelect(profile)}
className={cn(
'flex items-center gap-2 p-2 rounded-lg border text-left transition-all',
isSelected
@@ -56,14 +99,23 @@ export function ProfileQuickSelect({
)}
data-testid={`${testIdPrefix}-${profile.id}`}
>
<div className="w-7 h-7 rounded flex items-center justify-center shrink-0 bg-primary/10">
{IconComponent && <IconComponent className="w-4 h-4 text-primary" />}
<div
className={cn(
'w-7 h-7 rounded flex items-center justify-center shrink-0',
isCursorProfile ? 'bg-amber-500/10' : 'bg-primary/10'
)}
>
{isCursorProfile ? (
<Terminal className="w-4 h-4 text-amber-500" />
) : (
IconComponent && <IconComponent className="w-4 h-4 text-primary" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{profile.name}</p>
<p className="text-[10px] text-muted-foreground truncate">
{profile.model}
{profile.thinkingLevel !== 'none' && ` + ${profile.thinkingLevel}`}
{getProfileModelDisplay(profile)}
{getProfileThinkingDisplay(profile) && ` + ${getProfileThinkingDisplay(profile)}`}
</p>
</div>
</button>

View File

@@ -1,7 +1,10 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
import type { BranchInfo, GitRepoStatus } from '../types';
const logger = createLogger('Branches');
export function useBranches() {
const [branches, setBranches] = useState<BranchInfo[]>([]);
const [aheadCount, setAheadCount] = useState(0);
@@ -26,7 +29,7 @@ export function useBranches() {
try {
const api = getElectronAPI();
if (!api?.worktree?.listBranches) {
console.warn('List branches API not available');
logger.warn('List branches API not available');
return;
}
const result = await api.worktree.listBranches(worktreePath);
@@ -45,11 +48,11 @@ export function useBranches() {
setGitRepoStatus({ isGitRepo: true, hasCommits: false });
} else if (!result.success) {
// Other errors - log them
console.warn('Failed to fetch branches:', result.error);
logger.warn('Failed to fetch branches:', result.error);
resetBranchState();
}
} catch (error) {
console.error('Failed to fetch branches:', error);
logger.error('Failed to fetch branches:', error);
resetBranchState();
// Reset git status to unknown state on network/API errors
setGitRepoStatus({ isGitRepo: true, hasCommits: true });

View File

@@ -1,6 +1,9 @@
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
const logger = createLogger('DefaultEditor');
export function useDefaultEditor() {
const [defaultEditorName, setDefaultEditorName] = useState<string>('Editor');
@@ -15,7 +18,7 @@ export function useDefaultEditor() {
setDefaultEditorName(result.result.editorName);
}
} catch (error) {
console.error('Failed to fetch default editor:', error);
logger.error('Failed to fetch default editor:', error);
}
}, []);

View File

@@ -1,9 +1,12 @@
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
import { normalizePath } from '@/lib/utils';
import { toast } from 'sonner';
import type { DevServerInfo, WorktreeInfo } from '../types';
const logger = createLogger('DevServers');
interface UseDevServersOptions {
projectPath: string;
}
@@ -27,7 +30,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
setRunningDevServers(serversMap);
}
} catch (error) {
console.error('Failed to fetch dev servers:', error);
logger.error('Failed to fetch dev servers:', error);
}
}, []);
@@ -73,7 +76,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
toast.error(result.error || 'Failed to start dev server');
}
} catch (error) {
console.error('Start dev server failed:', error);
logger.error('Start dev server failed:', error);
toast.error('Failed to start dev server');
} finally {
setIsStartingDevServer(false);
@@ -105,7 +108,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
toast.error(result.error || 'Failed to stop dev server');
}
} catch (error) {
console.error('Stop dev server failed:', error);
logger.error('Stop dev server failed:', error);
toast.error('Failed to stop dev server');
}
},

View File

@@ -1,8 +1,11 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import type { WorktreeInfo } from '../types';
const logger = createLogger('WorktreeActions');
// Error codes that need special user-friendly handling
const GIT_STATUS_ERROR_CODES = ['NOT_GIT_REPO', 'NO_COMMITS'] as const;
type GitStatusErrorCode = (typeof GIT_STATUS_ERROR_CODES)[number];
@@ -56,7 +59,7 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
toast.error(result.error || 'Failed to switch branch');
}
} catch (error) {
console.error('Switch branch failed:', error);
logger.error('Switch branch failed:', error);
toast.error('Failed to switch branch');
} finally {
setIsSwitching(false);
@@ -84,7 +87,7 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
toast.error(result.error || 'Failed to pull latest changes');
}
} catch (error) {
console.error('Pull failed:', error);
logger.error('Pull failed:', error);
toast.error('Failed to pull latest changes');
} finally {
setIsPulling(false);
@@ -113,7 +116,7 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
toast.error(result.error || 'Failed to push changes');
}
} catch (error) {
console.error('Push failed:', error);
logger.error('Push failed:', error);
toast.error('Failed to push changes');
} finally {
setIsPushing(false);
@@ -126,7 +129,7 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
try {
const api = getElectronAPI();
if (!api?.worktree?.openInEditor) {
console.warn('Open in editor API not available');
logger.warn('Open in editor API not available');
return;
}
const result = await api.worktree.openInEditor(worktree.path);
@@ -136,7 +139,7 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
toast.error(result.error);
}
} catch (error) {
console.error('Open in editor failed:', error);
logger.error('Open in editor failed:', error);
}
}, []);

View File

@@ -1,9 +1,12 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { pathsEqual } from '@/lib/utils';
import type { WorktreeInfo } from '../types';
const logger = createLogger('Worktrees');
interface UseWorktreesOptions {
projectPath: string;
refreshTrigger?: number;
@@ -33,7 +36,7 @@ export function useWorktrees({
try {
const api = getElectronAPI();
if (!api?.worktree?.listAll) {
console.warn('Worktree API not available');
logger.warn('Worktree API not available');
return;
}
const result = await api.worktree.listAll(projectPath, true);
@@ -44,7 +47,7 @@ export function useWorktrees({
// Return removed worktrees so they can be handled by the caller
return result.removedWorktrees;
} catch (error) {
console.error('Failed to fetch worktrees:', error);
logger.error('Failed to fetch worktrees:', error);
return undefined;
} finally {
if (!silent) {

View File

@@ -1,4 +1,5 @@
import { useEffect, useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { Card, CardContent } from '@/components/ui/card';
@@ -6,6 +7,8 @@ import { Button } from '@/components/ui/button';
import { File, Folder, FolderOpen, ChevronRight, ChevronDown, RefreshCw, Code } from 'lucide-react';
import { cn } from '@/lib/utils';
const logger = createLogger('CodeView');
interface FileTreeNode {
name: string;
path: string;
@@ -60,7 +63,7 @@ export function CodeView() {
setFileTree(entries);
}
} catch (error) {
console.error('Failed to load file tree:', error);
logger.error('Failed to load file tree:', error);
} finally {
setIsLoading(false);
}
@@ -91,7 +94,7 @@ export function CodeView() {
}));
}
} catch (error) {
console.error('Failed to load subdirectory:', error);
logger.error('Failed to load subdirectory:', error);
}
return [];
};
@@ -107,7 +110,7 @@ export function CodeView() {
setSelectedFile(path);
}
} catch (error) {
console.error('Failed to load file:', error);
logger.error('Failed to load file:', error);
}
};

View File

@@ -1,4 +1,5 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
@@ -38,6 +39,8 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
const logger = createLogger('ContextView');
import { sanitizeFilename } from '@/lib/image-utils';
import { Markdown } from '../ui/markdown';
import {
@@ -160,7 +163,7 @@ export function ContextView() {
const metadataPath = `${contextPath}/context-metadata.json`;
await api.writeFile(metadataPath, JSON.stringify(metadata, null, 2));
} catch (error) {
console.error('Failed to save metadata:', error);
logger.error('Failed to save metadata:', error);
}
},
[getContextPath]
@@ -202,7 +205,7 @@ export function ContextView() {
setContextFiles(files);
}
} catch (error) {
console.error('Failed to load context files:', error);
logger.error('Failed to load context files:', error);
} finally {
setIsLoading(false);
}
@@ -223,7 +226,7 @@ export function ContextView() {
setHasChanges(false);
}
} catch (error) {
console.error('Failed to load file content:', error);
logger.error('Failed to load file content:', error);
}
}, []);
@@ -247,7 +250,7 @@ export function ContextView() {
setSelectedFile({ ...selectedFile, content: editedContent });
setHasChanges(false);
} catch (error) {
console.error('Failed to save file:', error);
logger.error('Failed to save file:', error);
} finally {
setIsSaving(false);
}
@@ -279,7 +282,7 @@ export function ContextView() {
result.error || `Automaker couldn't generate a description for “${fileName}”.`;
toast.error('Failed to generate description', { description: message });
} catch (error) {
console.error('Failed to generate description:', error);
logger.error('Failed to generate description:', error);
const message =
error instanceof Error
? error.message
@@ -315,7 +318,7 @@ export function ContextView() {
});
}
} catch (error) {
console.error('Failed to generate description:', error);
logger.error('Failed to generate description:', error);
} finally {
// Remove from generating set
setGeneratingDescriptions((prev) => {
@@ -401,7 +404,7 @@ export function ContextView() {
// For images, use the path in the images directory
generateDescriptionAsync(imagePathForDescription || filePath, fileName, isImage);
} catch (error) {
console.error('Failed to upload file:', error);
logger.error('Failed to upload file:', error);
toast.error('Failed to upload file', {
description: error instanceof Error ? error.message : 'Unknown error',
});
@@ -492,7 +495,7 @@ export function ContextView() {
setNewMarkdownDescription('');
setNewMarkdownContent('');
} catch (error) {
console.error('Failed to create markdown:', error);
logger.error('Failed to create markdown:', error);
}
};
@@ -515,7 +518,7 @@ export function ContextView() {
setHasChanges(false);
await loadContextFiles();
} catch (error) {
console.error('Failed to delete file:', error);
logger.error('Failed to delete file:', error);
}
};
@@ -537,14 +540,14 @@ export function ContextView() {
// Check if file with new name already exists
const exists = await api.exists(newPath);
if (exists) {
console.error('A file with this name already exists');
logger.error('A file with this name already exists');
return;
}
// Read current file content
const result = await api.readFile(selectedFile.path);
if (!result.success || result.content === undefined) {
console.error('Failed to read file for rename');
logger.error('Failed to read file for rename');
return;
}
@@ -578,7 +581,7 @@ export function ContextView() {
};
setSelectedFile(renamedFile);
} catch (error) {
console.error('Failed to rename file:', error);
logger.error('Failed to rename file:', error);
}
};
@@ -603,7 +606,7 @@ export function ContextView() {
setEditDescriptionValue('');
setEditDescriptionFileName('');
} catch (error) {
console.error('Failed to save description:', error);
logger.error('Failed to save description:', error);
}
};
@@ -634,7 +637,7 @@ export function ContextView() {
await loadContextFiles();
} catch (error) {
console.error('Failed to delete file:', error);
logger.error('Failed to delete file:', error);
}
};

View File

@@ -1,4 +1,5 @@
import { useState, useCallback, useMemo } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { CircleDot, RefreshCw } from 'lucide-react';
import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
@@ -11,8 +12,11 @@ import { useGithubIssues, useIssueValidation } 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';
const logger = createLogger('GitHubIssuesView');
export function GitHubIssuesView() {
const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null);
const [validationResult, setValidationResult] = useState<IssueValidationResult | null>(null);
@@ -24,6 +28,12 @@ export function GitHubIssuesView() {
const { currentProject, defaultAIProfileId, aiProfiles, getCurrentWorktree, worktreesByProject } =
useAppStore();
// Model override for validation
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
// Extract model string for API calls (backward compatibility)
const validationModelString = validationModelOverride.effectiveModel;
const { openIssues, closedIssues, loading, refreshing, error, refresh } = useGithubIssues();
const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } =
@@ -88,6 +98,9 @@ export function GitHubIssuesView() {
.filter(Boolean)
.join('\n');
// Use profile default model
const featureModel = defaultProfile?.model ?? 'opus';
const feature = {
id: `issue-${issue.number}-${crypto.randomUUID()}`,
title: issue.title,
@@ -96,7 +109,7 @@ export function GitHubIssuesView() {
status: 'backlog' as const,
passes: false,
priority: getFeaturePriority(validation.estimatedComplexity),
model: defaultProfile?.model ?? 'opus',
model: featureModel,
thinkingLevel: defaultProfile?.thinkingLevel ?? 'none',
branchName: currentBranch,
createdAt: new Date().toISOString(),
@@ -111,7 +124,7 @@ export function GitHubIssuesView() {
}
}
} catch (err) {
console.error('[GitHubIssuesView] Convert to task error:', err);
logger.error('Convert to task error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to create task');
}
},
@@ -211,6 +224,7 @@ export function GitHubIssuesView() {
setShowRevalidateConfirm(true);
}}
formatDate={formatDate}
modelOverride={validationModelOverride}
/>
)}
@@ -239,11 +253,14 @@ export function GitHubIssuesView() {
confirmText="Re-validate"
onConfirm={() => {
if (selectedIssue && pendingRevalidateOptions) {
console.log('[GitHubIssuesView] Revalidating with options:', {
logger.info('Revalidating with options:', {
commentsCount: pendingRevalidateOptions.comments?.length ?? 0,
linkedPRsCount: pendingRevalidateOptions.linkedPRs?.length ?? 0,
});
handleValidateIssue(selectedIssue, pendingRevalidateOptions);
handleValidateIssue(selectedIssue, {
...pendingRevalidateOptions,
forceRevalidate: true,
});
}
}}
/>

View File

@@ -21,6 +21,7 @@ import { Markdown } from '@/components/ui/markdown';
import { cn } from '@/lib/utils';
import type { IssueDetailPanelProps } from '../types';
import { isValidationStale } from '../utils';
import { ModelOverrideTrigger } from '@/components/shared';
import { useIssueComments } from '../hooks';
import { CommentItem } from './comment-item';
@@ -34,6 +35,7 @@ export function IssueDetailPanel({
onClose,
onShowRevalidateConfirm,
formatDate,
modelOverride,
}: IssueDetailPanelProps) {
const isValidating = validatingIssues.has(issue.number);
const cached = cachedValidations.get(issue.number);
@@ -56,6 +58,7 @@ export function IssueDetailPanel({
const getValidationOptions = (forceRevalidate = false) => {
return {
forceRevalidate,
modelEntry: modelOverride.effectiveModelEntry, // Pass the full PhaseModelEntry to preserve thinking level
comments: includeCommentsInAnalysis && comments.length > 0 ? comments : undefined,
linkedPRs: issue.linkedPRs?.map((pr) => ({
number: pr.number,
@@ -116,6 +119,15 @@ export function IssueDetailPanel({
<Clock className="h-4 w-4 mr-1 text-yellow-500" />
View (stale)
</Button>
<ModelOverrideTrigger
currentModelEntry={modelOverride.effectiveModelEntry}
onModelChange={modelOverride.setOverride}
phase="validationModel"
isOverridden={modelOverride.isOverridden}
size="sm"
variant="icon"
className="mx-1"
/>
<Button
variant="default"
size="sm"
@@ -129,14 +141,25 @@ export function IssueDetailPanel({
}
return (
<Button
variant="default"
size="sm"
onClick={() => onValidateIssue(issue, getValidationOptions())}
>
<Wand2 className="h-4 w-4 mr-1" />
Validate with AI
</Button>
<>
<ModelOverrideTrigger
currentModelEntry={modelOverride.effectiveModelEntry}
onModelChange={modelOverride.setOverride}
phase="validationModel"
isOverridden={modelOverride.isOverridden}
size="sm"
variant="icon"
className="mr-1"
/>
<Button
variant="default"
size="sm"
onClick={() => onValidateIssue(issue, getValidationOptions())}
>
<Wand2 className="h-4 w-4 mr-1" />
Validate with AI
</Button>
</>
);
})()}
<Button variant="outline" size="sm" onClick={() => onOpenInGitHub(issue.url)}>

View File

@@ -1,5 +1,8 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI, GitHubIssue } from '@/lib/electron';
const logger = createLogger('GitHubIssues');
import { useAppStore } from '@/store/app-store';
export function useGithubIssues() {
@@ -38,7 +41,7 @@ export function useGithubIssues() {
}
} catch (err) {
if (isMountedRef.current) {
console.error('[GitHubIssuesView] Error fetching issues:', err);
logger.error('Error fetching issues:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch issues');
}
} finally {

View File

@@ -1,5 +1,8 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI, GitHubComment } from '@/lib/electron';
const logger = createLogger('IssueComments');
import { useAppStore } from '@/store/app-store';
interface UseIssueCommentsResult {
@@ -69,7 +72,7 @@ export function useIssueComments(issueNumber: number | null): UseIssueCommentsRe
}
} catch (err) {
if (isMountedRef.current) {
console.error('[useIssueComments] Error fetching comments:', err);
logger.error('Error fetching comments:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch comments');
}
} finally {

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
getElectronAPI,
GitHubIssue,
@@ -7,11 +8,26 @@ import {
IssueValidationEvent,
StoredValidation,
} from '@/lib/electron';
import type { LinkedPRInfo } from '@automaker/types';
import type { LinkedPRInfo, PhaseModelEntry, ModelAlias, CursorModelId } from '@automaker/types';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import { isValidationStale } from '../utils';
const logger = createLogger('IssueValidation');
/**
* Extract model string from PhaseModelEntry or string (handles both formats)
*/
function extractModel(
entry: PhaseModelEntry | string | undefined
): ModelAlias | CursorModelId | undefined {
if (!entry) return undefined;
if (typeof entry === 'string') {
return entry as ModelAlias | CursorModelId;
}
return entry.model;
}
interface UseIssueValidationOptions {
selectedIssue: GitHubIssue | null;
showValidationDialog: boolean;
@@ -25,7 +41,7 @@ export function useIssueValidation({
onValidationResultChange,
onShowValidationDialogChange,
}: UseIssueValidationOptions) {
const { currentProject, validationModel, muteDoneSound } = useAppStore();
const { currentProject, phaseModels, muteDoneSound } = useAppStore();
const [validatingIssues, setValidatingIssues] = useState<Set<number>>(new Set());
const [cachedValidations, setCachedValidations] = useState<Map<number, StoredValidation>>(
new Map()
@@ -65,7 +81,7 @@ export function useIssueValidation({
}
} catch (err) {
if (isMounted) {
console.error('[GitHubIssuesView] Failed to load cached validations:', err);
logger.error('Failed to load cached validations:', err);
}
}
};
@@ -94,7 +110,7 @@ export function useIssueValidation({
}
} catch (err) {
if (isMounted) {
console.error('[GitHubIssuesView] Failed to load running validations:', err);
logger.error('Failed to load running validations:', err);
}
}
};
@@ -211,11 +227,13 @@ export function useIssueValidation({
issue: GitHubIssue,
options: {
forceRevalidate?: boolean;
model?: string | PhaseModelEntry; // Accept either string (backward compat) or PhaseModelEntry
modelEntry?: PhaseModelEntry; // New preferred way to pass model with thinking level
comments?: GitHubComment[];
linkedPRs?: LinkedPRInfo[];
} = {}
) => {
const { forceRevalidate = false, comments, linkedPRs } = options;
const { forceRevalidate = false, model, modelEntry, comments, linkedPRs } = options;
if (!currentProject?.path) {
toast.error('No project selected');
@@ -242,6 +260,22 @@ export function useIssueValidation({
description: 'You will be notified when the analysis is complete',
});
// Use provided model override or fall back to phaseModels.validationModel
// Extract model string and thinking level from PhaseModelEntry (handles both old string format and new object format)
const effectiveModelEntry = modelEntry
? modelEntry
: model
? typeof model === 'string'
? { model: model as ModelAlias | CursorModelId }
: model
: phaseModels.validationModel;
const normalizedEntry =
typeof effectiveModelEntry === 'string'
? { model: effectiveModelEntry as ModelAlias | CursorModelId }
: effectiveModelEntry;
const modelToUse = normalizedEntry.model;
const thinkingLevelToUse = normalizedEntry.thinkingLevel;
try {
const api = getElectronAPI();
if (api.github?.validateIssue) {
@@ -256,7 +290,8 @@ export function useIssueValidation({
const result = await api.github.validateIssue(
currentProject.path,
validationInput,
validationModel
modelToUse,
thinkingLevelToUse
);
if (!result.success) {
@@ -265,7 +300,7 @@ export function useIssueValidation({
// On success, the result will come through the event stream
}
} catch (err) {
console.error('[GitHubIssuesView] Validation error:', err);
logger.error('Validation error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to validate issue');
}
},
@@ -273,7 +308,7 @@ export function useIssueValidation({
currentProject?.path,
validatingIssues,
cachedValidations,
validationModel,
phaseModels.validationModel,
onValidationResultChange,
onShowValidationDialogChange,
]
@@ -307,7 +342,7 @@ export function useIssueValidation({
});
}
} catch (err) {
console.error('[GitHubIssuesView] Failed to mark validation as viewed:', err);
logger.error('Failed to mark validation as viewed:', err);
}
}
}

View File

@@ -1,5 +1,5 @@
import type { GitHubIssue, StoredValidation, GitHubComment } from '@/lib/electron';
import type { LinkedPRInfo } from '@automaker/types';
import type { ModelAlias, CursorModelId, LinkedPRInfo, PhaseModelEntry } from '@automaker/types';
export interface IssueRowProps {
issue: GitHubIssue;
@@ -34,4 +34,11 @@ export interface IssueDetailPanelProps {
/** Called when user wants to revalidate - receives the validation options including comments/linkedPRs */
onShowRevalidateConfirm: (options: ValidateIssueOptions) => void;
formatDate: (date: string) => string;
/** Model override state */
modelOverride: {
effectiveModelEntry: PhaseModelEntry;
effectiveModel: ModelAlias | CursorModelId;
isOverridden: boolean;
setOverride: (entry: PhaseModelEntry | null) => void;
};
}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { GitPullRequest, Loader2, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react';
import { getElectronAPI, GitHubPR } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
@@ -6,6 +7,8 @@ import { Button } from '@/components/ui/button';
import { Markdown } from '@/components/ui/markdown';
import { cn } from '@/lib/utils';
const logger = createLogger('GitHubPRsView');
export function GitHubPRsView() {
const [openPRs, setOpenPRs] = useState<GitHubPR[]>([]);
const [mergedPRs, setMergedPRs] = useState<GitHubPR[]>([]);
@@ -35,7 +38,7 @@ export function GitHubPRsView() {
}
}
} catch (err) {
console.error('[GitHubPRsView] Error fetching PRs:', err);
logger.error('Error fetching PRs:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch pull requests');
} finally {
setLoading(false);

View File

@@ -0,0 +1,372 @@
/**
* IdeationDashboard - Main dashboard showing all generated suggestions
* First page users see - shows all ideas ready for accept/reject
*/
import { useState, useMemo } from 'react';
import { Loader2, AlertCircle, Plus, X, Sparkles, Lightbulb } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useIdeationStore, type GenerationJob } from '@/store/ideation-store';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { AnalysisSuggestion } from '@automaker/types';
interface IdeationDashboardProps {
onGenerateIdeas: () => void;
}
function SuggestionCard({
suggestion,
job,
onAccept,
onRemove,
isAdding,
}: {
suggestion: AnalysisSuggestion;
job: GenerationJob;
onAccept: () => void;
onRemove: () => void;
isAdding: boolean;
}) {
return (
<Card className="transition-all hover:border-primary/50">
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium">{suggestion.title}</h4>
<Badge variant="outline" className="text-xs">
{suggestion.priority}
</Badge>
<Badge variant="secondary" className="text-xs">
{job.prompt.title}
</Badge>
</div>
<p className="text-sm text-muted-foreground">{suggestion.description}</p>
{suggestion.rationale && (
<p className="text-xs text-muted-foreground mt-2 italic">{suggestion.rationale}</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
size="sm"
variant="ghost"
onClick={onRemove}
disabled={isAdding}
className="text-muted-foreground hover:text-destructive"
>
<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" />
) : (
<>
<Plus className="w-4 h-4" />
Accept
</>
)}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
function GeneratingCard({ job }: { job: GenerationJob }) {
const { removeJob } = useIdeationStore();
const isError = job.status === 'error';
return (
<Card className={cn('transition-all', isError ? 'border-red-500/50' : 'border-blue-500/50')}>
<CardContent className="p-4">
<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>
<p className="font-medium">{job.prompt.title}</p>
<p className="text-sm text-muted-foreground">
{isError ? job.error || 'Failed to generate' : 'Generating ideas...'}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeJob(job.id)}
className="text-muted-foreground hover:text-destructive"
>
<X className="w-4 h-4" />
</Button>
</div>
</CardContent>
</Card>
);
}
function TagFilter({
tags,
tagCounts,
selectedTags,
onToggleTag,
}: {
tags: string[];
tagCounts: Record<string, number>;
selectedTags: Set<string>;
onToggleTag: (tag: string) => void;
}) {
if (tags.length === 0) return null;
return (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => {
const isSelected = selectedTags.has(tag);
const count = tagCounts[tag] || 0;
return (
<button
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',
isSelected
? 'bg-primary text-primary-foreground border-primary'
: 'bg-secondary/50 text-muted-foreground border-border hover:border-primary/50 hover:text-foreground'
)}
>
{tag}
<span
className={cn(
'text-xs',
isSelected ? 'text-primary-foreground/70' : 'text-muted-foreground/70'
)}
>
({count})
</span>
</button>
);
})}
{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"
>
Clear filters
</button>
)}
</div>
);
}
export function IdeationDashboard({ onGenerateIdeas }: 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 [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
// Get jobs for current project only (memoized to prevent unnecessary re-renders)
const projectJobs = useMemo(
() =>
currentProject?.path
? generationJobs.filter((job) => job.projectPath === currentProject.path)
: [],
[generationJobs, currentProject?.path]
);
// Separate jobs by status and compute counts in a single pass
const { activeJobs, readyJobs, generatingCount } = useMemo(() => {
const active: GenerationJob[] = [];
const ready: GenerationJob[] = [];
let generating = 0;
for (const job of projectJobs) {
if (job.status === 'generating') {
active.push(job);
generating++;
} else if (job.status === 'error') {
active.push(job);
} else if (job.status === 'ready' && job.suggestions.length > 0) {
ready.push(job);
}
}
return { activeJobs: active, readyJobs: ready, generatingCount: generating };
}, [projectJobs]);
// Flatten all suggestions with their parent job
const allSuggestions = useMemo(
() => readyJobs.flatMap((job) => job.suggestions.map((suggestion) => ({ suggestion, job }))),
[readyJobs]
);
// Extract unique tags and counts from all suggestions
const { availableTags, tagCounts } = useMemo(() => {
const counts: Record<string, number> = {};
allSuggestions.forEach(({ job }) => {
const tag = job.prompt.title;
counts[tag] = (counts[tag] || 0) + 1;
});
return {
availableTags: Object.keys(counts).sort(),
tagCounts: counts,
};
}, [allSuggestions]);
// Filter suggestions based on selected tags
const filteredSuggestions = useMemo(() => {
if (selectedTags.size === 0) return allSuggestions;
return allSuggestions.filter(({ job }) => selectedTags.has(job.prompt.title));
}, [allSuggestions, selectedTags]);
const handleToggleTag = (tag: string) => {
setSelectedTags((prev) => {
const next = new Set(prev);
if (next.has(tag)) {
next.delete(tag);
} else {
next.add(tag);
}
return next;
});
};
const handleAccept = async (suggestion: AnalysisSuggestion, jobId: string) => {
if (!currentProject?.path) {
toast.error('No project selected');
return;
}
setAddingId(suggestion.id);
try {
const api = getElectronAPI();
const result = await api.ideation?.addSuggestionToBoard(currentProject.path, suggestion);
if (result?.success) {
toast.success(`Added "${suggestion.title}" to board`);
removeSuggestionFromJob(jobId, suggestion.id);
} else {
toast.error(result?.error || 'Failed to add to board');
}
} catch (error) {
console.error('Failed to add to board:', error);
toast.error((error as Error).message);
} finally {
setAddingId(null);
}
};
const handleRemove = (suggestionId: string, jobId: string) => {
removeSuggestionFromJob(jobId, suggestionId);
toast.info('Idea removed');
};
const isEmpty = allSuggestions.length === 0 && activeJobs.length === 0;
return (
<div className="flex-1 flex flex-col p-6 overflow-auto">
<div className="max-w-3xl w-full mx-auto space-y-4">
{/* Status text */}
{(generatingCount > 0 || allSuggestions.length > 0) && (
<p className="text-sm text-muted-foreground">
{generatingCount > 0
? `Generating ${generatingCount} idea${generatingCount > 1 ? 's' : ''}...`
: selectedTags.size > 0
? `Showing ${filteredSuggestions.length} of ${allSuggestions.length} ideas`
: `${allSuggestions.length} idea${allSuggestions.length > 1 ? 's' : ''} ready for review`}
</p>
)}
{/* Tag Filters */}
{availableTags.length > 0 && (
<TagFilter
tags={availableTags}
tagCounts={tagCounts}
selectedTags={selectedTags}
onToggleTag={handleToggleTag}
/>
)}
{/* Generating/Error Jobs */}
{activeJobs.length > 0 && (
<div className="space-y-3">
{activeJobs.map((job) => (
<GeneratingCard key={job.id} job={job} />
))}
</div>
)}
{/* Suggestions List */}
{filteredSuggestions.length > 0 && (
<div className="space-y-3">
{filteredSuggestions.map(({ suggestion, job }) => (
<SuggestionCard
key={suggestion.id}
suggestion={suggestion}
job={job}
onAccept={() => handleAccept(suggestion, job.id)}
onRemove={() => handleRemove(suggestion.id, job.id)}
isAdding={addingId === suggestion.id}
/>
))}
</div>
)}
{/* No results after filtering */}
{filteredSuggestions.length === 0 && allSuggestions.length > 0 && (
<Card>
<CardContent className="py-8">
<div className="text-center text-muted-foreground">
<p>No ideas match the selected filters</p>
<button
onClick={() => setSelectedTags(new Set())}
className="text-primary hover:underline mt-2"
>
Clear filters
</button>
</div>
</CardContent>
</Card>
)}
{/* Generate More Ideas Button - shown when there are items */}
{!isEmpty && (
<div className="pt-2">
<Button onClick={onGenerateIdeas} variant="outline" className="w-full gap-2">
<Lightbulb className="w-4 h-4" />
Generate More Ideas
</Button>
</div>
)}
{/* Empty State */}
{isEmpty && (
<Card>
<CardContent className="py-16">
<div className="text-center">
<Sparkles className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium mb-2">No ideas yet</h3>
<p className="text-muted-foreground mb-6">
Generate ideas by selecting a category and prompt type
</p>
<Button onClick={onGenerateIdeas} size="lg" className="gap-2">
<Lightbulb className="w-5 h-5" />
Generate Ideas
</Button>
</div>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
/**
* PromptCategoryGrid - Grid of prompt categories to select from
*/
import {
ArrowLeft,
Zap,
Palette,
Code,
TrendingUp,
Cpu,
Shield,
Gauge,
Accessibility,
BarChart3,
Loader2,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
import type { IdeaCategory } from '@automaker/types';
interface PromptCategoryGridProps {
onSelect: (category: IdeaCategory) => void;
onBack: () => void;
}
const iconMap: Record<string, typeof Zap> = {
Zap,
Palette,
Code,
TrendingUp,
Cpu,
Shield,
Gauge,
Accessibility,
BarChart3,
};
export function PromptCategoryGrid({ onSelect, onBack }: PromptCategoryGridProps) {
const { categories, isLoading, error } = useGuidedPrompts();
return (
<div className="flex-1 flex flex-col p-6 overflow-auto">
<div className="max-w-4xl w-full mx-auto space-y-4">
{/* Back link */}
<button
onClick={onBack}
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span>Back</span>
</button>
{isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading categories...</span>
</div>
)}
{error && (
<div className="text-center py-12 text-destructive">
<p>Failed to load categories: {error}</p>
</div>
)}
{!isLoading && !error && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categories.map((category) => {
const Icon = iconMap[category.icon] || Zap;
return (
<Card
key={category.id}
className="cursor-pointer transition-all hover:border-primary hover:shadow-md"
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>
<div>
<h3 className="font-semibold text-lg">{category.name}</h3>
<p className="text-muted-foreground text-sm mt-1">{category.description}</p>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,193 @@
/**
* PromptList - List of prompts for a specific category
*/
import { useState, useMemo } from 'react';
import { ArrowLeft, Lightbulb, Loader2, CheckCircle2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
import { useIdeationStore } from '@/store/ideation-store';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { useNavigate } from '@tanstack/react-router';
import type { IdeaCategory, IdeationPrompt } from '@automaker/types';
interface PromptListProps {
category: IdeaCategory;
onBack: () => void;
}
export function PromptList({ category, onBack }: PromptListProps) {
const currentProject = useAppStore((s) => s.currentProject);
const generationJobs = useIdeationStore((s) => s.generationJobs);
const setMode = useIdeationStore((s) => s.setMode);
const addGenerationJob = useIdeationStore((s) => s.addGenerationJob);
const updateJobStatus = useIdeationStore((s) => s.updateJobStatus);
const [loadingPromptId, setLoadingPromptId] = useState<string | null>(null);
const [startedPrompts, setStartedPrompts] = useState<Set<string>>(new Set());
const navigate = useNavigate();
const {
getPromptsByCategory,
isLoading: isLoadingPrompts,
error: promptsError,
} = useGuidedPrompts();
const prompts = getPromptsByCategory(category);
// Get jobs for current project only (memoized to prevent unnecessary re-renders)
const projectJobs = useMemo(
() =>
currentProject?.path
? generationJobs.filter((job) => job.projectPath === currentProject.path)
: [],
[generationJobs, currentProject?.path]
);
// Check which prompts are already generating
const generatingPromptIds = useMemo(
() => new Set(projectJobs.filter((j) => j.status === 'generating').map((j) => j.prompt.id)),
[projectJobs]
);
const handleSelectPrompt = async (prompt: IdeationPrompt) => {
if (!currentProject?.path) {
toast.error('No project selected');
return;
}
if (loadingPromptId || generatingPromptIds.has(prompt.id)) return;
setLoadingPromptId(prompt.id);
// Add a job and navigate to dashboard
const jobId = addGenerationJob(currentProject.path, prompt);
setStartedPrompts((prev) => new Set(prev).add(prompt.id));
// Show toast and navigate to dashboard
toast.info(`Generating ideas for "${prompt.title}"...`);
setMode('dashboard');
try {
const api = getElectronAPI();
const result = await api.ideation?.generateSuggestions(
currentProject.path,
prompt.id,
category
);
if (result?.success && result.suggestions) {
updateJobStatus(jobId, 'ready', result.suggestions);
toast.success(`Generated ${result.suggestions.length} ideas for "${prompt.title}"`, {
duration: 10000,
action: {
label: 'View Ideas',
onClick: () => {
setMode('dashboard');
navigate({ to: '/ideation' });
},
},
});
} else {
updateJobStatus(
jobId,
'error',
undefined,
result?.error || 'Failed to generate suggestions'
);
toast.error(result?.error || 'Failed to generate suggestions');
}
} catch (error) {
console.error('Failed to generate suggestions:', error);
updateJobStatus(jobId, 'error', undefined, (error as Error).message);
toast.error((error as Error).message);
} finally {
setLoadingPromptId(null);
}
};
return (
<div className="flex-1 flex flex-col p-6 overflow-auto">
<div className="max-w-3xl w-full mx-auto space-y-4">
{/* Back link */}
<button
onClick={onBack}
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span>Back</span>
</button>
<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" />
<span className="ml-2 text-muted-foreground">Loading prompts...</span>
</div>
)}
{promptsError && (
<div className="text-center py-8 text-destructive">
<p>Failed to load prompts: {promptsError}</p>
</div>
)}
{!isLoadingPrompts &&
!promptsError &&
prompts.map((prompt) => {
const isLoading = loadingPromptId === prompt.id;
const isGenerating = generatingPromptIds.has(prompt.id);
const isStarted = startedPrompts.has(prompt.id);
const isDisabled = loadingPromptId !== null || isGenerating;
return (
<Card
key={prompt.id}
className={`transition-all ${
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' : ''
}`}
onClick={() => !isDisabled && handleSelectPrompt(prompt)}
>
<CardContent className="p-5">
<div className="flex items-start gap-4">
<div
className={`p-2 rounded-lg mt-0.5 ${
isLoading || isGenerating
? 'bg-blue-500/10'
: isStarted
? 'bg-green-500/10'
: 'bg-primary/10'
}`}
>
{isLoading || isGenerating ? (
<Loader2 className="w-4 h-4 text-blue-500 animate-spin" />
) : isStarted ? (
<CheckCircle2 className="w-4 h-4 text-green-500" />
) : (
<Lightbulb className="w-4 h-4 text-primary" />
)}
</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>
{(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>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,211 @@
/**
* IdeationView - Main view for brainstorming and idea management
* Dashboard-first design with Generate Ideas flow
*/
import { useCallback } from 'react';
import { useIdeationStore } from '@/store/ideation-store';
import { useAppStore } from '@/store/app-store';
import { PromptCategoryGrid } from './components/prompt-category-grid';
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 } from 'lucide-react';
import type { IdeaCategory } from '@automaker/types';
import type { IdeationMode } from '@/store/ideation-store';
// Breadcrumb component - compact inline breadcrumbs
function IdeationBreadcrumbs({
currentMode,
selectedCategory,
onNavigate,
}: {
currentMode: IdeationMode;
selectedCategory: IdeaCategory | null;
onNavigate: (mode: IdeationMode, category?: IdeaCategory | null) => void;
}) {
const { getCategoryById } = useGuidedPrompts();
const categoryInfo = selectedCategory ? getCategoryById(selectedCategory) : null;
// On dashboard, no breadcrumbs needed (it's the root)
if (currentMode === 'dashboard') {
return null;
}
return (
<nav className="flex items-center gap-1 text-sm text-muted-foreground">
<button
onClick={() => onNavigate('dashboard')}
className="hover:text-foreground transition-colors"
>
Dashboard
</button>
<ChevronRight className="w-3 h-3" />
{selectedCategory && categoryInfo ? (
<>
<button
onClick={() => onNavigate('prompts', null)}
className="hover:text-foreground transition-colors"
>
Generate Ideas
</button>
<ChevronRight className="w-3 h-3" />
<span className="text-foreground">{categoryInfo.name}</span>
</>
) : (
<span className="text-foreground">Generate Ideas</span>
)}
</nav>
);
}
// Header shown on all pages - matches other view headers
function IdeationHeader({
currentMode,
selectedCategory,
onNavigate,
onGenerateIdeas,
onBack,
}: {
currentMode: IdeationMode;
selectedCategory: IdeaCategory | null;
onNavigate: (mode: IdeationMode, category?: IdeaCategory | null) => void;
onGenerateIdeas: () => void;
onBack: () => void;
}) {
const { getCategoryById } = useGuidedPrompts();
const showBackButton = currentMode === 'prompts';
// Get subtitle text based on current mode
const getSubtitle = (): string => {
if (currentMode === 'dashboard') {
return 'Review and accept generated ideas';
}
if (currentMode === 'prompts') {
if (selectedCategory) {
const categoryInfo = getCategoryById(selectedCategory);
return `Select a prompt from ${categoryInfo?.name || 'category'}`;
}
return 'Select a category to generate ideas';
}
return '';
};
const subtitle = getSubtitle();
return (
<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">
{showBackButton && (
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="w-5 h-5" />
</Button>
)}
<div>
<div className="flex items-center gap-2">
<Lightbulb className="w-5 h-5 text-primary" />
<h1 className="text-xl font-bold">Ideation</h1>
</div>
{currentMode === 'dashboard' ? (
<p className="text-sm text-muted-foreground">{subtitle}</p>
) : (
<IdeationBreadcrumbs
currentMode={currentMode}
selectedCategory={selectedCategory}
onNavigate={onNavigate}
/>
)}
</div>
</div>
<div className="flex gap-2 items-center">
<Button onClick={onGenerateIdeas} className="gap-2">
<Lightbulb className="w-4 h-4" />
Generate Ideas
</Button>
</div>
</div>
);
}
export function IdeationView() {
const currentProject = useAppStore((s) => s.currentProject);
const { currentMode, selectedCategory, setMode, setCategory } = useIdeationStore();
const handleNavigate = useCallback(
(mode: IdeationMode, category?: IdeaCategory | null) => {
setMode(mode);
if (category !== undefined) {
setCategory(category);
} else if (mode !== 'prompts') {
setCategory(null);
}
},
[setMode, setCategory]
);
const handleSelectCategory = useCallback(
(category: IdeaCategory) => {
setCategory(category);
},
[setCategory]
);
const handleBackFromPrompts = useCallback(() => {
// If viewing a category, go back to category grid
if (selectedCategory) {
setCategory(null);
return;
}
// Otherwise, go back to dashboard
setMode('dashboard');
}, [selectedCategory, setCategory, setMode]);
const handleGenerateIdeas = useCallback(() => {
setMode('prompts');
setCategory(null);
}, [setMode, setCategory]);
if (!currentProject) {
return (
<div
className="flex-1 flex items-center justify-center content-bg"
data-testid="ideation-view"
>
<div className="text-center text-muted-foreground">
<p>Open a project to start brainstorming ideas</p>
</div>
</div>
);
}
return (
<div
className="flex-1 flex flex-col content-bg min-h-0 overflow-hidden"
data-testid="ideation-view"
>
{/* Header with breadcrumbs - always shown */}
<IdeationHeader
currentMode={currentMode}
selectedCategory={selectedCategory}
onNavigate={handleNavigate}
onGenerateIdeas={handleGenerateIdeas}
onBack={handleBackFromPrompts}
/>
{/* Dashboard - main view */}
{currentMode === 'dashboard' && <IdeationDashboard onGenerateIdeas={handleGenerateIdeas} />}
{/* Prompts - category selection */}
{currentMode === 'prompts' && !selectedCategory && (
<PromptCategoryGrid onSelect={handleSelectCategory} onBack={handleBackFromPrompts} />
)}
{/* Prompts - prompt selection within category */}
{currentMode === 'prompts' && selectedCategory && (
<PromptList category={selectedCategory} onBack={handleBackFromPrompts} />
)}
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore, Feature } from '@/store/app-store';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -12,6 +13,8 @@ import { toast } from 'sonner';
import { useNavigate } from '@tanstack/react-router';
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
const logger = createLogger('InterviewView');
interface InterviewMessage {
id: string;
role: 'user' | 'assistant';
@@ -97,7 +100,7 @@ export function InterviewView() {
setProjectPath(defaultDir);
}
} catch (error) {
console.error('Failed to load default workspace directory:', error);
logger.error('Failed to load default workspace directory:', error);
}
};
@@ -367,7 +370,7 @@ export function InterviewView() {
addProject(project);
setCurrentProject(project);
} catch (error) {
console.error('Failed to create project:', error);
logger.error('Failed to create project:', error);
setIsGenerating(false);
}
};

View File

@@ -4,13 +4,21 @@ import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { cn, modelSupportsThinking } from '@/lib/utils';
import { DialogFooter } from '@/components/ui/dialog';
import { Brain } from 'lucide-react';
import { Brain, Bot, Terminal } from 'lucide-react';
import { toast } from 'sonner';
import type { AIProfile, AgentModel, ThinkingLevel } from '@/store/app-store';
import type {
AIProfile,
ModelAlias,
ThinkingLevel,
ModelProvider,
CursorModelId,
} from '@automaker/types';
import { CURSOR_MODEL_MAP, cursorModelHasThinking } from '@automaker/types';
import { useAppStore } from '@/store/app-store';
import { CLAUDE_MODELS, THINKING_LEVELS, ICON_OPTIONS } from '../constants';
import { getProviderFromModel } from '../utils';
interface ProfileFormProps {
profile: Partial<AIProfile>;
@@ -27,39 +35,73 @@ export function ProfileForm({
isEditing,
hotkeyActive,
}: ProfileFormProps) {
const { enabledCursorModels } = useAppStore();
const [formData, setFormData] = useState({
name: profile.name || '',
description: profile.description || '',
model: profile.model || ('opus' as AgentModel),
provider: (profile.provider || 'claude') as ModelProvider,
// Claude-specific
model: profile.model || ('sonnet' as ModelAlias),
thinkingLevel: profile.thinkingLevel || ('none' as ThinkingLevel),
// Cursor-specific
cursorModel: profile.cursorModel || ('auto' as CursorModelId),
icon: profile.icon || 'Brain',
});
const provider = getProviderFromModel(formData.model);
const supportsThinking = modelSupportsThinking(formData.model);
const supportsThinking = formData.provider === 'claude' && modelSupportsThinking(formData.model);
const handleModelChange = (model: AgentModel) => {
const handleProviderChange = (provider: ModelProvider) => {
setFormData({
...formData,
provider,
// Reset to defaults when switching providers
model: provider === 'claude' ? 'sonnet' : formData.model,
thinkingLevel: provider === 'claude' ? 'none' : formData.thinkingLevel,
cursorModel: provider === 'cursor' ? 'auto' : formData.cursorModel,
});
};
const handleModelChange = (model: ModelAlias) => {
setFormData({
...formData,
model,
});
};
const handleCursorModelChange = (cursorModel: CursorModelId) => {
setFormData({
...formData,
cursorModel,
});
};
const handleSubmit = () => {
if (!formData.name.trim()) {
toast.error('Please enter a profile name');
return;
}
onSave({
const baseProfile = {
name: formData.name.trim(),
description: formData.description.trim(),
model: formData.model,
thinkingLevel: supportsThinking ? formData.thinkingLevel : 'none',
provider,
provider: formData.provider,
isBuiltIn: false,
icon: formData.icon,
});
};
if (formData.provider === 'cursor') {
onSave({
...baseProfile,
cursorModel: formData.cursorModel,
});
} else {
onSave({
...baseProfile,
model: formData.model,
thinkingLevel: supportsThinking ? formData.thinkingLevel : 'none',
});
}
};
return (
@@ -113,34 +155,136 @@ export function ProfileForm({
</div>
</div>
{/* Model Selection */}
{/* Provider Selection */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-primary" />
Model
</Label>
<div className="flex gap-2 flex-wrap">
{CLAUDE_MODELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => handleModelChange(id)}
className={cn(
'flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors',
formData.model === id
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`model-select-${id}`}
>
{label.replace('Claude ', '')}
</button>
))}
<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',
formData.provider === 'claude'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid="provider-select-claude"
>
<Bot 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',
formData.provider === 'cursor'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid="provider-select-cursor"
>
<Terminal className="w-4 h-4" />
Cursor CLI
</button>
</div>
</div>
{/* Thinking Level */}
{supportsThinking && (
{/* Claude Model Selection */}
{formData.provider === 'claude' && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-primary" />
Model
</Label>
<div className="flex gap-2 flex-wrap">
{CLAUDE_MODELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => handleModelChange(id)}
className={cn(
'flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors',
formData.model === id
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`model-select-${id}`}
>
{label.replace('Claude ', '')}
</button>
))}
</div>
</div>
)}
{/* Cursor Model Selection */}
{formData.provider === 'cursor' && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Terminal className="w-4 h-4 text-primary" />
Cursor Model
</Label>
<div className="flex flex-col gap-2">
{enabledCursorModels.length === 0 ? (
<div className="text-sm text-muted-foreground p-3 border border-dashed rounded-md text-center">
No Cursor models enabled. Enable models in Settings AI Providers.
</div>
) : (
Object.entries(CURSOR_MODEL_MAP)
.filter(([id]) => enabledCursorModels.includes(id as CursorModelId))
.map(([id, config]) => (
<button
key={id}
type="button"
onClick={() => handleCursorModelChange(id as CursorModelId)}
className={cn(
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
formData.cursorModel === id
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`cursor-model-select-${id}`}
>
<span>{config.label}</span>
<div className="flex gap-1">
{config.hasThinking && (
<Badge
variant="outline"
className={cn(
'text-xs',
formData.cursorModel === id
? 'border-primary-foreground/50 text-primary-foreground'
: 'border-amber-500/50 text-amber-600 dark:text-amber-400'
)}
>
Thinking
</Badge>
)}
<Badge
variant={config.tier === 'free' ? 'default' : 'secondary'}
className={cn(
'text-xs',
formData.cursorModel === id && 'bg-primary-foreground/20'
)}
>
{config.tier}
</Badge>
</div>
</button>
))
)}
</div>
{formData.cursorModel && cursorModelHasThinking(formData.cursorModel) && (
<p className="text-xs text-muted-foreground">
This model has built-in extended thinking capabilities.
</p>
)}
</div>
)}
{/* Claude Thinking Level */}
{formData.provider === 'claude' && supportsThinking && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-amber-500" />

View File

@@ -1,9 +1,10 @@
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { GripVertical, Lock, Pencil, Trash2, Brain } from 'lucide-react';
import { GripVertical, Lock, Pencil, Trash2, Brain, Bot, Terminal } from 'lucide-react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { AIProfile } from '@/store/app-store';
import type { AIProfile } from '@automaker/types';
import { CURSOR_MODEL_MAP, profileHasThinking } from '@automaker/types';
import { PROFILE_ICONS } from '../constants';
interface SortableProfileCardProps {
@@ -68,12 +69,29 @@ export function SortableProfileCard({ profile, onEdit, onDelete }: SortableProfi
</div>
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">{profile.description}</p>
<div className="flex items-center gap-2 mt-2 flex-wrap">
<span className="text-xs px-2 py-0.5 rounded-full border border-primary/30 text-primary bg-primary/10">
{profile.model}
{/* Provider badge */}
<span className="text-xs px-2 py-0.5 rounded-full border border-border text-muted-foreground bg-muted/50 flex items-center gap-1">
{profile.provider === 'cursor' ? (
<Terminal className="w-3 h-3" />
) : (
<Bot className="w-3 h-3" />
)}
{profile.provider === 'cursor' ? 'Cursor' : 'Claude'}
</span>
{profile.thinkingLevel !== 'none' && (
{/* Model badge */}
<span className="text-xs px-2 py-0.5 rounded-full border border-primary/30 text-primary bg-primary/10">
{profile.provider === 'cursor'
? CURSOR_MODEL_MAP[profile.cursorModel || 'auto']?.label ||
profile.cursorModel ||
'auto'
: profile.model || 'sonnet'}
</span>
{/* Thinking badge - works for both providers */}
{profileHasThinking(profile) && (
<span className="text-xs px-2 py-0.5 rounded-full border border-amber-500/30 text-amber-600 dark:text-amber-400 bg-amber-500/10">
{profile.thinkingLevel}
{profile.provider === 'cursor' ? 'Thinking' : profile.thinkingLevel}
</span>
)}
</div>

View File

@@ -1,5 +1,5 @@
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
import type { AgentModel, ThinkingLevel } from '@/store/app-store';
import type { ModelAlias, ThinkingLevel } from '@/store/app-store';
// Icon mapping for profiles
export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
@@ -22,7 +22,7 @@ export const ICON_OPTIONS = [
];
// Model options for the form
export const CLAUDE_MODELS: { id: AgentModel; label: string }[] = [
export const CLAUDE_MODELS: { id: ModelAlias; label: string }[] = [
{ id: 'haiku', label: 'Claude Haiku' },
{ id: 'sonnet', label: 'Claude Sonnet' },
{ id: 'opus', label: 'Claude Opus' },

View File

@@ -1,6 +1,48 @@
import type { AgentModel, ModelProvider } from '@/store/app-store';
import type { ModelAlias, ModelProvider, AIProfile } from '@automaker/types';
import { CURSOR_MODEL_MAP } from '@automaker/types';
// Helper to determine provider from model
export function getProviderFromModel(model: AgentModel): ModelProvider {
// Helper to determine provider from model (legacy, always returns 'claude')
export function getProviderFromModel(model: ModelAlias): ModelProvider {
return 'claude';
}
/**
* Validate an AI profile for completeness and correctness
*/
export function validateProfile(profile: Partial<AIProfile>): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
// Name is required
if (!profile.name?.trim()) {
errors.push('Profile name is required');
}
// Provider must be valid
if (!profile.provider || !['claude', 'cursor'].includes(profile.provider)) {
errors.push('Invalid provider');
}
// Claude-specific validation
if (profile.provider === 'claude') {
if (!profile.model) {
errors.push('Claude model is required');
} else if (!['haiku', 'sonnet', 'opus'].includes(profile.model)) {
errors.push('Invalid Claude model');
}
}
// Cursor-specific validation
if (profile.provider === 'cursor') {
if (profile.cursorModel && !(profile.cursorModel in CURSOR_MODEL_MAP)) {
errors.push('Invalid Cursor model');
}
}
return {
valid: errors.length === 0,
errors,
};
}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { Bot, Folder, Loader2, RefreshCw, Square, Activity, FileText } from 'lucide-react';
import { getElectronAPI, RunningAgent } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
@@ -7,6 +8,8 @@ import { cn } from '@/lib/utils';
import { useNavigate } from '@tanstack/react-router';
import { AgentOutputModal } from './board-view/dialogs/agent-output-modal';
const logger = createLogger('RunningAgentsView');
export function RunningAgentsView() {
const [runningAgents, setRunningAgents] = useState<RunningAgent[]>([]);
const [loading, setLoading] = useState(true);
@@ -25,7 +28,7 @@ export function RunningAgentsView() {
}
}
} catch (error) {
console.error('[RunningAgentsView] Error fetching running agents:', error);
logger.error('Error fetching running agents:', error);
} finally {
setLoading(false);
setRefreshing(false);
@@ -78,7 +81,7 @@ export function RunningAgentsView() {
fetchRunningAgents();
}
} catch (error) {
console.error('[RunningAgentsView] Error stopping agent:', error);
logger.error('Error stopping agent:', error);
}
},
[fetchRunningAgents]

View File

@@ -2,23 +2,21 @@ import { useState } from 'react';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { useCliStatus, useSettingsView } from './settings-view/hooks';
import { useSettingsView } 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 { ClaudeUsageSection } from './settings-view/api-keys/claude-usage-section';
import { ClaudeCliStatus } from './settings-view/cli-status/claude-cli-status';
import { ClaudeMdSettings } from './settings-view/claude/claude-md-settings';
import { AIEnhancementSection } from './settings-view/ai-enhancement';
import { ModelDefaultsSection } from './settings-view/model-defaults';
import { AppearanceSection } from './settings-view/appearance/appearance-section';
import { TerminalSection } from './settings-view/terminal/terminal-section';
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 { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
import { ProviderTabs } 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';
@@ -48,9 +46,6 @@ export function SettingsView() {
defaultAIProfileId,
setDefaultAIProfileId,
aiProfiles,
apiKeys,
validationModel,
setValidationModel,
autoLoadClaudeMd,
setAutoLoadClaudeMd,
enableSandboxMode,
@@ -61,19 +56,6 @@ export function SettingsView() {
setPromptCustomization,
} = useAppStore();
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
// Hide usage tracking when using API key (only show for Claude Code CLI users)
// Check both user-entered API key and environment variable ANTHROPIC_API_KEY
// Also hide on Windows for now (CLI usage command not supported)
// Only show if CLI has been verified/authenticated
const isWindows =
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
const hasApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
const isCliVerified =
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
const showUsageTracking = !hasApiKey && !isWindows && isCliVerified;
// Convert electron Project to settings-view Project type
const convertProject = (project: ElectronProject | null): SettingsProject | null => {
if (!project) return null;
@@ -101,9 +83,6 @@ export function SettingsView() {
}
};
// Use CLI status hook
const { claudeCliStatus, isCheckingClaudeCli, handleRefreshClaudeCli } = useCliStatus();
// Use settings view navigation hook
const { activeView, navigateTo } = useSettingsView();
@@ -113,23 +92,9 @@ export function SettingsView() {
// Render the active section based on current view
const renderActiveSection = () => {
switch (activeView) {
case 'claude':
return (
<div className="space-y-6">
<ClaudeCliStatus
status={claudeCliStatus}
isChecking={isCheckingClaudeCli}
onRefresh={handleRefreshClaudeCli}
/>
<ClaudeMdSettings
autoLoadClaudeMd={autoLoadClaudeMd}
onAutoLoadClaudeMdChange={setAutoLoadClaudeMd}
enableSandboxMode={enableSandboxMode}
onEnableSandboxModeChange={setEnableSandboxMode}
/>
{showUsageTracking && <ClaudeUsageSection />}
</div>
);
case 'providers':
case 'claude': // Backwards compatibility
return <ProviderTabs defaultTab={activeView === 'claude' ? 'claude' : undefined} />;
case 'mcp-servers':
return <MCPServersSection />;
case 'prompts':
@@ -139,8 +104,8 @@ export function SettingsView() {
onPromptCustomizationChange={setPromptCustomization}
/>
);
case 'ai-enhancement':
return <AIEnhancementSection />;
case 'model-defaults':
return <ModelDefaultsSection />;
case 'appearance':
return (
<AppearanceSection
@@ -170,7 +135,6 @@ export function SettingsView() {
defaultRequirePlanApproval={defaultRequirePlanApproval}
defaultAIProfileId={defaultAIProfileId}
aiProfiles={aiProfiles}
validationModel={validationModel}
onShowProfilesOnlyChange={setShowProfilesOnly}
onDefaultSkipTestsChange={setDefaultSkipTests}
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
@@ -178,7 +142,6 @@ export function SettingsView() {
onDefaultPlanningModeChange={setDefaultPlanningMode}
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
onDefaultAIProfileIdChange={setDefaultAIProfileId}
onValidationModelChange={setValidationModel}
/>
);
case 'danger':

View File

@@ -1,91 +0,0 @@
import { Label } from '@/components/ui/label';
import { Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { CLAUDE_MODELS } from '@/components/views/board-view/shared/model-constants';
export function AIEnhancementSection() {
const { enhancementModel, setEnhancementModel } = useAppStore();
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Sparkles className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">AI Enhancement</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Choose the model used when enhancing feature descriptions.
</p>
</div>
<div className="p-6 space-y-4">
<div className="space-y-4">
<Label className="text-foreground font-medium">Enhancement Model</Label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{CLAUDE_MODELS.map(({ id, label, description, badge }) => {
const isActive = enhancementModel === id;
return (
<button
key={id}
onClick={() => setEnhancementModel(id)}
className={cn(
'group flex flex-col items-start gap-2 px-4 py-4 rounded-xl text-left',
'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={`enhancement-model-${id}`}
>
<div className="flex items-center gap-2 w-full">
<span
className={cn(
'font-medium text-sm',
isActive ? 'text-foreground' : 'group-hover:text-foreground'
)}
>
{label}
</span>
{badge && (
<span
className={cn(
'ml-auto text-xs px-2 py-0.5 rounded-full',
isActive
? 'bg-brand-500/20 text-brand-500'
: 'bg-accent text-muted-foreground'
)}
>
{badge}
</span>
)}
</div>
<span className="text-xs text-muted-foreground/80">{description}</span>
</button>
);
})}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1 +0,0 @@
export { AIEnhancementSection } from './ai-enhancement-section';

View File

@@ -4,7 +4,6 @@ import { Button } from '@/components/ui/button';
import { Key, CheckCircle2, Settings, Trash2, Loader2 } from 'lucide-react';
import { ApiKeyField } from './api-key-field';
import { buildProviderConfigs } from '@/config/api-providers';
import { AuthenticationStatusDisplay } from './authentication-status-display';
import { SecurityNotice } from './security-notice';
import { useApiKeyManagement } from './hooks/use-api-key-management';
import { cn } from '@/lib/utils';
@@ -19,7 +18,7 @@ export function ApiKeysSection() {
const [isDeletingAnthropicKey, setIsDeletingAnthropicKey] = useState(false);
const navigate = useNavigate();
const { providerConfigParams, apiKeyStatus, handleSave, saved } = useApiKeyManagement();
const { providerConfigParams, handleSave, saved } = useApiKeyManagement();
const providerConfigs = buildProviderConfigs(providerConfigParams);
@@ -84,13 +83,6 @@ export function ApiKeysSection() {
<ApiKeyField key={provider.key} config={provider} />
))}
{/* Authentication Status Display */}
<AuthenticationStatusDisplay
claudeAuthStatus={claudeAuthStatus}
apiKeyStatus={apiKeyStatus}
apiKeys={apiKeys}
/>
{/* Security Notice */}
<SecurityNotice />

View File

@@ -1,5 +1,8 @@
import { useState, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
const logger = createLogger('ApiKeyManagement');
import { getElectronAPI } from '@/lib/electron';
import type { ProviderConfigParams } from '@/config/api-providers';
@@ -60,7 +63,7 @@ export function useApiKeyManagement() {
});
}
} catch (error) {
console.error('Failed to check API key status:', error);
logger.error('Failed to check API key status:', error);
}
}
};
@@ -69,12 +72,22 @@ export function useApiKeyManagement() {
// Test Anthropic/Claude connection
const handleTestAnthropicConnection = async () => {
// Validate input first
if (!anthropicKey || anthropicKey.trim().length === 0) {
setTestResult({
success: false,
message: 'Please enter an API key to test.',
});
return;
}
setTestingConnection(true);
setTestResult(null);
try {
const api = getElectronAPI();
const data = await api.setup.verifyClaudeAuth('api_key');
// Pass the current input value to test unsaved keys
const data = await api.setup.verifyClaudeAuth('api_key', anthropicKey);
if (data.success && data.authenticated) {
setTestResult({

View File

@@ -1,16 +1,86 @@
import { Button } from '@/components/ui/button';
import { Terminal, CheckCircle2, AlertCircle, RefreshCw } from 'lucide-react';
import { Terminal, CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
import type { ClaudeAuthStatus } from '@/store/setup-store';
interface CliStatusProps {
status: CliStatus | null;
authStatus?: ClaudeAuthStatus | null;
isChecking: boolean;
onRefresh: () => void;
}
export function ClaudeCliStatus({ status, isChecking, onRefresh }: CliStatusProps) {
if (!status) return null;
function getAuthMethodLabel(method: string): string {
switch (method) {
case 'oauth_token':
return 'OAuth Token (Subscription)';
case 'oauth_token_env':
return 'OAuth Token (Environment)';
case 'api_key':
return 'API Key';
case 'api_key_env':
return 'API Key (Environment)';
case 'credentials_file':
return 'Credentials File';
case 'cli_authenticated':
return 'CLI Authentication';
default:
return method || 'Unknown';
}
}
function SkeletonPulse({ className }: { className?: string }) {
return <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
}
function ClaudeCliStatusSkeleton() {
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 justify-between mb-2">
<div className="flex items-center gap-3">
<SkeletonPulse className="w-9 h-9 rounded-xl" />
<SkeletonPulse className="h-6 w-36" />
</div>
<SkeletonPulse className="w-9 h-9 rounded-lg" />
</div>
<div className="ml-12">
<SkeletonPulse className="h-4 w-80" />
</div>
</div>
<div className="p-6 space-y-4">
{/* Installation status skeleton */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
<SkeletonPulse className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<SkeletonPulse className="h-4 w-40" />
<SkeletonPulse className="h-3 w-32" />
<SkeletonPulse className="h-3 w-48" />
</div>
</div>
{/* Auth status skeleton */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
<SkeletonPulse className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<SkeletonPulse className="h-4 w-28" />
<SkeletonPulse className="h-3 w-36" />
</div>
</div>
</div>
</div>
);
}
export function ClaudeCliStatus({ status, authStatus, isChecking, onRefresh }: CliStatusProps) {
if (!status) return <ClaudeCliStatusSkeleton />;
return (
<div
@@ -80,6 +150,37 @@ export function ClaudeCliStatus({ status, isChecking, onRefresh }: CliStatusProp
</div>
</div>
</div>
{/* Authentication Status */}
{authStatus?.authenticated ? (
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">Authenticated</p>
<div className="text-xs text-emerald-400/70 mt-1.5">
<p>
Method:{' '}
<span className="font-mono">{getAuthMethodLabel(authStatus.method)}</span>
</p>
</div>
</div>
</div>
) : (
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
<XCircle className="w-5 h-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-amber-400">Not Authenticated</p>
<p className="text-xs text-amber-400/70 mt-1">
Run <code className="font-mono bg-amber-500/10 px-1 rounded">claude login</code>{' '}
or set an API key to authenticate.
</p>
</div>
</div>
)}
{status.recommendation && (
<p className="text-xs text-muted-foreground/70 ml-1">{status.recommendation}</p>
)}

View File

@@ -0,0 +1,324 @@
import { Button } from '@/components/ui/button';
import { Terminal, CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
interface CursorStatus {
installed: boolean;
version?: string;
authenticated: boolean;
method?: string;
}
interface CursorCliStatusProps {
status: CursorStatus | null;
isChecking: boolean;
onRefresh: () => void;
}
function SkeletonPulse({ className }: { className?: string }) {
return <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
}
export function CursorCliStatusSkeleton() {
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 justify-between mb-2">
<div className="flex items-center gap-3">
<SkeletonPulse className="w-9 h-9 rounded-xl" />
<SkeletonPulse className="h-6 w-28" />
</div>
<SkeletonPulse className="w-9 h-9 rounded-lg" />
</div>
<div className="ml-12">
<SkeletonPulse className="h-4 w-72" />
</div>
</div>
<div className="p-6 space-y-4">
{/* Installation status skeleton */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
<SkeletonPulse className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<SkeletonPulse className="h-4 w-36" />
<SkeletonPulse className="h-3 w-28" />
</div>
</div>
{/* Auth status skeleton */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
<SkeletonPulse className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<SkeletonPulse className="h-4 w-28" />
<SkeletonPulse className="h-3 w-32" />
</div>
</div>
</div>
</div>
);
}
export function CursorPermissionsSkeleton() {
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 flex items-center justify-between">
<div className="flex items-center gap-3">
<SkeletonPulse className="w-9 h-9 rounded-xl" />
<div className="text-left">
<SkeletonPulse className="h-6 w-32 mb-2" />
<SkeletonPulse className="h-4 w-48" />
</div>
</div>
<div className="flex items-center gap-2">
<SkeletonPulse className="h-6 w-20 rounded-full" />
<SkeletonPulse className="w-5 h-5 rounded" />
</div>
</div>
<div className="p-6 space-y-6">
{/* Security Warning skeleton */}
<div className="flex items-start gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
<SkeletonPulse className="w-5 h-5 rounded shrink-0 mt-0.5" />
<div className="flex-1 space-y-2">
<SkeletonPulse className="h-4 w-32" />
<SkeletonPulse className="h-3 w-full" />
<SkeletonPulse className="h-3 w-3/4" />
</div>
</div>
{/* Permission Profiles skeleton */}
<div className="space-y-3">
<SkeletonPulse className="h-4 w-36" />
<div className="grid gap-3">
{[1, 2].map((i) => (
<div key={i} className="p-4 rounded-xl border border-border/30 bg-muted/10">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<SkeletonPulse className="w-4 h-4 rounded" />
<SkeletonPulse className="h-4 w-24" />
<SkeletonPulse className="h-4 w-12 rounded-full" />
</div>
<SkeletonPulse className="h-3 w-full" />
<SkeletonPulse className="h-3 w-2/3" />
<div className="flex items-center gap-2">
<SkeletonPulse className="h-3 w-20" />
<SkeletonPulse className="h-3 w-1" />
<SkeletonPulse className="h-3 w-20" />
</div>
</div>
<div className="flex flex-col gap-2">
<SkeletonPulse className="h-8 w-28 rounded-md" />
<SkeletonPulse className="h-8 w-28 rounded-md" />
</div>
</div>
</div>
))}
</div>
</div>
{/* Config File Locations skeleton */}
<div className="space-y-3">
<SkeletonPulse className="h-4 w-40" />
<div className="p-4 rounded-xl border border-border/30 bg-muted/10 space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-1">
<SkeletonPulse className="h-4 w-24" />
<SkeletonPulse className="h-3 w-48" />
</div>
<SkeletonPulse className="w-8 h-8 rounded" />
</div>
<div className="border-t border-border/30 pt-2 space-y-1">
<SkeletonPulse className="h-4 w-28" />
<SkeletonPulse className="h-3 w-40" />
</div>
</div>
</div>
</div>
</div>
);
}
export function ModelConfigSkeleton() {
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">
<SkeletonPulse className="w-9 h-9 rounded-xl" />
<SkeletonPulse className="h-6 w-40" />
</div>
<div className="ml-12">
<SkeletonPulse className="h-4 w-72" />
</div>
</div>
<div className="p-6 space-y-6">
{/* Default Model skeleton */}
<div className="space-y-2">
<SkeletonPulse className="h-4 w-24" />
<SkeletonPulse className="h-10 w-full rounded-md" />
</div>
{/* Available Models skeleton */}
<div className="space-y-3">
<SkeletonPulse className="h-4 w-32" />
<div className="grid gap-3">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="flex items-center justify-between p-3 rounded-xl border border-border/30 bg-muted/10"
>
<div className="flex items-center gap-3">
<SkeletonPulse className="w-5 h-5 rounded" />
<div className="space-y-1.5">
<SkeletonPulse className="h-4 w-32" />
<SkeletonPulse className="h-3 w-48" />
</div>
</div>
<SkeletonPulse className="h-5 w-12 rounded-full" />
</div>
))}
</div>
</div>
</div>
</div>
);
}
export function CursorCliStatus({ status, isChecking, onRefresh }: CursorCliStatusProps) {
if (!status) return <CursorCliStatusSkeleton />;
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 justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Terminal className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Cursor CLI</h2>
</div>
<Button
variant="ghost"
size="icon"
onClick={onRefresh}
disabled={isChecking}
data-testid="refresh-cursor-cli"
title="Refresh Cursor CLI detection"
className={cn(
'h-9 w-9 rounded-lg',
'hover:bg-accent/50 hover:scale-105',
'transition-all duration-200'
)}
>
<RefreshCw className={cn('w-4 h-4', isChecking && 'animate-spin')} />
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Cursor CLI enables AI-powered code editing using Cursor's models.
</p>
</div>
<div className="p-6 space-y-4">
{status.installed ? (
<div className="space-y-3">
{/* Installation Status - Success */}
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">Cursor CLI Installed</p>
<div className="text-xs text-emerald-400/70 mt-1.5 space-y-0.5">
{status.version && (
<p>
Version: <span className="font-mono">{status.version}</span>
</p>
)}
</div>
</div>
</div>
{/* Authentication Status */}
{status.authenticated ? (
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">Authenticated</p>
<div className="text-xs text-emerald-400/70 mt-1.5">
<p>
Method:{' '}
<span className="font-mono">
{status.method === 'api_key' ? 'API Key' : 'Browser Login'}
</span>
</p>
</div>
</div>
</div>
) : (
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
<XCircle className="w-5 h-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-amber-400">Not Authenticated</p>
<p className="text-xs text-amber-400/70 mt-1">
Run <code className="font-mono bg-amber-500/10 px-1 rounded">cursor auth</code>{' '}
to authenticate with Cursor.
</p>
</div>
</div>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
<AlertCircle className="w-5 h-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-amber-400">Cursor CLI Not Detected</p>
<p className="text-xs text-amber-400/70 mt-1">
Install Cursor CLI to use Cursor models in AutoMaker.
</p>
</div>
</div>
<div className="space-y-3">
<p className="text-xs font-medium text-foreground/80">Installation:</p>
<a
href="https://cursor.com/docs/cli"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-xs text-brand-400 hover:text-brand-300 transition-colors"
>
View installation guide
</a>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,14 +1,14 @@
import type { LucideIcon } from 'lucide-react';
import {
Key,
Terminal,
Bot,
SquareTerminal,
Palette,
Settings2,
Volume2,
FlaskConical,
Trash2,
Sparkles,
Workflow,
Plug,
MessageSquareText,
} from 'lucide-react';
@@ -23,10 +23,10 @@ export interface NavigationItem {
// Navigation items for the settings side panel
export const NAV_ITEMS: NavigationItem[] = [
{ id: 'api-keys', label: 'API Keys', icon: Key },
{ id: 'claude', label: 'Claude', icon: Terminal },
{ id: 'providers', label: 'AI Providers', icon: Bot },
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug },
{ id: 'prompts', label: 'Prompt Customization', icon: MessageSquareText },
{ id: 'ai-enhancement', label: 'AI Enhancement', icon: Sparkles },
{ id: 'model-defaults', label: 'Model Defaults', icon: Workflow },
{ id: 'appearance', label: 'Appearance', icon: Palette },
{ id: 'terminal', label: 'Terminal', icon: SquareTerminal },
{ id: 'keyboard', label: 'Keyboard Shortcuts', icon: Settings2 },

View File

@@ -12,7 +12,6 @@ import {
ScrollText,
ShieldCheck,
User,
Sparkles,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
@@ -23,7 +22,6 @@ import {
SelectValue,
} from '@/components/ui/select';
import type { AIProfile } from '@/store/app-store';
import type { AgentModel } from '@automaker/types';
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
@@ -36,7 +34,6 @@ interface FeatureDefaultsSectionProps {
defaultRequirePlanApproval: boolean;
defaultAIProfileId: string | null;
aiProfiles: AIProfile[];
validationModel: AgentModel;
onShowProfilesOnlyChange: (value: boolean) => void;
onDefaultSkipTestsChange: (value: boolean) => void;
onEnableDependencyBlockingChange: (value: boolean) => void;
@@ -44,7 +41,6 @@ interface FeatureDefaultsSectionProps {
onDefaultPlanningModeChange: (value: PlanningMode) => void;
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
onDefaultAIProfileIdChange: (value: string | null) => void;
onValidationModelChange: (value: AgentModel) => void;
}
export function FeatureDefaultsSection({
@@ -56,7 +52,6 @@ export function FeatureDefaultsSection({
defaultRequirePlanApproval,
defaultAIProfileId,
aiProfiles,
validationModel,
onShowProfilesOnlyChange,
onDefaultSkipTestsChange,
onEnableDependencyBlockingChange,
@@ -64,7 +59,6 @@ export function FeatureDefaultsSection({
onDefaultPlanningModeChange,
onDefaultRequirePlanApprovalChange,
onDefaultAIProfileIdChange,
onValidationModelChange,
}: FeatureDefaultsSectionProps) {
// Find the selected profile name for display
const selectedProfile = defaultAIProfileId
@@ -233,45 +227,6 @@ export function FeatureDefaultsSection({
{/* Separator */}
<div className="border-t border-border/30" />
{/* Issue Validation Model */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<div className="w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0 bg-purple-500/10">
<Sparkles className="w-5 h-5 text-purple-500" />
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Issue Validation Model</Label>
<Select
value={validationModel}
onValueChange={(v: string) => onValidationModelChange(v as AgentModel)}
>
<SelectTrigger className="w-[140px] h-8" data-testid="validation-model-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="opus">
<span>Opus</span>
<span className="text-[10px] text-muted-foreground ml-1">(Default)</span>
</SelectItem>
<SelectItem value="sonnet">
<span>Sonnet</span>
</SelectItem>
<SelectItem value="haiku">
<span>Haiku</span>
</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Model used for validating GitHub issues. Opus provides the most thorough analysis,
while Haiku is faster and more cost-effective.
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Profiles Only Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox

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