mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
Merge remote-tracking branch 'refs/remotes/origin/main'
# Conflicts: # apps/ui/src/routes/__root.tsx
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
7
apps/ui/src/components/shared/index.ts
Normal file
7
apps/ui/src/components/shared/index.ts
Normal 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';
|
||||
126
apps/ui/src/components/shared/model-override-trigger.tsx
Normal file
126
apps/ui/src/components/shared/model-override-trigger.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
apps/ui/src/components/shared/use-model-override.ts
Normal file
114
apps/ui/src/components/shared/use-model-override.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
4
apps/ui/src/components/views/agent-view/hooks/index.ts
Normal file
4
apps/ui/src/components/views/agent-view/hooks/index.ts
Normal 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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
2
apps/ui/src/components/views/agent-view/shared/index.ts
Normal file
2
apps/ui/src/components/views/agent-view/shared/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AgentModelSelector } from './agent-model-selector';
|
||||
export * from './constants';
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
211
apps/ui/src/components/views/ideation-view/index.tsx
Normal file
211
apps/ui/src/components/views/ideation-view/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { AIEnhancementSection } from './ai-enhancement-section';
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user