Merge v0.8.0rc into feat/cursor-cli

Resolved conflicts:
- sdk-options.ts: kept HEAD (MCP & thinking level features)
- auto-mode-service.ts: kept HEAD (MCP features + fallback code)
- agent-output-modal.tsx: used v0.8.0rc (effectiveViewMode + pr-8 spacing)
- feature-suggestions-dialog.tsx: accepted deletion
- electron.ts: used v0.8.0rc (Ideation types)
- package-lock.json: regenerated

Fixed sdk-options.test.ts to expect 'default' permissionMode for read-only operations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kacper
2026-01-04 13:12:45 +01:00
81 changed files with 6933 additions and 1958 deletions

View File

@@ -16,7 +16,8 @@ import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
import { useAppStore, defaultBackgroundSettings } from '@/store/app-store';
import { getHttpApiClient, getServerUrlSync } from '@/lib/http-api-client';
import { getHttpApiClient } from '@/lib/http-api-client';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { useBoardBackgroundSettings } from '@/hooks/use-board-background-settings';
import { toast } from 'sonner';
import {
@@ -65,12 +66,13 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
// Update preview image when background settings change
useEffect(() => {
if (currentProject && backgroundSettings.imagePath) {
const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync();
// Add cache-busting query parameter to force browser to reload image
const cacheBuster = imageVersion ? `&v=${imageVersion}` : `&v=${Date.now()}`;
const imagePath = `${serverUrl}/api/fs/image?path=${encodeURIComponent(
backgroundSettings.imagePath
)}&projectPath=${encodeURIComponent(currentProject.path)}${cacheBuster}`;
const cacheBuster = imageVersion ?? Date.now().toString();
const imagePath = getAuthenticatedImageUrl(
backgroundSettings.imagePath,
currentProject.path,
cacheBuster
);
setPreviewImage(imagePath);
} else {
setPreviewImage(null);

View File

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

View File

@@ -6,7 +6,7 @@ const logger = createLogger('DescriptionImageDropZone');
import { ImageIcon, X, Loader2, FileText } from 'lucide-react';
import { Textarea } from '@/components/ui/textarea';
import { getElectronAPI } from '@/lib/electron';
import { getServerUrlSync } from '@/lib/http-api-client';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { useAppStore, type FeatureImagePath, type FeatureTextFilePath } from '@/store/app-store';
import {
sanitizeFilename,
@@ -97,9 +97,8 @@ export function DescriptionImageDropZone({
// Construct server URL for loading saved images
const getImageServerUrl = useCallback(
(imagePath: string): string => {
const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync();
const projectPath = currentProject?.path || '';
return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`;
return getAuthenticatedImageUrl(imagePath, projectPath);
},
[currentProject?.path]
);

View File

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

View File

@@ -34,7 +34,6 @@ import {
ArchiveAllVerifiedDialog,
DeleteCompletedFeatureDialog,
EditFeatureDialog,
FeatureSuggestionsDialog,
FollowUpDialog,
PlanApprovalDialog,
} from './board-view/dialogs';
@@ -57,7 +56,6 @@ import {
useBoardBackground,
useBoardPersistence,
useFollowUpState,
useSuggestionsState,
} from './board-view/hooks';
// Stable empty array to avoid infinite loop in selector
@@ -156,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
@@ -203,9 +188,6 @@ export function BoardView() {
currentProject,
specCreatingForProject,
setSpecCreatingForProject,
setSuggestionsCount,
setFeatureSuggestions,
setIsGeneratingSuggestions,
checkContextExists,
features: hookFeatures,
isLoading,
@@ -1122,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
@@ -1272,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}

View File

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

View File

@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useEffect, useRef, useState, useMemo } from 'react';
import {
Dialog,
DialogContent,
@@ -7,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 {
@@ -28,9 +29,7 @@ interface AgentOutputModalProps {
projectPath?: string;
}
type ViewMode = 'parsed' | 'raw' | 'changes';
const logger = createLogger('AgentOutputModal');
type ViewMode = 'summary' | 'parsed' | 'raw' | 'changes';
export function AgentOutputModal({
open,
@@ -43,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>('');
@@ -91,7 +96,7 @@ export function AgentOutputModal({
setOutput('');
}
} catch (error) {
logger.error('Failed to load output:', error);
console.error('Failed to load output:', error);
setOutput('');
} finally {
setIsLoading(false);
@@ -108,11 +113,11 @@ export function AgentOutputModal({
const api = getElectronAPI();
if (!api?.autoMode) return;
logger.info('Subscribing to events for featureId:', featureId);
console.log('[AgentOutputModal] Subscribing to events for featureId:', featureId);
const unsubscribe = api.autoMode.onEvent((event) => {
logger.debug(
'Received event:',
console.log(
'[AgentOutputModal] Received event:',
event.type,
'featureId:',
'featureId' in event ? event.featureId : 'none',
@@ -122,7 +127,7 @@ export function AgentOutputModal({
// Filter events for this specific feature only (skip events without featureId)
if ('featureId' in event && event.featureId !== featureId) {
logger.debug('Skipping event - featureId mismatch');
console.log('[AgentOutputModal] Skipping event - featureId mismatch');
return;
}
@@ -299,11 +304,11 @@ export function AgentOutputModal({
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col overflow-hidden min-h-0 gap-3"
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col"
data-testid="agent-output-modal"
>
<DialogHeader className="shrink-0">
<div className="flex items-center justify-between">
<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" />
@@ -311,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'
}`}
@@ -326,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'
}`}
@@ -338,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'
}`}
@@ -350,7 +369,7 @@ export function AgentOutputModal({
</div>
</div>
<DialogDescription
className="mt-1 max-h-24 overflow-y-auto wrap-break-word"
className="mt-1 max-h-24 overflow-y-auto break-words"
data-testid="agent-output-description"
>
{featureDescription}
@@ -361,12 +380,11 @@ export function AgentOutputModal({
<TaskProgressPanel
featureId={featureId}
projectPath={projectPath}
className="shrink-0 rounded-lg"
defaultExpanded={false}
className="flex-shrink-0 mx-1"
/>
{viewMode === 'changes' ? (
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-visible">
{effectiveViewMode === 'changes' ? (
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
{projectPath ? (
<GitDiffPanel
projectPath={projectPath}
@@ -382,12 +400,16 @@ 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
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 min-h-0 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs scrollbar-visible"
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[400px] max-h-[60vh] scrollbar-visible"
>
{isLoading && !output ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
@@ -398,14 +420,14 @@ 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 wrap-break-word text-zinc-300">{output}</div>
<div className="whitespace-pre-wrap break-words text-zinc-300">{output}</div>
)}
</div>
<div className="text-xs text-muted-foreground text-center shrink-0">
<div className="text-xs text-muted-foreground text-center flex-shrink-0">
{autoScrollRef.current
? 'Auto-scrolling enabled'
: 'Scroll to bottom to enable auto-scroll'}

View File

@@ -1,599 +0,0 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
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';
import { useModelOverride } from '@/components/shared/use-model-override';
import { ModelOverrideTrigger } from '@/components/shared/model-override-trigger';
const logger = createLogger('FeatureSuggestions');
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();
// Model override for suggestions
const { effectiveModelEntry, isOverridden, setOverride } = useModelOverride({
phase: 'suggestionsModel',
});
// 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 {
// Pass model and thinkingLevel from the effective model entry
const result = await api.suggestions.generate(
projectPath,
suggestionType,
effectiveModelEntry.model,
effectiveModelEntry.thinkingLevel
);
if (!result.success) {
toast.error(result.error || 'Failed to start generation');
setIsGenerating(false);
}
} catch (error) {
logger.error('Failed to generate suggestions:', error);
toast.error('Failed to start generation');
setIsGenerating(false);
}
},
[projectPath, setIsGenerating, setSuggestions, effectiveModelEntry]
);
// 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) {
logger.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) {
logger.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
</>
)}
<ModelOverrideTrigger
currentModelEntry={effectiveModelEntry}
onModelChange={setOverride}
phase="suggestionsModel"
isOverridden={isOverridden}
size="sm"
variant="icon"
/>
</DialogTitle>
<DialogDescription>
{currentConfig
? currentConfig.description
: 'Analyze your project to discover improvements. Choose a suggestion type below.'}
</DialogDescription>
</DialogHeader>
{!hasStarted ? (
// Initial state - show suggestion type buttons
<div className="flex-1 flex flex-col items-center justify-center py-8">
<p className="text-muted-foreground text-center max-w-lg mb-8">
Our AI will analyze your project and generate actionable suggestions. Choose what type
of analysis you want to perform:
</p>
<div className="grid grid-cols-2 gap-4 w-full max-w-2xl">
{(
Object.entries(suggestionTypeConfig) as [
SuggestionType,
(typeof suggestionTypeConfig)[SuggestionType],
][]
).map(([type, config]) => {
const Icon = config.icon;
return (
<Button
key={type}
variant="outline"
className="h-auto py-6 px-6 flex flex-col items-center gap-3 hover:border-primary/50 transition-colors"
onClick={() => handleGenerate(type)}
data-testid={`generate-${type}-btn`}
>
<Icon className={`w-8 h-8 ${config.color}`} />
<div className="text-center">
<div className="font-semibold">
{config.label.replace(' Suggestions', '')}
</div>
<div className="text-xs text-muted-foreground mt-1">{config.description}</div>
</div>
</Button>
);
})}
</div>
</div>
) : isGenerating ? (
// Generating state - show progress
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
Analyzing project...
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
<button
onClick={() => setViewMode('parsed')}
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium transition-all ${
viewMode === 'parsed'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-parsed"
>
<List className="w-3 h-3" />
Logs
</button>
<button
onClick={() => setViewMode('raw')}
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium transition-all ${
viewMode === 'raw'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-raw"
>
<FileText className="w-3 h-3" />
Raw
</button>
</div>
<Button variant="destructive" size="sm" onClick={handleStop}>
<StopCircle className="w-4 h-4 mr-2" />
Stop
</Button>
</div>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[200px] max-h-[400px]"
>
{progress.length === 0 ? (
<div className="flex items-center justify-center min-h-[168px] text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
Waiting for AI response...
</div>
) : viewMode === 'parsed' ? (
<LogViewer output={progress.join('')} />
) : (
<div className="whitespace-pre-wrap break-words text-zinc-300">
{progress.join('')}
</div>
)}
</div>
</div>
) : hasSuggestions ? (
// Results state - show suggestions list
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
{suggestions.length} suggestions generated
</span>
<Button variant="ghost" size="sm" onClick={toggleSelectAll}>
{selectedIds.size === suggestions.length ? 'Deselect All' : 'Select All'}
</Button>
</div>
<span className="text-sm font-medium">{selectedIds.size} selected</span>
</div>
<div
ref={scrollRef}
className="flex-1 overflow-y-auto space-y-2 min-h-[200px] max-h-[400px] pr-2"
>
{suggestions.map((suggestion) => {
const isSelected = selectedIds.has(suggestion.id);
const isExpanded = expandedIds.has(suggestion.id);
return (
<div
key={suggestion.id}
className={`border rounded-lg p-3 transition-colors ${
isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
data-testid={`suggestion-${suggestion.id}`}
>
<div className="flex items-start gap-3">
<Checkbox
id={suggestion.id}
checked={isSelected}
onCheckedChange={() => toggleSelection(suggestion.id)}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<button
onClick={() => toggleExpanded(suggestion.id)}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/20 text-primary font-medium">
#{suggestion.priority}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-secondary text-secondary-foreground">
{suggestion.category}
</span>
</div>
<Label
htmlFor={suggestion.id}
className="text-sm font-medium cursor-pointer"
>
{suggestion.description}
</Label>
{isExpanded && suggestion.reasoning && (
<div className="mt-3 text-sm">
<p className="text-muted-foreground italic">{suggestion.reasoning}</p>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
) : (
// No results state
<div className="flex-1 flex flex-col items-center justify-center py-8 text-center">
<p className="text-muted-foreground mb-4">
No suggestions were generated. Try running the analysis again.
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={handleBackToSelection}>
Back to Selection
</Button>
{currentSuggestionType && (
<Button onClick={() => handleGenerate(currentSuggestionType)}>
<Lightbulb className="w-4 h-4 mr-2" />
Try Again
</Button>
)}
</div>
</div>
)}
<DialogFooter className="flex-shrink-0">
{hasSuggestions && (
<div className="flex gap-2 w-full justify-between">
<div className="flex gap-2">
<Button variant="outline" onClick={handleBackToSelection}>
Back
</Button>
{currentSuggestionType && (
<Button variant="outline" onClick={() => handleGenerate(currentSuggestionType)}>
{currentConfig && <currentConfig.icon className="w-4 h-4 mr-2" />}
Regenerate
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<HotkeyButton
onClick={handleImport}
disabled={selectedIds.size === 0 || isImporting}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open && hasSuggestions}
>
{isImporting ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Download className="w-4 h-4 mr-2" />
)}
Import {selectedIds.size} Feature
{selectedIds.size !== 1 ? 's' : ''}
</HotkeyButton>
</div>
</div>
)}
{!hasSuggestions && !isGenerating && hasStarted && (
<Button variant="ghost" onClick={onClose}>
Close
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

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

View File

@@ -7,4 +7,3 @@ export { useBoardEffects } from './use-board-effects';
export { useBoardBackground } from './use-board-background';
export { useBoardPersistence } from './use-board-persistence';
export { useFollowUpState } from './use-follow-up-state';
export { useSuggestionsState } from './use-suggestions-state';

View File

@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useAppStore, defaultBackgroundSettings } from '@/store/app-store';
import { getServerUrlSync } from '@/lib/http-api-client';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
interface UseBoardBackgroundProps {
currentProject: { path: string; id: string } | null;
@@ -22,14 +22,14 @@ export function useBoardBackground({ currentProject }: UseBoardBackgroundProps)
return {};
}
const imageUrl = getAuthenticatedImageUrl(
backgroundSettings.imagePath,
currentProject.path,
backgroundSettings.imageVersion
);
return {
backgroundImage: `url(${
import.meta.env.VITE_SERVER_URL || getServerUrlSync()
}/api/fs/image?path=${encodeURIComponent(
backgroundSettings.imagePath
)}&projectPath=${encodeURIComponent(currentProject.path)}${
backgroundSettings.imageVersion ? `&v=${backgroundSettings.imageVersion}` : ''
})`,
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',

View File

@@ -9,9 +9,6 @@ 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;
@@ -23,9 +20,6 @@ export function useBoardEffects({
currentProject,
specCreatingForProject,
setSpecCreatingForProject,
setSuggestionsCount,
setFeatureSuggestions,
setIsGeneratingSuggestions,
checkContextExists,
features,
isLoading,
@@ -47,26 +41,6 @@ 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();

View File

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

View File

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

View File

@@ -0,0 +1,340 @@
/**
* 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, removeSuggestionFromJob } = useIdeationStore();
const [addingId, setAddingId] = useState<string | null>(null);
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
// Separate generating/error jobs from ready jobs with suggestions
const activeJobs = generationJobs.filter(
(j) => j.status === 'generating' || j.status === 'error'
);
const readyJobs = generationJobs.filter((j) => j.status === 'ready' && j.suggestions.length > 0);
// 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 generatingCount = generationJobs.filter((j) => j.status === 'generating').length;
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>
)}
{/* Empty State */}
{isEmpty && (
<Card>
<CardContent className="py-16">
<div className="text-center">
<Sparkles className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium mb-2">No ideas yet</h3>
<p className="text-muted-foreground mb-6">
Generate ideas by selecting a category and prompt type
</p>
<Button onClick={onGenerateIdeas} size="lg" className="gap-2">
<Lightbulb className="w-5 h-5" />
Generate Ideas
</Button>
</div>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,180 @@
/**
* PromptList - List of prompts for a specific category
*/
import { useState } 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 { setMode, addGenerationJob, updateJobStatus, generationJobs } = useIdeationStore();
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);
// Check which prompts are already generating
const generatingPromptIds = new Set(
generationJobs.filter((j) => j.status === 'generating').map((j) => j.prompt.id)
);
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(prompt);
setStartedPrompts((prev) => new Set(prev).add(prompt.id));
// Show toast and navigate to dashboard
toast.info(`Generating ideas for "${prompt.title}"...`);
setMode('dashboard');
try {
const api = getElectronAPI();
const result = await api.ideation?.generateSuggestions(
currentProject.path,
prompt.id,
category
);
if (result?.success && result.suggestions) {
updateJobStatus(jobId, 'ready', result.suggestions);
toast.success(`Generated ${result.suggestions.length} ideas for "${prompt.title}"`, {
duration: 10000,
action: {
label: 'View Ideas',
onClick: () => {
setMode('dashboard');
navigate({ to: '/ideation' });
},
},
});
} else {
updateJobStatus(
jobId,
'error',
undefined,
result?.error || 'Failed to generate suggestions'
);
toast.error(result?.error || 'Failed to generate suggestions');
}
} catch (error) {
console.error('Failed to generate suggestions:', error);
updateJobStatus(jobId, 'error', undefined, (error as Error).message);
toast.error((error as Error).message);
} finally {
setLoadingPromptId(null);
}
};
return (
<div className="flex-1 flex flex-col p-6 overflow-auto">
<div className="max-w-3xl w-full mx-auto space-y-4">
{/* Back link */}
<button
onClick={onBack}
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span>Back</span>
</button>
<div className="space-y-3">
{isLoadingPrompts && (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading prompts...</span>
</div>
)}
{promptsError && (
<div className="text-center py-8 text-destructive">
<p>Failed to load prompts: {promptsError}</p>
</div>
)}
{!isLoadingPrompts &&
!promptsError &&
prompts.map((prompt) => {
const isLoading = loadingPromptId === prompt.id;
const isGenerating = generatingPromptIds.has(prompt.id);
const isStarted = startedPrompts.has(prompt.id);
const isDisabled = loadingPromptId !== null || isGenerating;
return (
<Card
key={prompt.id}
className={`transition-all ${
isDisabled
? 'opacity-60 cursor-not-allowed'
: 'cursor-pointer hover:border-primary hover:shadow-md'
} ${isLoading || isGenerating ? 'border-blue-500 ring-1 ring-blue-500' : ''} ${
isStarted && !isGenerating ? 'border-green-500/50' : ''
}`}
onClick={() => !isDisabled && handleSelectPrompt(prompt)}
>
<CardContent className="p-5">
<div className="flex items-start gap-4">
<div
className={`p-2 rounded-lg mt-0.5 ${
isLoading || isGenerating
? 'bg-blue-500/10'
: isStarted
? 'bg-green-500/10'
: 'bg-primary/10'
}`}
>
{isLoading || isGenerating ? (
<Loader2 className="w-4 h-4 text-blue-500 animate-spin" />
) : isStarted ? (
<CheckCircle2 className="w-4 h-4 text-green-500" />
) : (
<Lightbulb className="w-4 h-4 text-primary" />
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold">{prompt.title}</h3>
<p className="text-muted-foreground text-sm mt-1">{prompt.description}</p>
{(isLoading || isGenerating) && (
<p className="text-blue-500 text-sm mt-2">Generating in dashboard...</p>
)}
{isStarted && !isGenerating && (
<p className="text-green-500 text-sm mt-2">
Already generated - check dashboard
</p>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
</div>
);
}

View File

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

View File

@@ -1,4 +1,3 @@
export { MCPServerHeader } from './mcp-server-header';
export { MCPPermissionSettings } from './mcp-permission-settings';
export { MCPToolsWarning } from './mcp-tools-warning';
export { MCPServerCard } from './mcp-server-card';

View File

@@ -1,96 +0,0 @@
import { ShieldAlert } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { syncSettingsToServer } from '@/hooks/use-settings-migration';
import { cn } from '@/lib/utils';
interface MCPPermissionSettingsProps {
mcpAutoApproveTools: boolean;
mcpUnrestrictedTools: boolean;
onAutoApproveChange: (checked: boolean) => void;
onUnrestrictedChange: (checked: boolean) => void;
}
export function MCPPermissionSettings({
mcpAutoApproveTools,
mcpUnrestrictedTools,
onAutoApproveChange,
onUnrestrictedChange,
}: MCPPermissionSettingsProps) {
const hasAnyEnabled = mcpAutoApproveTools || mcpUnrestrictedTools;
return (
<div className="px-6 py-4 border-b border-border/50 bg-muted/20">
<div className="space-y-4">
<div className="flex items-start gap-3">
<Switch
id="mcp-auto-approve"
checked={mcpAutoApproveTools}
onCheckedChange={async (checked) => {
onAutoApproveChange(checked);
await syncSettingsToServer();
}}
data-testid="mcp-auto-approve-toggle"
className="mt-0.5"
/>
<div className="space-y-1 flex-1">
<Label htmlFor="mcp-auto-approve" className="text-sm font-medium cursor-pointer">
Auto-approve MCP tool calls
</Label>
<p className="text-xs text-muted-foreground">
When enabled, the AI agent can use MCP tools without permission prompts.
</p>
{mcpAutoApproveTools && (
<p className="text-xs text-amber-600 flex items-center gap-1 mt-1">
<ShieldAlert className="h-3 w-3" />
Bypasses normal permission checks
</p>
)}
</div>
</div>
<div className="flex items-start gap-3">
<Switch
id="mcp-unrestricted"
checked={mcpUnrestrictedTools}
onCheckedChange={async (checked) => {
onUnrestrictedChange(checked);
await syncSettingsToServer();
}}
data-testid="mcp-unrestricted-toggle"
className="mt-0.5"
/>
<div className="space-y-1 flex-1">
<Label htmlFor="mcp-unrestricted" className="text-sm font-medium cursor-pointer">
Unrestricted tool access
</Label>
<p className="text-xs text-muted-foreground">
When enabled, the AI agent can use any tool, not just the default set.
</p>
{mcpUnrestrictedTools && (
<p className="text-xs text-amber-600 flex items-center gap-1 mt-1">
<ShieldAlert className="h-3 w-3" />
Agent has full tool access including file writes and bash
</p>
)}
</div>
</div>
{hasAnyEnabled && (
<div
className={cn(
'rounded-md border border-amber-500/30 bg-amber-500/10 p-3 mt-2',
'text-xs text-amber-700 dark:text-amber-400'
)}
>
<p className="font-medium mb-1">Security Note</p>
<p>
These settings reduce security restrictions for MCP tool usage. Only enable if you
trust all configured MCP servers.
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -24,16 +24,7 @@ interface PendingServerData {
}
export function useMCPServers() {
const {
mcpServers,
addMCPServer,
updateMCPServer,
removeMCPServer,
mcpAutoApproveTools,
mcpUnrestrictedTools,
setMcpAutoApproveTools,
setMcpUnrestrictedTools,
} = useAppStore();
const { mcpServers, addMCPServer, updateMCPServer, removeMCPServer } = useAppStore();
// State
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
@@ -941,10 +932,6 @@ export function useMCPServers() {
return {
// Store state
mcpServers,
mcpAutoApproveTools,
mcpUnrestrictedTools,
setMcpAutoApproveTools,
setMcpUnrestrictedTools,
// Dialog state
isAddDialogOpen,

View File

@@ -1,12 +1,7 @@
import { Plug } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useMCPServers } from './hooks';
import {
MCPServerHeader,
MCPPermissionSettings,
MCPToolsWarning,
MCPServerCard,
} from './components';
import { MCPServerHeader, MCPToolsWarning, MCPServerCard } from './components';
import {
AddEditServerDialog,
DeleteServerDialog,
@@ -20,10 +15,6 @@ export function MCPServersSection() {
const {
// Store state
mcpServers,
mcpAutoApproveTools,
mcpUnrestrictedTools,
setMcpAutoApproveTools,
setMcpUnrestrictedTools,
// Dialog state
isAddDialogOpen,
@@ -98,15 +89,6 @@ export function MCPServersSection() {
onAdd={handleOpenAddDialog}
/>
{mcpServers.length > 0 && (
<MCPPermissionSettings
mcpAutoApproveTools={mcpAutoApproveTools}
mcpUnrestrictedTools={mcpUnrestrictedTools}
onAutoApproveChange={setMcpAutoApproveTools}
onUnrestrictedChange={setMcpUnrestrictedTools}
/>
)}
{showToolsWarning && <MCPToolsWarning totalTools={totalToolsCount} />}
<div className="p-6">