Compare commits

...

4 Commits

Author SHA1 Message Date
webdevcody
6c19bb60d1 feat: enhance BottomDock and GitHubPanel with new state management and validation features
- Integrated keyboard shortcuts into BottomDock for improved accessibility and navigation.
- Refactored dock state management to include expanded and maximized states, allowing for more dynamic UI behavior.
- Added issue validation functionality in GitHubPanel, enabling users to validate GitHub issues with real-time feedback.
- Implemented a validation dialog for displaying results and managing issue validation status.

These enhancements significantly improve user interaction and functionality within the application, particularly in managing dock states and GitHub issues.
2026-01-10 12:24:28 -05:00
webdevcody
a2ccf200a9 feat: implement session verification with retry logic
- Enhanced session verification to include retry logic for handling network and timeout errors, particularly during server restarts.
- Introduced a maximum of 8 attempts with exponential backoff for retries, improving resilience in unstable network conditions.
- Updated logging to provide clearer feedback on session verification attempts and failures.

These changes enhance the application's reliability in maintaining user sessions, especially in environments with intermittent connectivity.
2026-01-09 23:56:47 -05:00
webdevcody
5543b46f64 feat: enhance BottomDock and ContextPanel with new functionality
- Added click-outside detection to BottomDock for improved user experience when interacting with the panel.
- Introduced new dialog components in ContextPanel for creating, renaming, and deleting markdown files, enhancing file management capabilities.
- Implemented file saving and description editing features, allowing users to manage file content more effectively.
- Updated the SpecPanel to support spec regeneration with user-defined project descriptions and feature generation options.

These changes significantly improve the usability and functionality of the application, making it easier for users to manage their projects and specifications.
2026-01-09 23:30:58 -05:00
webdevcody
d9299b4680 feat: add command palette and dashboard view components
- Introduced a command palette for enhanced navigation and command execution.
- Added a new dashboard view with project management features, including project cards and an empty state for new users.
- Updated routing to include the new dashboard view and integrated it with the existing layout.
- Enhanced the app store to manage pinned projects and GitHub cache for issues and pull requests.

These changes improve user experience by streamlining project management and navigation within the application.
2026-01-09 23:02:57 -05:00
39 changed files with 7448 additions and 452 deletions

View File

@@ -0,0 +1,163 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { useAppStore } from '@/store/app-store';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command';
import {
Plus,
Sparkles,
Play,
Square,
FileText,
FolderOpen,
Terminal,
Bot,
Settings,
Github,
BookOpen,
Wand2,
Search,
LayoutGrid,
} from 'lucide-react';
interface CommandPaletteProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
const navigate = useNavigate();
const { currentProject, getAutoModeState, setAutoModeRunning } = useAppStore();
const autoModeState = currentProject ? getAutoModeState(currentProject.id) : null;
const isAutoModeRunning = autoModeState?.isRunning ?? false;
const runCommand = useCallback(
(command: () => void) => {
onOpenChange(false);
command();
},
[onOpenChange]
);
const handleNavigate = useCallback(
(path: string) => {
runCommand(() => navigate({ to: path }));
},
[navigate, runCommand]
);
const handleToggleAutoMode = useCallback(() => {
if (currentProject) {
runCommand(() => setAutoModeRunning(currentProject.id, !isAutoModeRunning));
}
}, [currentProject, isAutoModeRunning, setAutoModeRunning, runCommand]);
return (
<CommandDialog open={open} onOpenChange={onOpenChange}>
<CommandInput placeholder="Search commands..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{currentProject && (
<>
<CommandGroup heading="Quick Actions">
<CommandItem onSelect={() => handleNavigate('/board')}>
<Plus className="mr-2 h-4 w-4" />
<span>Add Feature</span>
</CommandItem>
<CommandItem onSelect={() => handleNavigate('/ideation')}>
<Sparkles className="mr-2 h-4 w-4" />
<span>Generate Ideas</span>
</CommandItem>
<CommandItem onSelect={handleToggleAutoMode}>
{isAutoModeRunning ? (
<>
<Square className="mr-2 h-4 w-4" />
<span>Stop Auto Mode</span>
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
<span>Start Auto Mode</span>
</>
)}
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Navigation">
<CommandItem onSelect={() => handleNavigate('/board')}>
<LayoutGrid className="mr-2 h-4 w-4" />
<span>Kanban Board</span>
</CommandItem>
<CommandItem onSelect={() => handleNavigate('/running-agents')}>
<Bot className="mr-2 h-4 w-4" />
<span>Running Agents</span>
</CommandItem>
<CommandItem onSelect={() => handleNavigate('/terminal')}>
<Terminal className="mr-2 h-4 w-4" />
<span>Terminal</span>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Project">
<CommandItem onSelect={() => handleNavigate('/spec')}>
<FileText className="mr-2 h-4 w-4" />
<span>App Specification</span>
</CommandItem>
<CommandItem onSelect={() => handleNavigate('/context')}>
<FolderOpen className="mr-2 h-4 w-4" />
<span>Context Files</span>
</CommandItem>
<CommandItem onSelect={() => handleNavigate('/github-issues')}>
<Github className="mr-2 h-4 w-4" />
<span>GitHub Issues</span>
</CommandItem>
<CommandItem onSelect={() => handleNavigate('/github-prs')}>
<Github className="mr-2 h-4 w-4" />
<span>Pull Requests</span>
</CommandItem>
</CommandGroup>
<CommandSeparator />
</>
)}
<CommandGroup heading="Settings">
<CommandItem onSelect={() => handleNavigate('/profiles')}>
<Wand2 className="mr-2 h-4 w-4" />
<span>AI Profiles</span>
</CommandItem>
<CommandItem onSelect={() => handleNavigate('/settings')}>
<Settings className="mr-2 h-4 w-4" />
<span>Settings</span>
</CommandItem>
<CommandItem onSelect={() => handleNavigate('/wiki')}>
<BookOpen className="mr-2 h-4 w-4" />
<span>Documentation</span>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Projects">
<CommandItem onSelect={() => handleNavigate('/dashboard')}>
<Search className="mr-2 h-4 w-4" />
<span>All Projects</span>
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
);
}

View File

@@ -0,0 +1 @@
export { CommandPalette } from './command-palette';

View File

@@ -0,0 +1 @@
export { OnboardingWizard } from './onboarding-wizard';

View File

@@ -0,0 +1,386 @@
import { useState, useCallback } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
import { useAppStore, type ThemeMode } from '@/store/app-store';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Slider } from '@/components/ui/slider';
import {
FolderOpen,
FileText,
Sparkles,
ArrowRight,
ArrowLeft,
Check,
Loader2,
} from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { initializeProject, hasAutomakerDir, hasAppSpec } from '@/lib/project-init';
import { toast } from 'sonner';
type OnboardingStep = 'select-folder' | 'project-name' | 'app-spec' | 'complete';
type OnboardingMode = 'new' | 'existing';
interface OnboardingWizardProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: OnboardingMode;
initialPath?: string;
}
export function OnboardingWizard({ open, onOpenChange, mode, initialPath }: OnboardingWizardProps) {
const navigate = useNavigate();
const {
upsertAndSetCurrentProject,
theme: globalTheme,
trashedProjects,
setSpecCreatingForProject,
} = useAppStore();
const [step, setStep] = useState<OnboardingStep>(initialPath ? 'project-name' : 'select-folder');
const [projectPath, setProjectPath] = useState(initialPath || '');
const [projectName, setProjectName] = useState('');
const [projectOverview, setProjectOverview] = useState('');
const [generateFeatures, setGenerateFeatures] = useState(true);
const [featureCount, setFeatureCount] = useState(5);
const [isProcessing, setIsProcessing] = useState(false);
const handleSelectFolder = useCallback(async () => {
const api = getElectronAPI();
const result = await api.openDirectory();
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
setProjectPath(path);
setProjectName(name);
// Check if it's an existing automaker project
const hadAutomakerDir = await hasAutomakerDir(path);
const specExists = await hasAppSpec(path);
if (hadAutomakerDir && specExists) {
// Existing project with spec - skip to complete
try {
const initResult = await initializeProject(path);
if (initResult.success) {
const trashedProject = trashedProjects.find((p) => p.path === path);
const effectiveTheme = (trashedProject?.theme as ThemeMode | undefined) || globalTheme;
upsertAndSetCurrentProject(path, name, effectiveTheme);
toast.success('Project opened', { description: `Opened ${name}` });
onOpenChange(false);
navigate({ to: '/board' });
}
} catch (error) {
toast.error('Failed to open project');
}
} else {
setStep('project-name');
}
}
}, [trashedProjects, globalTheme, upsertAndSetCurrentProject, onOpenChange, navigate]);
const handleNext = useCallback(() => {
if (step === 'project-name') {
setStep('app-spec');
}
}, [step]);
const handleBack = useCallback(() => {
if (step === 'app-spec') {
setStep('project-name');
} else if (step === 'project-name') {
setStep('select-folder');
}
}, [step]);
const handleSkipSpec = useCallback(async () => {
setIsProcessing(true);
try {
const initResult = await initializeProject(projectPath);
if (!initResult.success) {
toast.error('Failed to initialize project');
return;
}
const trashedProject = trashedProjects.find((p) => p.path === projectPath);
const effectiveTheme = (trashedProject?.theme as ThemeMode | undefined) || globalTheme;
upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
toast.success('Project created', { description: `Created ${projectName}` });
onOpenChange(false);
navigate({ to: '/board' });
} finally {
setIsProcessing(false);
}
}, [
projectPath,
projectName,
trashedProjects,
globalTheme,
upsertAndSetCurrentProject,
onOpenChange,
navigate,
]);
const handleGenerateSpec = useCallback(async () => {
setIsProcessing(true);
try {
const initResult = await initializeProject(projectPath);
if (!initResult.success) {
toast.error('Failed to initialize project');
return;
}
const trashedProject = trashedProjects.find((p) => p.path === projectPath);
const effectiveTheme = (trashedProject?.theme as ThemeMode | undefined) || globalTheme;
upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
// Start spec generation in background
setSpecCreatingForProject(projectPath);
onOpenChange(false);
navigate({ to: '/board' });
// Use the spec regeneration API
const api = getElectronAPI();
if (api.specRegeneration && projectOverview.trim()) {
const result = await api.specRegeneration.create(
projectPath,
projectOverview.trim(),
generateFeatures,
true, // analyzeProject
generateFeatures ? featureCount : undefined
);
if (!result.success) {
setSpecCreatingForProject(null);
toast.error('Failed to create specification', {
description: result.error,
});
} else {
toast.info('Generating app specification...', {
description: "This may take a minute. You'll be notified when complete.",
});
}
} else {
toast.success('Project created', { description: `Created ${projectName}` });
setSpecCreatingForProject(null);
}
} catch (error) {
setSpecCreatingForProject(null);
toast.error('Failed to create project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsProcessing(false);
}
}, [
projectPath,
projectName,
projectOverview,
generateFeatures,
featureCount,
trashedProjects,
globalTheme,
upsertAndSetCurrentProject,
setSpecCreatingForProject,
onOpenChange,
navigate,
]);
const renderStep = () => {
switch (step) {
case 'select-folder':
return (
<div className="space-y-6">
<div className="text-center py-8">
<div className="inline-flex items-center justify-center h-16 w-16 rounded-2xl bg-primary/10 mb-4">
<FolderOpen className="h-8 w-8 text-primary" />
</div>
<h3 className="text-lg font-medium mb-2">Select Root Directory</h3>
<p className="text-sm text-muted-foreground max-w-sm mx-auto">
Select the root directory of your project. This can be an empty directory for a new
project or an existing codebase.
</p>
</div>
<Button onClick={handleSelectFolder} className="w-full" size="lg">
<FolderOpen className="h-4 w-4 mr-2" />
Browse Folders
</Button>
</div>
);
case 'project-name':
return (
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="project-name">Project Name</Label>
<Input
id="project-name"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder="My Awesome Project"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Location</Label>
<p className="text-sm bg-muted/50 rounded-md p-2 font-mono truncate">{projectPath}</p>
</div>
<div className="flex justify-between pt-4">
<Button variant="ghost" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<Button onClick={handleNext} disabled={!projectName.trim()}>
Next
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
</div>
</div>
);
case 'app-spec':
return (
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="project-overview">
Project Description <span className="text-muted-foreground">(optional)</span>
</Label>
<Textarea
id="project-overview"
value={projectOverview}
onChange={(e) => setProjectOverview(e.target.value)}
placeholder="Describe your project in a few sentences. This helps the AI understand what you're building."
rows={4}
/>
</div>
<div className="space-y-4 p-4 bg-muted/30 rounded-lg">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="generate-features" className="font-medium">
Generate initial features
</Label>
<p className="text-sm text-muted-foreground">
AI will suggest features based on your project
</p>
</div>
<Switch
id="generate-features"
checked={generateFeatures}
onCheckedChange={setGenerateFeatures}
/>
</div>
{generateFeatures && (
<div className="space-y-2 pt-2">
<div className="flex items-center justify-between">
<Label className="text-sm">Number of features</Label>
<span className="text-sm font-medium">{featureCount}</span>
</div>
<Slider
value={[featureCount]}
onValueChange={([val]) => setFeatureCount(val)}
min={1}
max={15}
step={1}
/>
</div>
)}
</div>
<div className="flex justify-between pt-4">
<Button variant="ghost" onClick={handleBack} disabled={isProcessing}>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={handleSkipSpec} disabled={isProcessing}>
Skip for now
</Button>
<Button onClick={handleGenerateSpec} disabled={isProcessing}>
{isProcessing ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Sparkles className="h-4 w-4 mr-2" />
)}
{isProcessing ? 'Creating...' : 'Create Project'}
</Button>
</div>
</div>
</div>
);
default:
return null;
}
};
const getStepTitle = () => {
switch (step) {
case 'select-folder':
return 'Create New Project';
case 'project-name':
return 'Name Your Project';
case 'app-spec':
return 'Project Setup';
default:
return '';
}
};
const getStepDescription = () => {
switch (step) {
case 'select-folder':
return 'Start by selecting the root directory of your project';
case 'project-name':
return 'Give your project a memorable name';
case 'app-spec':
return 'Help the AI understand your project better';
default:
return '';
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{getStepTitle()}</DialogTitle>
<DialogDescription>{getStepDescription()}</DialogDescription>
</DialogHeader>
{/* Progress indicator */}
<div className="flex items-center gap-2 py-2">
{['select-folder', 'project-name', 'app-spec'].map((s, i) => (
<div
key={s}
className={cn(
'h-1 flex-1 rounded-full transition-colors',
step === s
? 'bg-primary'
: ['project-name', 'app-spec'].indexOf(step) > i
? 'bg-primary/50'
: 'bg-muted'
)}
/>
))}
</div>
{renderStep()}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1 @@
export { SettingsDialog } from './settings-dialog';

View File

@@ -0,0 +1,59 @@
import { Settings } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { SettingsContent } from '@/components/views/settings-view/settings-content';
import { cn } from '@/lib/utils';
interface SettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0 overflow-hidden"
data-testid="settings-dialog"
>
{/* Header */}
<DialogHeader
className={cn(
'shrink-0 px-6 py-4',
'border-b border-border/50',
'bg-gradient-to-r from-card/90 via-card/70 to-card/80'
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
'w-10 h-10 rounded-xl flex items-center justify-center',
'bg-gradient-to-br from-brand-500 to-brand-600',
'shadow-lg shadow-brand-500/25',
'ring-1 ring-white/10'
)}
>
<Settings className="w-5 h-5 text-white" />
</div>
<div>
<DialogTitle className="text-xl font-bold">Settings</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground/80 mt-0.5">
Configure your API keys and preferences
</DialogDescription>
</div>
</div>
</DialogHeader>
{/* Content */}
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
<SettingsContent compact />
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,655 @@
import { useState, useCallback, useSyncExternalStore, useRef, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { Button } from '@/components/ui/button';
import {
Terminal,
Bot,
FileText,
FolderOpen,
Github,
ChevronUp,
ChevronDown,
ChevronLeft,
ChevronRight,
Maximize2,
Minimize2,
MessageSquare,
Sparkles,
PanelBottom,
PanelRight,
PanelLeft,
} from 'lucide-react';
import {
GitHubPanel,
AgentsPanel,
SpecPanel,
ContextPanel,
TerminalPanelDock,
ChatPanel,
IdeationPanel,
} from './panels';
type DockTab = 'terminal' | 'agents' | 'spec' | 'context' | 'github' | 'chat' | 'ideation';
export type DockPosition = 'bottom' | 'right' | 'left';
const DOCK_POSITION_STORAGE_KEY = 'automaker:dock-position';
// Event emitter for dock state changes
const stateListeners = new Set<() => void>();
function emitStateChange() {
stateListeners.forEach((listener) => listener());
}
// Cached dock state
interface DockState {
position: DockPosition;
isExpanded: boolean;
isMaximized: boolean;
}
let cachedState: DockState = {
position: 'bottom',
isExpanded: false,
isMaximized: false,
};
// Initialize position from localStorage
try {
const stored = localStorage.getItem(DOCK_POSITION_STORAGE_KEY) as DockPosition | null;
if (stored && ['bottom', 'right', 'left'].includes(stored)) {
cachedState.position = stored;
}
} catch {
// Ignore localStorage errors
}
function getDockState(): DockState {
return cachedState;
}
function updatePosition(position: DockPosition) {
if (cachedState.position !== position) {
cachedState = { ...cachedState, position };
try {
localStorage.setItem(DOCK_POSITION_STORAGE_KEY, position);
} catch {
// Ignore localStorage errors
}
emitStateChange();
}
}
function updateExpanded(isExpanded: boolean) {
if (cachedState.isExpanded !== isExpanded) {
cachedState = { ...cachedState, isExpanded };
emitStateChange();
}
}
function updateMaximized(isMaximized: boolean) {
if (cachedState.isMaximized !== isMaximized) {
cachedState = { ...cachedState, isMaximized };
emitStateChange();
}
}
// Hook for external components to read dock state
export function useDockState(): DockState {
return useSyncExternalStore(
(callback) => {
stateListeners.add(callback);
return () => stateListeners.delete(callback);
},
getDockState,
getDockState
);
}
interface BottomDockProps {
className?: string;
}
export function BottomDock({ className }: BottomDockProps) {
const { currentProject, getAutoModeState } = useAppStore();
const [isExpanded, setIsExpanded] = useState(false);
const [activeTab, setActiveTab] = useState<DockTab | null>(null);
const [isMaximized, setIsMaximized] = useState(false);
// Use external store for position - single source of truth
const position = useSyncExternalStore(
(callback) => {
stateListeners.add(callback);
return () => stateListeners.delete(callback);
},
() => getDockState().position,
() => getDockState().position
);
// Sync local expanded/maximized state to external store for other components
useEffect(() => {
updateExpanded(isExpanded);
}, [isExpanded]);
useEffect(() => {
updateMaximized(isMaximized);
}, [isMaximized]);
const autoModeState = currentProject ? getAutoModeState(currentProject.id) : null;
const runningAgentsCount = autoModeState?.runningTasks?.length ?? 0;
// Ref for click-outside detection
const dockRef = useRef<HTMLDivElement>(null);
// Handle click outside to close the panel
useEffect(() => {
if (!isExpanded) return;
const handleClickOutside = (event: MouseEvent) => {
if (dockRef.current && !dockRef.current.contains(event.target as Node)) {
setIsExpanded(false);
setIsMaximized(false);
}
};
// Use mousedown for more responsive feel
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isExpanded]);
const handleTabClick = useCallback(
(tab: DockTab) => {
if (activeTab === tab) {
setIsExpanded(!isExpanded);
} else {
setActiveTab(tab);
setIsExpanded(true);
}
},
[activeTab, isExpanded]
);
// Get keyboard shortcuts from config
const shortcuts = useKeyboardShortcutsConfig();
// Register keyboard shortcuts for dock tabs
useKeyboardShortcuts([
{
key: shortcuts.terminal,
action: () => handleTabClick('terminal'),
description: 'Toggle Terminal panel',
},
{
key: shortcuts.ideation,
action: () => handleTabClick('ideation'),
description: 'Toggle Ideation panel',
},
{
key: shortcuts.spec,
action: () => handleTabClick('spec'),
description: 'Toggle Spec panel',
},
{
key: shortcuts.context,
action: () => handleTabClick('context'),
description: 'Toggle Context panel',
},
{
key: shortcuts.githubIssues,
action: () => handleTabClick('github'),
description: 'Toggle GitHub panel',
},
{
key: shortcuts.agent,
action: () => handleTabClick('agents'),
description: 'Toggle Agents panel',
},
]);
const handleDoubleClick = useCallback(() => {
if (isExpanded) {
setIsMaximized(!isMaximized);
} else {
setIsExpanded(true);
if (!activeTab) {
setActiveTab('terminal');
}
}
}, [isExpanded, isMaximized, activeTab]);
// All tabs combined for easier rendering
const allTabs = [
{
id: 'terminal' as DockTab,
label: 'Terminal',
icon: Terminal,
badge: null,
badgeColor: undefined,
group: 'operations',
},
{
id: 'chat' as DockTab,
label: 'Chat',
icon: MessageSquare,
badge: null,
badgeColor: undefined,
group: 'operations',
},
{
id: 'ideation' as DockTab,
label: 'Ideate',
icon: Sparkles,
badge: null,
badgeColor: undefined,
group: 'planning',
},
{
id: 'spec' as DockTab,
label: 'Spec',
icon: FileText,
badge: null,
badgeColor: undefined,
group: 'planning',
},
{
id: 'context' as DockTab,
label: 'Context',
icon: FolderOpen,
badge: null,
badgeColor: undefined,
group: 'planning',
},
{
id: 'github' as DockTab,
label: 'GitHub',
icon: Github,
badge: null,
badgeColor: undefined,
group: 'planning',
},
{
id: 'agents' as DockTab,
label: 'Agents',
icon: Bot,
badge: runningAgentsCount > 0 ? runningAgentsCount : null,
badgeColor: 'bg-green-500',
group: 'agents',
},
];
const isRightDock = position === 'right';
const isLeftDock = position === 'left';
const isSideDock = isRightDock || isLeftDock;
// Render panel content directly to avoid remounting on state changes
const renderPanelContent = () => (
<>
{activeTab === 'terminal' && <TerminalPanelDock />}
{activeTab === 'agents' && <AgentsPanel />}
{activeTab === 'spec' && <SpecPanel />}
{activeTab === 'context' && <ContextPanel />}
{activeTab === 'github' && <GitHubPanel />}
{activeTab === 'chat' && <ChatPanel />}
{activeTab === 'ideation' && <IdeationPanel />}
</>
);
// Side dock layout (left or right)
if (isSideDock) {
const dockWidth = isMaximized ? 'w-[50vw]' : isExpanded ? 'w-96' : 'w-10';
return (
<div
ref={dockRef}
className={cn(
'bg-background/95 backdrop-blur-sm',
'transition-all duration-300 ease-in-out flex',
'fixed top-12 bottom-0 z-30',
isLeftDock ? 'left-0 border-r border-border' : 'right-0 border-l border-border',
dockWidth,
className
)}
>
{/* Vertical Tab Bar */}
<div
className={cn(
'flex flex-col w-10 py-2 cursor-pointer select-none shrink-0',
isLeftDock ? 'border-r border-border/50' : 'border-r border-border/50'
)}
onDoubleClick={handleDoubleClick}
>
{/* Tab Icons */}
<div className="flex flex-col items-center gap-1">
{allTabs.map((tab, index) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id && isExpanded;
const showDivider = (index === 1 || index === 5) && index < allTabs.length - 1;
return (
<div key={tab.id}>
<button
onClick={(e) => {
e.stopPropagation();
handleTabClick(tab.id);
}}
onDoubleClick={(e) => e.stopPropagation()}
className={cn(
'relative flex items-center justify-center w-7 h-7 rounded-md',
'transition-colors',
isActive
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
)}
title={tab.label}
>
<Icon className="h-4 w-4" />
{tab.badge && (
<span
className={cn(
'absolute -top-1 -right-1 flex items-center justify-center h-3.5 min-w-3.5 px-0.5 rounded-full text-[9px] text-white',
tab.badgeColor || 'bg-primary'
)}
>
{tab.badge}
</span>
)}
</button>
{showDivider && <div className="w-5 h-px bg-border my-1 mx-auto" />}
</div>
);
})}
</div>
<div className="flex-1" />
{/* Dock Controls */}
<div className="flex flex-col items-center gap-1" onClick={(e) => e.stopPropagation()}>
{/* Position buttons - show other positions (not current) */}
{position !== 'left' && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => updatePosition('left')}
title="Dock to left"
>
<PanelLeft className="h-3.5 w-3.5" />
</Button>
)}
{position !== 'bottom' && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => updatePosition('bottom')}
title="Dock to bottom"
>
<PanelBottom className="h-3.5 w-3.5" />
</Button>
)}
{position !== 'right' && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => updatePosition('right')}
title="Dock to right"
>
<PanelRight className="h-3.5 w-3.5" />
</Button>
)}
{isExpanded && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setIsMaximized(!isMaximized)}
title={isMaximized ? 'Restore' : 'Maximize'}
>
{isMaximized ? (
<Minimize2 className="h-3.5 w-3.5" />
) : (
<Maximize2 className="h-3.5 w-3.5" />
)}
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
if (isExpanded) {
setIsMaximized(false);
}
setIsExpanded(!isExpanded);
}}
title={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? (
isLeftDock ? (
<ChevronLeft className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)
) : isLeftDock ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</Button>
</div>
</div>
{/* Panel Content */}
{isExpanded && <div className="flex-1 h-full overflow-hidden">{renderPanelContent()}</div>}
</div>
);
}
// Bottom dock layout - uses fixed positioning like side docks
const dockHeight = isMaximized ? 'h-[70vh]' : isExpanded ? 'h-72' : 'h-10';
// Group tabs for bottom layout
const operationsTabs = allTabs.filter((t) => t.group === 'operations');
const planningTabs = allTabs.filter((t) => t.group === 'planning');
const agentTab = allTabs.find((t) => t.group === 'agents')!;
return (
<div
ref={dockRef}
className={cn(
'fixed left-0 right-0 bottom-0 border-t border-border bg-background/95 backdrop-blur-sm z-30',
'transition-all duration-300 ease-in-out flex flex-col',
dockHeight,
className
)}
>
{/* Tab Bar - double click to expand/maximize */}
<div
className="flex items-center h-10 px-2 border-b border-border/50 cursor-pointer select-none shrink-0"
onDoubleClick={handleDoubleClick}
>
<div className="flex items-center gap-1">
{/* Operations tabs */}
{operationsTabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id && isExpanded;
return (
<button
key={tab.id}
onClick={(e) => {
e.stopPropagation();
handleTabClick(tab.id);
}}
onDoubleClick={(e) => e.stopPropagation()}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm',
'transition-colors',
isActive
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
)}
>
<Icon className="h-4 w-4" />
<span>{tab.label}</span>
{tab.badge && (
<span
className={cn(
'flex items-center justify-center h-4 min-w-4 px-1 rounded-full text-[10px] text-white',
tab.badgeColor || 'bg-primary'
)}
>
{tab.badge}
</span>
)}
</button>
);
})}
{/* Divider */}
<div className="h-5 w-px bg-border mx-1" />
{/* Planning tabs */}
{planningTabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id && isExpanded;
return (
<button
key={tab.id}
onClick={(e) => {
e.stopPropagation();
handleTabClick(tab.id);
}}
onDoubleClick={(e) => e.stopPropagation()}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm',
'transition-colors',
isActive
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
)}
>
<Icon className="h-4 w-4" />
<span>{tab.label}</span>
{tab.badge && (
<span
className={cn(
'flex items-center justify-center h-4 min-w-4 px-1 rounded-full text-[10px] text-white',
tab.badgeColor || 'bg-primary'
)}
>
{tab.badge}
</span>
)}
</button>
);
})}
{/* Divider */}
<div className="h-5 w-px bg-border mx-1" />
{/* Agents tab (separate section) */}
{(() => {
const Icon = agentTab.icon;
const isActive = activeTab === agentTab.id && isExpanded;
return (
<button
onClick={(e) => {
e.stopPropagation();
handleTabClick(agentTab.id);
}}
onDoubleClick={(e) => e.stopPropagation()}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm',
'transition-colors',
isActive
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
)}
>
<Icon className="h-4 w-4" />
<span>{agentTab.label}</span>
{agentTab.badge && (
<span
className={cn(
'flex items-center justify-center h-4 min-w-4 px-1 rounded-full text-[10px] text-white',
agentTab.badgeColor || 'bg-primary'
)}
>
{agentTab.badge}
</span>
)}
</button>
);
})()}
</div>
<div className="flex-1" />
{/* Dock Controls */}
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
{/* Position buttons - show other positions (not current) */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => updatePosition('left')}
title="Dock to left"
>
<PanelLeft className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => updatePosition('right')}
title="Dock to right"
>
<PanelRight className="h-3.5 w-3.5" />
</Button>
{isExpanded && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setIsMaximized(!isMaximized)}
title={isMaximized ? 'Restore' : 'Maximize'}
>
{isMaximized ? (
<Minimize2 className="h-3.5 w-3.5" />
) : (
<Maximize2 className="h-3.5 w-3.5" />
)}
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
if (isExpanded) {
setIsMaximized(false);
}
setIsExpanded(!isExpanded);
}}
title={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
</Button>
</div>
</div>
{/* Panel Content */}
{isExpanded && <div className="flex-1 overflow-hidden min-h-0">{renderPanelContent()}</div>}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { BottomDock, useDockState } from './bottom-dock';
export type { DockPosition } from './bottom-dock';

View File

@@ -0,0 +1,143 @@
import { useState, useEffect, useCallback } from 'react';
import { Bot, Square, Loader2, Activity } from 'lucide-react';
import { getElectronAPI, RunningAgent } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
export function AgentsPanel() {
const { currentProject } = useAppStore();
const [runningAgents, setRunningAgents] = useState<RunningAgent[]>([]);
const [loading, setLoading] = useState(true);
const [stoppingAgents, setStoppingAgents] = useState<Set<string>>(new Set());
const fetchRunningAgents = useCallback(async () => {
try {
const api = getElectronAPI();
if (api.runningAgents) {
const result = await api.runningAgents.getAll();
if (result.success && result.runningAgents) {
// Filter to current project if one is selected
const agents = currentProject?.path
? result.runningAgents.filter((a) => a.projectPath === currentProject.path)
: result.runningAgents;
setRunningAgents(agents);
}
}
} catch (error) {
console.error('Error fetching running agents:', error);
} finally {
setLoading(false);
}
}, [currentProject?.path]);
// Initial fetch and auto-refresh
useEffect(() => {
fetchRunningAgents();
const interval = setInterval(fetchRunningAgents, 2000);
return () => clearInterval(interval);
}, [fetchRunningAgents]);
// Subscribe to auto-mode events
useEffect(() => {
const api = getElectronAPI();
if (!api.autoMode) return;
const unsubscribe = api.autoMode.onEvent((event) => {
if (event.type === 'auto_mode_feature_complete' || event.type === 'auto_mode_error') {
fetchRunningAgents();
}
});
return () => unsubscribe();
}, [fetchRunningAgents]);
const handleStopAgent = useCallback(async (featureId: string) => {
setStoppingAgents((prev) => new Set(prev).add(featureId));
try {
const api = getElectronAPI();
if (api.autoMode) {
await api.autoMode.stopFeature(featureId);
toast.success('Agent stopped');
}
} catch (error) {
toast.error('Failed to stop agent');
} finally {
setStoppingAgents((prev) => {
const next = new Set(prev);
next.delete(featureId);
return next;
});
}
}, []);
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<div className="flex items-center gap-2">
<Activity className="h-3.5 w-3.5 text-green-500" />
<span className="text-xs font-medium">{runningAgents.length} Running</span>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto">
<div className="p-2 space-y-2">
{runningAgents.length === 0 ? (
<div className="text-center py-8">
<Bot className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-xs text-muted-foreground">No agents running</p>
<p className="text-[10px] text-muted-foreground mt-1">
Enable Auto Mode to start processing features
</p>
</div>
) : (
runningAgents.map((agent) => (
<div key={agent.featureId} className="p-2 rounded-md border border-border bg-card">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{agent.featureTitle}</p>
<p className="text-[10px] text-muted-foreground mt-0.5">
{agent.status === 'running' ? 'In progress...' : agent.status}
</p>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
onClick={() => handleStopAgent(agent.featureId)}
disabled={stoppingAgents.has(agent.featureId)}
>
{stoppingAgents.has(agent.featureId) ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Square className="h-3 w-3" />
)}
</Button>
</div>
{agent.currentPhase && (
<div className="flex items-center gap-1.5 mt-2">
<div className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
<span className="text-[10px] text-muted-foreground capitalize">
{agent.currentPhase}
</span>
</div>
)}
</div>
))
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,697 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import {
MessageSquare,
Plus,
Loader2,
Archive,
ArchiveRestore,
Trash2,
X,
Send,
Square,
Bot,
User,
AlertCircle,
ArchiveX,
} from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { useElectronAgent } from '@/hooks/use-electron-agent';
import { Button } from '@/components/ui/button';
import { Markdown } from '@/components/ui/markdown';
import { cn } from '@/lib/utils';
import { AgentModelSelector } from '@/components/views/agent-view/shared/agent-model-selector';
import { DeleteSessionDialog } from '@/components/dialogs/delete-session-dialog';
import type { SessionListItem } from '@/types/electron';
import type { Message } from '@/types/electron';
import type { PhaseModelEntry } from '@automaker/types';
// Random session name generator
const adjectives = [
'Swift',
'Bright',
'Clever',
'Dynamic',
'Eager',
'Focused',
'Gentle',
'Happy',
'Inventive',
'Jolly',
'Keen',
'Lively',
'Mighty',
'Noble',
'Optimal',
'Peaceful',
];
const nouns = [
'Agent',
'Builder',
'Coder',
'Developer',
'Explorer',
'Forge',
'Garden',
'Helper',
'Journey',
'Mission',
'Navigator',
'Project',
'Quest',
'Runner',
'Spark',
'Task',
];
function generateRandomSessionName(): string {
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
const number = Math.floor(Math.random() * 100);
return `${adjective} ${noun} ${number}`;
}
// Compact message bubble for dock panel
function CompactMessageBubble({ message }: { message: Message }) {
const isError = message.isError && message.role === 'assistant';
return (
<div className={cn('flex gap-2', message.role === 'user' ? 'flex-row-reverse' : '')}>
{/* Avatar */}
<div
className={cn(
'w-6 h-6 rounded-lg flex items-center justify-center shrink-0',
isError ? 'bg-red-500/10' : message.role === 'assistant' ? 'bg-primary/10' : 'bg-muted'
)}
>
{isError ? (
<AlertCircle className="w-3 h-3 text-red-500" />
) : message.role === 'assistant' ? (
<Bot className="w-3 h-3 text-primary" />
) : (
<User className="w-3 h-3 text-muted-foreground" />
)}
</div>
{/* Message */}
<div
className={cn(
'flex-1 max-w-[85%] rounded-lg px-2.5 py-1.5 text-xs',
isError
? 'bg-red-500/10 border border-red-500/30'
: message.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-card border border-border'
)}
>
{message.role === 'assistant' ? (
<Markdown
className={cn(
'text-xs prose-p:leading-relaxed prose-p:my-1 prose-headings:text-sm prose-headings:my-1',
isError ? 'text-red-600 dark:text-red-400' : 'text-foreground'
)}
>
{message.content}
</Markdown>
) : (
<p className="whitespace-pre-wrap leading-relaxed">{message.content}</p>
)}
</div>
</div>
);
}
// Compact thinking indicator
function CompactThinkingIndicator() {
return (
<div className="flex gap-2">
<div className="w-6 h-6 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<Bot className="w-3 h-3 text-primary" />
</div>
<div className="bg-card border border-border rounded-lg px-2.5 py-1.5">
<div className="flex items-center gap-2">
<div className="flex items-center gap-0.5">
<span
className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '0ms' }}
/>
<span
className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '150ms' }}
/>
<span
className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '300ms' }}
/>
</div>
<span className="text-xs text-muted-foreground">Thinking...</span>
</div>
</div>
</div>
);
}
// Embedded chat component for a session
function EmbeddedChat({ sessionId, projectPath }: { sessionId: string; projectPath: string }) {
const [input, setInput] = useState('');
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'sonnet' });
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { messages, isProcessing, isConnected, sendMessage, stopExecution } = useElectronAgent({
sessionId,
workingDirectory: projectPath,
model: modelSelection.model,
thinkingLevel: modelSelection.thinkingLevel,
});
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, isProcessing]);
// Focus input on mount
useEffect(() => {
inputRef.current?.focus();
}, [sessionId]);
const handleSend = useCallback(async () => {
if (!input.trim() || isProcessing) return;
const messageContent = input;
setInput('');
await sendMessage(messageContent);
}, [input, isProcessing, sendMessage]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// Show welcome message if no messages
const displayMessages =
messages.length === 0
? [
{
id: 'welcome',
role: 'assistant' as const,
content: "Hello! I'm the Automaker Agent. How can I help you today?",
timestamp: new Date().toISOString(),
},
]
: messages;
return (
<div className="flex flex-col h-full">
{/* Messages area */}
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-3">
{displayMessages.map((message) => (
<CompactMessageBubble key={message.id} message={message} />
))}
{isProcessing && <CompactThinkingIndicator />}
<div ref={messagesEndRef} />
</div>
{/* Input area */}
<div className="border-t border-border/50 p-2">
<div className="flex items-center gap-2">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={isConnected ? 'Type a message...' : 'Connecting...'}
disabled={!isConnected}
className={cn(
'flex-1 h-8 rounded-md border border-border bg-background px-3 text-xs',
'placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary'
)}
/>
<AgentModelSelector
value={modelSelection}
onChange={setModelSelection}
disabled={isProcessing}
triggerClassName="h-8"
/>
{isProcessing ? (
<Button
size="sm"
variant="destructive"
className="h-8 w-8 p-0"
onClick={stopExecution}
title="Stop"
>
<Square className="h-3.5 w-3.5" />
</Button>
) : (
<Button
size="sm"
className="h-8 w-8 p-0"
onClick={handleSend}
disabled={!input.trim() || !isConnected}
title="Send"
>
<Send className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
</div>
);
}
export function ChatPanel() {
const { currentProject } = useAppStore();
const [sessions, setSessions] = useState<SessionListItem[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false);
const [archivingAll, setArchivingAll] = useState(false);
// Delete dialog state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
const loadSessions = useCallback(async () => {
try {
const api = getElectronAPI();
if (api?.sessions) {
const result = await api.sessions.list(true);
if (result.success && result.sessions) {
setSessions(result.sessions);
// Set active session to first active session if none selected
const activeSessions = result.sessions.filter((s) => !s.isArchived);
if (!activeSessionId && activeSessions.length > 0) {
setActiveSessionId(activeSessions[0].id);
}
}
}
} catch (error) {
console.error('Error fetching sessions:', error);
} finally {
setLoading(false);
}
}, [activeSessionId]);
useEffect(() => {
loadSessions();
}, [loadSessions]);
const handleCreateSession = useCallback(async () => {
if (!currentProject) return;
setCreating(true);
try {
const api = getElectronAPI();
if (api?.sessions) {
const sessionName = generateRandomSessionName();
const result = await api.sessions.create(
sessionName,
currentProject.path,
currentProject.path
);
if (result.success && result.session?.id) {
await loadSessions();
setActiveSessionId(result.session.id);
setShowArchived(false);
}
}
} catch (error) {
console.error('Error creating session:', error);
} finally {
setCreating(false);
}
}, [currentProject, loadSessions]);
const handleArchiveSession = useCallback(
async (sessionId: string, e?: React.MouseEvent) => {
e?.stopPropagation();
try {
const api = getElectronAPI();
if (api?.sessions) {
await api.sessions.archive(sessionId);
await loadSessions();
// If archived session was active, switch to first active session
if (sessionId === activeSessionId) {
const updatedSessions = sessions.filter((s) => s.id !== sessionId && !s.isArchived);
setActiveSessionId(updatedSessions.length > 0 ? updatedSessions[0].id : null);
}
}
} catch (error) {
console.error('Error archiving session:', error);
}
},
[loadSessions, activeSessionId, sessions]
);
const handleArchiveAll = useCallback(async () => {
const activeSessions = sessions.filter((s) => !s.isArchived);
if (activeSessions.length === 0) return;
setArchivingAll(true);
try {
const api = getElectronAPI();
if (api?.sessions) {
for (const session of activeSessions) {
await api.sessions.archive(session.id);
}
await loadSessions();
setActiveSessionId(null);
}
} catch (error) {
console.error('Error archiving all sessions:', error);
} finally {
setArchivingAll(false);
}
}, [sessions, loadSessions]);
const handleUnarchiveSession = useCallback(
async (sessionId: string, e?: React.MouseEvent) => {
e?.stopPropagation();
try {
const api = getElectronAPI();
if (api?.sessions) {
await api.sessions.unarchive(sessionId);
await loadSessions();
setActiveSessionId(sessionId);
setShowArchived(false);
}
} catch (error) {
console.error('Error unarchiving session:', error);
}
},
[loadSessions]
);
const handleDeleteSession = useCallback((session: SessionListItem, e?: React.MouseEvent) => {
e?.stopPropagation();
setSessionToDelete(session);
setDeleteDialogOpen(true);
}, []);
const confirmDeleteSession = useCallback(
async (sessionId: string) => {
try {
const api = getElectronAPI();
if (api?.sessions) {
await api.sessions.delete(sessionId);
await loadSessions();
// If deleted session was active, switch to first available session
if (sessionId === activeSessionId) {
const remainingSessions = sessions.filter((s) => s.id !== sessionId);
const activeSessions = remainingSessions.filter((s) => !s.isArchived);
setActiveSessionId(activeSessions.length > 0 ? activeSessions[0].id : null);
}
}
} catch (error) {
console.error('Error deleting session:', error);
} finally {
setDeleteDialogOpen(false);
setSessionToDelete(null);
}
},
[loadSessions, activeSessionId, sessions]
);
const activeSessions = sessions.filter((s) => !s.isArchived);
const archivedSessions = sessions.filter((s) => s.isArchived);
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (!currentProject) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<MessageSquare className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-xs text-muted-foreground">Select a project to start chatting</p>
</div>
</div>
);
}
// Show archived sessions list view
if (showArchived) {
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50 shrink-0">
<div className="flex items-center gap-2">
<Archive className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium">{archivedSessions.length} Archived</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={() => setShowArchived(false)}
>
<MessageSquare className="h-3 w-3 mr-1" />
Active
</Button>
</div>
{/* Archived Sessions List */}
<div className="flex-1 overflow-auto">
<div className="p-2 space-y-1">
{archivedSessions.length === 0 ? (
<div className="text-center py-6">
<Archive className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-xs text-muted-foreground">No archived sessions</p>
</div>
) : (
archivedSessions.map((session) => (
<div
key={session.id}
className={cn(
'p-2 rounded-md border border-border bg-card',
'hover:bg-accent/50 transition-colors group'
)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<MessageSquare className="h-3 w-3 text-muted-foreground shrink-0" />
<p className="text-xs font-medium truncate">{session.name}</p>
</div>
<div className="flex items-center gap-2 mt-1 ml-4">
<span className="text-[10px] text-muted-foreground">
{session.messageCount} messages
</span>
</div>
</div>
{/* Actions */}
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => handleUnarchiveSession(session.id, e)}
title="Restore"
>
<ArchiveRestore className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-destructive"
onClick={(e) => handleDeleteSession(session, e)}
title="Delete"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
</div>
))
)}
</div>
</div>
{/* Delete Dialog */}
<DeleteSessionDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
session={sessionToDelete}
onConfirm={confirmDeleteSession}
/>
</div>
);
}
// No active sessions - show empty state
if (activeSessions.length === 0) {
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50 shrink-0">
<div className="flex items-center gap-2">
<MessageSquare className="h-3.5 w-3.5" />
<span className="text-xs font-medium">Chat</span>
</div>
<div className="flex items-center gap-1">
{archivedSessions.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={() => setShowArchived(true)}
>
<Archive className="h-3 w-3 mr-1" />
{archivedSessions.length}
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={handleCreateSession}
disabled={creating}
title="New session"
>
{creating ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Plus className="h-3 w-3" />
)}
</Button>
</div>
</div>
{/* Empty State */}
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<MessageSquare className="h-12 w-12 mx-auto text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground mb-1">No chat sessions</p>
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={handleCreateSession}
disabled={creating}
>
{creating ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<Plus className="h-3 w-3 mr-1" />
)}
New Chat Session
</Button>
</div>
</div>
</div>
);
}
// Active sessions view with tabs and embedded chat
return (
<div className="h-full flex flex-col">
{/* Tab bar */}
<div className="flex items-center px-2 py-1 border-b border-border/50 shrink-0 gap-1 overflow-x-auto">
{activeSessions.map((session) => (
<button
key={session.id}
onClick={() => setActiveSessionId(session.id)}
className={cn(
'flex items-center gap-1 px-2 py-1 text-xs rounded transition-colors shrink-0',
session.id === activeSessionId
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
)}
>
<MessageSquare className="h-3 w-3" />
<span className="max-w-20 truncate">{session.name}</span>
<button
className="ml-0.5 p-0.5 rounded hover:bg-background/50 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleArchiveSession(session.id, e);
}}
title="Archive session"
>
<X className="h-2.5 w-2.5" />
</button>
</button>
))}
<button
className="flex items-center justify-center p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground shrink-0"
onClick={handleCreateSession}
disabled={creating}
title="New Session"
>
{creating ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
</button>
<div className="flex-1" />
<div className="flex items-center gap-0.5 shrink-0">
{activeSessions.length > 1 && (
<Button
variant="ghost"
size="sm"
className="h-6 text-xs px-2"
onClick={handleArchiveAll}
disabled={archivingAll}
title="Archive all sessions"
>
{archivingAll ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<ArchiveX className="h-3 w-3" />
)}
</Button>
)}
{archivedSessions.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 text-xs px-2"
onClick={() => setShowArchived(true)}
title="View archived sessions"
>
<Archive className="h-3 w-3 mr-1" />
{archivedSessions.length}
</Button>
)}
</div>
</div>
{/* Embedded chat content */}
<div className="flex-1 overflow-hidden">
{activeSessionId && currentProject ? (
<EmbeddedChat
key={activeSessionId}
sessionId={activeSessionId}
projectPath={currentProject.path}
/>
) : (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<MessageSquare className="h-8 w-8 mx-auto text-muted-foreground/30 mb-2" />
<p className="text-xs text-muted-foreground">Select a session</p>
</div>
</div>
)}
</div>
{/* Delete Dialog */}
<DeleteSessionDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
session={sessionToDelete}
onConfirm={confirmDeleteSession}
/>
</div>
);
}

View File

@@ -0,0 +1,978 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import {
FolderOpen,
FileText,
Image,
Loader2,
Upload,
FilePlus,
Save,
Trash2,
Pencil,
Eye,
MoreVertical,
ArrowLeft,
} from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import { useAppStore } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { sanitizeFilename } from '@/lib/image-utils';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card } from '@/components/ui/card';
import { Markdown } from '@/components/ui/markdown';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface ContextFile {
name: string;
type: 'text' | 'image';
path: string;
description?: string;
}
interface ContextMetadata {
files: Record<string, { description: string }>;
}
export function ContextPanel() {
const { currentProject } = useAppStore();
const [files, setFiles] = useState<ContextFile[]>([]);
const [loading, setLoading] = useState(true);
const [selectedFile, setSelectedFile] = useState<ContextFile | null>(null);
const [fileContent, setFileContent] = useState<string>('');
const [originalContent, setOriginalContent] = useState<string>('');
const [isDropHovering, setIsDropHovering] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isPreviewMode, setIsPreviewMode] = useState(false);
const [generatingDescriptions, setGeneratingDescriptions] = useState<Set<string>>(new Set());
const fileInputRef = useRef<HTMLInputElement>(null);
// Dialog states
const [isCreateMarkdownOpen, setIsCreateMarkdownOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [isEditDescriptionOpen, setIsEditDescriptionOpen] = useState(false);
// Dialog form values
const [newMarkdownName, setNewMarkdownName] = useState('');
const [newMarkdownDescription, setNewMarkdownDescription] = useState('');
const [newMarkdownContent, setNewMarkdownContent] = useState('');
const [renameFileName, setRenameFileName] = useState('');
const [editDescriptionValue, setEditDescriptionValue] = useState('');
const [editDescriptionFileName, setEditDescriptionFileName] = useState('');
const hasChanges = fileContent !== originalContent;
// Helper functions
const isImageFile = (filename: string): boolean => {
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp'];
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
return imageExtensions.includes(ext);
};
const isMarkdownFile = (filename: string): boolean => {
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
return ext === '.md' || ext === '.markdown';
};
const getContextPath = useCallback(() => {
if (!currentProject) return null;
return `${currentProject.path}/.automaker/context`;
}, [currentProject]);
// Load context metadata
const loadMetadata = useCallback(async (): Promise<ContextMetadata> => {
const contextPath = getContextPath();
if (!contextPath) return { files: {} };
try {
const api = getElectronAPI();
const metadataPath = `${contextPath}/context-metadata.json`;
const result = await api.readFile(metadataPath);
if (result.success && result.content) {
return JSON.parse(result.content);
}
} catch {
// Metadata file doesn't exist yet
}
return { files: {} };
}, [getContextPath]);
// Save context metadata
const saveMetadata = useCallback(
async (metadata: ContextMetadata) => {
const contextPath = getContextPath();
if (!contextPath) return;
try {
const api = getElectronAPI();
const metadataPath = `${contextPath}/context-metadata.json`;
await api.writeFile(metadataPath, JSON.stringify(metadata, null, 2));
} catch (error) {
console.error('Failed to save metadata:', error);
}
},
[getContextPath]
);
const loadContextFiles = useCallback(async () => {
const contextPath = getContextPath();
if (!contextPath) return;
setLoading(true);
try {
const api = getElectronAPI();
// Ensure context directory exists
await api.mkdir(contextPath);
// Load metadata for descriptions
const metadata = await loadMetadata();
// Read directory contents
const result = await api.readdir(contextPath);
if (result.success && result.entries) {
const contextFiles: ContextFile[] = result.entries
.filter((entry) => entry.isFile && entry.name !== 'context-metadata.json')
.map((entry) => ({
name: entry.name,
type: isImageFile(entry.name) ? 'image' : 'text',
path: `${contextPath}/${entry.name}`,
description: metadata.files[entry.name]?.description,
}));
setFiles(contextFiles);
}
} catch (error) {
console.error('Error loading context files:', error);
} finally {
setLoading(false);
}
}, [getContextPath, loadMetadata]);
useEffect(() => {
loadContextFiles();
}, [loadContextFiles]);
const handleSelectFile = useCallback(async (file: ContextFile) => {
try {
const api = getElectronAPI();
const result = await api.readFile(file.path);
if (result.success && result.content !== undefined) {
setSelectedFile(file);
setFileContent(result.content);
setOriginalContent(result.content);
setIsPreviewMode(isMarkdownFile(file.name));
}
} catch (error) {
console.error('Error reading file:', error);
}
}, []);
// Save file content
const handleSaveFile = useCallback(async () => {
if (!selectedFile || !hasChanges) return;
setIsSaving(true);
try {
const api = getElectronAPI();
await api.writeFile(selectedFile.path, fileContent);
setOriginalContent(fileContent);
toast.success('File saved');
} catch (error) {
console.error('Failed to save file:', error);
toast.error('Failed to save file');
} finally {
setIsSaving(false);
}
}, [selectedFile, fileContent, hasChanges]);
// Generate description for a file
const generateDescription = async (
filePath: string,
fileName: string,
isImage: boolean
): Promise<string | undefined> => {
try {
const httpClient = getHttpApiClient();
const result = isImage
? await httpClient.context.describeImage(filePath)
: await httpClient.context.describeFile(filePath);
if (result.success && result.description) {
return result.description;
}
} catch (error) {
console.error('Failed to generate description:', error);
}
return undefined;
};
// Generate description in background and update metadata
const generateDescriptionAsync = useCallback(
async (filePath: string, fileName: string, isImage: boolean) => {
setGeneratingDescriptions((prev) => new Set(prev).add(fileName));
try {
const description = await generateDescription(filePath, fileName, isImage);
if (description) {
const metadata = await loadMetadata();
metadata.files[fileName] = { description };
await saveMetadata(metadata);
await loadContextFiles();
setSelectedFile((current) => {
if (current?.name === fileName) {
return { ...current, description };
}
return current;
});
}
} catch (error) {
console.error('Failed to generate description:', error);
} finally {
setGeneratingDescriptions((prev) => {
const next = new Set(prev);
next.delete(fileName);
return next;
});
}
},
[loadMetadata, saveMetadata, loadContextFiles]
);
// Upload a file
const uploadFile = async (file: globalThis.File) => {
const contextPath = getContextPath();
if (!contextPath) return;
setIsUploading(true);
try {
const api = getElectronAPI();
const isImage = isImageFile(file.name);
let filePath: string;
let fileName: string;
let imagePathForDescription: string | undefined;
if (isImage) {
fileName = sanitizeFilename(file.name);
const dataUrl = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = (event) => resolve(event.target?.result as string);
reader.readAsDataURL(file);
});
const base64Data = dataUrl.split(',')[1] || dataUrl;
const mimeType = file.type || 'image/png';
const saveResult = await api.saveImageToTemp?.(
base64Data,
fileName,
mimeType,
currentProject!.path
);
if (!saveResult?.success || !saveResult.path) {
throw new Error(saveResult?.error || 'Failed to save image');
}
imagePathForDescription = saveResult.path;
filePath = `${contextPath}/${fileName}`;
await api.writeFile(filePath, dataUrl);
} else {
fileName = file.name;
filePath = `${contextPath}/${fileName}`;
const content = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = (event) => resolve(event.target?.result as string);
reader.readAsText(file);
});
await api.writeFile(filePath, content);
}
await loadContextFiles();
generateDescriptionAsync(imagePathForDescription || filePath, fileName, isImage);
} catch (error) {
console.error('Failed to upload file:', error);
toast.error('Failed to upload file', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsUploading(false);
}
};
// Handle file drop
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDropHovering(false);
const droppedFiles = Array.from(e.dataTransfer.files);
if (droppedFiles.length === 0) return;
for (const file of droppedFiles) {
await uploadFile(file);
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDropHovering(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDropHovering(false);
};
// Handle file import via button
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const inputFiles = e.target.files;
if (!inputFiles || inputFiles.length === 0) return;
for (const file of Array.from(inputFiles)) {
await uploadFile(file);
}
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
// Create markdown file
const handleCreateMarkdown = async () => {
const contextPath = getContextPath();
if (!contextPath || !newMarkdownName.trim()) return;
try {
const api = getElectronAPI();
let filename = newMarkdownName.trim();
if (!filename.includes('.')) {
filename += '.md';
}
const filePath = `${contextPath}/${filename}`;
await api.writeFile(filePath, newMarkdownContent);
if (newMarkdownDescription.trim()) {
const metadata = await loadMetadata();
metadata.files[filename] = { description: newMarkdownDescription.trim() };
await saveMetadata(metadata);
}
await loadContextFiles();
setIsCreateMarkdownOpen(false);
setNewMarkdownName('');
setNewMarkdownDescription('');
setNewMarkdownContent('');
toast.success('Markdown file created');
} catch (error) {
console.error('Failed to create markdown:', error);
toast.error('Failed to create file');
}
};
// Delete selected file
const handleDeleteFile = async () => {
if (!selectedFile) return;
try {
const api = getElectronAPI();
await api.deleteFile(selectedFile.path);
const metadata = await loadMetadata();
delete metadata.files[selectedFile.name];
await saveMetadata(metadata);
setIsDeleteDialogOpen(false);
setSelectedFile(null);
setFileContent('');
setOriginalContent('');
await loadContextFiles();
toast.success('File deleted');
} catch (error) {
console.error('Failed to delete file:', error);
toast.error('Failed to delete file');
}
};
// Rename selected file
const handleRenameFile = async () => {
const contextPath = getContextPath();
if (!selectedFile || !contextPath || !renameFileName.trim()) return;
const newName = renameFileName.trim();
if (newName === selectedFile.name) {
setIsRenameDialogOpen(false);
return;
}
try {
const api = getElectronAPI();
const newPath = `${contextPath}/${newName}`;
const exists = await api.exists(newPath);
if (exists) {
toast.error('A file with this name already exists');
return;
}
const result = await api.readFile(selectedFile.path);
if (!result.success || result.content === undefined) {
toast.error('Failed to read file for rename');
return;
}
await api.writeFile(newPath, result.content);
await api.deleteFile(selectedFile.path);
const metadata = await loadMetadata();
if (metadata.files[selectedFile.name]) {
metadata.files[newName] = metadata.files[selectedFile.name];
delete metadata.files[selectedFile.name];
await saveMetadata(metadata);
}
setIsRenameDialogOpen(false);
setRenameFileName('');
await loadContextFiles();
const renamedFile: ContextFile = {
name: newName,
type: isImageFile(newName) ? 'image' : 'text',
path: newPath,
description: metadata.files[newName]?.description,
};
setSelectedFile(renamedFile);
toast.success('File renamed');
} catch (error) {
console.error('Failed to rename file:', error);
toast.error('Failed to rename file');
}
};
// Save edited description
const handleSaveDescription = async () => {
if (!editDescriptionFileName) return;
try {
const metadata = await loadMetadata();
metadata.files[editDescriptionFileName] = { description: editDescriptionValue.trim() };
await saveMetadata(metadata);
if (selectedFile?.name === editDescriptionFileName) {
setSelectedFile({ ...selectedFile, description: editDescriptionValue.trim() });
}
await loadContextFiles();
setIsEditDescriptionOpen(false);
setEditDescriptionValue('');
setEditDescriptionFileName('');
toast.success('Description saved');
} catch (error) {
console.error('Failed to save description:', error);
toast.error('Failed to save description');
}
};
// Delete file from list (dropdown action)
const handleDeleteFromList = async (file: ContextFile) => {
try {
const api = getElectronAPI();
await api.deleteFile(file.path);
const metadata = await loadMetadata();
delete metadata.files[file.name];
await saveMetadata(metadata);
if (selectedFile?.path === file.path) {
setSelectedFile(null);
setFileContent('');
setOriginalContent('');
}
await loadContextFiles();
toast.success('File deleted');
} catch (error) {
console.error('Failed to delete file:', error);
toast.error('Failed to delete file');
}
};
// Go back to file list
const handleBack = useCallback(() => {
setSelectedFile(null);
setFileContent('');
setOriginalContent('');
setIsPreviewMode(false);
}, []);
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div
className={cn(
'h-full flex flex-col relative',
isDropHovering && 'ring-2 ring-primary ring-inset'
)}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileInputChange}
/>
{/* Drop overlay */}
{isDropHovering && (
<div className="absolute inset-0 bg-primary/10 z-50 flex items-center justify-center pointer-events-none">
<div className="flex flex-col items-center text-primary">
<Upload className="w-8 h-8 mb-1" />
<span className="text-sm font-medium">Drop files to upload</span>
</div>
</div>
)}
{/* Uploading overlay */}
{isUploading && (
<div className="absolute inset-0 bg-background/80 z-50 flex items-center justify-center">
<div className="flex flex-col items-center">
<Loader2 className="w-6 h-6 animate-spin text-primary mb-1" />
<span className="text-xs font-medium">Uploading...</span>
</div>
</div>
)}
{/* Single View: Either File List OR File Content */}
{!selectedFile ? (
/* File List View */
<>
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<span className="text-xs font-medium">Context Files</span>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setIsCreateMarkdownOpen(true)}
title="Create markdown"
>
<FilePlus className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleImportClick}
disabled={isUploading}
title="Import file"
>
<Upload className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
{files.length === 0 ? (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<FolderOpen className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-xs text-muted-foreground">No context files</p>
<p className="text-[10px] text-muted-foreground mt-1">
Drop files here or click + to add
</p>
</div>
</div>
) : (
<div className="p-2 space-y-1">
{files.map((file) => {
const isGenerating = generatingDescriptions.has(file.name);
return (
<div
key={file.name}
className={cn(
'group flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer',
'text-sm transition-colors',
'hover:bg-accent/50'
)}
onClick={() => handleSelectFile(file)}
>
{file.type === 'image' ? (
<Image className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="flex-1 min-w-0">
<span className="truncate block font-medium">{file.name}</span>
{isGenerating ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Generating description...
</span>
) : file.description ? (
<span className="text-xs text-muted-foreground line-clamp-1">
{file.description}
</span>
) : null}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-accent rounded transition-opacity"
>
<MoreVertical className="w-4 h-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setRenameFileName(file.name);
setSelectedFile(file);
setIsRenameDialogOpen(true);
}}
>
<Pencil className="w-3 h-3 mr-2" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setEditDescriptionFileName(file.name);
setEditDescriptionValue(file.description || '');
setIsEditDescriptionOpen(true);
}}
>
<FileText className="w-3 h-3 mr-2" />
Edit Description
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleDeleteFromList(file);
}}
className="text-red-500 focus:text-red-500"
>
<Trash2 className="w-3 h-3 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
)}
</div>
</>
) : (
/* File Content View */
<>
<div className="flex items-center justify-between px-2 py-2 border-b border-border/50">
<div className="flex items-center gap-2 min-w-0">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={handleBack}
title="Back to files"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<span className="text-xs font-medium truncate">{selectedFile.name}</span>
{hasChanges && <span className="text-[10px] text-amber-500 shrink-0">Unsaved</span>}
</div>
<div className="flex items-center gap-1">
{selectedFile.type === 'text' && isMarkdownFile(selectedFile.name) && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setIsPreviewMode(!isPreviewMode)}
title={isPreviewMode ? 'Edit' : 'Preview'}
>
{isPreviewMode ? (
<Pencil className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</Button>
)}
{selectedFile.type === 'text' && hasChanges && (
<Button
variant="ghost"
size="sm"
className="h-6 text-xs px-2"
onClick={handleSaveFile}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Save className="h-3.5 w-3.5 mr-1" />
Save
</>
)}
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-red-500 hover:text-red-400"
onClick={() => setIsDeleteDialogOpen(true)}
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* Description section */}
<div className="px-2 pt-2">
<div className="bg-muted/30 rounded p-2 text-xs">
<div className="flex items-start justify-between gap-1">
<div className="flex-1 min-w-0">
<span className="text-[10px] font-medium text-muted-foreground uppercase">
Description
</span>
{generatingDescriptions.has(selectedFile.name) ? (
<div className="flex items-center gap-1 text-muted-foreground mt-0.5">
<Loader2 className="w-3 h-3 animate-spin" />
<span>Generating...</span>
</div>
) : selectedFile.description ? (
<p className="text-xs mt-0.5">{selectedFile.description}</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5 italic">No description</p>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0"
onClick={() => {
setEditDescriptionFileName(selectedFile.name);
setEditDescriptionValue(selectedFile.description || '');
setIsEditDescriptionOpen(true);
}}
>
<Pencil className="w-3 h-3" />
</Button>
</div>
</div>
</div>
{/* Content area */}
<div className="flex-1 overflow-auto p-2">
{selectedFile.type === 'image' ? (
<div className="h-full flex items-center justify-center bg-muted/20 rounded">
<img
src={fileContent}
alt={selectedFile.name}
className="max-w-full max-h-full object-contain"
/>
</div>
) : isPreviewMode && isMarkdownFile(selectedFile.name) ? (
<Card className="h-full overflow-auto p-3">
<Markdown>{fileContent}</Markdown>
</Card>
) : (
<textarea
value={fileContent}
onChange={(e) => setFileContent(e.target.value)}
className={cn(
'w-full h-full p-2 font-mono text-xs bg-muted/30 rounded resize-none',
'focus:outline-none focus:ring-1 focus:ring-ring'
)}
placeholder="Enter content..."
spellCheck={false}
/>
)}
</div>
</>
)}
{/* Create Markdown Dialog */}
<Dialog open={isCreateMarkdownOpen} onOpenChange={setIsCreateMarkdownOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Create Markdown File</DialogTitle>
<DialogDescription>Create a new markdown file for AI context.</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-1">
<Label htmlFor="md-filename" className="text-xs">
File Name
</Label>
<Input
id="md-filename"
value={newMarkdownName}
onChange={(e) => setNewMarkdownName(e.target.value)}
placeholder="context-file.md"
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label htmlFor="md-description" className="text-xs">
Description
</Label>
<Input
id="md-description"
value={newMarkdownDescription}
onChange={(e) => setNewMarkdownDescription(e.target.value)}
placeholder="e.g., Coding style guidelines"
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label htmlFor="md-content" className="text-xs">
Content
</Label>
<Textarea
id="md-content"
value={newMarkdownContent}
onChange={(e) => setNewMarkdownContent(e.target.value)}
placeholder="Enter markdown content..."
className="h-32 text-sm font-mono resize-none"
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setIsCreateMarkdownOpen(false)}>
Cancel
</Button>
<Button size="sm" onClick={handleCreateMarkdown} disabled={!newMarkdownName.trim()}>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Delete File</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{selectedFile?.name}"? This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setIsDeleteDialogOpen(false)}>
Cancel
</Button>
<Button variant="destructive" size="sm" onClick={handleDeleteFile}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Rename Dialog */}
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Rename File</DialogTitle>
<DialogDescription>Enter a new name for "{selectedFile?.name}".</DialogDescription>
</DialogHeader>
<div className="py-2">
<Input
value={renameFileName}
onChange={(e) => setRenameFileName(e.target.value)}
placeholder="Enter new filename"
className="h-8 text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter' && renameFileName.trim()) {
handleRenameFile();
}
}}
/>
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setIsRenameDialogOpen(false)}>
Cancel
</Button>
<Button
size="sm"
onClick={handleRenameFile}
disabled={!renameFileName.trim() || renameFileName === selectedFile?.name}
>
Rename
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Description Dialog */}
<Dialog open={isEditDescriptionOpen} onOpenChange={setIsEditDescriptionOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Edit Description</DialogTitle>
<DialogDescription>
Update the description for "{editDescriptionFileName}".
</DialogDescription>
</DialogHeader>
<div className="py-2">
<Textarea
value={editDescriptionValue}
onChange={(e) => setEditDescriptionValue(e.target.value)}
placeholder="Enter description..."
className="h-24 text-sm resize-none"
/>
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setIsEditDescriptionOpen(false)}>
Cancel
</Button>
<Button size="sm" onClick={handleSaveDescription}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,394 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import {
CircleDot,
GitPullRequest,
RefreshCw,
ExternalLink,
Loader2,
Wand2,
CheckCircle,
Clock,
X,
} from 'lucide-react';
import {
getElectronAPI,
GitHubIssue,
GitHubPR,
IssueValidationResult,
StoredValidation,
} from '@/lib/electron';
import { useAppStore, GitHubCacheIssue, GitHubCachePR } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useIssueValidation } from '@/components/views/github-issues-view/hooks';
import { ValidationDialog } from '@/components/views/github-issues-view/dialogs';
import { useModelOverride } from '@/components/shared';
import { toast } from 'sonner';
type GitHubTab = 'issues' | 'prs';
// Cache duration: 5 minutes
const CACHE_DURATION_MS = 5 * 60 * 1000;
// Check if validation is stale (> 24 hours)
function isValidationStale(validatedAt: string): boolean {
const VALIDATION_CACHE_TTL_HOURS = 24;
const validatedTime = new Date(validatedAt).getTime();
const hoursSinceValidation = (Date.now() - validatedTime) / (1000 * 60 * 60);
return hoursSinceValidation > VALIDATION_CACHE_TTL_HOURS;
}
export function GitHubPanel() {
const { currentProject, getGitHubCache, setGitHubCache, setGitHubCacheFetching } = useAppStore();
const [activeTab, setActiveTab] = useState<GitHubTab>('issues');
const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null);
const [validationResult, setValidationResult] = useState<IssueValidationResult | null>(null);
const [showValidationDialog, setShowValidationDialog] = useState(false);
const fetchingRef = useRef(false);
const projectPath = currentProject?.path || '';
const cache = getGitHubCache(projectPath);
const issues = cache?.issues || [];
const prs = cache?.prs || [];
const isFetching = cache?.isFetching || false;
const lastFetched = cache?.lastFetched || null;
const hasCache = issues.length > 0 || prs.length > 0 || lastFetched !== null;
// Model override for validation
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
// Use the issue validation hook
const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } =
useIssueValidation({
selectedIssue,
showValidationDialog,
onValidationResultChange: setValidationResult,
onShowValidationDialogChange: setShowValidationDialog,
});
const fetchData = useCallback(
async (isBackgroundRefresh = false) => {
if (!projectPath || fetchingRef.current) return;
fetchingRef.current = true;
if (!isBackgroundRefresh) {
setGitHubCacheFetching(projectPath, true);
}
try {
const api = getElectronAPI();
const fetchedIssues: GitHubCacheIssue[] = [];
const fetchedPrs: GitHubCachePR[] = [];
// Fetch issues
if (api.github?.listIssues) {
const issuesResult = await api.github.listIssues(projectPath);
if (issuesResult.success && issuesResult.openIssues) {
// Map to cache format
fetchedIssues.push(
...issuesResult.openIssues.slice(0, 20).map((issue: GitHubIssue) => ({
number: issue.number,
title: issue.title,
url: issue.url,
author: issue.author,
}))
);
}
}
// Fetch PRs
if (api.github?.listPRs) {
const prsResult = await api.github.listPRs(projectPath);
if (prsResult.success && prsResult.openPRs) {
// Map to cache format
fetchedPrs.push(
...prsResult.openPRs.slice(0, 20).map((pr: GitHubPR) => ({
number: pr.number,
title: pr.title,
url: pr.url,
author: pr.author,
}))
);
}
}
setGitHubCache(projectPath, { issues: fetchedIssues, prs: fetchedPrs });
} catch (error) {
console.error('Error fetching GitHub data:', error);
// On error, just mark as not fetching but keep existing cache
setGitHubCacheFetching(projectPath, false);
} finally {
fetchingRef.current = false;
}
},
[projectPath, setGitHubCache, setGitHubCacheFetching]
);
// Initial fetch or refresh if cache is stale
useEffect(() => {
if (!projectPath) return;
const isCacheStale = !lastFetched || Date.now() - lastFetched > CACHE_DURATION_MS;
if (!hasCache) {
// No cache, do initial fetch (show spinner)
fetchData(false);
} else if (isCacheStale && !isFetching) {
// Cache is stale, refresh in background (no spinner, show cached data)
fetchData(true);
}
}, [projectPath, hasCache, lastFetched, isFetching, fetchData]);
// Auto-refresh interval
useEffect(() => {
if (!projectPath) return;
const interval = setInterval(() => {
const currentCache = getGitHubCache(projectPath);
const isStale =
!currentCache?.lastFetched || Date.now() - currentCache.lastFetched > CACHE_DURATION_MS;
if (isStale && !fetchingRef.current) {
fetchData(true);
}
}, CACHE_DURATION_MS);
return () => clearInterval(interval);
}, [projectPath, getGitHubCache, fetchData]);
const handleRefresh = useCallback(() => {
fetchData(false);
}, [fetchData]);
const handleOpenInGitHub = useCallback((url: string) => {
const api = getElectronAPI();
api.openExternalLink(url);
}, []);
// Handle validation for an issue (converts cache issue to GitHubIssue format)
const handleValidate = useCallback(
(cacheIssue: GitHubCacheIssue) => {
// Convert cache issue to GitHubIssue format for validation
const issue: GitHubIssue = {
number: cacheIssue.number,
title: cacheIssue.title,
url: cacheIssue.url,
author: cacheIssue.author || { login: 'unknown' },
state: 'OPEN',
body: '',
createdAt: new Date().toISOString(),
labels: [],
comments: { totalCount: 0 },
};
setSelectedIssue(issue);
handleValidateIssue(issue, {
modelEntry: validationModelOverride.effectiveModelEntry,
});
},
[handleValidateIssue, validationModelOverride.effectiveModelEntry]
);
// Handle viewing cached validation
const handleViewValidation = useCallback(
(cacheIssue: GitHubCacheIssue) => {
// Convert cache issue to GitHubIssue format
const issue: GitHubIssue = {
number: cacheIssue.number,
title: cacheIssue.title,
url: cacheIssue.url,
author: cacheIssue.author || { login: 'unknown' },
state: 'OPEN',
body: '',
createdAt: new Date().toISOString(),
labels: [],
comments: { totalCount: 0 },
};
setSelectedIssue(issue);
handleViewCachedValidation(issue);
},
[handleViewCachedValidation]
);
// Get validation status for an issue
const getValidationStatus = useCallback(
(issueNumber: number) => {
const isValidating = validatingIssues.has(issueNumber);
const cached = cachedValidations.get(issueNumber);
const isStale = cached ? isValidationStale(cached.validatedAt) : false;
return { isValidating, cached, isStale };
},
[validatingIssues, cachedValidations]
);
// Only show loading spinner if no cached data AND fetching
if (!hasCache && isFetching) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="h-full flex flex-col">
{/* Header with tabs */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<div className="flex items-center gap-1">
<button
onClick={() => setActiveTab('issues')}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors',
activeTab === 'issues'
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
<CircleDot className="h-3 w-3" />
Issues ({issues.length})
</button>
<button
onClick={() => setActiveTab('prs')}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors',
activeTab === 'prs'
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
<GitPullRequest className="h-3 w-3" />
PRs ({prs.length})
</button>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={handleRefresh}
disabled={isFetching}
>
<RefreshCw className={cn('h-3 w-3', isFetching && 'animate-spin')} />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto">
<div className="p-2 space-y-1">
{activeTab === 'issues' ? (
issues.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-4">No open issues</p>
) : (
issues.map((issue) => {
const { isValidating, cached, isStale } = getValidationStatus(issue.number);
return (
<div
key={issue.number}
className="flex items-start gap-2 p-2 rounded-md hover:bg-accent/50 group"
>
<CircleDot className="h-3.5 w-3.5 mt-0.5 text-green-500 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{issue.title}</p>
<p className="text-[10px] text-muted-foreground">
#{issue.number} opened by {issue.author?.login}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{/* Validation status/action */}
{isValidating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
) : cached && !isStale ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5"
onClick={(e) => {
e.stopPropagation();
handleViewValidation(issue);
}}
title="View validation result"
>
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
</Button>
) : cached && isStale ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5"
onClick={(e) => {
e.stopPropagation();
handleValidate(issue);
}}
title="Re-validate (stale)"
>
<Clock className="h-3.5 w-3.5 text-yellow-500" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
handleValidate(issue);
}}
title="Validate with AI"
>
<Wand2 className="h-3.5 w-3.5" />
</Button>
)}
{/* Open in GitHub */}
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
handleOpenInGitHub(issue.url);
}}
title="Open in GitHub"
>
<ExternalLink className="h-3 w-3" />
</Button>
</div>
</div>
);
})
)
) : prs.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-4">No open pull requests</p>
) : (
prs.map((pr) => (
<div
key={pr.number}
className="flex items-start gap-2 p-2 rounded-md hover:bg-accent/50 cursor-pointer group"
onClick={() => handleOpenInGitHub(pr.url)}
>
<GitPullRequest className="h-3.5 w-3.5 mt-0.5 text-purple-500 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{pr.title}</p>
<p className="text-[10px] text-muted-foreground">
#{pr.number} by {pr.author?.login}
</p>
</div>
<ExternalLink className="h-3 w-3 opacity-0 group-hover:opacity-100 text-muted-foreground" />
</div>
))
)}
</div>
</div>
{/* Validation Dialog */}
<ValidationDialog
open={showValidationDialog}
onOpenChange={setShowValidationDialog}
issue={selectedIssue}
validationResult={validationResult}
onConvertToTask={() => {
// Task conversion not supported in dock panel - need to go to full view
toast.info('Open GitHub Issues view for task conversion');
}}
/>
</div>
);
}

View File

@@ -0,0 +1,592 @@
/**
* IdeationPanel - Bottom dock panel for brainstorming and idea generation
* Embeds the full ideation flow: dashboard, category selection, and prompt selection
*/
import { useState, useMemo, useCallback } from 'react';
import {
Sparkles,
Lightbulb,
ArrowLeft,
Loader2,
AlertCircle,
Plus,
X,
ChevronRight,
Zap,
Palette,
Code,
TrendingUp,
Cpu,
Shield,
Gauge,
Accessibility,
BarChart3,
CheckCircle2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { useIdeationStore, type GenerationJob } from '@/store/ideation-store';
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import type { IdeaCategory, IdeationPrompt, AnalysisSuggestion } from '@automaker/types';
type PanelMode = 'dashboard' | 'categories' | 'prompts';
const iconMap: Record<string, typeof Zap> = {
Zap,
Palette,
Code,
TrendingUp,
Cpu,
Shield,
Gauge,
Accessibility,
BarChart3,
};
// Suggestion card for dashboard view
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-2.5">
<div className="flex flex-col gap-2">
{/* Title and remove button */}
<div className="flex items-start justify-between gap-2">
<h4 className="font-medium text-sm leading-tight">{suggestion.title}</h4>
<Button
size="sm"
variant="ghost"
onClick={onRemove}
disabled={isAdding}
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive shrink-0"
>
<X className="w-3 h-3" />
</Button>
</div>
{/* Badges */}
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="text-[10px] h-5">
{suggestion.priority}
</Badge>
<Badge variant="secondary" className="text-[10px] h-5">
{job.prompt.title}
</Badge>
</div>
{/* Description */}
<p className="text-xs text-muted-foreground line-clamp-2">{suggestion.description}</p>
{/* Accept button */}
<Button
size="sm"
onClick={onAccept}
disabled={isAdding}
className="h-7 gap-1 text-xs w-full"
>
{isAdding ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<>
<Plus className="w-3 h-3" />
Accept
</>
)}
</Button>
</div>
</CardContent>
</Card>
);
}
// Generating card for active jobs
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-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isError ? (
<AlertCircle className="w-4 h-4 text-red-500" />
) : (
<Loader2 className="w-4 h-4 text-blue-500 animate-spin" />
)}
<div>
<p className="font-medium text-sm">{job.prompt.title}</p>
<p className="text-xs text-muted-foreground">
{isError ? job.error || 'Failed to generate' : 'Generating ideas...'}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeJob(job.id)}
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
>
<X className="w-3.5 h-3.5" />
</Button>
</div>
</CardContent>
</Card>
);
}
// Dashboard view - shows generated ideas
function DashboardView({ onGenerateIdeas }: { onGenerateIdeas: () => void }) {
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 projectJobs = useMemo(
() =>
currentProject?.path
? generationJobs.filter((job) => job.projectPath === currentProject.path)
: [],
[generationJobs, currentProject?.path]
);
const { activeJobs, readyJobs } = useMemo(() => {
const active: GenerationJob[] = [];
const ready: GenerationJob[] = [];
for (const job of projectJobs) {
if (job.status === 'generating' || job.status === 'error') {
active.push(job);
} else if (job.status === 'ready' && job.suggestions.length > 0) {
ready.push(job);
}
}
return { activeJobs: active, readyJobs: ready };
}, [projectJobs]);
const allSuggestions = useMemo(
() => readyJobs.flatMap((job) => job.suggestions.map((suggestion) => ({ suggestion, job }))),
[readyJobs]
);
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 overflow-auto p-3 space-y-2">
{/* Active jobs */}
{activeJobs.map((job) => (
<GeneratingCard key={job.id} job={job} />
))}
{/* Suggestions */}
{allSuggestions.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}
/>
))}
{/* Empty state */}
{isEmpty && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Sparkles className="w-8 h-8 text-muted-foreground/50 mb-3" />
<h3 className="text-sm font-medium mb-1">No ideas yet</h3>
<p className="text-xs text-muted-foreground mb-4">
Generate ideas by selecting a category and prompt
</p>
<Button onClick={onGenerateIdeas} size="sm" className="gap-2">
<Lightbulb className="w-4 h-4" />
Generate Ideas
</Button>
</div>
)}
{/* Generate more button */}
{!isEmpty && (
<Button onClick={onGenerateIdeas} variant="outline" size="sm" className="w-full gap-2 mt-2">
<Lightbulb className="w-4 h-4" />
Generate More Ideas
</Button>
)}
</div>
);
}
// Category grid view
function CategoryGridView({
onSelect,
onBack,
}: {
onSelect: (category: IdeaCategory) => void;
onBack: () => void;
}) {
const { categories, isLoading, error } = useGuidedPrompts();
return (
<div className="flex-1 overflow-auto p-3">
<button
onClick={onBack}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-3"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Back to dashboard</span>
</button>
{isLoading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">Loading categories...</span>
</div>
)}
{error && (
<div className="text-center py-8 text-destructive text-sm">
<p>Failed to load categories: {error}</p>
</div>
)}
{!isLoading && !error && (
<div className="grid grid-cols-3 gap-2">
{categories.map((category) => {
const Icon = iconMap[category.icon] || Zap;
return (
<Card
key={category.id}
className="cursor-pointer transition-all hover:border-primary hover:shadow-sm"
onClick={() => onSelect(category.id)}
>
<CardContent className="p-3">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-primary/10">
<Icon className="w-4 h-4 text-primary" />
</div>
<div className="min-w-0">
<h3 className="font-medium text-sm truncate">{category.name}</h3>
<p className="text-xs text-muted-foreground truncate">
{category.description}
</p>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}
// Prompt list view
function PromptListView({
category,
onBack,
onDone,
}: {
category: IdeaCategory;
onBack: () => void;
onDone: () => void;
}) {
const currentProject = useAppStore((s) => s.currentProject);
const generationJobs = useIdeationStore((s) => s.generationJobs);
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 { getPromptsByCategory, getCategoryById, isLoading, error } = useGuidedPrompts();
const prompts = getPromptsByCategory(category);
const categoryInfo = getCategoryById(category);
const projectJobs = useMemo(
() =>
currentProject?.path
? generationJobs.filter((job) => job.projectPath === currentProject.path)
: [],
[generationJobs, currentProject?.path]
);
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);
const jobId = addGenerationJob(currentProject.path, prompt);
setStartedPrompts((prev) => new Set(prev).add(prompt.id));
toast.info(`Generating ideas for "${prompt.title}"...`);
onDone(); // Navigate back to 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}"`);
} 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 overflow-auto p-3">
<button
onClick={onBack}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-3"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Back to categories</span>
</button>
{categoryInfo && (
<p className="text-xs text-muted-foreground mb-3">
Select a prompt from{' '}
<span className="font-medium text-foreground">{categoryInfo.name}</span>
</p>
)}
{isLoading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">Loading prompts...</span>
</div>
)}
{error && (
<div className="text-center py-8 text-destructive text-sm">
<p>Failed to load prompts: {error}</p>
</div>
)}
{!isLoading && !error && (
<div className="space-y-2">
{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={cn(
'transition-all',
isDisabled
? 'opacity-60 cursor-not-allowed'
: 'cursor-pointer hover:border-primary hover:shadow-sm',
(isLoading || isGenerating) && 'border-blue-500 ring-1 ring-blue-500',
isStarted && !isGenerating && 'border-green-500/50'
)}
onClick={() => !isDisabled && handleSelectPrompt(prompt)}
>
<CardContent className="p-3">
<div className="flex items-start gap-2">
<div
className={cn(
'p-1.5 rounded-md mt-0.5',
isLoading || isGenerating
? 'bg-blue-500/10'
: isStarted
? 'bg-green-500/10'
: 'bg-primary/10'
)}
>
{isLoading || isGenerating ? (
<Loader2 className="w-3.5 h-3.5 text-blue-500 animate-spin" />
) : isStarted ? (
<CheckCircle2 className="w-3.5 h-3.5 text-green-500" />
) : (
<Lightbulb className="w-3.5 h-3.5 text-primary" />
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm">{prompt.title}</h3>
<p className="text-xs text-muted-foreground line-clamp-2">
{prompt.description}
</p>
{(isLoading || isGenerating) && (
<p className="text-blue-500 text-xs mt-1">Generating...</p>
)}
{isStarted && !isGenerating && (
<p className="text-green-500 text-xs mt-1">Generated - check dashboard</p>
)}
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0 mt-1" />
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}
export function IdeationPanel() {
const { currentProject } = useAppStore();
const [mode, setMode] = useState<PanelMode>('dashboard');
const [selectedCategory, setSelectedCategory] = useState<IdeaCategory | null>(null);
const handleGenerateIdeas = useCallback(() => {
setMode('categories');
setSelectedCategory(null);
}, []);
const handleSelectCategory = useCallback((category: IdeaCategory) => {
setSelectedCategory(category);
setMode('prompts');
}, []);
const handleBackFromCategories = useCallback(() => {
setMode('dashboard');
}, []);
const handleBackFromPrompts = useCallback(() => {
setMode('categories');
setSelectedCategory(null);
}, []);
const handlePromptDone = useCallback(() => {
setMode('dashboard');
setSelectedCategory(null);
}, []);
if (!currentProject) {
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<div className="flex items-center gap-2">
<Sparkles className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-medium">Ideation</span>
</div>
</div>
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-muted-foreground">Open a project to start brainstorming</p>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<div className="flex items-center gap-2">
<Sparkles className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-medium">Ideation</span>
{mode === 'dashboard' && (
<span className="text-xs text-muted-foreground">- Review and accept ideas</span>
)}
{mode === 'categories' && (
<span className="text-xs text-muted-foreground">- Select a category</span>
)}
{mode === 'prompts' && selectedCategory && (
<span className="text-xs text-muted-foreground">- Select a prompt</span>
)}
</div>
{mode === 'dashboard' && (
<Button
onClick={handleGenerateIdeas}
size="sm"
variant="outline"
className="h-7 gap-1.5 text-xs"
>
<Lightbulb className="w-3.5 h-3.5" />
Generate
</Button>
)}
</div>
{/* Content */}
{mode === 'dashboard' && <DashboardView onGenerateIdeas={handleGenerateIdeas} />}
{mode === 'categories' && (
<CategoryGridView onSelect={handleSelectCategory} onBack={handleBackFromCategories} />
)}
{mode === 'prompts' && selectedCategory && (
<PromptListView
category={selectedCategory}
onBack={handleBackFromPrompts}
onDone={handlePromptDone}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,7 @@
export { GitHubPanel } from './github-panel';
export { AgentsPanel } from './agents-panel';
export { SpecPanel } from './spec-panel';
export { ContextPanel } from './context-panel';
export { TerminalPanelDock } from './terminal-panel';
export { ChatPanel } from './chat-panel';
export { IdeationPanel } from './ideation-panel';

View File

@@ -0,0 +1,586 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import {
FileText,
Loader2,
Save,
Sparkles,
RefreshCw,
FilePlus2,
Clock,
AlertCircle,
} from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { XmlSyntaxEditor } from '@/components/ui/xml-syntax-editor';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { SpecRegenerationEvent } from '@/types/electron';
// Feature count options
type FeatureCount = 20 | 50 | 100;
const FEATURE_COUNT_OPTIONS: { value: FeatureCount; label: string; warning?: string }[] = [
{ value: 20, label: '20' },
{ value: 50, label: '50', warning: 'May take up to 5 minutes' },
{ value: 100, label: '100', warning: 'May take up to 5 minutes' },
];
const PHASE_LABELS: Record<string, string> = {
initialization: 'Initializing...',
setup: 'Setting up tools...',
analysis: 'Analyzing project...',
spec_complete: 'Spec created! Generating features...',
feature_generation: 'Creating features...',
complete: 'Complete!',
error: 'Error occurred',
};
const SPEC_FILE_WRITE_DELAY = 500;
export function SpecPanel() {
const { currentProject, appSpec, setAppSpec } = useAppStore();
const [specContent, setSpecContent] = useState('');
const [originalContent, setOriginalContent] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [specExists, setSpecExists] = useState(false);
// Generation state
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false);
const [projectOverview, setProjectOverview] = useState('');
const [projectDefinition, setProjectDefinition] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [generateFeatures, setGenerateFeatures] = useState(true);
const [analyzeProject, setAnalyzeProject] = useState(true);
const [featureCount, setFeatureCount] = useState<FeatureCount>(50);
const [currentPhase, setCurrentPhase] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const hasChanges = specContent !== originalContent;
// Load spec from file
const loadSpec = useCallback(async () => {
if (!currentProject?.path) return;
setLoading(true);
try {
const api = getElectronAPI();
// Check if generation is running
if (api.specRegeneration) {
const status = await api.specRegeneration.status();
if (status.success && status.isRunning) {
setIsGenerating(true);
if (status.currentPhase) {
setCurrentPhase(status.currentPhase);
}
setLoading(false);
return;
}
}
// Read the spec file using the correct API
const specPath = `${currentProject.path}/.automaker/app_spec.txt`;
const result = await api.readFile(specPath);
if (result.success && result.content) {
setSpecContent(result.content);
setOriginalContent(result.content);
setAppSpec(result.content);
setSpecExists(true);
} else {
setSpecContent('');
setOriginalContent('');
setSpecExists(false);
}
} catch (error) {
console.error('Error loading spec:', error);
setSpecExists(false);
} finally {
setLoading(false);
}
}, [currentProject?.path, setAppSpec]);
useEffect(() => {
loadSpec();
}, [loadSpec]);
// Sync with store
useEffect(() => {
if (appSpec && appSpec !== specContent && !hasChanges) {
setSpecContent(appSpec);
setOriginalContent(appSpec);
setSpecExists(true);
}
}, [appSpec, specContent, hasChanges]);
// Subscribe to spec regeneration events
useEffect(() => {
if (!currentProject?.path) return;
const api = getElectronAPI();
if (!api.specRegeneration) return;
const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => {
if (event.projectPath !== currentProject.path) return;
if (event.type === 'spec_regeneration_progress') {
setIsGenerating(true);
const phaseMatch = event.content.match(/\[Phase:\s*([^\]]+)\]/);
if (phaseMatch) {
setCurrentPhase(phaseMatch[1]);
}
if (event.content.includes('All tasks completed')) {
setIsGenerating(false);
setCurrentPhase('');
setTimeout(() => loadSpec(), SPEC_FILE_WRITE_DELAY);
}
} else if (event.type === 'spec_regeneration_complete') {
const isFinal =
event.message?.includes('All tasks completed') ||
event.message === 'Spec regeneration complete!' ||
event.message === 'Initial spec creation complete!';
if (isFinal) {
setIsGenerating(false);
setCurrentPhase('');
setShowCreateDialog(false);
setShowRegenerateDialog(false);
setProjectOverview('');
setProjectDefinition('');
setErrorMessage('');
setTimeout(() => loadSpec(), SPEC_FILE_WRITE_DELAY);
toast.success('Spec Generation Complete', {
description: 'Your app specification has been saved.',
});
}
} else if (event.type === 'spec_regeneration_error') {
setIsGenerating(false);
setCurrentPhase('error');
setErrorMessage(event.error);
}
});
return () => unsubscribe();
}, [currentProject?.path, loadSpec]);
// Save spec
const handleSave = useCallback(async () => {
if (!currentProject?.path || !hasChanges) return;
setSaving(true);
try {
const api = getElectronAPI();
const specPath = `${currentProject.path}/.automaker/app_spec.txt`;
await api.writeFile(specPath, specContent);
setOriginalContent(specContent);
setAppSpec(specContent);
toast.success('Spec saved');
} catch (error) {
toast.error('Failed to save spec');
} finally {
setSaving(false);
}
}, [currentProject?.path, specContent, hasChanges, setAppSpec]);
// Create spec
const handleCreateSpec = useCallback(async () => {
if (!currentProject?.path || !projectOverview.trim()) return;
setIsGenerating(true);
setShowCreateDialog(false);
setCurrentPhase('initialization');
setErrorMessage('');
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
setIsGenerating(false);
toast.error('Spec generation not available');
return;
}
const result = await api.specRegeneration.create(
currentProject.path,
projectOverview.trim(),
generateFeatures,
analyzeProject,
generateFeatures ? featureCount : undefined
);
if (!result.success) {
setIsGenerating(false);
setCurrentPhase('error');
setErrorMessage(result.error || 'Failed to create spec');
}
} catch (error) {
setIsGenerating(false);
setCurrentPhase('error');
setErrorMessage(error instanceof Error ? error.message : 'Failed to create spec');
}
}, [currentProject?.path, projectOverview, generateFeatures, analyzeProject, featureCount]);
// Regenerate spec
const handleRegenerate = useCallback(async () => {
if (!currentProject?.path || !projectDefinition.trim()) return;
setIsGenerating(true);
setShowRegenerateDialog(false);
setCurrentPhase('initialization');
setErrorMessage('');
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
setIsGenerating(false);
toast.error('Spec generation not available');
return;
}
const result = await api.specRegeneration.generate(
currentProject.path,
projectDefinition.trim(),
generateFeatures,
analyzeProject,
generateFeatures ? featureCount : undefined
);
if (!result.success) {
setIsGenerating(false);
setCurrentPhase('error');
setErrorMessage(result.error || 'Failed to regenerate spec');
}
} catch (error) {
setIsGenerating(false);
setCurrentPhase('error');
setErrorMessage(error instanceof Error ? error.message : 'Failed to regenerate spec');
}
}, [currentProject?.path, projectDefinition, generateFeatures, analyzeProject, featureCount]);
const selectedOption = FEATURE_COUNT_OPTIONS.find((o) => o.value === featureCount);
const phaseLabel = PHASE_LABELS[currentPhase] || currentPhase;
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
// Generation in progress view
if (isGenerating) {
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<div className="flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
<span className="text-xs font-medium">Generating Spec...</span>
</div>
</div>
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-center">
<div className="mb-4">
<div className="p-3 rounded-full bg-primary/10 inline-block">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
</div>
<p className="text-sm font-medium mb-2">
{currentPhase === 'feature_generation'
? 'Creating Features...'
: 'Generating Specification'}
</p>
{currentPhase && <p className="text-xs text-muted-foreground">{phaseLabel}</p>}
{errorMessage && (
<div className="mt-3 p-2 rounded bg-destructive/10 border border-destructive/20">
<p className="text-xs text-destructive">{errorMessage}</p>
</div>
)}
</div>
</div>
</div>
);
}
// Empty state - no spec exists
if (!specExists) {
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<div className="flex items-center gap-2">
<FileText className="h-3.5 w-3.5" />
<span className="text-xs font-medium">App Specification</span>
</div>
</div>
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-center">
<FilePlus2 className="h-10 w-10 mx-auto text-muted-foreground/50 mb-3" />
<p className="text-sm font-medium mb-1">No Spec Found</p>
<p className="text-xs text-muted-foreground mb-4">
Create an app specification to help AI understand your project.
</p>
<Button size="sm" onClick={() => setShowCreateDialog(true)}>
<Sparkles className="h-3.5 w-3.5 mr-1.5" />
Create Spec
</Button>
</div>
</div>
{/* Create Spec Dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Create App Specification</DialogTitle>
<DialogDescription>
Describe your project and we'll generate a comprehensive specification.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium">Project Overview</label>
<Textarea
value={projectOverview}
onChange={(e) => setProjectOverview(e.target.value)}
placeholder="Describe what your project does and what features you want to build..."
className="h-32 resize-none font-mono text-sm"
autoFocus
/>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="analyze-project"
checked={analyzeProject}
onCheckedChange={(checked) => setAnalyzeProject(checked === true)}
/>
<div className="space-y-0.5">
<label htmlFor="analyze-project" className="text-sm font-medium cursor-pointer">
Analyze current project
</label>
<p className="text-xs text-muted-foreground">
Research your codebase to understand the tech stack.
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="generate-features"
checked={generateFeatures}
onCheckedChange={(checked) => setGenerateFeatures(checked === true)}
/>
<div className="space-y-0.5">
<label htmlFor="generate-features" className="text-sm font-medium cursor-pointer">
Generate feature list
</label>
<p className="text-xs text-muted-foreground">
Automatically create features from the spec.
</p>
</div>
</div>
{generateFeatures && (
<div className="space-y-2 pl-6">
<label className="text-sm font-medium">Number of Features</label>
<div className="flex gap-2">
{FEATURE_COUNT_OPTIONS.map((option) => (
<Button
key={option.value}
type="button"
variant={featureCount === option.value ? 'default' : 'outline'}
size="sm"
onClick={() => setFeatureCount(option.value)}
className="flex-1"
>
{option.label}
</Button>
))}
</div>
{selectedOption?.warning && (
<p className="text-xs text-amber-500 flex items-center gap-1">
<Clock className="w-3 h-3" />
{selectedOption.warning}
</p>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowCreateDialog(false)}>
Cancel
</Button>
<Button onClick={handleCreateSpec} disabled={!projectOverview.trim()}>
<Sparkles className="w-4 h-4 mr-2" />
Generate Spec
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// Main view - spec exists
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<div className="flex items-center gap-2">
<FileText className="h-3.5 w-3.5" />
<span className="text-xs font-medium">App Specification</span>
{hasChanges && <span className="text-[10px] text-amber-500">Unsaved</span>}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setShowRegenerateDialog(true)}
title="Regenerate spec"
>
<RefreshCw className="h-3 w-3" />
</Button>
{hasChanges && (
<Button
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={handleSave}
disabled={saving}
>
{saving ? (
<Loader2 className="h-3 w-3 animate-spin mr-1" />
) : (
<Save className="h-3 w-3 mr-1" />
)}
Save
</Button>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden bg-muted/30 rounded-md m-2">
<XmlSyntaxEditor
value={specContent}
onChange={(value) => setSpecContent(value)}
placeholder="Enter your app specification..."
className="h-full"
/>
</div>
{/* Regenerate Spec Dialog */}
<Dialog open={showRegenerateDialog} onOpenChange={setShowRegenerateDialog}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Regenerate App Specification</DialogTitle>
<DialogDescription>
We'll regenerate your spec based on your project description.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium">Describe your project</label>
<Textarea
value={projectDefinition}
onChange={(e) => setProjectDefinition(e.target.value)}
placeholder="Describe what your app should do..."
className="h-32 resize-none font-mono text-sm"
autoFocus
/>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="regen-analyze-project"
checked={analyzeProject}
onCheckedChange={(checked) => setAnalyzeProject(checked === true)}
/>
<div className="space-y-0.5">
<label
htmlFor="regen-analyze-project"
className="text-sm font-medium cursor-pointer"
>
Analyze current project
</label>
<p className="text-xs text-muted-foreground">
Research your codebase to understand the tech stack.
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="regen-generate-features"
checked={generateFeatures}
onCheckedChange={(checked) => setGenerateFeatures(checked === true)}
/>
<div className="space-y-0.5">
<label
htmlFor="regen-generate-features"
className="text-sm font-medium cursor-pointer"
>
Generate feature list
</label>
<p className="text-xs text-muted-foreground">
Automatically create features from the spec.
</p>
</div>
</div>
{generateFeatures && (
<div className="space-y-2 pl-6">
<label className="text-sm font-medium">Number of Features</label>
<div className="flex gap-2">
{FEATURE_COUNT_OPTIONS.map((option) => (
<Button
key={option.value}
type="button"
variant={featureCount === option.value ? 'default' : 'outline'}
size="sm"
onClick={() => setFeatureCount(option.value)}
className="flex-1"
>
{option.label}
</Button>
))}
</div>
{selectedOption?.warning && (
<p className="text-xs text-amber-500 flex items-center gap-1">
<Clock className="w-3 h-3" />
{selectedOption.warning}
</p>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowRegenerateDialog(false)}>
Cancel
</Button>
<Button onClick={handleRegenerate} disabled={!projectDefinition.trim()}>
<Sparkles className="w-4 h-4 mr-2" />
Regenerate Spec
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,551 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import {
Terminal,
Plus,
Loader2,
AlertCircle,
SplitSquareHorizontal,
SplitSquareVertical,
X,
} from 'lucide-react';
import { useAppStore, type TerminalPanelContent, type TerminalTab } from '@/store/app-store';
import { useShallow } from 'zustand/react/shallow';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { TerminalPanel as XTermPanel } from '@/components/views/terminal-view/terminal-panel';
import { TerminalErrorBoundary } from '@/components/views/terminal-view/terminal-error-boundary';
import { apiFetch, apiGet, apiDeleteRaw } from '@/lib/api-fetch';
import { createLogger } from '@automaker/utils/logger';
import { toast } from 'sonner';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
const logger = createLogger('DockTerminal');
interface TerminalStatus {
enabled: boolean;
passwordRequired: boolean;
}
const CREATE_COOLDOWN_MS = 500;
export function TerminalPanelDock() {
// Use useShallow for terminal state to prevent unnecessary re-renders
const terminalState = useAppStore(useShallow((state) => state.terminalState));
const {
tabs,
activeTabId,
activeSessionId,
authToken,
isUnlocked,
defaultFontSize,
maximizedSessionId,
} = terminalState;
// Get stable action references (these don't change between renders)
const currentProject = useAppStore((state) => state.currentProject);
const setTerminalUnlocked = useAppStore((state) => state.setTerminalUnlocked);
const addTerminalToLayout = useAppStore((state) => state.addTerminalToLayout);
const removeTerminalFromLayout = useAppStore((state) => state.removeTerminalFromLayout);
const setActiveTerminalSession = useAppStore((state) => state.setActiveTerminalSession);
const addTerminalTab = useAppStore((state) => state.addTerminalTab);
const removeTerminalTab = useAppStore((state) => state.removeTerminalTab);
const setActiveTerminalTab = useAppStore((state) => state.setActiveTerminalTab);
const setTerminalPanelFontSize = useAppStore((state) => state.setTerminalPanelFontSize);
const toggleTerminalMaximized = useAppStore((state) => state.toggleTerminalMaximized);
const updateTerminalPanelSizes = useAppStore((state) => state.updateTerminalPanelSizes);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<TerminalStatus | null>(null);
const isCreatingRef = useRef(false);
const lastCreateTimeRef = useRef(0);
// Refs to stabilize callbacks and prevent cascading re-renders
const createTerminalRef = useRef<
((direction?: 'horizontal' | 'vertical', targetSessionId?: string) => Promise<void>) | null
>(null);
const killTerminalRef = useRef<((sessionId: string) => Promise<void>) | null>(null);
const createTerminalInNewTabRef = useRef<(() => Promise<void>) | null>(null);
const navigateToTerminalRef = useRef<
((direction: 'up' | 'down' | 'left' | 'right') => void) | null
>(null);
// Fetch terminal status
const fetchStatus = useCallback(async () => {
try {
const data = await apiGet<{ success: boolean; data?: TerminalStatus; error?: string }>(
'/api/terminal/status'
);
if (data.success && data.data) {
setStatus(data.data);
if (!data.data.passwordRequired) {
setTerminalUnlocked(true);
}
} else {
setError(data.error || 'Failed to get terminal status');
}
} catch (err) {
setError('Failed to connect to server');
logger.error('Status fetch error:', err);
} finally {
setLoading(false);
}
}, [setTerminalUnlocked]);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
// Helper to check if terminal creation should be debounced
const canCreateTerminal = (): boolean => {
const now = Date.now();
if (now - lastCreateTimeRef.current < CREATE_COOLDOWN_MS || isCreatingRef.current) {
return false;
}
lastCreateTimeRef.current = now;
isCreatingRef.current = true;
return true;
};
// Create a new terminal session
const createTerminal = useCallback(
async (direction?: 'horizontal' | 'vertical', targetSessionId?: string) => {
if (!canCreateTerminal()) return;
try {
const headers: Record<string, string> = {};
if (authToken) {
headers['X-Terminal-Token'] = authToken;
}
const response = await apiFetch('/api/terminal/sessions', 'POST', {
headers,
body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
});
const data = await response.json();
if (data.success) {
addTerminalToLayout(data.data.id, direction, targetSessionId);
} else {
if (response.status === 429 || data.error?.includes('Maximum')) {
toast.error('Terminal session limit reached', {
description: data.details || 'Please close unused terminals.',
});
} else {
toast.error('Failed to create terminal', { description: data.error });
}
}
} catch (err) {
logger.error('Create session error:', err);
toast.error('Failed to create terminal');
} finally {
isCreatingRef.current = false;
}
},
[currentProject?.path, authToken, addTerminalToLayout]
);
// Create terminal in new tab
const createTerminalInNewTab = useCallback(async () => {
if (!canCreateTerminal()) return;
const tabId = addTerminalTab();
try {
const headers: Record<string, string> = {};
if (authToken) {
headers['X-Terminal-Token'] = authToken;
}
const response = await apiFetch('/api/terminal/sessions', 'POST', {
headers,
body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
});
const data = await response.json();
if (data.success) {
const { addTerminalToTab } = useAppStore.getState();
addTerminalToTab(data.data.id, tabId);
} else {
removeTerminalTab(tabId);
toast.error('Failed to create terminal', { description: data.error });
}
} catch (err) {
logger.error('Create session error:', err);
removeTerminalTab(tabId);
toast.error('Failed to create terminal');
} finally {
isCreatingRef.current = false;
}
}, [currentProject?.path, authToken, addTerminalTab, removeTerminalTab]);
// Kill a terminal session
const killTerminal = useCallback(
async (sessionId: string) => {
try {
const headers: Record<string, string> = {};
if (authToken) {
headers['X-Terminal-Token'] = authToken;
}
await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
removeTerminalFromLayout(sessionId);
} catch (err) {
logger.error('Kill session error:', err);
removeTerminalFromLayout(sessionId);
}
},
[authToken, removeTerminalFromLayout]
);
// Kill all terminals in a tab
const killTerminalTab = useCallback(
async (tabId: string) => {
const tab = tabs.find((t) => t.id === tabId);
if (!tab) return;
const collectSessionIds = (node: TerminalPanelContent | null): string[] => {
if (!node) return [];
if (node.type === 'terminal') return [node.sessionId];
return node.panels.flatMap(collectSessionIds);
};
const sessionIds = collectSessionIds(tab.layout);
const headers: Record<string, string> = {};
if (authToken) {
headers['X-Terminal-Token'] = authToken;
}
await Promise.all(
sessionIds.map(async (sessionId) => {
try {
await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
} catch (err) {
logger.error(`Failed to kill session ${sessionId}:`, err);
}
})
);
removeTerminalTab(tabId);
},
[tabs, authToken, removeTerminalTab]
);
// Get panel key for stable rendering
const getPanelKey = (panel: TerminalPanelContent): string => {
if (panel.type === 'terminal') return panel.sessionId;
return panel.id;
};
// Navigate between terminals
const navigateToTerminal = useCallback(
(direction: 'up' | 'down' | 'left' | 'right') => {
const activeTab = tabs.find((t) => t.id === activeTabId);
if (!activeTab?.layout) return;
const currentSessionId = activeSessionId;
if (!currentSessionId) return;
const getTerminalIds = (panel: TerminalPanelContent): string[] => {
if (panel.type === 'terminal') return [panel.sessionId];
return panel.panels.flatMap(getTerminalIds);
};
const terminalIds = getTerminalIds(activeTab.layout);
const currentIndex = terminalIds.indexOf(currentSessionId);
if (currentIndex === -1) return;
let nextIndex = currentIndex;
if (direction === 'right' || direction === 'down') {
nextIndex = (currentIndex + 1) % terminalIds.length;
} else {
nextIndex = (currentIndex - 1 + terminalIds.length) % terminalIds.length;
}
if (terminalIds[nextIndex]) {
setActiveTerminalSession(terminalIds[nextIndex]);
}
},
[tabs, activeTabId, activeSessionId, setActiveTerminalSession]
);
// Keep refs updated with latest callbacks
createTerminalRef.current = createTerminal;
killTerminalRef.current = killTerminal;
createTerminalInNewTabRef.current = createTerminalInNewTab;
navigateToTerminalRef.current = navigateToTerminal;
// Render panel content recursively - use refs for callbacks to prevent re-renders
const renderPanelContent = useCallback(
(content: TerminalPanelContent, activeTabData: TerminalTab): React.ReactNode => {
if (content.type === 'terminal') {
const terminalFontSize = content.fontSize ?? defaultFontSize;
return (
<TerminalErrorBoundary
key={`boundary-${content.sessionId}`}
sessionId={content.sessionId}
onRestart={() => {
killTerminalRef.current?.(content.sessionId);
createTerminalRef.current?.();
}}
>
<XTermPanel
key={content.sessionId}
sessionId={content.sessionId}
authToken={authToken}
isActive={activeSessionId === content.sessionId}
onFocus={() => setActiveTerminalSession(content.sessionId)}
onClose={() => killTerminalRef.current?.(content.sessionId)}
onSplitHorizontal={() => createTerminalRef.current?.('horizontal', content.sessionId)}
onSplitVertical={() => createTerminalRef.current?.('vertical', content.sessionId)}
onNewTab={() => createTerminalInNewTabRef.current?.()}
onNavigateUp={() => navigateToTerminalRef.current?.('up')}
onNavigateDown={() => navigateToTerminalRef.current?.('down')}
onNavigateLeft={() => navigateToTerminalRef.current?.('left')}
onNavigateRight={() => navigateToTerminalRef.current?.('right')}
onSessionInvalid={() => killTerminalRef.current?.(content.sessionId)}
fontSize={terminalFontSize}
onFontSizeChange={(size) => setTerminalPanelFontSize(content.sessionId, size)}
isMaximized={maximizedSessionId === content.sessionId}
onToggleMaximize={() => toggleTerminalMaximized(content.sessionId)}
/>
</TerminalErrorBoundary>
);
}
const isHorizontal = content.direction === 'horizontal';
const defaultSizePerPanel = 100 / content.panels.length;
const handleLayoutChange = (sizes: number[]) => {
const panelKeys = content.panels.map(getPanelKey);
updateTerminalPanelSizes(activeTabData.id, panelKeys, sizes);
};
return (
<PanelGroup direction={content.direction} onLayout={handleLayoutChange}>
{content.panels.map((panel, index) => {
const panelSize =
panel.type === 'terminal' && panel.size ? panel.size : defaultSizePerPanel;
const panelKey = getPanelKey(panel);
return (
<React.Fragment key={panelKey}>
{index > 0 && (
<PanelResizeHandle
className={
isHorizontal
? 'w-1 h-full bg-border hover:bg-brand-500 transition-colors'
: 'h-1 w-full bg-border hover:bg-brand-500 transition-colors'
}
/>
)}
<Panel id={panelKey} order={index} defaultSize={panelSize} minSize={20}>
{renderPanelContent(panel, activeTabData)}
</Panel>
</React.Fragment>
);
})}
</PanelGroup>
);
},
[
defaultFontSize,
authToken,
activeSessionId,
maximizedSessionId,
setActiveTerminalSession,
setTerminalPanelFontSize,
toggleTerminalMaximized,
updateTerminalPanelSizes,
]
);
const activeTab = tabs.find((t) => t.id === activeTabId);
// Header component for all states
const Header = ({ children }: { children?: React.ReactNode }) => (
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50 shrink-0">
<div className="flex items-center gap-2">
<Terminal className="h-3.5 w-3.5" />
<span className="text-xs font-medium">Terminal</span>
</div>
{children && <div className="flex items-center gap-1">{children}</div>}
</div>
);
// Loading state
if (loading) {
return (
<div className="h-full flex flex-col">
<Header />
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
</div>
);
}
// Error state
if (error) {
return (
<div className="h-full flex flex-col">
<Header />
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<AlertCircle className="h-8 w-8 mx-auto text-destructive/50 mb-2" />
<p className="text-xs text-muted-foreground mb-2">{error}</p>
<Button variant="outline" size="sm" className="text-xs h-7" onClick={fetchStatus}>
Retry
</Button>
</div>
</div>
</div>
);
}
// Password required
if (status?.passwordRequired && !isUnlocked) {
return (
<div className="h-full flex flex-col">
<Header />
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<Terminal className="h-12 w-12 mx-auto text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground mb-1">Terminal requires authentication</p>
<p className="text-xs text-muted-foreground/70">Password required to use terminal</p>
</div>
</div>
</div>
);
}
// No project selected
if (!currentProject) {
return (
<div className="h-full flex flex-col">
<Header />
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<Terminal className="h-12 w-12 mx-auto text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground">No project selected</p>
</div>
</div>
</div>
);
}
// No terminals yet
if (tabs.length === 0) {
return (
<div className="h-full flex flex-col">
<Header>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => createTerminal()}
title="New terminal"
>
<Plus className="h-3 w-3" />
</Button>
</Header>
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<Terminal className="h-12 w-12 mx-auto text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground mb-1">No terminals open</p>
<Button variant="outline" size="sm" className="mt-2" onClick={() => createTerminal()}>
<Plus className="h-3 w-3 mr-1" />
New Terminal
</Button>
</div>
</div>
</div>
);
}
// Terminal view with tabs
return (
<div className="h-full flex flex-col">
{/* Tab bar */}
<div className="flex items-center px-2 py-1 border-b border-border/50 shrink-0 gap-1 overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTerminalTab(tab.id)}
className={cn(
'flex items-center gap-1 px-2 py-1 text-xs rounded transition-colors shrink-0',
tab.id === activeTabId
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
)}
>
<Terminal className="h-3 w-3" />
<span className="max-w-16 truncate">{tab.name}</span>
<button
className="ml-0.5 p-0.5 rounded hover:bg-background/50 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
killTerminalTab(tab.id);
}}
>
<X className="h-2.5 w-2.5" />
</button>
</button>
))}
<button
className="flex items-center justify-center p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground shrink-0"
onClick={createTerminalInNewTab}
title="New Tab"
>
<Plus className="h-3 w-3" />
</button>
<div className="flex-1" />
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => createTerminal('horizontal')}
title="Split Right"
>
<SplitSquareHorizontal className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => createTerminal('vertical')}
title="Split Down"
>
<SplitSquareVertical className="h-3 w-3" />
</Button>
</div>
</div>
{/* Terminal content */}
<div className="flex-1 overflow-hidden">
{activeTab?.layout ? (
renderPanelContent(activeTab.layout, activeTab)
) : (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<Terminal className="h-8 w-8 mx-auto text-muted-foreground/30 mb-2" />
<p className="text-xs text-muted-foreground">No terminal in this tab</p>
<Button
variant="outline"
size="sm"
className="mt-2 text-xs h-7"
onClick={() => createTerminal()}
>
<Plus className="h-3 w-3 mr-1" />
Add Terminal
</Button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { TopBar } from './top-bar';
export { PinnedProjects } from './pinned-projects';
export { ProjectSwitcher } from './project-switcher';
export { TopBarActions } from './top-bar-actions';

View File

@@ -0,0 +1,128 @@
import { useCallback } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import type { Project } from '@/lib/electron';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import { Star, Settings, Trash2 } from 'lucide-react';
interface PinnedProjectsProps {
pinnedProjects: Project[];
currentProject: Project | null;
}
export function PinnedProjects({ pinnedProjects, currentProject }: PinnedProjectsProps) {
const navigate = useNavigate();
const { setCurrentProject, unpinProject, moveProjectToTrash } = useAppStore();
const handleProjectClick = useCallback(
(project: Project) => {
setCurrentProject(project);
navigate({ to: '/board' });
},
[setCurrentProject, navigate]
);
const handleUnpin = useCallback(
(projectId: string) => {
unpinProject(projectId);
},
[unpinProject]
);
const handleProjectSettings = useCallback(
(project: Project) => {
setCurrentProject(project);
navigate({ to: '/settings' });
},
[setCurrentProject, navigate]
);
const handleRemoveProject = useCallback(
(projectId: string) => {
moveProjectToTrash(projectId);
},
[moveProjectToTrash]
);
if (pinnedProjects.length === 0) {
return null;
}
return (
<TooltipProvider>
<div className="flex items-center gap-1">
{pinnedProjects.map((project) => {
const isActive = currentProject?.id === project.id;
// TODO: Get running agent count from store
const runningCount = 0;
return (
<ContextMenu key={project.id}>
<ContextMenuTrigger>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => handleProjectClick(project)}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium',
'transition-all duration-200',
'hover:bg-accent/50',
isActive && 'bg-accent text-accent-foreground',
!isActive && 'text-muted-foreground'
)}
>
<span className="truncate max-w-[120px]">{project.name}</span>
{runningCount > 0 && (
<span className="flex h-2 w-2 rounded-full bg-green-500 animate-pulse" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<div className="font-medium">{project.name}</div>
<div className="text-muted-foreground">{project.path}</div>
{runningCount > 0 && (
<div className="text-green-500 mt-1">
{runningCount} agent{runningCount > 1 ? 's' : ''} running
</div>
)}
</TooltipContent>
</Tooltip>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => handleProjectClick(project)}>Open</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => handleUnpin(project.id)}>
<Star className="h-4 w-4 mr-2" />
Unpin from bar
</ContextMenuItem>
<ContextMenuItem onClick={() => handleProjectSettings(project)}>
<Settings className="h-4 w-4 mr-2" />
Project Settings
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => handleRemoveProject(project.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Remove Project
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
})}
{/* Separator after pinned projects */}
<div className="h-6 w-px bg-border/60 mx-2" />
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,202 @@
import { useCallback } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import type { Project } from '@/lib/electron';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuLabel,
} from '@/components/ui/dropdown-menu';
import { ChevronDown, Star, Plus, FolderOpen, Check } from 'lucide-react';
interface ProjectSwitcherProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
currentProject: Project | null;
projects: Project[];
pinnedProjectIds: string[];
onNewProject: () => void;
onOpenFolder: () => void;
showCurrentProjectName?: boolean;
}
export function ProjectSwitcher({
isOpen,
onOpenChange,
currentProject,
projects,
pinnedProjectIds,
onNewProject,
onOpenFolder,
showCurrentProjectName = true,
}: ProjectSwitcherProps) {
const navigate = useNavigate();
const { setCurrentProject, pinProject, unpinProject } = useAppStore();
const pinnedProjects = projects.filter((p) => pinnedProjectIds.includes(p.id));
const unpinnedProjects = projects.filter((p) => !pinnedProjectIds.includes(p.id));
const handleSelectProject = useCallback(
(project: Project) => {
setCurrentProject(project);
navigate({ to: '/board' });
onOpenChange(false);
},
[setCurrentProject, navigate, onOpenChange]
);
const handleTogglePin = useCallback(
(e: React.MouseEvent, projectId: string) => {
e.stopPropagation();
if (pinnedProjectIds.includes(projectId)) {
unpinProject(projectId);
} else {
pinProject(projectId);
}
},
[pinnedProjectIds, pinProject, unpinProject]
);
const handleNewProject = useCallback(() => {
onOpenChange(false);
onNewProject();
}, [onOpenChange, onNewProject]);
const handleOpenFolder = useCallback(() => {
onOpenChange(false);
onOpenFolder();
}, [onOpenChange, onOpenFolder]);
const handleAllProjects = useCallback(() => {
onOpenChange(false);
navigate({ to: '/dashboard' });
}, [onOpenChange, navigate]);
// TODO: Get running agent counts from store
const getRunningCount = (projectId: string) => 0;
// Determine if we should show the current project name in the trigger
// Don't show if it's already visible as a pinned project
const currentProjectIsPinned = currentProject && pinnedProjectIds.includes(currentProject.id);
const shouldShowProjectName = showCurrentProjectName && currentProject && !currentProjectIsPinned;
return (
<DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<button
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium',
'transition-all duration-200',
'hover:bg-accent/50',
'text-foreground'
)}
>
{shouldShowProjectName && (
<span className="truncate max-w-[200px]">{currentProject.name}</span>
)}
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
{/* Pinned Projects */}
{pinnedProjects.length > 0 && (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground">Pinned</DropdownMenuLabel>
{pinnedProjects.map((project) => {
const isActive = currentProject?.id === project.id;
const runningCount = getRunningCount(project.id);
return (
<DropdownMenuItem
key={project.id}
onClick={() => handleSelectProject(project)}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{isActive && <Check className="h-4 w-4 text-primary shrink-0" />}
<span className={cn('truncate', !isActive && 'ml-6')}>{project.name}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{runningCount > 0 && (
<span className="text-xs text-green-500 flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
{runningCount}
</span>
)}
<button
onClick={(e) => handleTogglePin(e, project.id)}
className="p-0.5 hover:bg-accent rounded"
>
<Star className="h-3.5 w-3.5 fill-yellow-500 text-yellow-500" />
</button>
</div>
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
</>
)}
{/* Other Projects */}
{unpinnedProjects.length > 0 && (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Other Projects
</DropdownMenuLabel>
{unpinnedProjects.map((project) => {
const isActive = currentProject?.id === project.id;
const runningCount = getRunningCount(project.id);
return (
<DropdownMenuItem
key={project.id}
onClick={() => handleSelectProject(project)}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{isActive && <Check className="h-4 w-4 text-primary shrink-0" />}
<span className={cn('truncate', !isActive && 'ml-6')}>{project.name}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{runningCount > 0 && (
<span className="text-xs text-green-500 flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
{runningCount}
</span>
)}
<button
onClick={(e) => handleTogglePin(e, project.id)}
className="p-0.5 hover:bg-accent rounded"
>
<Star className="h-3.5 w-3.5 text-muted-foreground hover:text-yellow-500" />
</button>
</div>
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
</>
)}
{/* Actions */}
<DropdownMenuItem onClick={handleNewProject}>
<Plus className="h-4 w-4 mr-2" />
New Project
</DropdownMenuItem>
<DropdownMenuItem onClick={handleOpenFolder}>
<FolderOpen className="h-4 w-4 mr-2" />
Open Folder
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleAllProjects}>
<FolderOpen className="h-4 w-4 mr-2" />
All Projects
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,389 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useNavigate, useLocation } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import type { Project } from '@/lib/electron';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Settings,
Bot,
Bell,
Wand2,
GitBranch,
Search,
X,
ImageIcon,
Archive,
Minimize2,
Square,
Maximize2,
Columns3,
Network,
} from 'lucide-react';
import { SettingsDialog } from '@/components/dialogs/settings-dialog';
interface TopBarActionsProps {
currentProject: Project | null;
}
export function TopBarActions({ currentProject }: TopBarActionsProps) {
const navigate = useNavigate();
const location = useLocation();
const {
getAutoModeState,
setAutoModeRunning,
maxConcurrency,
setMaxConcurrency,
worktreePanelCollapsed,
setWorktreePanelCollapsed,
boardSearchQuery,
setBoardSearchQuery,
kanbanCardDetailLevel,
setKanbanCardDetailLevel,
boardViewMode,
setBoardViewMode,
} = useAppStore();
const [showAgentSettings, setShowAgentSettings] = useState(false);
const [showSettingsDialog, setShowSettingsDialog] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const autoModeState = currentProject ? getAutoModeState(currentProject.id) : null;
const isAutoModeRunning = autoModeState?.isRunning ?? false;
const runningAgentsCount = autoModeState?.runningTasks?.length ?? 0;
const isOnBoardView = location.pathname === '/board';
// Focus search input when "/" is pressed (only on board view)
useEffect(() => {
if (!isOnBoardView) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.key === '/' &&
!(e.target instanceof HTMLInputElement) &&
!(e.target instanceof HTMLTextAreaElement)
) {
e.preventDefault();
searchInputRef.current?.focus();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOnBoardView]);
const handlePlan = useCallback(() => {
if (isOnBoardView) {
// Dispatch custom event for board-view to handle
window.dispatchEvent(new CustomEvent('automaker:open-plan-dialog'));
} else {
// Navigate to board first, then open plan dialog
navigate({ to: '/board' });
setTimeout(() => {
window.dispatchEvent(new CustomEvent('automaker:open-plan-dialog'));
}, 100);
}
}, [isOnBoardView, navigate]);
const handleAutoModeToggle = useCallback(
(enabled: boolean) => {
if (currentProject) {
setAutoModeRunning(currentProject.id, enabled);
}
},
[currentProject, setAutoModeRunning]
);
const handleSettings = useCallback(() => {
setShowSettingsDialog(true);
}, []);
const handleNotifications = useCallback(() => {
// TODO: Open notifications panel
}, []);
const handleShowBoardBackground = useCallback(() => {
window.dispatchEvent(new CustomEvent('automaker:open-board-background'));
}, []);
const handleShowCompletedFeatures = useCallback(() => {
window.dispatchEvent(new CustomEvent('automaker:open-completed-features'));
}, []);
return (
<TooltipProvider>
<div className="flex items-center gap-1">
{currentProject && (
<>
{/* Worktree Panel Toggle */}
{isOnBoardView && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={worktreePanelCollapsed ? 'ghost' : 'secondary'}
size="sm"
onClick={() => setWorktreePanelCollapsed(!worktreePanelCollapsed)}
className="gap-2"
>
<GitBranch className="h-4 w-4" />
<span>Worktrees</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{worktreePanelCollapsed ? 'Show worktree panel' : 'Hide worktree panel'}
</TooltipContent>
</Tooltip>
)}
{/* Board Controls - only show on board view */}
{isOnBoardView && (
<>
<div className="h-6 w-px bg-border/60 mx-1" />
{/* Search Bar */}
<div className="relative w-48">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
<Input
ref={searchInputRef}
type="text"
placeholder="Search..."
value={boardSearchQuery}
onChange={(e) => setBoardSearchQuery(e.target.value)}
className="h-8 pl-8 pr-8 text-sm border-border"
data-testid="topbar-search-input"
/>
{boardSearchQuery ? (
<button
onClick={() => setBoardSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded-sm hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear search"
>
<X className="w-3.5 h-3.5" />
</button>
) : (
<span className="absolute right-2 top-1/2 -translate-y-1/2 px-1 py-0.5 text-[9px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70">
/
</span>
)}
</div>
{/* View Mode Toggle */}
<div className="flex items-center rounded-md bg-secondary border border-border ml-2">
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setBoardViewMode('kanban')}
className={cn(
'p-1.5 rounded-l-md transition-colors',
boardViewMode === 'kanban'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
>
<Columns3 className="w-3.5 h-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Kanban Board View</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setBoardViewMode('graph')}
className={cn(
'p-1.5 rounded-r-md transition-colors',
boardViewMode === 'graph'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
>
<Network className="w-3.5 h-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Dependency Graph View</TooltipContent>
</Tooltip>
</div>
{/* Board Background */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={handleShowBoardBackground}
className="h-8 w-8 p-0"
>
<ImageIcon className="w-3.5 h-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Board Background</TooltipContent>
</Tooltip>
{/* Completed Features */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={handleShowCompletedFeatures}
className="h-8 w-8 p-0 relative"
>
<Archive className="w-3.5 h-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Completed Features</TooltipContent>
</Tooltip>
{/* Detail Level Toggle */}
<div className="flex items-center rounded-md bg-secondary border border-border">
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setKanbanCardDetailLevel('minimal')}
className={cn(
'p-1.5 rounded-l-md transition-colors',
kanbanCardDetailLevel === 'minimal'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
>
<Minimize2 className="w-3.5 h-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Minimal</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setKanbanCardDetailLevel('standard')}
className={cn(
'p-1.5 transition-colors',
kanbanCardDetailLevel === 'standard'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
>
<Square className="w-3.5 h-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Standard</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setKanbanCardDetailLevel('detailed')}
className={cn(
'p-1.5 rounded-r-md transition-colors',
kanbanCardDetailLevel === 'detailed'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
>
<Maximize2 className="w-3.5 h-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Detailed</TooltipContent>
</Tooltip>
</div>
</>
)}
<div className="h-6 w-px bg-border/60 mx-1" />
{/* Agents Control */}
<Popover open={showAgentSettings} onOpenChange={setShowAgentSettings}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn('gap-2 px-3', runningAgentsCount > 0 && 'text-green-500')}
>
<Bot className="h-4 w-4" />
<span className="text-sm font-medium">
{runningAgentsCount}/{maxConcurrency}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-64" align="end">
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Max Agents</span>
<span className="text-sm text-muted-foreground">{maxConcurrency}</span>
</div>
<Slider
value={[maxConcurrency]}
onValueChange={(value) => setMaxConcurrency(value[0])}
min={1}
max={10}
step={1}
/>
<p className="text-xs text-muted-foreground">
Maximum concurrent agents when auto mode is running
</p>
</div>
</PopoverContent>
</Popover>
{/* Auto Mode Toggle */}
<div
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-md',
'transition-colors',
isAutoModeRunning ? 'bg-green-500/20 text-green-500' : 'hover:bg-accent/50'
)}
>
<span className="text-sm font-medium">Auto</span>
<Switch
checked={isAutoModeRunning}
onCheckedChange={handleAutoModeToggle}
className="data-[state=checked]:bg-green-500"
/>
</div>
<div className="h-6 w-px bg-border/60 mx-1" />
{/* Plan Button */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" onClick={handlePlan} className="gap-2">
<Wand2 className="h-4 w-4" />
<span>Plan</span>
</Button>
</TooltipTrigger>
<TooltipContent>Plan features with AI</TooltipContent>
</Tooltip>
</>
)}
{/* Notifications */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={handleNotifications} className="relative">
<Bell className="h-4 w-4" />
{/* Notification badge - show when there are unread notifications */}
{/* <span className="absolute -top-1 -right-1 h-4 w-4 rounded-full bg-red-500 text-[10px] text-white flex items-center justify-center">3</span> */}
</Button>
</TooltipTrigger>
<TooltipContent>Notifications</TooltipContent>
</Tooltip>
{/* Settings */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={handleSettings}>
<Settings className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
{/* Settings Dialog */}
<SettingsDialog open={showSettingsDialog} onOpenChange={setShowSettingsDialog} />
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,157 @@
import { useState, useCallback } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
import { useAppStore, type ThemeMode } from '@/store/app-store';
import type { Project } from '@/lib/electron';
import { ProjectSwitcher } from './project-switcher';
import { PinnedProjects } from './pinned-projects';
import { TopBarActions } from './top-bar-actions';
import { OnboardingWizard } from '@/components/dialogs/onboarding-wizard';
import { getElectronAPI } from '@/lib/electron';
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
import { toast } from 'sonner';
export function TopBar() {
const navigate = useNavigate();
const {
currentProject,
projects,
pinnedProjectIds,
trashedProjects,
theme: globalTheme,
upsertAndSetCurrentProject,
} = useAppStore();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [showOnboarding, setShowOnboarding] = useState(false);
const [onboardingMode, setOnboardingMode] = useState<'new' | 'existing'>('new');
const [pendingProjectPath, setPendingProjectPath] = useState<string | undefined>(undefined);
const pinnedProjects = projects.filter((p) => pinnedProjectIds.includes(p.id));
const handleLogoClick = useCallback(() => {
navigate({ to: '/dashboard' });
}, [navigate]);
const handleNewProject = useCallback(() => {
setPendingProjectPath(undefined);
setOnboardingMode('new');
setShowOnboarding(true);
}, []);
const handleOpenFolder = useCallback(async () => {
const api = getElectronAPI();
const result = await api.openDirectory();
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
try {
const hadAutomakerDir = await hasAutomakerDir(path);
const initResult = await initializeProject(path);
if (!initResult.success) {
toast.error('Failed to initialize project', {
description: initResult.error || 'Unknown error occurred',
});
return;
}
const trashedProject = trashedProjects.find((p) => p.path === path);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(path, name, effectiveTheme);
const specExists = await hasAppSpec(path);
if (!hadAutomakerDir || !specExists) {
setPendingProjectPath(path);
setOnboardingMode(hadAutomakerDir ? 'existing' : 'new');
setShowOnboarding(true);
} else {
navigate({ to: '/board' });
toast.success('Project opened', { description: `Opened ${name}` });
}
} catch (error) {
toast.error('Failed to open project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
}
}, [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject, navigate]);
return (
<header
className={cn(
'flex items-center h-12 px-4 border-b border-border/60',
'bg-gradient-to-r from-sidebar/95 via-sidebar/90 to-sidebar/95 backdrop-blur-xl',
'shadow-sm'
)}
>
{/* Logo */}
<button
onClick={handleLogoClick}
className="flex items-center gap-2 mr-4 hover:opacity-80 transition-opacity"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" className="h-7 w-7">
<defs>
<linearGradient
id="topbar-logo-bg"
x1="0"
y1="0"
x2="256"
y2="256"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
</linearGradient>
</defs>
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#topbar-logo-bg)" />
<g
fill="none"
stroke="#FFFFFF"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
</button>
{/* Pinned Projects */}
<PinnedProjects pinnedProjects={pinnedProjects} currentProject={currentProject} />
{/* Project Dropdown */}
<ProjectSwitcher
isOpen={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
currentProject={currentProject}
projects={projects}
pinnedProjectIds={pinnedProjectIds}
onNewProject={handleNewProject}
onOpenFolder={handleOpenFolder}
/>
{/* Spacer */}
<div className="flex-1" />
{/* Actions */}
<TopBarActions currentProject={currentProject} />
{/* Onboarding Wizard */}
<OnboardingWizard
open={showOnboarding}
onOpenChange={setShowOnboarding}
mode={onboardingMode}
initialPath={pendingProjectPath}
/>
</header>
);
}

View File

@@ -0,0 +1,186 @@
import * as React from 'react';
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
import { cn } from '@/lib/utils';
import { Check, ChevronRight, Circle } from 'lucide-react';
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold text-foreground', inset && 'pl-8', className)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
{...props}
/>
);
};
ContextMenuShortcut.displayName = 'ContextMenuShortcut';
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

@@ -16,10 +16,24 @@ interface AgentModelSelectorProps {
onChange: (entry: PhaseModelEntry) => void;
/** Disabled state */
disabled?: boolean;
/** Custom trigger class name */
triggerClassName?: string;
}
export function AgentModelSelector({ value, onChange, disabled }: AgentModelSelectorProps) {
export function AgentModelSelector({
value,
onChange,
disabled,
triggerClassName,
}: AgentModelSelectorProps) {
return (
<PhaseModelSelector value={value} onChange={onChange} disabled={disabled} compact align="end" />
<PhaseModelSelector
value={value}
onChange={onChange}
disabled={disabled}
compact
align="end"
triggerClassName={triggerClassName}
/>
);
}

View File

@@ -22,9 +22,6 @@ import { useAutoMode } from '@/hooks/use-auto-mode';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useWindowState } from '@/hooks/use-window-state';
// Board-view specific imports
import { BoardHeader } from './board-view/board-header';
import { BoardSearchBar } from './board-view/board-search-bar';
import { BoardControls } from './board-view/board-controls';
import { KanbanBoard } from './board-view/kanban-board';
import { GraphView } from './graph-view';
import {
@@ -172,8 +169,9 @@ export function BoardView() {
} = useSelectionMode();
const [showMassEditDialog, setShowMassEditDialog] = useState(false);
// Search filter for Kanban cards
const [searchQuery, setSearchQuery] = useState('');
// Search filter for Kanban cards - using store state for top bar integration
const searchQuery = useAppStore((state) => state.boardSearchQuery);
const setSearchQuery = useAppStore((state) => state.setBoardSearchQuery);
// Plan approval loading state
const [isPlanApprovalLoading, setIsPlanApprovalLoading] = useState(false);
// Derive spec creation state from store - check if current project is the one being created
@@ -247,6 +245,26 @@ export function BoardView() {
setIsMounted(true);
}, []);
// Listen for custom events from top bar to open dialogs
useEffect(() => {
const handleOpenAddFeature = () => setShowAddDialog(true);
const handleOpenPlanDialog = () => setShowPlanDialog(true);
const handleOpenBoardBackground = () => setShowBoardBackgroundModal(true);
const handleOpenCompletedFeatures = () => setShowCompletedModal(true);
window.addEventListener('automaker:open-add-feature-dialog', handleOpenAddFeature);
window.addEventListener('automaker:open-plan-dialog', handleOpenPlanDialog);
window.addEventListener('automaker:open-board-background', handleOpenBoardBackground);
window.addEventListener('automaker:open-completed-features', handleOpenCompletedFeatures);
return () => {
window.removeEventListener('automaker:open-add-feature-dialog', handleOpenAddFeature);
window.removeEventListener('automaker:open-plan-dialog', handleOpenPlanDialog);
window.removeEventListener('automaker:open-board-background', handleOpenBoardBackground);
window.removeEventListener('automaker:open-completed-features', handleOpenCompletedFeatures);
};
}, []);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
@@ -1138,30 +1156,6 @@ export function BoardView() {
className="flex-1 flex flex-col overflow-hidden content-bg relative"
data-testid="board-view"
>
{/* Header */}
<BoardHeader
projectName={currentProject.name}
maxConcurrency={maxConcurrency}
runningAgentsCount={runningAutoTasks.length}
onConcurrencyChange={setMaxConcurrency}
isAutoModeRunning={autoMode.isRunning}
onAutoModeToggle={(enabled) => {
if (enabled) {
autoMode.start();
} else {
autoMode.stop();
}
}}
onAddFeature={() => setShowAddDialog(true)}
onOpenPlanDialog={() => setShowPlanDialog(true)}
addFeatureShortcut={{
key: shortcuts.addFeature,
action: () => setShowAddDialog(true),
description: 'Add new feature',
}}
isMounted={isMounted}
/>
{/* Worktree Panel */}
<WorktreePanel
refreshTrigger={worktreeRefreshKey}
@@ -1196,28 +1190,6 @@ export function BoardView() {
{/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Search Bar Row */}
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
<BoardSearchBar
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
isCreatingSpec={isCreatingSpec}
creatingSpecProjectPath={creatingSpecProjectPath ?? undefined}
currentProjectPath={currentProject?.path}
/>
{/* Board Background & Detail Level Controls */}
<BoardControls
isMounted={isMounted}
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
onShowCompletedModal={() => setShowCompletedModal(true)}
completedCount={completedFeatures.length}
kanbanCardDetailLevel={kanbanCardDetailLevel}
onDetailLevelChange={setKanbanCardDetailLevel}
boardViewMode={boardViewMode}
onBoardViewModeChange={setBoardViewMode}
/>
</div>
{/* View Content - Kanban or Graph */}
{boardViewMode === 'kanban' ? (
<KanbanBoard
@@ -1257,6 +1229,7 @@ export function BoardView() {
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onToggleSelectionMode={toggleSelectionMode}
onAddFeature={() => setShowAddDialog(true)}
/>
) : (
<GraphView

View File

@@ -1,50 +1,26 @@
import { useState } from 'react';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Plus, Bot, Wand2, Settings2 } from 'lucide-react';
import { Plus, Wand2 } from 'lucide-react';
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
import { UsagePopover } from '@/components/usage-popover';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
interface BoardHeaderProps {
projectName: string;
maxConcurrency: number;
runningAgentsCount: number;
onConcurrencyChange: (value: number) => void;
isAutoModeRunning: boolean;
onAutoModeToggle: (enabled: boolean) => void;
onAddFeature: () => void;
onOpenPlanDialog: () => void;
addFeatureShortcut: KeyboardShortcut;
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,
runningAgentsCount,
onConcurrencyChange,
isAutoModeRunning,
onAutoModeToggle,
onAddFeature,
onOpenPlanDialog,
addFeatureShortcut,
isMounted,
}: BoardHeaderProps) {
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
const apiKeys = useAppStore((state) => state.apiKeys);
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode);
const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode);
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
// Claude usage tracking visibility logic
@@ -62,90 +38,30 @@ export function BoardHeader({
const showCodexUsage = !!codexAuthStatus?.authenticated;
return (
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div>
<h1 className="text-xl font-bold">Kanban Board</h1>
<p className="text-sm text-muted-foreground">{projectName}</p>
</div>
<div className="flex gap-2 items-center">
{/* Usage Popover - show if either provider is authenticated */}
{isMounted && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
<div className="flex items-center justify-end gap-2 px-4 py-2 border-b border-border bg-glass backdrop-blur-md">
{/* Usage Popover - show if either provider is authenticated */}
{isMounted && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
{isMounted && (
<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
value={[maxConcurrency]}
onValueChange={(value) => onConcurrencyChange(value[0])}
min={1}
max={10}
step={1}
className="w-20"
data-testid="concurrency-slider"
/>
<span
className="text-sm text-muted-foreground min-w-[5ch] text-center"
data-testid="concurrency-value"
>
{runningAgentsCount} / {maxConcurrency}
</span>
</div>
)}
<Button
size="sm"
variant="outline"
onClick={onOpenPlanDialog}
data-testid="plan-backlog-button"
>
<Wand2 className="w-4 h-4 mr-2" />
Plan
</Button>
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
{isMounted && (
<div className={controlContainerClass} data-testid="auto-mode-toggle-container">
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
Auto Mode
</Label>
<Switch
id="auto-mode-toggle"
checked={isAutoModeRunning}
onCheckedChange={onAutoModeToggle}
data-testid="auto-mode-toggle"
/>
<button
onClick={() => setShowAutoModeSettings(true)}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Auto Mode Settings"
data-testid="auto-mode-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
)}
{/* Auto Mode Settings Dialog */}
<AutoModeSettingsDialog
open={showAutoModeSettings}
onOpenChange={setShowAutoModeSettings}
skipVerificationInAutoMode={skipVerificationInAutoMode}
onSkipVerificationChange={setSkipVerificationInAutoMode}
/>
<Button
size="sm"
variant="outline"
onClick={onOpenPlanDialog}
data-testid="plan-backlog-button"
>
<Wand2 className="w-4 h-4 mr-2" />
Plan
</Button>
<HotkeyButton
size="sm"
onClick={onAddFeature}
hotkey={addFeatureShortcut}
hotkeyActive={false}
data-testid="add-feature-button"
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
</HotkeyButton>
</div>
<HotkeyButton
size="sm"
onClick={onAddFeature}
hotkey={addFeatureShortcut}
hotkeyActive={false}
data-testid="add-feature-button"
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
</HotkeyButton>
</div>
);
}

View File

@@ -4,10 +4,11 @@ import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { Button } from '@/components/ui/button';
import { KanbanColumn, KanbanCard } from './components';
import { Feature } from '@/store/app-store';
import { Archive, Settings2, CheckSquare, GripVertical } from 'lucide-react';
import { Archive, Settings2, CheckSquare, GripVertical, Plus } from 'lucide-react';
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
import { getColumnsWithPipeline, type ColumnId } from './constants';
import type { PipelineConfig } from '@automaker/types';
import { useDockState } from '@/components/layout/bottom-dock/bottom-dock';
interface KanbanBoardProps {
sensors: any;
@@ -50,6 +51,8 @@ interface KanbanBoardProps {
selectedFeatureIds?: Set<string>;
onToggleFeatureSelection?: (featureId: string) => void;
onToggleSelectionMode?: () => void;
// Add feature action
onAddFeature?: () => void;
}
export function KanbanBoard({
@@ -84,6 +87,7 @@ export function KanbanBoard({
selectedFeatureIds = new Set(),
onToggleFeatureSelection,
onToggleSelectionMode,
onAddFeature,
}: KanbanBoardProps) {
// Generate columns including pipeline steps
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
@@ -92,15 +96,40 @@ export function KanbanBoard({
// containerStyle handles centering and ensures columns fit without horizontal scroll in Electron
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
// Get dock state to add padding when dock is expanded on the side
const {
position: dockPosition,
isExpanded: dockExpanded,
isMaximized: dockMaximized,
} = useDockState();
// Calculate padding based on dock state
// Dock widths: collapsed=w-10 (2.5rem), expanded=w-96 (24rem), maximized=w-[50vw]
const getSideDockPadding = () => {
if (!dockExpanded) return undefined;
if (dockMaximized) return '50vw';
return '25rem'; // 24rem dock width + 1rem breathing room
};
const sideDockPadding = getSideDockPadding();
return (
<div className="flex-1 overflow-x-auto px-5 pb-4 relative" style={backgroundImageStyle}>
<div
className="flex-1 overflow-x-auto px-5 pb-4 relative transition-[padding] duration-300"
style={{
...backgroundImageStyle,
// Add padding when dock is expanded on the side so content can scroll past the overlay
paddingRight: dockPosition === 'right' ? sideDockPadding : undefined,
paddingLeft: dockPosition === 'left' ? sideDockPadding : undefined,
}}
>
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<div className="h-full py-1" style={containerStyle}>
<div className="h-full pt-4 pb-1" style={containerStyle}>
{columns.map((column) => {
const columnFeatures = getColumnFeatures(column.id as ColumnId);
return (
@@ -127,26 +156,36 @@ export function KanbanBoard({
Complete All
</Button>
) : column.id === 'backlog' ? (
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs ${isSelectionMode ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={onToggleSelectionMode}
title={isSelectionMode ? 'Switch to Drag Mode' : 'Select Multiple'}
data-testid="selection-mode-button"
>
{isSelectionMode ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
</>
) : (
<>
<CheckSquare className="w-3.5 h-3.5 mr-1" />
Select
</>
)}
</Button>
<div className="flex items-center gap-1">
<Button
size="sm"
className="h-6 px-2 text-xs gap-1"
onClick={onAddFeature}
data-testid="backlog-add-feature-button"
>
<Plus className="w-3 h-3" />
Add
</Button>
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs gap-1 ${isSelectionMode ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={onToggleSelectionMode}
data-testid="selection-mode-button"
>
{isSelectionMode ? (
<>
<GripVertical className="w-3 h-3" />
Drag
</>
) : (
<>
<CheckSquare className="w-3 h-3" />
Select
</>
)}
</Button>
</div>
) : column.id === 'in_progress' ? (
<Button
variant="ghost"

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { GitBranch, Plus, RefreshCw, PanelLeftOpen, PanelLeftClose } from 'lucide-react';
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
import { cn, pathsEqual } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import type { WorktreePanelProps, WorktreeInfo } from './types';
@@ -85,9 +85,6 @@ export function WorktreePanel({
// Collapse state from store (synced via API)
const isCollapsed = useAppStore((s) => s.worktreePanelCollapsed);
const setWorktreePanelCollapsed = useAppStore((s) => s.setWorktreePanelCollapsed);
const toggleCollapsed = () => setWorktreePanelCollapsed(!isCollapsed);
// Periodic interval check (5 seconds) to detect branch changes on disk
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
@@ -138,44 +135,14 @@ export function WorktreePanel({
const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
// Collapsed view - just show current branch and toggle
// When collapsed, hide the entire panel
if (isCollapsed) {
return (
<div className="flex items-center gap-2 px-4 py-1.5 border-b border-border bg-glass/50 backdrop-blur-sm">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={toggleCollapsed}
title="Expand worktree panel"
>
<PanelLeftOpen className="w-4 h-4" />
</Button>
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Branch:</span>
<span className="text-sm font-mono font-medium">{selectedWorktree?.branch ?? 'main'}</span>
{selectedWorktree?.hasChanges && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30">
{selectedWorktree.changedFilesCount ?? '!'}
</span>
)}
</div>
);
return null;
}
// Expanded view - full worktree panel
return (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={toggleCollapsed}
title="Collapse worktree panel"
>
<PanelLeftClose className="w-4 h-4" />
</Button>
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">Branch:</span>

View File

@@ -0,0 +1,320 @@
import { useState, useCallback } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { ProjectCard } from './project-card';
import { EmptyState } from './empty-state';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Plus, Search, FolderOpen } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
import type { ThemeMode } from '@/store/app-store';
import { toast } from 'sonner';
import { OnboardingWizard } from '@/components/dialogs/onboarding-wizard';
import { useOSDetection } from '@/hooks/use-os-detection';
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0';
function getOSAbbreviation(os: string): string {
switch (os) {
case 'mac':
return 'M';
case 'windows':
return 'W';
case 'linux':
return 'L';
default:
return '?';
}
}
export function DashboardView() {
const navigate = useNavigate();
const {
projects,
trashedProjects,
currentProject,
upsertAndSetCurrentProject,
theme: globalTheme,
} = useAppStore();
const { os } = useOSDetection();
const appMode = import.meta.env.VITE_APP_MODE || '?';
const versionSuffix = `${getOSAbbreviation(os)}${appMode}`;
const [searchQuery, setSearchQuery] = useState('');
const [showOnboarding, setShowOnboarding] = useState(false);
const [onboardingMode, setOnboardingMode] = useState<'new' | 'existing'>('new');
const [pendingProjectPath, setPendingProjectPath] = useState<string | undefined>(undefined);
const filteredProjects = projects.filter((p) =>
p.name.toLowerCase().includes(searchQuery.toLowerCase())
);
// Sort by last opened (most recent first)
const sortedProjects = [...filteredProjects].sort((a, b) => {
const aTime = a.lastOpened ? new Date(a.lastOpened).getTime() : 0;
const bTime = b.lastOpened ? new Date(b.lastOpened).getTime() : 0;
return bTime - aTime;
});
const handleOpenFolder = useCallback(async () => {
const api = getElectronAPI();
const result = await api.openDirectory();
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
try {
const hadAutomakerDir = await hasAutomakerDir(path);
const initResult = await initializeProject(path);
if (!initResult.success) {
toast.error('Failed to initialize project', {
description: initResult.error || 'Unknown error occurred',
});
return;
}
const trashedProject = trashedProjects.find((p) => p.path === path);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(path, name, effectiveTheme);
const specExists = await hasAppSpec(path);
if (!hadAutomakerDir || !specExists) {
// Show onboarding for project that needs setup
setPendingProjectPath(path);
setOnboardingMode(hadAutomakerDir ? 'existing' : 'new');
setShowOnboarding(true);
} else {
navigate({ to: '/board' });
toast.success('Project opened', { description: `Opened ${name}` });
}
} catch (error) {
toast.error('Failed to open project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
}
}, [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject, navigate]);
const handleNewProject = useCallback(() => {
setPendingProjectPath(undefined);
setOnboardingMode('new');
setShowOnboarding(true);
}, []);
const handleProjectClick = useCallback(
(projectId: string) => {
const project = projects.find((p) => p.id === projectId);
if (project) {
upsertAndSetCurrentProject(
project.path,
project.name,
project.theme as ThemeMode | undefined
);
navigate({ to: '/board' });
}
},
[projects, upsertAndSetCurrentProject, navigate]
);
// Show empty state for new users
if (projects.length === 0) {
return (
<div className="flex-1 flex flex-col">
{/* Branding Header */}
<div className="flex items-center gap-2 p-6">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
role="img"
aria-label="Automaker Logo"
className="size-9"
>
<defs>
<linearGradient
id="dashboard-empty-logo-bg"
x1="0"
y1="0"
x2="256"
y2="256"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
</linearGradient>
<filter id="dashboard-empty-logo-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow
dx="0"
dy="4"
stdDeviation="4"
floodColor="#000000"
floodOpacity="0.25"
/>
</filter>
</defs>
<rect
x="16"
y="16"
width="224"
height="224"
rx="56"
fill="url(#dashboard-empty-logo-bg)"
/>
<g
fill="none"
stroke="#FFFFFF"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
filter="url(#dashboard-empty-logo-shadow)"
>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
<div className="flex flex-col">
<span className="font-bold text-foreground text-xl tracking-tight leading-none">
automaker<span className="text-brand-500">.</span>
</span>
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium mt-0.5">
v{appVersion} {versionSuffix}
</span>
</div>
</div>
<EmptyState onNewProject={handleNewProject} onOpenFolder={handleOpenFolder} />
<OnboardingWizard
open={showOnboarding}
onOpenChange={setShowOnboarding}
mode={onboardingMode}
initialPath={pendingProjectPath}
/>
</div>
);
}
return (
<div className="flex-1 overflow-auto p-6">
{/* Branding Header */}
<div className="flex items-center gap-2 mb-8">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
role="img"
aria-label="Automaker Logo"
className="size-9"
>
<defs>
<linearGradient
id="dashboard-logo-bg"
x1="0"
y1="0"
x2="256"
y2="256"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
</linearGradient>
<filter id="dashboard-logo-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow
dx="0"
dy="4"
stdDeviation="4"
floodColor="#000000"
floodOpacity="0.25"
/>
</filter>
</defs>
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#dashboard-logo-bg)" />
<g
fill="none"
stroke="#FFFFFF"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
filter="url(#dashboard-logo-shadow)"
>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
<div className="flex flex-col">
<span className="font-bold text-foreground text-xl tracking-tight leading-none">
automaker<span className="text-brand-500">.</span>
</span>
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium mt-0.5">
v{appVersion} {versionSuffix}
</span>
</div>
</div>
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-semibold">Projects</h1>
<p className="text-muted-foreground mt-1">
{projects.length} project{projects.length !== 1 ? 's' : ''}
</p>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" onClick={handleOpenFolder}>
<FolderOpen className="h-4 w-4 mr-2" />
Open Folder
</Button>
<Button onClick={handleNewProject}>
<Plus className="h-4 w-4 mr-2" />
New Project
</Button>
</div>
</div>
{/* Search */}
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search projects..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
{/* Project Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{sortedProjects.map((project) => (
<ProjectCard
key={project.id}
project={project}
onClick={() => handleProjectClick(project.id)}
/>
))}
</div>
{/* No results */}
{filteredProjects.length === 0 && searchQuery && (
<div className="text-center py-12">
<p className="text-muted-foreground">No projects matching "{searchQuery}"</p>
</div>
)}
</div>
<OnboardingWizard
open={showOnboarding}
onOpenChange={setShowOnboarding}
mode={onboardingMode}
initialPath={pendingProjectPath}
/>
</div>
);
}

View File

@@ -0,0 +1,131 @@
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Plus, FolderOpen, Sparkles, Rocket } from 'lucide-react';
interface EmptyStateProps {
onNewProject: () => void;
onOpenFolder: () => void;
}
export function EmptyState({ onNewProject, onOpenFolder }: EmptyStateProps) {
return (
<div className="flex-1 flex items-center justify-center p-6">
<div className="max-w-2xl w-full">
{/* Welcome Header */}
<div className="text-center mb-10">
<div className="inline-flex items-center justify-center h-16 w-16 rounded-2xl bg-primary/10 mb-4">
<Rocket className="h-8 w-8 text-primary" />
</div>
<h1 className="text-3xl font-semibold mb-2">Welcome to Automaker</h1>
<p className="text-lg text-muted-foreground">
Your AI-powered development studio. Let's get started.
</p>
</div>
{/* Options */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* New Project */}
<Card
className={cn(
'cursor-pointer transition-all duration-200',
'hover:bg-accent/50 hover:border-primary/50',
'group'
)}
onClick={onNewProject}
>
<CardContent className="p-6">
<div className="flex items-start gap-4">
<div
className={cn(
'flex items-center justify-center h-12 w-12 rounded-xl',
'bg-green-500/10 text-green-500',
'group-hover:bg-green-500/20 transition-colors'
)}
>
<Plus className="h-6 w-6" />
</div>
<div className="flex-1">
<h3 className="font-semibold mb-1">New Project</h3>
<p className="text-sm text-muted-foreground">
Start fresh with a new project. We'll help you set up your app spec and generate
initial features.
</p>
</div>
</div>
<div className="mt-4 pt-4 border-t border-border/50">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Sparkles className="h-3.5 w-3.5" />
<span>Includes AI-powered feature ideation</span>
</div>
</div>
</CardContent>
</Card>
{/* Open Existing */}
<Card
className={cn(
'cursor-pointer transition-all duration-200',
'hover:bg-accent/50 hover:border-primary/50',
'group'
)}
onClick={onOpenFolder}
>
<CardContent className="p-6">
<div className="flex items-start gap-4">
<div
className={cn(
'flex items-center justify-center h-12 w-12 rounded-xl',
'bg-blue-500/10 text-blue-500',
'group-hover:bg-blue-500/20 transition-colors'
)}
>
<FolderOpen className="h-6 w-6" />
</div>
<div className="flex-1">
<h3 className="font-semibold mb-1">Open Existing Project</h3>
<p className="text-sm text-muted-foreground">
Already have a codebase? Open it and let AI help you build new features.
</p>
</div>
</div>
<div className="mt-4 pt-4 border-t border-border/50">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Sparkles className="h-3.5 w-3.5" />
<span>Auto-detects your tech stack</span>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Getting Started Steps */}
<div className="mt-10 text-center">
<h2 className="text-sm font-medium text-muted-foreground mb-4">How it works</h2>
<div className="flex items-center justify-center gap-8 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span className="flex items-center justify-center h-6 w-6 rounded-full bg-primary/10 text-primary text-xs font-medium">
1
</span>
<span>Add your project</span>
</div>
<div className="h-px w-8 bg-border" />
<div className="flex items-center gap-2">
<span className="flex items-center justify-center h-6 w-6 rounded-full bg-primary/10 text-primary text-xs font-medium">
2
</span>
<span>Create features</span>
</div>
<div className="h-px w-8 bg-border" />
<div className="flex items-center gap-2">
<span className="flex items-center justify-center h-6 w-6 rounded-full bg-primary/10 text-primary text-xs font-medium">
3
</span>
<span>Let AI build</span>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { DashboardView } from './dashboard-view';
export { ProjectCard } from './project-card';
export { EmptyState } from './empty-state';

View File

@@ -0,0 +1,129 @@
import { useCallback } from 'react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import type { Project } from '@/lib/electron';
import { Card, CardContent } from '@/components/ui/card';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import { Folder, Star, Settings, Trash2, MoreVertical } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
interface ProjectCardProps {
project: Project;
onClick: () => void;
}
export function ProjectCard({ project, onClick }: ProjectCardProps) {
const { pinnedProjectIds, pinProject, unpinProject, moveProjectToTrash, getAutoModeState } =
useAppStore();
const isPinned = pinnedProjectIds.includes(project.id);
const autoModeState = getAutoModeState(project.id);
const runningCount = autoModeState?.runningTasks?.length ?? 0;
const handleTogglePin = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (isPinned) {
unpinProject(project.id);
} else {
pinProject(project.id);
}
},
[isPinned, project.id, pinProject, unpinProject]
);
const handleRemove = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
moveProjectToTrash(project.id);
},
[project.id, moveProjectToTrash]
);
const lastOpened = project.lastOpened
? formatDistanceToNow(new Date(project.lastOpened), { addSuffix: true })
: 'Never opened';
return (
<ContextMenu>
<ContextMenuTrigger>
<Card
className={cn(
'cursor-pointer transition-all duration-200',
'hover:bg-accent/50 hover:border-accent-foreground/20',
'group'
)}
onClick={onClick}
>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div
className={cn(
'flex items-center justify-center h-10 w-10 rounded-lg',
'bg-primary/10 text-primary'
)}
>
<Folder className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">{project.name}</h3>
{isPinned && (
<Star className="h-3.5 w-3.5 fill-yellow-500 text-yellow-500 shrink-0" />
)}
{runningCount > 0 && (
<span className="flex items-center gap-1 text-xs text-green-500 shrink-0">
<span className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
{runningCount}
</span>
)}
</div>
<p className="text-xs text-muted-foreground truncate mt-0.5">{project.path}</p>
</div>
</div>
<button
onClick={handleTogglePin}
className={cn(
'p-1.5 rounded-md opacity-0 group-hover:opacity-100 transition-opacity',
'hover:bg-accent'
)}
>
<Star
className={cn(
'h-4 w-4',
isPinned
? 'fill-yellow-500 text-yellow-500'
: 'text-muted-foreground hover:text-yellow-500'
)}
/>
</button>
</div>
<div className="mt-3 pt-3 border-t border-border/50">
<p className="text-xs text-muted-foreground">{lastOpened}</p>
</div>
</CardContent>
</Card>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={onClick}>Open Project</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleTogglePin}>
<Star className="h-4 w-4 mr-2" />
{isPinned ? 'Unpin from bar' : 'Pin to bar'}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleRemove} className="text-destructive focus:text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
Remove Project
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}

View File

@@ -1,227 +1,14 @@
import { useState } from 'react';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { useSettingsView, type SettingsViewId } from './settings-view/hooks';
import { NAV_ITEMS } from './settings-view/config/navigation';
import { SettingsHeader } from './settings-view/components/settings-header';
import { KeyboardMapDialog } from './settings-view/components/keyboard-map-dialog';
import { DeleteProjectDialog } from './settings-view/components/delete-project-dialog';
import { SettingsNavigation } from './settings-view/components/settings-navigation';
import { ApiKeysSection } from './settings-view/api-keys/api-keys-section';
import { ModelDefaultsSection } from './settings-view/model-defaults';
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 { AccountSection } from './settings-view/account';
import { SecuritySection } from './settings-view/security';
import {
ClaudeSettingsTab,
CursorSettingsTab,
CodexSettingsTab,
OpencodeSettingsTab,
} from './settings-view/providers';
import { MCPServersSection } from './settings-view/mcp-servers';
import { PromptCustomizationSection } from './settings-view/prompts';
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
import type { Project as ElectronProject } from '@/lib/electron';
import { SettingsContent } from './settings-view/settings-content';
export function SettingsView() {
const {
theme,
setTheme,
setProjectTheme,
defaultSkipTests,
setDefaultSkipTests,
enableDependencyBlocking,
setEnableDependencyBlocking,
skipVerificationInAutoMode,
setSkipVerificationInAutoMode,
useWorktrees,
setUseWorktrees,
showProfilesOnly,
setShowProfilesOnly,
muteDoneSound,
setMuteDoneSound,
currentProject,
moveProjectToTrash,
defaultPlanningMode,
setDefaultPlanningMode,
defaultRequirePlanApproval,
setDefaultRequirePlanApproval,
defaultAIProfileId,
setDefaultAIProfileId,
aiProfiles,
autoLoadClaudeMd,
setAutoLoadClaudeMd,
promptCustomization,
setPromptCustomization,
skipSandboxWarning,
setSkipSandboxWarning,
} = useAppStore();
// Convert electron Project to settings-view Project type
const convertProject = (project: ElectronProject | null): SettingsProject | null => {
if (!project) return null;
return {
id: project.id,
name: project.name,
path: project.path,
theme: project.theme as Theme | undefined,
};
};
const settingsProject = convertProject(currentProject);
// Compute the effective theme for the current project
const effectiveTheme = (settingsProject?.theme || theme) as Theme;
// Handler to set theme - always updates global theme (user's preference),
// and also sets per-project theme if a project is selected
const handleSetTheme = (newTheme: typeof theme) => {
// Always update global theme so user's preference persists across all projects
setTheme(newTheme);
// Also set per-project theme if a project is selected
if (currentProject) {
setProjectTheme(currentProject.id, newTheme);
}
};
// Use settings view navigation hook
const { activeView, navigateTo } = useSettingsView();
// Handle navigation - if navigating to 'providers', default to 'claude-provider'
const handleNavigate = (viewId: SettingsViewId) => {
if (viewId === 'providers') {
navigateTo('claude-provider');
} else {
navigateTo(viewId);
}
};
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
// Render the active section based on current view
const renderActiveSection = () => {
switch (activeView) {
case 'claude-provider':
return <ClaudeSettingsTab />;
case 'cursor-provider':
return <CursorSettingsTab />;
case 'codex-provider':
return <CodexSettingsTab />;
case 'opencode-provider':
return <OpencodeSettingsTab />;
case 'providers':
case 'claude': // Backwards compatibility - redirect to claude-provider
return <ClaudeSettingsTab />;
case 'mcp-servers':
return <MCPServersSection />;
case 'prompts':
return (
<PromptCustomizationSection
promptCustomization={promptCustomization}
onPromptCustomizationChange={setPromptCustomization}
/>
);
case 'model-defaults':
return <ModelDefaultsSection />;
case 'appearance':
return (
<AppearanceSection
effectiveTheme={effectiveTheme as any}
currentProject={settingsProject as any}
onThemeChange={(theme) => handleSetTheme(theme as any)}
/>
);
case 'terminal':
return <TerminalSection />;
case 'keyboard':
return (
<KeyboardShortcutsSection onOpenKeyboardMap={() => setShowKeyboardMapDialog(true)} />
);
case 'audio':
return (
<AudioSection muteDoneSound={muteDoneSound} onMuteDoneSoundChange={setMuteDoneSound} />
);
case 'defaults':
return (
<FeatureDefaultsSection
showProfilesOnly={showProfilesOnly}
defaultSkipTests={defaultSkipTests}
enableDependencyBlocking={enableDependencyBlocking}
skipVerificationInAutoMode={skipVerificationInAutoMode}
useWorktrees={useWorktrees}
defaultPlanningMode={defaultPlanningMode}
defaultRequirePlanApproval={defaultRequirePlanApproval}
defaultAIProfileId={defaultAIProfileId}
aiProfiles={aiProfiles}
onShowProfilesOnlyChange={setShowProfilesOnly}
onDefaultSkipTestsChange={setDefaultSkipTests}
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
onUseWorktreesChange={setUseWorktrees}
onDefaultPlanningModeChange={setDefaultPlanningMode}
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
onDefaultAIProfileIdChange={setDefaultAIProfileId}
/>
);
case 'account':
return <AccountSection />;
case 'security':
return (
<SecuritySection
skipSandboxWarning={skipSandboxWarning}
onSkipSandboxWarningChange={setSkipSandboxWarning}
/>
);
case 'danger':
return (
<DangerZoneSection
project={settingsProject}
onDeleteClick={() => setShowDeleteDialog(true)}
/>
);
default:
return <ApiKeysSection />;
}
};
return (
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="settings-view">
{/* Header Section */}
<SettingsHeader />
{/* Content Area with Sidebar */}
<div className="flex-1 flex overflow-hidden">
{/* Side Navigation - No longer scrolls, just switches views */}
<SettingsNavigation
navItems={NAV_ITEMS}
activeSection={activeView}
currentProject={currentProject}
onNavigate={handleNavigate}
/>
{/* Content Panel - Shows only the active section */}
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-4xl mx-auto">{renderActiveSection()}</div>
</div>
</div>
{/* Keyboard Map Dialog */}
<KeyboardMapDialog open={showKeyboardMapDialog} onOpenChange={setShowKeyboardMapDialog} />
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
project={currentProject}
onConfirm={moveProjectToTrash}
/>
<SettingsContent />
</div>
);
}

View File

@@ -13,6 +13,7 @@ interface SettingsNavigationProps {
activeSection: SettingsViewId;
currentProject: Project | null;
onNavigate: (sectionId: SettingsViewId) => void;
compact?: boolean;
}
function NavButton({
@@ -167,11 +168,13 @@ export function SettingsNavigation({
activeSection,
currentProject,
onNavigate,
compact = false,
}: SettingsNavigationProps) {
return (
<nav
className={cn(
'hidden lg:block w-64 shrink-0 overflow-y-auto',
'shrink-0 overflow-y-auto',
compact ? 'w-56' : 'hidden lg:block w-64',
'border-r border-border/50',
'bg-gradient-to-b from-card/80 via-card/60 to-card/40 backdrop-blur-xl'
)}

View File

@@ -0,0 +1,229 @@
import { useState } from 'react';
import { useAppStore } from '@/store/app-store';
import { useSettingsView, type SettingsViewId } from './hooks';
import { NAV_ITEMS } from './config/navigation';
import { KeyboardMapDialog } from './components/keyboard-map-dialog';
import { DeleteProjectDialog } from './components/delete-project-dialog';
import { SettingsNavigation } from './components/settings-navigation';
import { ApiKeysSection } from './api-keys/api-keys-section';
import { ModelDefaultsSection } from './model-defaults';
import { AppearanceSection } from './appearance/appearance-section';
import { TerminalSection } from './terminal/terminal-section';
import { AudioSection } from './audio/audio-section';
import { KeyboardShortcutsSection } from './keyboard-shortcuts/keyboard-shortcuts-section';
import { FeatureDefaultsSection } from './feature-defaults/feature-defaults-section';
import { DangerZoneSection } from './danger-zone/danger-zone-section';
import { AccountSection } from './account';
import { SecuritySection } from './security';
import {
ClaudeSettingsTab,
CursorSettingsTab,
CodexSettingsTab,
OpencodeSettingsTab,
} from './providers';
import { MCPServersSection } from './mcp-servers';
import { PromptCustomizationSection } from './prompts';
import type { Project as SettingsProject, Theme } from './shared/types';
import type { Project as ElectronProject } from '@/lib/electron';
interface SettingsContentProps {
compact?: boolean;
}
export function SettingsContent({ compact = false }: SettingsContentProps) {
const {
theme,
setTheme,
setProjectTheme,
defaultSkipTests,
setDefaultSkipTests,
enableDependencyBlocking,
setEnableDependencyBlocking,
skipVerificationInAutoMode,
setSkipVerificationInAutoMode,
useWorktrees,
setUseWorktrees,
showProfilesOnly,
setShowProfilesOnly,
muteDoneSound,
setMuteDoneSound,
currentProject,
moveProjectToTrash,
defaultPlanningMode,
setDefaultPlanningMode,
defaultRequirePlanApproval,
setDefaultRequirePlanApproval,
defaultAIProfileId,
setDefaultAIProfileId,
aiProfiles,
autoLoadClaudeMd,
setAutoLoadClaudeMd,
promptCustomization,
setPromptCustomization,
skipSandboxWarning,
setSkipSandboxWarning,
} = useAppStore();
// Convert electron Project to settings-view Project type
const convertProject = (project: ElectronProject | null): SettingsProject | null => {
if (!project) return null;
return {
id: project.id,
name: project.name,
path: project.path,
theme: project.theme as Theme | undefined,
};
};
const settingsProject = convertProject(currentProject);
// Compute the effective theme for the current project
const effectiveTheme = (settingsProject?.theme || theme) as Theme;
// Handler to set theme - always updates global theme (user's preference),
// and also sets per-project theme if a project is selected
const handleSetTheme = (newTheme: typeof theme) => {
// Always update global theme so user's preference persists across all projects
setTheme(newTheme);
// Also set per-project theme if a project is selected
if (currentProject) {
setProjectTheme(currentProject.id, newTheme);
}
};
// Use settings view navigation hook
const { activeView, navigateTo } = useSettingsView();
// Handle navigation - if navigating to 'providers', default to 'claude-provider'
const handleNavigate = (viewId: SettingsViewId) => {
if (viewId === 'providers') {
navigateTo('claude-provider');
} else {
navigateTo(viewId);
}
};
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
// Render the active section based on current view
const renderActiveSection = () => {
switch (activeView) {
case 'claude-provider':
return <ClaudeSettingsTab />;
case 'cursor-provider':
return <CursorSettingsTab />;
case 'codex-provider':
return <CodexSettingsTab />;
case 'opencode-provider':
return <OpencodeSettingsTab />;
case 'providers':
case 'claude': // Backwards compatibility - redirect to claude-provider
return <ClaudeSettingsTab />;
case 'mcp-servers':
return <MCPServersSection />;
case 'prompts':
return (
<PromptCustomizationSection
promptCustomization={promptCustomization}
onPromptCustomizationChange={setPromptCustomization}
/>
);
case 'model-defaults':
return <ModelDefaultsSection />;
case 'appearance':
return (
<AppearanceSection
effectiveTheme={effectiveTheme as any}
currentProject={settingsProject as any}
onThemeChange={(theme) => handleSetTheme(theme as any)}
/>
);
case 'terminal':
return <TerminalSection />;
case 'keyboard':
return (
<KeyboardShortcutsSection onOpenKeyboardMap={() => setShowKeyboardMapDialog(true)} />
);
case 'audio':
return (
<AudioSection muteDoneSound={muteDoneSound} onMuteDoneSoundChange={setMuteDoneSound} />
);
case 'defaults':
return (
<FeatureDefaultsSection
showProfilesOnly={showProfilesOnly}
defaultSkipTests={defaultSkipTests}
enableDependencyBlocking={enableDependencyBlocking}
skipVerificationInAutoMode={skipVerificationInAutoMode}
useWorktrees={useWorktrees}
defaultPlanningMode={defaultPlanningMode}
defaultRequirePlanApproval={defaultRequirePlanApproval}
defaultAIProfileId={defaultAIProfileId}
aiProfiles={aiProfiles}
onShowProfilesOnlyChange={setShowProfilesOnly}
onDefaultSkipTestsChange={setDefaultSkipTests}
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
onUseWorktreesChange={setUseWorktrees}
onDefaultPlanningModeChange={setDefaultPlanningMode}
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
onDefaultAIProfileIdChange={setDefaultAIProfileId}
/>
);
case 'account':
return <AccountSection />;
case 'security':
return (
<SecuritySection
skipSandboxWarning={skipSandboxWarning}
onSkipSandboxWarningChange={setSkipSandboxWarning}
/>
);
case 'danger':
return (
<DangerZoneSection
project={settingsProject}
onDeleteClick={() => setShowDeleteDialog(true)}
/>
);
default:
return <ApiKeysSection />;
}
};
return (
<>
{/* Content Area with Sidebar */}
<div className="flex-1 flex overflow-hidden min-h-0">
{/* Side Navigation - No longer scrolls, just switches views */}
<SettingsNavigation
navItems={NAV_ITEMS}
activeSection={activeView}
currentProject={currentProject}
onNavigate={handleNavigate}
compact={compact}
/>
{/* Content Panel - Shows only the active section */}
<div className={`flex-1 overflow-y-auto ${compact ? 'p-4' : 'p-8'}`}>
<div className={compact ? 'max-w-3xl mx-auto' : 'max-w-4xl mx-auto'}>
{renderActiveSection()}
</div>
</div>
</div>
{/* Keyboard Map Dialog */}
<KeyboardMapDialog open={showKeyboardMapDialog} onOpenChange={setShowKeyboardMapDialog} />
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
project={currentProject}
onConfirm={moveProjectToTrash}
/>
</>
);
}

View File

@@ -1,7 +1,9 @@
import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
import { useEffect, useState, useCallback, useDeferredValue, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { Sidebar } from '@/components/layout/sidebar';
import { TopBar } from '@/components/layout/top-bar';
import { BottomDock, useDockState } from '@/components/layout/bottom-dock';
import { CommandPalette } from '@/components/command-palette';
import {
FileBrowserProvider,
useFileBrowser,
@@ -73,9 +75,11 @@ function RootLayoutContent() {
const navigate = useNavigate();
const [isMounted, setIsMounted] = useState(false);
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const authChecked = useAuthStore((s) => s.authChecked);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const { openFileBrowser } = useFileBrowser();
const { position: dockPosition } = useDockState();
// Load project settings when switching projects
useProjectSettingsLoader();
@@ -83,6 +87,7 @@ function RootLayoutContent() {
const isSetupRoute = location.pathname === '/setup';
const isLoginRoute = location.pathname === '/login';
const isLoggedOutRoute = location.pathname === '/logged-out';
const isDashboardRoute = location.pathname === '/dashboard';
// Sandbox environment check state
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
@@ -128,6 +133,21 @@ function RootLayoutContent() {
};
}, [handleStreamerPanelShortcut]);
// Command palette keyboard shortcut (Cmd/Ctrl + K)
useEffect(() => {
const handleCommandPalette = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
event.preventDefault();
setCommandPaletteOpen((prev) => !prev);
}
};
window.addEventListener('keydown', handleCommandPalette);
return () => {
window.removeEventListener('keydown', handleCommandPalette);
};
}, []);
const effectiveTheme = getEffectiveTheme();
// Defer the theme value to keep UI responsive during rapid hover changes
const deferredTheme = useDeferredValue(effectiveTheme);
@@ -264,13 +284,36 @@ function RootLayoutContent() {
// Initialize API key for Electron mode
await initApiKey();
// 1. Verify session (Single Request, ALL modes)
// 1. Verify session with retry logic for server restarts
// verifySession() returns false for definitive auth failures (401/403)
// and throws for network/timeout errors (server might be restarting)
let isValid = false;
try {
isValid = await verifySession();
} catch (error) {
logger.warn('Session verification failed (likely network/server issue):', error);
isValid = false;
const maxSessionAttempts = 8;
const baseSessionDelayMs = 500;
for (let attempt = 1; attempt <= maxSessionAttempts; attempt++) {
try {
isValid = await verifySession();
// If we get here, verifySession completed (returned true or false)
// false = definitive auth failure (401/403), no point retrying
break;
} catch (error) {
// Network/timeout error - server might be restarting
if (attempt === maxSessionAttempts) {
logger.warn(
`Session verification failed after ${maxSessionAttempts} attempts (server unavailable):`,
error
);
isValid = false;
break;
}
const delayMs = Math.min(2000, baseSessionDelayMs * attempt);
logger.info(
`Session verification failed (attempt ${attempt}/${maxSessionAttempts}), server may be restarting. Retrying in ${delayMs}ms...`
);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
if (isValid) {
@@ -424,10 +467,14 @@ function RootLayoutContent() {
testConnection();
}, [setIpcConnected]);
// Restore to board view if a project was previously open
// Navigate to appropriate view based on project state
useEffect(() => {
if (isMounted && currentProject && location.pathname === '/') {
navigate({ to: '/board' });
if (isMounted && location.pathname === '/') {
if (currentProject) {
navigate({ to: '/board' });
} else {
navigate({ to: '/dashboard' });
}
}
}, [isMounted, currentProject, location.pathname, navigate]);
@@ -501,7 +548,7 @@ function RootLayoutContent() {
return (
<>
<main className="flex h-screen overflow-hidden" data-testid="app-container">
<main className="flex flex-col h-screen overflow-hidden" data-testid="app-container">
{/* Full-width titlebar drag region for Electron window dragging */}
{isElectron() && (
<div
@@ -509,13 +556,21 @@ function RootLayoutContent() {
aria-hidden="true"
/>
)}
<Sidebar />
{!isDashboardRoute && <TopBar />}
<div
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}
className="flex-1 flex overflow-hidden transition-all duration-300"
style={{
marginRight: streamerPanelOpen ? '250px' : '0',
marginLeft:
!isDashboardRoute && currentProject && dockPosition === 'left' ? '40px' : '0',
}}
>
<Outlet />
<div className="flex-1 flex flex-col overflow-hidden">
<Outlet />
</div>
</div>
{/* Single BottomDock instance - handles its own positioning */}
{!isDashboardRoute && currentProject && <BottomDock />}
{/* Hidden streamer panel - opens with "\" key, pushes content */}
<div
@@ -530,6 +585,7 @@ function RootLayoutContent() {
onConfirm={handleSandboxConfirm}
onDeny={handleSandboxDeny}
/>
<CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} />
</>
);
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { DashboardView } from '@/components/views/dashboard-view';
export const Route = createFileRoute('/dashboard')({
component: DashboardView,
});

View File

@@ -467,6 +467,21 @@ export interface PersistedTerminalSettings {
maxSessions: number;
}
// GitHub cache types - matching the electron API types
export interface GitHubCacheIssue {
number: number;
title: string;
url: string;
author?: { login: string };
}
export interface GitHubCachePR {
number: number;
title: string;
url: string;
author?: { login: string };
}
export interface AppState {
// Project state
projects: Project[];
@@ -474,6 +489,7 @@ export interface AppState {
trashedProjects: TrashedProject[];
projectHistory: string[]; // Array of project IDs in MRU order (most recent first)
projectHistoryIndex: number; // Current position in project history for cycling
pinnedProjectIds: string[]; // Array of project IDs that are pinned to the top bar
// View state
currentView: ViewMode;
@@ -516,6 +532,7 @@ export interface AppState {
// Kanban Card Display Settings
kanbanCardDetailLevel: KanbanCardDetailLevel; // Level of detail shown on kanban cards
boardViewMode: BoardViewMode; // Whether to show kanban or dependency graph view
boardSearchQuery: string; // Search query for filtering kanban cards
// Feature Default Settings
defaultSkipTests: boolean; // Default value for skip tests when creating new features
@@ -662,6 +679,17 @@ export interface AppState {
lastProjectDir: string;
/** Recently accessed folders for quick access */
recentFolders: string[];
// GitHub Cache (per-project, keyed by project path)
gitHubCacheByProject: Record<
string,
{
issues: GitHubCacheIssue[];
prs: GitHubCachePR[];
lastFetched: number | null; // timestamp in ms
isFetching: boolean;
}
>;
}
// Claude Usage interface matching the server response
@@ -822,6 +850,8 @@ export interface AppActions {
cyclePrevProject: () => void; // Cycle back through project history (Q)
cycleNextProject: () => void; // Cycle forward through project history (E)
clearProjectHistory: () => void; // Clear history, keeping only current project
pinProject: (projectId: string) => void; // Pin a project to the top bar
unpinProject: (projectId: string) => void; // Unpin a project from the top bar
// View actions
setCurrentView: (view: ViewMode) => void;
@@ -877,6 +907,7 @@ export interface AppActions {
// Kanban Card Settings actions
setKanbanCardDetailLevel: (level: KanbanCardDetailLevel) => void;
setBoardViewMode: (mode: BoardViewMode) => void;
setBoardSearchQuery: (query: string) => void;
// Feature Default Settings actions
setDefaultSkipTests: (skip: boolean) => void;
@@ -1093,6 +1124,19 @@ export interface AppActions {
// Codex Usage Tracking actions
setCodexUsage: (usage: CodexUsage | null) => void;
// GitHub Cache actions
getGitHubCache: (projectPath: string) => {
issues: GitHubCacheIssue[];
prs: GitHubCachePR[];
lastFetched: number | null;
isFetching: boolean;
} | null;
setGitHubCache: (
projectPath: string,
data: { issues: GitHubCacheIssue[]; prs: GitHubCachePR[] }
) => void;
setGitHubCacheFetching: (projectPath: string, isFetching: boolean) => void;
// Reset
reset: () => void;
}
@@ -1149,6 +1193,7 @@ const initialState: AppState = {
trashedProjects: [],
projectHistory: [],
projectHistoryIndex: -1,
pinnedProjectIds: [],
currentView: 'welcome',
sidebarOpen: true,
lastSelectedSessionByProject: {},
@@ -1169,6 +1214,7 @@ const initialState: AppState = {
maxConcurrency: 3, // Default to 3 concurrent agents
kanbanCardDetailLevel: 'standard', // Default to standard detail level
boardViewMode: 'kanban', // Default to kanban view
boardSearchQuery: '', // Default to empty search
defaultSkipTests: true, // Default to manual verification (tests disabled)
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
skipVerificationInAutoMode: false, // Default to disabled (require dependencies to be verified)
@@ -1238,6 +1284,8 @@ const initialState: AppState = {
worktreePanelCollapsed: false,
lastProjectDir: '',
recentFolders: [],
// GitHub Cache
gitHubCacheByProject: {},
};
export const useAppStore = create<AppState & AppActions>()((set, get) => ({
@@ -1477,6 +1525,19 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
}
},
pinProject: (projectId) => {
const { pinnedProjectIds, projects } = get();
// Only pin if project exists and not already pinned
if (projects.some((p) => p.id === projectId) && !pinnedProjectIds.includes(projectId)) {
set({ pinnedProjectIds: [...pinnedProjectIds, projectId] });
}
},
unpinProject: (projectId) => {
const { pinnedProjectIds } = get();
set({ pinnedProjectIds: pinnedProjectIds.filter((id) => id !== projectId) });
},
// View actions
setCurrentView: (view) => set({ currentView: view }),
toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }),
@@ -1754,6 +1815,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
// Kanban Card Settings actions
setKanbanCardDetailLevel: (level) => set({ kanbanCardDetailLevel: level }),
setBoardViewMode: (mode) => set({ boardViewMode: mode }),
setBoardSearchQuery: (query) => set({ boardSearchQuery: query }),
// Feature Default Settings actions
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
@@ -3016,6 +3078,43 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
codexUsageLastUpdated: usage ? Date.now() : null,
}),
// GitHub Cache actions
getGitHubCache: (projectPath: string) => {
return get().gitHubCacheByProject[projectPath] || null;
},
setGitHubCache: (
projectPath: string,
data: { issues: GitHubCacheIssue[]; prs: GitHubCachePR[] }
) => {
set({
gitHubCacheByProject: {
...get().gitHubCacheByProject,
[projectPath]: {
issues: data.issues,
prs: data.prs,
lastFetched: Date.now(),
isFetching: false,
},
},
});
},
setGitHubCacheFetching: (projectPath: string, isFetching: boolean) => {
const existing = get().gitHubCacheByProject[projectPath];
set({
gitHubCacheByProject: {
...get().gitHubCacheByProject,
[projectPath]: {
issues: existing?.issues || [],
prs: existing?.prs || [],
lastFetched: existing?.lastFetched || null,
isFetching,
},
},
});
},
// Pipeline actions
setPipelineConfig: (projectPath, config) => {
set({

44
package-lock.json generated
View File

@@ -13,7 +13,9 @@
"libs/*"
],
"dependencies": {
"@radix-ui/react-context-menu": "^2.2.16",
"cross-spawn": "7.0.6",
"date-fns": "^4.1.0",
"rehype-sanitize": "6.0.0",
"tree-kill": "1.2.2"
},
@@ -29,7 +31,7 @@
},
"apps/server": {
"name": "@automaker/server",
"version": "0.8.0",
"version": "0.9.0",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.1.76",
@@ -80,7 +82,7 @@
},
"apps/ui": {
"name": "@automaker/ui",
"version": "0.8.0",
"version": "0.9.0",
"hasInstallScript": true,
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
@@ -4207,6 +4209,34 @@
}
}
},
"node_modules/@radix-ui/react-context-menu": {
"version": "2.2.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz",
"integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-menu": "2.1.16",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
@@ -8720,6 +8750,16 @@
"lodash": "^4.17.15"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",

View File

@@ -61,7 +61,9 @@
]
},
"dependencies": {
"@radix-ui/react-context-menu": "^2.2.16",
"cross-spawn": "7.0.6",
"date-fns": "^4.1.0",
"rehype-sanitize": "6.0.0",
"tree-kill": "1.2.2"
},