mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
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.
This commit is contained in:
163
apps/ui/src/components/command-palette/command-palette.tsx
Normal file
163
apps/ui/src/components/command-palette/command-palette.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
apps/ui/src/components/command-palette/index.ts
Normal file
1
apps/ui/src/components/command-palette/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CommandPalette } from './command-palette';
|
||||
@@ -0,0 +1 @@
|
||||
export { OnboardingWizard } from './onboarding-wizard';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
1
apps/ui/src/components/dialogs/settings-dialog/index.ts
Normal file
1
apps/ui/src/components/dialogs/settings-dialog/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SettingsDialog } from './settings-dialog';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
564
apps/ui/src/components/layout/bottom-dock/bottom-dock.tsx
Normal file
564
apps/ui/src/components/layout/bottom-dock/bottom-dock.tsx
Normal file
@@ -0,0 +1,564 @@
|
||||
import { useState, useCallback, useSyncExternalStore } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
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 position changes
|
||||
const positionListeners = new Set<() => void>();
|
||||
|
||||
function emitPositionChange() {
|
||||
positionListeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
// Cached position to avoid creating new objects on every read
|
||||
let cachedPosition: DockPosition = 'bottom';
|
||||
|
||||
// Initialize from localStorage
|
||||
try {
|
||||
const stored = localStorage.getItem(DOCK_POSITION_STORAGE_KEY) as DockPosition | null;
|
||||
if (stored && ['bottom', 'right', 'left'].includes(stored)) {
|
||||
cachedPosition = stored;
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
|
||||
function getPosition(): DockPosition {
|
||||
return cachedPosition;
|
||||
}
|
||||
|
||||
function updatePosition(position: DockPosition) {
|
||||
if (cachedPosition !== position) {
|
||||
cachedPosition = position;
|
||||
try {
|
||||
localStorage.setItem(DOCK_POSITION_STORAGE_KEY, position);
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
emitPositionChange();
|
||||
}
|
||||
}
|
||||
|
||||
// Hook for external components to read dock position
|
||||
export function useDockState(): { position: DockPosition } {
|
||||
const position = useSyncExternalStore(
|
||||
(callback) => {
|
||||
positionListeners.add(callback);
|
||||
return () => positionListeners.delete(callback);
|
||||
},
|
||||
getPosition,
|
||||
getPosition
|
||||
);
|
||||
return { position };
|
||||
}
|
||||
|
||||
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) => {
|
||||
positionListeners.add(callback);
|
||||
return () => positionListeners.delete(callback);
|
||||
},
|
||||
getPosition,
|
||||
getPosition
|
||||
);
|
||||
|
||||
const autoModeState = currentProject ? getAutoModeState(currentProject.id) : null;
|
||||
const runningAgentsCount = autoModeState?.runningTasks?.length ?? 0;
|
||||
|
||||
const handleTabClick = useCallback(
|
||||
(tab: DockTab) => {
|
||||
if (activeTab === tab) {
|
||||
setIsExpanded(!isExpanded);
|
||||
} else {
|
||||
setActiveTab(tab);
|
||||
setIsExpanded(true);
|
||||
}
|
||||
},
|
||||
[activeTab, isExpanded]
|
||||
);
|
||||
|
||||
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
|
||||
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
|
||||
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>
|
||||
);
|
||||
}
|
||||
2
apps/ui/src/components/layout/bottom-dock/index.ts
Normal file
2
apps/ui/src/components/layout/bottom-dock/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { BottomDock, useDockState } from './bottom-dock';
|
||||
export type { DockPosition } from './bottom-dock';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
697
apps/ui/src/components/layout/bottom-dock/panels/chat-panel.tsx
Normal file
697
apps/ui/src/components/layout/bottom-dock/panels/chat-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { FolderOpen, FileText, Image, Loader2, Upload, FilePlus } 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';
|
||||
|
||||
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 [isDropHovering, setIsDropHovering] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [generatingDescriptions, setGeneratingDescriptions] = useState<Set<string>>(new Set());
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 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 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) => {
|
||||
if (file.type === 'image') {
|
||||
setSelectedFile(file);
|
||||
setFileContent('');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.readFile(file.path);
|
||||
if (result.success && result.content) {
|
||||
setSelectedFile(file);
|
||||
setFileContent(result.content);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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 = '';
|
||||
}
|
||||
};
|
||||
|
||||
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 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>
|
||||
)}
|
||||
|
||||
{/* File List */}
|
||||
<div className="w-48 border-r border-border/50 flex flex-col">
|
||||
<div className="flex items-center justify-between px-2 py-2 border-b border-border/50">
|
||||
<span className="text-xs font-medium">Files</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={handleImportClick}
|
||||
disabled={isUploading}
|
||||
title="Import file"
|
||||
>
|
||||
<FilePlus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="p-1 space-y-0.5">
|
||||
{files.length === 0 ? (
|
||||
<div className="text-center py-4 px-2">
|
||||
<Upload className="h-6 w-6 mx-auto text-muted-foreground/50 mb-1" />
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
No context files.
|
||||
<br />
|
||||
Drop files here or click +
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
files.map((file) => {
|
||||
const isGenerating = generatingDescriptions.has(file.name);
|
||||
return (
|
||||
<button
|
||||
key={file.name}
|
||||
onClick={() => handleSelectFile(file)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-1.5 px-2 py-1.5 rounded text-left',
|
||||
'text-xs transition-colors',
|
||||
selectedFile?.name === file.name
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
{file.type === 'image' ? (
|
||||
<Image className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<FileText className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="truncate block">{file.name}</span>
|
||||
{isGenerating && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<Loader2 className="h-2 w-2 animate-spin" />
|
||||
Generating...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Preview */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
|
||||
<span className="text-xs font-medium">{selectedFile?.name || 'Select a file'}</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="p-3">
|
||||
{selectedFile ? (
|
||||
selectedFile.type === 'image' ? (
|
||||
<div className="text-center">
|
||||
<Image className="h-12 w-12 mx-auto text-muted-foreground/50 mb-2" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Image preview not available in panel
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap text-muted-foreground">
|
||||
{fileContent || 'No content'}
|
||||
</pre>
|
||||
)
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<FolderOpen className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
|
||||
<p className="text-xs text-muted-foreground">Select a file to preview</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">Or drop files to add them</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { CircleDot, GitPullRequest, RefreshCw, ExternalLink, Loader2 } from 'lucide-react';
|
||||
import { getElectronAPI, GitHubIssue, GitHubPR } from '@/lib/electron';
|
||||
import { useAppStore, GitHubCacheIssue, GitHubCachePR } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type GitHubTab = 'issues' | 'prs';
|
||||
|
||||
// Cache duration: 5 minutes
|
||||
const CACHE_DURATION_MS = 5 * 60 * 1000;
|
||||
|
||||
export function GitHubPanel() {
|
||||
const { currentProject, getGitHubCache, setGitHubCache, setGitHubCacheFetching } = useAppStore();
|
||||
const [activeTab, setActiveTab] = useState<GitHubTab>('issues');
|
||||
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;
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
// 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) => (
|
||||
<div
|
||||
key={issue.number}
|
||||
className="flex items-start gap-2 p-2 rounded-md hover:bg-accent/50 cursor-pointer group"
|
||||
onClick={() => handleOpenInGitHub(issue.url)}
|
||||
>
|
||||
<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>
|
||||
<ExternalLink className="h-3 w-3 opacity-0 group-hover:opacity-100 text-muted-foreground" />
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
123
apps/ui/src/components/layout/bottom-dock/panels/spec-panel.tsx
Normal file
123
apps/ui/src/components/layout/bottom-dock/panels/spec-panel.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { FileText, Loader2, Save } 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 { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function SpecPanel() {
|
||||
const { currentProject } = useAppStore();
|
||||
const [specContent, setSpecContent] = useState('');
|
||||
const [originalContent, setOriginalContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const hasChanges = specContent !== originalContent;
|
||||
|
||||
const loadSpec = useCallback(async () => {
|
||||
if (!currentProject?.path) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api.spec?.read) {
|
||||
const result = await api.spec.read(currentProject.path);
|
||||
if (result.success && result.content !== undefined) {
|
||||
setSpecContent(result.content);
|
||||
setOriginalContent(result.content);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading spec:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentProject?.path]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSpec();
|
||||
}, [loadSpec]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!currentProject?.path || !hasChanges) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api.spec?.write) {
|
||||
const result = await api.spec.write(currentProject.path, specContent);
|
||||
if (result.success) {
|
||||
setOriginalContent(specContent);
|
||||
toast.success('Spec saved');
|
||||
} else {
|
||||
toast.error('Failed to save spec');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to save spec');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [currentProject?.path, specContent, hasChanges]);
|
||||
|
||||
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">
|
||||
<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 changes</span>}
|
||||
</div>
|
||||
{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>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 p-2 overflow-hidden">
|
||||
{specContent ? (
|
||||
<Textarea
|
||||
value={specContent}
|
||||
onChange={(e) => setSpecContent(e.target.value)}
|
||||
className={cn(
|
||||
'h-full w-full resize-none font-mono text-xs',
|
||||
'bg-muted/30 border-0 focus-visible:ring-1'
|
||||
)}
|
||||
placeholder="Enter your app specification..."
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<FileText className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
|
||||
<p className="text-xs text-muted-foreground">No spec file found</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
4
apps/ui/src/components/layout/top-bar/index.ts
Normal file
4
apps/ui/src/components/layout/top-bar/index.ts
Normal 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';
|
||||
128
apps/ui/src/components/layout/top-bar/pinned-projects.tsx
Normal file
128
apps/ui/src/components/layout/top-bar/pinned-projects.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
202
apps/ui/src/components/layout/top-bar/project-switcher.tsx
Normal file
202
apps/ui/src/components/layout/top-bar/project-switcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
389
apps/ui/src/components/layout/top-bar/top-bar-actions.tsx
Normal file
389
apps/ui/src/components/layout/top-bar/top-bar-actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
apps/ui/src/components/layout/top-bar/top-bar.tsx
Normal file
157
apps/ui/src/components/layout/top-bar/top-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
apps/ui/src/components/ui/context-menu.tsx
Normal file
186
apps/ui/src/components/ui/context-menu.tsx
Normal 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,
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ 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';
|
||||
@@ -50,6 +50,8 @@ interface KanbanBoardProps {
|
||||
selectedFeatureIds?: Set<string>;
|
||||
onToggleFeatureSelection?: (featureId: string) => void;
|
||||
onToggleSelectionMode?: () => void;
|
||||
// Add feature action
|
||||
onAddFeature?: () => void;
|
||||
}
|
||||
|
||||
export function KanbanBoard({
|
||||
@@ -84,6 +86,7 @@ export function KanbanBoard({
|
||||
selectedFeatureIds = new Set(),
|
||||
onToggleFeatureSelection,
|
||||
onToggleSelectionMode,
|
||||
onAddFeature,
|
||||
}: KanbanBoardProps) {
|
||||
// Generate columns including pipeline steps
|
||||
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
|
||||
@@ -100,7 +103,7 @@ export function KanbanBoard({
|
||||
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 +130,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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
320
apps/ui/src/components/views/dashboard-view/dashboard-view.tsx
Normal file
320
apps/ui/src/components/views/dashboard-view/dashboard-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
apps/ui/src/components/views/dashboard-view/empty-state.tsx
Normal file
131
apps/ui/src/components/views/dashboard-view/empty-state.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
apps/ui/src/components/views/dashboard-view/index.ts
Normal file
3
apps/ui/src/components/views/dashboard-view/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { DashboardView } from './dashboard-view';
|
||||
export { ProjectCard } from './project-card';
|
||||
export { EmptyState } from './empty-state';
|
||||
129
apps/ui/src/components/views/dashboard-view/project-card.tsx
Normal file
129
apps/ui/src/components/views/dashboard-view/project-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
|
||||
229
apps/ui/src/components/views/settings-view/settings-content.tsx
Normal file
229
apps/ui/src/components/views/settings-view/settings-content.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -424,10 +444,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 +525,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 +533,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 +562,7 @@ function RootLayoutContent() {
|
||||
onConfirm={handleSandboxConfirm}
|
||||
onDeny={handleSandboxDeny}
|
||||
/>
|
||||
<CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
6
apps/ui/src/routes/dashboard.tsx
Normal file
6
apps/ui/src/routes/dashboard.tsx
Normal 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,
|
||||
});
|
||||
@@ -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
44
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user