mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
Compare commits
4 Commits
memory-ui
...
feature/ui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c19bb60d1 | ||
|
|
a2ccf200a9 | ||
|
|
5543b46f64 | ||
|
|
d9299b4680 |
@@ -32,7 +32,7 @@
|
|||||||
"@automaker/prompts": "1.0.0",
|
"@automaker/prompts": "1.0.0",
|
||||||
"@automaker/types": "1.0.0",
|
"@automaker/types": "1.0.0",
|
||||||
"@automaker/utils": "1.0.0",
|
"@automaker/utils": "1.0.0",
|
||||||
"@modelcontextprotocol/sdk": "1.25.2",
|
"@modelcontextprotocol/sdk": "1.25.1",
|
||||||
"@openai/codex-sdk": "^0.77.0",
|
"@openai/codex-sdk": "^0.77.0",
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
655
apps/ui/src/components/layout/bottom-dock/bottom-dock.tsx
Normal file
655
apps/ui/src/components/layout/bottom-dock/bottom-dock.tsx
Normal file
@@ -0,0 +1,655 @@
|
|||||||
|
import { useState, useCallback, useSyncExternalStore, useRef, useEffect } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Terminal,
|
||||||
|
Bot,
|
||||||
|
FileText,
|
||||||
|
FolderOpen,
|
||||||
|
Github,
|
||||||
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
MessageSquare,
|
||||||
|
Sparkles,
|
||||||
|
PanelBottom,
|
||||||
|
PanelRight,
|
||||||
|
PanelLeft,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
GitHubPanel,
|
||||||
|
AgentsPanel,
|
||||||
|
SpecPanel,
|
||||||
|
ContextPanel,
|
||||||
|
TerminalPanelDock,
|
||||||
|
ChatPanel,
|
||||||
|
IdeationPanel,
|
||||||
|
} from './panels';
|
||||||
|
|
||||||
|
type DockTab = 'terminal' | 'agents' | 'spec' | 'context' | 'github' | 'chat' | 'ideation';
|
||||||
|
export type DockPosition = 'bottom' | 'right' | 'left';
|
||||||
|
|
||||||
|
const DOCK_POSITION_STORAGE_KEY = 'automaker:dock-position';
|
||||||
|
|
||||||
|
// Event emitter for dock state changes
|
||||||
|
const stateListeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function emitStateChange() {
|
||||||
|
stateListeners.forEach((listener) => listener());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cached dock state
|
||||||
|
interface DockState {
|
||||||
|
position: DockPosition;
|
||||||
|
isExpanded: boolean;
|
||||||
|
isMaximized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedState: DockState = {
|
||||||
|
position: 'bottom',
|
||||||
|
isExpanded: false,
|
||||||
|
isMaximized: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize position from localStorage
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(DOCK_POSITION_STORAGE_KEY) as DockPosition | null;
|
||||||
|
if (stored && ['bottom', 'right', 'left'].includes(stored)) {
|
||||||
|
cachedState.position = stored;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage errors
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDockState(): DockState {
|
||||||
|
return cachedState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePosition(position: DockPosition) {
|
||||||
|
if (cachedState.position !== position) {
|
||||||
|
cachedState = { ...cachedState, position };
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DOCK_POSITION_STORAGE_KEY, position);
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage errors
|
||||||
|
}
|
||||||
|
emitStateChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExpanded(isExpanded: boolean) {
|
||||||
|
if (cachedState.isExpanded !== isExpanded) {
|
||||||
|
cachedState = { ...cachedState, isExpanded };
|
||||||
|
emitStateChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMaximized(isMaximized: boolean) {
|
||||||
|
if (cachedState.isMaximized !== isMaximized) {
|
||||||
|
cachedState = { ...cachedState, isMaximized };
|
||||||
|
emitStateChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook for external components to read dock state
|
||||||
|
export function useDockState(): DockState {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(callback) => {
|
||||||
|
stateListeners.add(callback);
|
||||||
|
return () => stateListeners.delete(callback);
|
||||||
|
},
|
||||||
|
getDockState,
|
||||||
|
getDockState
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BottomDockProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BottomDock({ className }: BottomDockProps) {
|
||||||
|
const { currentProject, getAutoModeState } = useAppStore();
|
||||||
|
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<DockTab | null>(null);
|
||||||
|
const [isMaximized, setIsMaximized] = useState(false);
|
||||||
|
|
||||||
|
// Use external store for position - single source of truth
|
||||||
|
const position = useSyncExternalStore(
|
||||||
|
(callback) => {
|
||||||
|
stateListeners.add(callback);
|
||||||
|
return () => stateListeners.delete(callback);
|
||||||
|
},
|
||||||
|
() => getDockState().position,
|
||||||
|
() => getDockState().position
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync local expanded/maximized state to external store for other components
|
||||||
|
useEffect(() => {
|
||||||
|
updateExpanded(isExpanded);
|
||||||
|
}, [isExpanded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateMaximized(isMaximized);
|
||||||
|
}, [isMaximized]);
|
||||||
|
|
||||||
|
const autoModeState = currentProject ? getAutoModeState(currentProject.id) : null;
|
||||||
|
const runningAgentsCount = autoModeState?.runningTasks?.length ?? 0;
|
||||||
|
|
||||||
|
// Ref for click-outside detection
|
||||||
|
const dockRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Handle click outside to close the panel
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isExpanded) return;
|
||||||
|
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dockRef.current && !dockRef.current.contains(event.target as Node)) {
|
||||||
|
setIsExpanded(false);
|
||||||
|
setIsMaximized(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use mousedown for more responsive feel
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [isExpanded]);
|
||||||
|
|
||||||
|
const handleTabClick = useCallback(
|
||||||
|
(tab: DockTab) => {
|
||||||
|
if (activeTab === tab) {
|
||||||
|
setIsExpanded(!isExpanded);
|
||||||
|
} else {
|
||||||
|
setActiveTab(tab);
|
||||||
|
setIsExpanded(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeTab, isExpanded]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get keyboard shortcuts from config
|
||||||
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
|
|
||||||
|
// Register keyboard shortcuts for dock tabs
|
||||||
|
useKeyboardShortcuts([
|
||||||
|
{
|
||||||
|
key: shortcuts.terminal,
|
||||||
|
action: () => handleTabClick('terminal'),
|
||||||
|
description: 'Toggle Terminal panel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: shortcuts.ideation,
|
||||||
|
action: () => handleTabClick('ideation'),
|
||||||
|
description: 'Toggle Ideation panel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: shortcuts.spec,
|
||||||
|
action: () => handleTabClick('spec'),
|
||||||
|
description: 'Toggle Spec panel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: shortcuts.context,
|
||||||
|
action: () => handleTabClick('context'),
|
||||||
|
description: 'Toggle Context panel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: shortcuts.githubIssues,
|
||||||
|
action: () => handleTabClick('github'),
|
||||||
|
description: 'Toggle GitHub panel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: shortcuts.agent,
|
||||||
|
action: () => handleTabClick('agents'),
|
||||||
|
description: 'Toggle Agents panel',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDoubleClick = useCallback(() => {
|
||||||
|
if (isExpanded) {
|
||||||
|
setIsMaximized(!isMaximized);
|
||||||
|
} else {
|
||||||
|
setIsExpanded(true);
|
||||||
|
if (!activeTab) {
|
||||||
|
setActiveTab('terminal');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isExpanded, isMaximized, activeTab]);
|
||||||
|
|
||||||
|
// All tabs combined for easier rendering
|
||||||
|
const allTabs = [
|
||||||
|
{
|
||||||
|
id: 'terminal' as DockTab,
|
||||||
|
label: 'Terminal',
|
||||||
|
icon: Terminal,
|
||||||
|
badge: null,
|
||||||
|
badgeColor: undefined,
|
||||||
|
group: 'operations',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chat' as DockTab,
|
||||||
|
label: 'Chat',
|
||||||
|
icon: MessageSquare,
|
||||||
|
badge: null,
|
||||||
|
badgeColor: undefined,
|
||||||
|
group: 'operations',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ideation' as DockTab,
|
||||||
|
label: 'Ideate',
|
||||||
|
icon: Sparkles,
|
||||||
|
badge: null,
|
||||||
|
badgeColor: undefined,
|
||||||
|
group: 'planning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'spec' as DockTab,
|
||||||
|
label: 'Spec',
|
||||||
|
icon: FileText,
|
||||||
|
badge: null,
|
||||||
|
badgeColor: undefined,
|
||||||
|
group: 'planning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'context' as DockTab,
|
||||||
|
label: 'Context',
|
||||||
|
icon: FolderOpen,
|
||||||
|
badge: null,
|
||||||
|
badgeColor: undefined,
|
||||||
|
group: 'planning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'github' as DockTab,
|
||||||
|
label: 'GitHub',
|
||||||
|
icon: Github,
|
||||||
|
badge: null,
|
||||||
|
badgeColor: undefined,
|
||||||
|
group: 'planning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'agents' as DockTab,
|
||||||
|
label: 'Agents',
|
||||||
|
icon: Bot,
|
||||||
|
badge: runningAgentsCount > 0 ? runningAgentsCount : null,
|
||||||
|
badgeColor: 'bg-green-500',
|
||||||
|
group: 'agents',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const isRightDock = position === 'right';
|
||||||
|
const isLeftDock = position === 'left';
|
||||||
|
const isSideDock = isRightDock || isLeftDock;
|
||||||
|
|
||||||
|
// Render panel content directly to avoid remounting on state changes
|
||||||
|
const renderPanelContent = () => (
|
||||||
|
<>
|
||||||
|
{activeTab === 'terminal' && <TerminalPanelDock />}
|
||||||
|
{activeTab === 'agents' && <AgentsPanel />}
|
||||||
|
{activeTab === 'spec' && <SpecPanel />}
|
||||||
|
{activeTab === 'context' && <ContextPanel />}
|
||||||
|
{activeTab === 'github' && <GitHubPanel />}
|
||||||
|
{activeTab === 'chat' && <ChatPanel />}
|
||||||
|
{activeTab === 'ideation' && <IdeationPanel />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Side dock layout (left or right)
|
||||||
|
if (isSideDock) {
|
||||||
|
const dockWidth = isMaximized ? 'w-[50vw]' : isExpanded ? 'w-96' : 'w-10';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={dockRef}
|
||||||
|
className={cn(
|
||||||
|
'bg-background/95 backdrop-blur-sm',
|
||||||
|
'transition-all duration-300 ease-in-out flex',
|
||||||
|
'fixed top-12 bottom-0 z-30',
|
||||||
|
isLeftDock ? 'left-0 border-r border-border' : 'right-0 border-l border-border',
|
||||||
|
dockWidth,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Vertical Tab Bar */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col w-10 py-2 cursor-pointer select-none shrink-0',
|
||||||
|
isLeftDock ? 'border-r border-border/50' : 'border-r border-border/50'
|
||||||
|
)}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
>
|
||||||
|
{/* Tab Icons */}
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
{allTabs.map((tab, index) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
const isActive = activeTab === tab.id && isExpanded;
|
||||||
|
const showDivider = (index === 1 || index === 5) && index < allTabs.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={tab.id}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleTabClick(tab.id);
|
||||||
|
}}
|
||||||
|
onDoubleClick={(e) => e.stopPropagation()}
|
||||||
|
className={cn(
|
||||||
|
'relative flex items-center justify-center w-7 h-7 rounded-md',
|
||||||
|
'transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||||
|
)}
|
||||||
|
title={tab.label}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{tab.badge && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute -top-1 -right-1 flex items-center justify-center h-3.5 min-w-3.5 px-0.5 rounded-full text-[9px] text-white',
|
||||||
|
tab.badgeColor || 'bg-primary'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{showDivider && <div className="w-5 h-px bg-border my-1 mx-auto" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Dock Controls */}
|
||||||
|
<div className="flex flex-col items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{/* Position buttons - show other positions (not current) */}
|
||||||
|
{position !== 'left' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => updatePosition('left')}
|
||||||
|
title="Dock to left"
|
||||||
|
>
|
||||||
|
<PanelLeft className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{position !== 'bottom' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => updatePosition('bottom')}
|
||||||
|
title="Dock to bottom"
|
||||||
|
>
|
||||||
|
<PanelBottom className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{position !== 'right' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => updatePosition('right')}
|
||||||
|
title="Dock to right"
|
||||||
|
>
|
||||||
|
<PanelRight className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => setIsMaximized(!isMaximized)}
|
||||||
|
title={isMaximized ? 'Restore' : 'Maximize'}
|
||||||
|
>
|
||||||
|
{isMaximized ? (
|
||||||
|
<Minimize2 className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Maximize2 className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => {
|
||||||
|
if (isExpanded) {
|
||||||
|
setIsMaximized(false);
|
||||||
|
}
|
||||||
|
setIsExpanded(!isExpanded);
|
||||||
|
}}
|
||||||
|
title={isExpanded ? 'Collapse' : 'Expand'}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
isLeftDock ? (
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
)
|
||||||
|
) : isLeftDock ? (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Panel Content */}
|
||||||
|
{isExpanded && <div className="flex-1 h-full overflow-hidden">{renderPanelContent()}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom dock layout - uses fixed positioning like side docks
|
||||||
|
const dockHeight = isMaximized ? 'h-[70vh]' : isExpanded ? 'h-72' : 'h-10';
|
||||||
|
|
||||||
|
// Group tabs for bottom layout
|
||||||
|
const operationsTabs = allTabs.filter((t) => t.group === 'operations');
|
||||||
|
const planningTabs = allTabs.filter((t) => t.group === 'planning');
|
||||||
|
const agentTab = allTabs.find((t) => t.group === 'agents')!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={dockRef}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-0 right-0 bottom-0 border-t border-border bg-background/95 backdrop-blur-sm z-30',
|
||||||
|
'transition-all duration-300 ease-in-out flex flex-col',
|
||||||
|
dockHeight,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Tab Bar - double click to expand/maximize */}
|
||||||
|
<div
|
||||||
|
className="flex items-center h-10 px-2 border-b border-border/50 cursor-pointer select-none shrink-0"
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* Operations tabs */}
|
||||||
|
{operationsTabs.map((tab) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
const isActive = activeTab === tab.id && isExpanded;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleTabClick(tab.id);
|
||||||
|
}}
|
||||||
|
onDoubleClick={(e) => e.stopPropagation()}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm',
|
||||||
|
'transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
{tab.badge && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center h-4 min-w-4 px-1 rounded-full text-[10px] text-white',
|
||||||
|
tab.badgeColor || 'bg-primary'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="h-5 w-px bg-border mx-1" />
|
||||||
|
|
||||||
|
{/* Planning tabs */}
|
||||||
|
{planningTabs.map((tab) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
const isActive = activeTab === tab.id && isExpanded;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleTabClick(tab.id);
|
||||||
|
}}
|
||||||
|
onDoubleClick={(e) => e.stopPropagation()}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm',
|
||||||
|
'transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
{tab.badge && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center h-4 min-w-4 px-1 rounded-full text-[10px] text-white',
|
||||||
|
tab.badgeColor || 'bg-primary'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="h-5 w-px bg-border mx-1" />
|
||||||
|
|
||||||
|
{/* Agents tab (separate section) */}
|
||||||
|
{(() => {
|
||||||
|
const Icon = agentTab.icon;
|
||||||
|
const isActive = activeTab === agentTab.id && isExpanded;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleTabClick(agentTab.id);
|
||||||
|
}}
|
||||||
|
onDoubleClick={(e) => e.stopPropagation()}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm',
|
||||||
|
'transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span>{agentTab.label}</span>
|
||||||
|
{agentTab.badge && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center h-4 min-w-4 px-1 rounded-full text-[10px] text-white',
|
||||||
|
agentTab.badgeColor || 'bg-primary'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{agentTab.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Dock Controls */}
|
||||||
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{/* Position buttons - show other positions (not current) */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => updatePosition('left')}
|
||||||
|
title="Dock to left"
|
||||||
|
>
|
||||||
|
<PanelLeft className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => updatePosition('right')}
|
||||||
|
title="Dock to right"
|
||||||
|
>
|
||||||
|
<PanelRight className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => setIsMaximized(!isMaximized)}
|
||||||
|
title={isMaximized ? 'Restore' : 'Maximize'}
|
||||||
|
>
|
||||||
|
{isMaximized ? (
|
||||||
|
<Minimize2 className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Maximize2 className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => {
|
||||||
|
if (isExpanded) {
|
||||||
|
setIsMaximized(false);
|
||||||
|
}
|
||||||
|
setIsExpanded(!isExpanded);
|
||||||
|
}}
|
||||||
|
title={isExpanded ? 'Collapse' : 'Expand'}
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Panel Content */}
|
||||||
|
{isExpanded && <div className="flex-1 overflow-hidden min-h-0">{renderPanelContent()}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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,978 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
FolderOpen,
|
||||||
|
FileText,
|
||||||
|
Image,
|
||||||
|
Loader2,
|
||||||
|
Upload,
|
||||||
|
FilePlus,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
Pencil,
|
||||||
|
Eye,
|
||||||
|
MoreVertical,
|
||||||
|
ArrowLeft,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { sanitizeFilename } from '@/lib/image-utils';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Markdown } from '@/components/ui/markdown';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
|
interface ContextFile {
|
||||||
|
name: string;
|
||||||
|
type: 'text' | 'image';
|
||||||
|
path: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContextMetadata {
|
||||||
|
files: Record<string, { description: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextPanel() {
|
||||||
|
const { currentProject } = useAppStore();
|
||||||
|
const [files, setFiles] = useState<ContextFile[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedFile, setSelectedFile] = useState<ContextFile | null>(null);
|
||||||
|
const [fileContent, setFileContent] = useState<string>('');
|
||||||
|
const [originalContent, setOriginalContent] = useState<string>('');
|
||||||
|
const [isDropHovering, setIsDropHovering] = useState(false);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
||||||
|
const [generatingDescriptions, setGeneratingDescriptions] = useState<Set<string>>(new Set());
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Dialog states
|
||||||
|
const [isCreateMarkdownOpen, setIsCreateMarkdownOpen] = useState(false);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||||
|
const [isEditDescriptionOpen, setIsEditDescriptionOpen] = useState(false);
|
||||||
|
|
||||||
|
// Dialog form values
|
||||||
|
const [newMarkdownName, setNewMarkdownName] = useState('');
|
||||||
|
const [newMarkdownDescription, setNewMarkdownDescription] = useState('');
|
||||||
|
const [newMarkdownContent, setNewMarkdownContent] = useState('');
|
||||||
|
const [renameFileName, setRenameFileName] = useState('');
|
||||||
|
const [editDescriptionValue, setEditDescriptionValue] = useState('');
|
||||||
|
const [editDescriptionFileName, setEditDescriptionFileName] = useState('');
|
||||||
|
|
||||||
|
const hasChanges = fileContent !== originalContent;
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
const isImageFile = (filename: string): boolean => {
|
||||||
|
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp'];
|
||||||
|
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||||
|
return imageExtensions.includes(ext);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMarkdownFile = (filename: string): boolean => {
|
||||||
|
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||||
|
return ext === '.md' || ext === '.markdown';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContextPath = useCallback(() => {
|
||||||
|
if (!currentProject) return null;
|
||||||
|
return `${currentProject.path}/.automaker/context`;
|
||||||
|
}, [currentProject]);
|
||||||
|
|
||||||
|
// Load context metadata
|
||||||
|
const loadMetadata = useCallback(async (): Promise<ContextMetadata> => {
|
||||||
|
const contextPath = getContextPath();
|
||||||
|
if (!contextPath) return { files: {} };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const metadataPath = `${contextPath}/context-metadata.json`;
|
||||||
|
const result = await api.readFile(metadataPath);
|
||||||
|
if (result.success && result.content) {
|
||||||
|
return JSON.parse(result.content);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Metadata file doesn't exist yet
|
||||||
|
}
|
||||||
|
return { files: {} };
|
||||||
|
}, [getContextPath]);
|
||||||
|
|
||||||
|
// Save context metadata
|
||||||
|
const saveMetadata = useCallback(
|
||||||
|
async (metadata: ContextMetadata) => {
|
||||||
|
const contextPath = getContextPath();
|
||||||
|
if (!contextPath) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const metadataPath = `${contextPath}/context-metadata.json`;
|
||||||
|
await api.writeFile(metadataPath, JSON.stringify(metadata, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save metadata:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getContextPath]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadContextFiles = useCallback(async () => {
|
||||||
|
const contextPath = getContextPath();
|
||||||
|
if (!contextPath) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
|
||||||
|
// Ensure context directory exists
|
||||||
|
await api.mkdir(contextPath);
|
||||||
|
|
||||||
|
// Load metadata for descriptions
|
||||||
|
const metadata = await loadMetadata();
|
||||||
|
|
||||||
|
// Read directory contents
|
||||||
|
const result = await api.readdir(contextPath);
|
||||||
|
if (result.success && result.entries) {
|
||||||
|
const contextFiles: ContextFile[] = result.entries
|
||||||
|
.filter((entry) => entry.isFile && entry.name !== 'context-metadata.json')
|
||||||
|
.map((entry) => ({
|
||||||
|
name: entry.name,
|
||||||
|
type: isImageFile(entry.name) ? 'image' : 'text',
|
||||||
|
path: `${contextPath}/${entry.name}`,
|
||||||
|
description: metadata.files[entry.name]?.description,
|
||||||
|
}));
|
||||||
|
setFiles(contextFiles);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading context files:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [getContextPath, loadMetadata]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadContextFiles();
|
||||||
|
}, [loadContextFiles]);
|
||||||
|
|
||||||
|
const handleSelectFile = useCallback(async (file: ContextFile) => {
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.readFile(file.path);
|
||||||
|
if (result.success && result.content !== undefined) {
|
||||||
|
setSelectedFile(file);
|
||||||
|
setFileContent(result.content);
|
||||||
|
setOriginalContent(result.content);
|
||||||
|
setIsPreviewMode(isMarkdownFile(file.name));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading file:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save file content
|
||||||
|
const handleSaveFile = useCallback(async () => {
|
||||||
|
if (!selectedFile || !hasChanges) return;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
await api.writeFile(selectedFile.path, fileContent);
|
||||||
|
setOriginalContent(fileContent);
|
||||||
|
toast.success('File saved');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save file:', error);
|
||||||
|
toast.error('Failed to save file');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [selectedFile, fileContent, hasChanges]);
|
||||||
|
|
||||||
|
// Generate description for a file
|
||||||
|
const generateDescription = async (
|
||||||
|
filePath: string,
|
||||||
|
fileName: string,
|
||||||
|
isImage: boolean
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
try {
|
||||||
|
const httpClient = getHttpApiClient();
|
||||||
|
const result = isImage
|
||||||
|
? await httpClient.context.describeImage(filePath)
|
||||||
|
: await httpClient.context.describeFile(filePath);
|
||||||
|
|
||||||
|
if (result.success && result.description) {
|
||||||
|
return result.description;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate description:', error);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate description in background and update metadata
|
||||||
|
const generateDescriptionAsync = useCallback(
|
||||||
|
async (filePath: string, fileName: string, isImage: boolean) => {
|
||||||
|
setGeneratingDescriptions((prev) => new Set(prev).add(fileName));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const description = await generateDescription(filePath, fileName, isImage);
|
||||||
|
|
||||||
|
if (description) {
|
||||||
|
const metadata = await loadMetadata();
|
||||||
|
metadata.files[fileName] = { description };
|
||||||
|
await saveMetadata(metadata);
|
||||||
|
await loadContextFiles();
|
||||||
|
|
||||||
|
setSelectedFile((current) => {
|
||||||
|
if (current?.name === fileName) {
|
||||||
|
return { ...current, description };
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate description:', error);
|
||||||
|
} finally {
|
||||||
|
setGeneratingDescriptions((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(fileName);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadMetadata, saveMetadata, loadContextFiles]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Upload a file
|
||||||
|
const uploadFile = async (file: globalThis.File) => {
|
||||||
|
const contextPath = getContextPath();
|
||||||
|
if (!contextPath) return;
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const isImage = isImageFile(file.name);
|
||||||
|
|
||||||
|
let filePath: string;
|
||||||
|
let fileName: string;
|
||||||
|
let imagePathForDescription: string | undefined;
|
||||||
|
|
||||||
|
if (isImage) {
|
||||||
|
fileName = sanitizeFilename(file.name);
|
||||||
|
|
||||||
|
const dataUrl = await new Promise<string>((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => resolve(event.target?.result as string);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
const base64Data = dataUrl.split(',')[1] || dataUrl;
|
||||||
|
const mimeType = file.type || 'image/png';
|
||||||
|
|
||||||
|
const saveResult = await api.saveImageToTemp?.(
|
||||||
|
base64Data,
|
||||||
|
fileName,
|
||||||
|
mimeType,
|
||||||
|
currentProject!.path
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!saveResult?.success || !saveResult.path) {
|
||||||
|
throw new Error(saveResult?.error || 'Failed to save image');
|
||||||
|
}
|
||||||
|
|
||||||
|
imagePathForDescription = saveResult.path;
|
||||||
|
filePath = `${contextPath}/${fileName}`;
|
||||||
|
await api.writeFile(filePath, dataUrl);
|
||||||
|
} else {
|
||||||
|
fileName = file.name;
|
||||||
|
filePath = `${contextPath}/${fileName}`;
|
||||||
|
|
||||||
|
const content = await new Promise<string>((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => resolve(event.target?.result as string);
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
await api.writeFile(filePath, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadContextFiles();
|
||||||
|
generateDescriptionAsync(imagePathForDescription || filePath, fileName, isImage);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to upload file:', error);
|
||||||
|
toast.error('Failed to upload file', {
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle file drop
|
||||||
|
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDropHovering(false);
|
||||||
|
|
||||||
|
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||||
|
if (droppedFiles.length === 0) return;
|
||||||
|
|
||||||
|
for (const file of droppedFiles) {
|
||||||
|
await uploadFile(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDropHovering(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDropHovering(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle file import via button
|
||||||
|
const handleImportClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const inputFiles = e.target.files;
|
||||||
|
if (!inputFiles || inputFiles.length === 0) return;
|
||||||
|
|
||||||
|
for (const file of Array.from(inputFiles)) {
|
||||||
|
await uploadFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create markdown file
|
||||||
|
const handleCreateMarkdown = async () => {
|
||||||
|
const contextPath = getContextPath();
|
||||||
|
if (!contextPath || !newMarkdownName.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
let filename = newMarkdownName.trim();
|
||||||
|
|
||||||
|
if (!filename.includes('.')) {
|
||||||
|
filename += '.md';
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = `${contextPath}/${filename}`;
|
||||||
|
await api.writeFile(filePath, newMarkdownContent);
|
||||||
|
|
||||||
|
if (newMarkdownDescription.trim()) {
|
||||||
|
const metadata = await loadMetadata();
|
||||||
|
metadata.files[filename] = { description: newMarkdownDescription.trim() };
|
||||||
|
await saveMetadata(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadContextFiles();
|
||||||
|
setIsCreateMarkdownOpen(false);
|
||||||
|
setNewMarkdownName('');
|
||||||
|
setNewMarkdownDescription('');
|
||||||
|
setNewMarkdownContent('');
|
||||||
|
toast.success('Markdown file created');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create markdown:', error);
|
||||||
|
toast.error('Failed to create file');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete selected file
|
||||||
|
const handleDeleteFile = async () => {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
await api.deleteFile(selectedFile.path);
|
||||||
|
|
||||||
|
const metadata = await loadMetadata();
|
||||||
|
delete metadata.files[selectedFile.name];
|
||||||
|
await saveMetadata(metadata);
|
||||||
|
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setSelectedFile(null);
|
||||||
|
setFileContent('');
|
||||||
|
setOriginalContent('');
|
||||||
|
await loadContextFiles();
|
||||||
|
toast.success('File deleted');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete file:', error);
|
||||||
|
toast.error('Failed to delete file');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rename selected file
|
||||||
|
const handleRenameFile = async () => {
|
||||||
|
const contextPath = getContextPath();
|
||||||
|
if (!selectedFile || !contextPath || !renameFileName.trim()) return;
|
||||||
|
|
||||||
|
const newName = renameFileName.trim();
|
||||||
|
if (newName === selectedFile.name) {
|
||||||
|
setIsRenameDialogOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const newPath = `${contextPath}/${newName}`;
|
||||||
|
|
||||||
|
const exists = await api.exists(newPath);
|
||||||
|
if (exists) {
|
||||||
|
toast.error('A file with this name already exists');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.readFile(selectedFile.path);
|
||||||
|
if (!result.success || result.content === undefined) {
|
||||||
|
toast.error('Failed to read file for rename');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.writeFile(newPath, result.content);
|
||||||
|
await api.deleteFile(selectedFile.path);
|
||||||
|
|
||||||
|
const metadata = await loadMetadata();
|
||||||
|
if (metadata.files[selectedFile.name]) {
|
||||||
|
metadata.files[newName] = metadata.files[selectedFile.name];
|
||||||
|
delete metadata.files[selectedFile.name];
|
||||||
|
await saveMetadata(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRenameDialogOpen(false);
|
||||||
|
setRenameFileName('');
|
||||||
|
await loadContextFiles();
|
||||||
|
|
||||||
|
const renamedFile: ContextFile = {
|
||||||
|
name: newName,
|
||||||
|
type: isImageFile(newName) ? 'image' : 'text',
|
||||||
|
path: newPath,
|
||||||
|
description: metadata.files[newName]?.description,
|
||||||
|
};
|
||||||
|
setSelectedFile(renamedFile);
|
||||||
|
toast.success('File renamed');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to rename file:', error);
|
||||||
|
toast.error('Failed to rename file');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save edited description
|
||||||
|
const handleSaveDescription = async () => {
|
||||||
|
if (!editDescriptionFileName) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const metadata = await loadMetadata();
|
||||||
|
metadata.files[editDescriptionFileName] = { description: editDescriptionValue.trim() };
|
||||||
|
await saveMetadata(metadata);
|
||||||
|
|
||||||
|
if (selectedFile?.name === editDescriptionFileName) {
|
||||||
|
setSelectedFile({ ...selectedFile, description: editDescriptionValue.trim() });
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadContextFiles();
|
||||||
|
setIsEditDescriptionOpen(false);
|
||||||
|
setEditDescriptionValue('');
|
||||||
|
setEditDescriptionFileName('');
|
||||||
|
toast.success('Description saved');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save description:', error);
|
||||||
|
toast.error('Failed to save description');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete file from list (dropdown action)
|
||||||
|
const handleDeleteFromList = async (file: ContextFile) => {
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
await api.deleteFile(file.path);
|
||||||
|
|
||||||
|
const metadata = await loadMetadata();
|
||||||
|
delete metadata.files[file.name];
|
||||||
|
await saveMetadata(metadata);
|
||||||
|
|
||||||
|
if (selectedFile?.path === file.path) {
|
||||||
|
setSelectedFile(null);
|
||||||
|
setFileContent('');
|
||||||
|
setOriginalContent('');
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadContextFiles();
|
||||||
|
toast.success('File deleted');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete file:', error);
|
||||||
|
toast.error('Failed to delete file');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Go back to file list
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
setSelectedFile(null);
|
||||||
|
setFileContent('');
|
||||||
|
setOriginalContent('');
|
||||||
|
setIsPreviewMode(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full flex flex-col relative',
|
||||||
|
isDropHovering && 'ring-2 ring-primary ring-inset'
|
||||||
|
)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
>
|
||||||
|
{/* Hidden file input */}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileInputChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Drop overlay */}
|
||||||
|
{isDropHovering && (
|
||||||
|
<div className="absolute inset-0 bg-primary/10 z-50 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="flex flex-col items-center text-primary">
|
||||||
|
<Upload className="w-8 h-8 mb-1" />
|
||||||
|
<span className="text-sm font-medium">Drop files to upload</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Uploading overlay */}
|
||||||
|
{isUploading && (
|
||||||
|
<div className="absolute inset-0 bg-background/80 z-50 flex items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-primary mb-1" />
|
||||||
|
<span className="text-xs font-medium">Uploading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Single View: Either File List OR File Content */}
|
||||||
|
{!selectedFile ? (
|
||||||
|
/* File List View */
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
|
||||||
|
<span className="text-xs font-medium">Context Files</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => setIsCreateMarkdownOpen(true)}
|
||||||
|
title="Create markdown"
|
||||||
|
>
|
||||||
|
<FilePlus className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={handleImportClick}
|
||||||
|
disabled={isUploading}
|
||||||
|
title="Import file"
|
||||||
|
>
|
||||||
|
<Upload className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<FolderOpen className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
|
||||||
|
<p className="text-xs text-muted-foreground">No context files</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
Drop files here or click + to add
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
{files.map((file) => {
|
||||||
|
const isGenerating = generatingDescriptions.has(file.name);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={file.name}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer',
|
||||||
|
'text-sm transition-colors',
|
||||||
|
'hover:bg-accent/50'
|
||||||
|
)}
|
||||||
|
onClick={() => handleSelectFile(file)}
|
||||||
|
>
|
||||||
|
{file.type === 'image' ? (
|
||||||
|
<Image className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="truncate block font-medium">{file.name}</span>
|
||||||
|
{isGenerating ? (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
Generating description...
|
||||||
|
</span>
|
||||||
|
) : file.description ? (
|
||||||
|
<span className="text-xs text-muted-foreground line-clamp-1">
|
||||||
|
{file.description}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-accent rounded transition-opacity"
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setRenameFileName(file.name);
|
||||||
|
setSelectedFile(file);
|
||||||
|
setIsRenameDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="w-3 h-3 mr-2" />
|
||||||
|
Rename
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditDescriptionFileName(file.name);
|
||||||
|
setEditDescriptionValue(file.description || '');
|
||||||
|
setIsEditDescriptionOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3 mr-2" />
|
||||||
|
Edit Description
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteFromList(file);
|
||||||
|
}}
|
||||||
|
className="text-red-500 focus:text-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3 mr-2" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* File Content View */
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between px-2 py-2 border-b border-border/50">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0"
|
||||||
|
onClick={handleBack}
|
||||||
|
title="Back to files"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs font-medium truncate">{selectedFile.name}</span>
|
||||||
|
{hasChanges && <span className="text-[10px] text-amber-500 shrink-0">Unsaved</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{selectedFile.type === 'text' && isMarkdownFile(selectedFile.name) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => setIsPreviewMode(!isPreviewMode)}
|
||||||
|
title={isPreviewMode ? 'Edit' : 'Preview'}
|
||||||
|
>
|
||||||
|
{isPreviewMode ? (
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{selectedFile.type === 'text' && hasChanges && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-xs px-2"
|
||||||
|
onClick={handleSaveFile}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Save
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-red-500 hover:text-red-400"
|
||||||
|
onClick={() => setIsDeleteDialogOpen(true)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description section */}
|
||||||
|
<div className="px-2 pt-2">
|
||||||
|
<div className="bg-muted/30 rounded p-2 text-xs">
|
||||||
|
<div className="flex items-start justify-between gap-1">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground uppercase">
|
||||||
|
Description
|
||||||
|
</span>
|
||||||
|
{generatingDescriptions.has(selectedFile.name) ? (
|
||||||
|
<div className="flex items-center gap-1 text-muted-foreground mt-0.5">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
<span>Generating...</span>
|
||||||
|
</div>
|
||||||
|
) : selectedFile.description ? (
|
||||||
|
<p className="text-xs mt-0.5">{selectedFile.description}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 italic">No description</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 shrink-0"
|
||||||
|
onClick={() => {
|
||||||
|
setEditDescriptionFileName(selectedFile.name);
|
||||||
|
setEditDescriptionValue(selectedFile.description || '');
|
||||||
|
setIsEditDescriptionOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content area */}
|
||||||
|
<div className="flex-1 overflow-auto p-2">
|
||||||
|
{selectedFile.type === 'image' ? (
|
||||||
|
<div className="h-full flex items-center justify-center bg-muted/20 rounded">
|
||||||
|
<img
|
||||||
|
src={fileContent}
|
||||||
|
alt={selectedFile.name}
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : isPreviewMode && isMarkdownFile(selectedFile.name) ? (
|
||||||
|
<Card className="h-full overflow-auto p-3">
|
||||||
|
<Markdown>{fileContent}</Markdown>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
value={fileContent}
|
||||||
|
onChange={(e) => setFileContent(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full h-full p-2 font-mono text-xs bg-muted/30 rounded resize-none',
|
||||||
|
'focus:outline-none focus:ring-1 focus:ring-ring'
|
||||||
|
)}
|
||||||
|
placeholder="Enter content..."
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Markdown Dialog */}
|
||||||
|
<Dialog open={isCreateMarkdownOpen} onOpenChange={setIsCreateMarkdownOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Markdown File</DialogTitle>
|
||||||
|
<DialogDescription>Create a new markdown file for AI context.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="md-filename" className="text-xs">
|
||||||
|
File Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="md-filename"
|
||||||
|
value={newMarkdownName}
|
||||||
|
onChange={(e) => setNewMarkdownName(e.target.value)}
|
||||||
|
placeholder="context-file.md"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="md-description" className="text-xs">
|
||||||
|
Description
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="md-description"
|
||||||
|
value={newMarkdownDescription}
|
||||||
|
onChange={(e) => setNewMarkdownDescription(e.target.value)}
|
||||||
|
placeholder="e.g., Coding style guidelines"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="md-content" className="text-xs">
|
||||||
|
Content
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="md-content"
|
||||||
|
value={newMarkdownContent}
|
||||||
|
onChange={(e) => setNewMarkdownContent(e.target.value)}
|
||||||
|
placeholder="Enter markdown content..."
|
||||||
|
className="h-32 text-sm font-mono resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setIsCreateMarkdownOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleCreateMarkdown} disabled={!newMarkdownName.trim()}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete File</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete "{selectedFile?.name}"? This cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setIsDeleteDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" onClick={handleDeleteFile}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Rename Dialog */}
|
||||||
|
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Rename File</DialogTitle>
|
||||||
|
<DialogDescription>Enter a new name for "{selectedFile?.name}".</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2">
|
||||||
|
<Input
|
||||||
|
value={renameFileName}
|
||||||
|
onChange={(e) => setRenameFileName(e.target.value)}
|
||||||
|
placeholder="Enter new filename"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && renameFileName.trim()) {
|
||||||
|
handleRenameFile();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setIsRenameDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRenameFile}
|
||||||
|
disabled={!renameFileName.trim() || renameFileName === selectedFile?.name}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit Description Dialog */}
|
||||||
|
<Dialog open={isEditDescriptionOpen} onOpenChange={setIsEditDescriptionOpen}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Description</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update the description for "{editDescriptionFileName}".
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2">
|
||||||
|
<Textarea
|
||||||
|
value={editDescriptionValue}
|
||||||
|
onChange={(e) => setEditDescriptionValue(e.target.value)}
|
||||||
|
placeholder="Enter description..."
|
||||||
|
className="h-24 text-sm resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setIsEditDescriptionOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleSaveDescription}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
CircleDot,
|
||||||
|
GitPullRequest,
|
||||||
|
RefreshCw,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
Wand2,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
getElectronAPI,
|
||||||
|
GitHubIssue,
|
||||||
|
GitHubPR,
|
||||||
|
IssueValidationResult,
|
||||||
|
StoredValidation,
|
||||||
|
} from '@/lib/electron';
|
||||||
|
import { useAppStore, GitHubCacheIssue, GitHubCachePR } from '@/store/app-store';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useIssueValidation } from '@/components/views/github-issues-view/hooks';
|
||||||
|
import { ValidationDialog } from '@/components/views/github-issues-view/dialogs';
|
||||||
|
import { useModelOverride } from '@/components/shared';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
type GitHubTab = 'issues' | 'prs';
|
||||||
|
|
||||||
|
// Cache duration: 5 minutes
|
||||||
|
const CACHE_DURATION_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// Check if validation is stale (> 24 hours)
|
||||||
|
function isValidationStale(validatedAt: string): boolean {
|
||||||
|
const VALIDATION_CACHE_TTL_HOURS = 24;
|
||||||
|
const validatedTime = new Date(validatedAt).getTime();
|
||||||
|
const hoursSinceValidation = (Date.now() - validatedTime) / (1000 * 60 * 60);
|
||||||
|
return hoursSinceValidation > VALIDATION_CACHE_TTL_HOURS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GitHubPanel() {
|
||||||
|
const { currentProject, getGitHubCache, setGitHubCache, setGitHubCacheFetching } = useAppStore();
|
||||||
|
const [activeTab, setActiveTab] = useState<GitHubTab>('issues');
|
||||||
|
const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null);
|
||||||
|
const [validationResult, setValidationResult] = useState<IssueValidationResult | null>(null);
|
||||||
|
const [showValidationDialog, setShowValidationDialog] = useState(false);
|
||||||
|
const fetchingRef = useRef(false);
|
||||||
|
|
||||||
|
const projectPath = currentProject?.path || '';
|
||||||
|
const cache = getGitHubCache(projectPath);
|
||||||
|
|
||||||
|
const issues = cache?.issues || [];
|
||||||
|
const prs = cache?.prs || [];
|
||||||
|
const isFetching = cache?.isFetching || false;
|
||||||
|
const lastFetched = cache?.lastFetched || null;
|
||||||
|
const hasCache = issues.length > 0 || prs.length > 0 || lastFetched !== null;
|
||||||
|
|
||||||
|
// Model override for validation
|
||||||
|
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
|
||||||
|
|
||||||
|
// Use the issue validation hook
|
||||||
|
const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } =
|
||||||
|
useIssueValidation({
|
||||||
|
selectedIssue,
|
||||||
|
showValidationDialog,
|
||||||
|
onValidationResultChange: setValidationResult,
|
||||||
|
onShowValidationDialogChange: setShowValidationDialog,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchData = useCallback(
|
||||||
|
async (isBackgroundRefresh = false) => {
|
||||||
|
if (!projectPath || fetchingRef.current) return;
|
||||||
|
|
||||||
|
fetchingRef.current = true;
|
||||||
|
if (!isBackgroundRefresh) {
|
||||||
|
setGitHubCacheFetching(projectPath, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const fetchedIssues: GitHubCacheIssue[] = [];
|
||||||
|
const fetchedPrs: GitHubCachePR[] = [];
|
||||||
|
|
||||||
|
// Fetch issues
|
||||||
|
if (api.github?.listIssues) {
|
||||||
|
const issuesResult = await api.github.listIssues(projectPath);
|
||||||
|
if (issuesResult.success && issuesResult.openIssues) {
|
||||||
|
// Map to cache format
|
||||||
|
fetchedIssues.push(
|
||||||
|
...issuesResult.openIssues.slice(0, 20).map((issue: GitHubIssue) => ({
|
||||||
|
number: issue.number,
|
||||||
|
title: issue.title,
|
||||||
|
url: issue.url,
|
||||||
|
author: issue.author,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch PRs
|
||||||
|
if (api.github?.listPRs) {
|
||||||
|
const prsResult = await api.github.listPRs(projectPath);
|
||||||
|
if (prsResult.success && prsResult.openPRs) {
|
||||||
|
// Map to cache format
|
||||||
|
fetchedPrs.push(
|
||||||
|
...prsResult.openPRs.slice(0, 20).map((pr: GitHubPR) => ({
|
||||||
|
number: pr.number,
|
||||||
|
title: pr.title,
|
||||||
|
url: pr.url,
|
||||||
|
author: pr.author,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setGitHubCache(projectPath, { issues: fetchedIssues, prs: fetchedPrs });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching GitHub data:', error);
|
||||||
|
// On error, just mark as not fetching but keep existing cache
|
||||||
|
setGitHubCacheFetching(projectPath, false);
|
||||||
|
} finally {
|
||||||
|
fetchingRef.current = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectPath, setGitHubCache, setGitHubCacheFetching]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial fetch or refresh if cache is stale
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectPath) return;
|
||||||
|
|
||||||
|
const isCacheStale = !lastFetched || Date.now() - lastFetched > CACHE_DURATION_MS;
|
||||||
|
|
||||||
|
if (!hasCache) {
|
||||||
|
// No cache, do initial fetch (show spinner)
|
||||||
|
fetchData(false);
|
||||||
|
} else if (isCacheStale && !isFetching) {
|
||||||
|
// Cache is stale, refresh in background (no spinner, show cached data)
|
||||||
|
fetchData(true);
|
||||||
|
}
|
||||||
|
}, [projectPath, hasCache, lastFetched, isFetching, fetchData]);
|
||||||
|
|
||||||
|
// Auto-refresh interval
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectPath) return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const currentCache = getGitHubCache(projectPath);
|
||||||
|
const isStale =
|
||||||
|
!currentCache?.lastFetched || Date.now() - currentCache.lastFetched > CACHE_DURATION_MS;
|
||||||
|
|
||||||
|
if (isStale && !fetchingRef.current) {
|
||||||
|
fetchData(true);
|
||||||
|
}
|
||||||
|
}, CACHE_DURATION_MS);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [projectPath, getGitHubCache, fetchData]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
fetchData(false);
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const handleOpenInGitHub = useCallback((url: string) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
api.openExternalLink(url);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle validation for an issue (converts cache issue to GitHubIssue format)
|
||||||
|
const handleValidate = useCallback(
|
||||||
|
(cacheIssue: GitHubCacheIssue) => {
|
||||||
|
// Convert cache issue to GitHubIssue format for validation
|
||||||
|
const issue: GitHubIssue = {
|
||||||
|
number: cacheIssue.number,
|
||||||
|
title: cacheIssue.title,
|
||||||
|
url: cacheIssue.url,
|
||||||
|
author: cacheIssue.author || { login: 'unknown' },
|
||||||
|
state: 'OPEN',
|
||||||
|
body: '',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
labels: [],
|
||||||
|
comments: { totalCount: 0 },
|
||||||
|
};
|
||||||
|
setSelectedIssue(issue);
|
||||||
|
handleValidateIssue(issue, {
|
||||||
|
modelEntry: validationModelOverride.effectiveModelEntry,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[handleValidateIssue, validationModelOverride.effectiveModelEntry]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle viewing cached validation
|
||||||
|
const handleViewValidation = useCallback(
|
||||||
|
(cacheIssue: GitHubCacheIssue) => {
|
||||||
|
// Convert cache issue to GitHubIssue format
|
||||||
|
const issue: GitHubIssue = {
|
||||||
|
number: cacheIssue.number,
|
||||||
|
title: cacheIssue.title,
|
||||||
|
url: cacheIssue.url,
|
||||||
|
author: cacheIssue.author || { login: 'unknown' },
|
||||||
|
state: 'OPEN',
|
||||||
|
body: '',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
labels: [],
|
||||||
|
comments: { totalCount: 0 },
|
||||||
|
};
|
||||||
|
setSelectedIssue(issue);
|
||||||
|
handleViewCachedValidation(issue);
|
||||||
|
},
|
||||||
|
[handleViewCachedValidation]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get validation status for an issue
|
||||||
|
const getValidationStatus = useCallback(
|
||||||
|
(issueNumber: number) => {
|
||||||
|
const isValidating = validatingIssues.has(issueNumber);
|
||||||
|
const cached = cachedValidations.get(issueNumber);
|
||||||
|
const isStale = cached ? isValidationStale(cached.validatedAt) : false;
|
||||||
|
return { isValidating, cached, isStale };
|
||||||
|
},
|
||||||
|
[validatingIssues, cachedValidations]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only show loading spinner if no cached data AND fetching
|
||||||
|
if (!hasCache && isFetching) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{/* Header with tabs */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('issues')}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors',
|
||||||
|
activeTab === 'issues'
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CircleDot className="h-3 w-3" />
|
||||||
|
Issues ({issues.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('prs')}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors',
|
||||||
|
activeTab === 'prs'
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<GitPullRequest className="h-3 w-3" />
|
||||||
|
PRs ({prs.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isFetching}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('h-3 w-3', isFetching && 'animate-spin')} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
{activeTab === 'issues' ? (
|
||||||
|
issues.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-4">No open issues</p>
|
||||||
|
) : (
|
||||||
|
issues.map((issue) => {
|
||||||
|
const { isValidating, cached, isStale } = getValidationStatus(issue.number);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={issue.number}
|
||||||
|
className="flex items-start gap-2 p-2 rounded-md hover:bg-accent/50 group"
|
||||||
|
>
|
||||||
|
<CircleDot className="h-3.5 w-3.5 mt-0.5 text-green-500 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium truncate">{issue.title}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
#{issue.number} opened by {issue.author?.login}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{/* Validation status/action */}
|
||||||
|
{isValidating ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
|
||||||
|
) : cached && !isStale ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-1.5"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleViewValidation(issue);
|
||||||
|
}}
|
||||||
|
title="View validation result"
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||||||
|
</Button>
|
||||||
|
) : cached && isStale ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-1.5"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleValidate(issue);
|
||||||
|
}}
|
||||||
|
title="Re-validate (stale)"
|
||||||
|
>
|
||||||
|
<Clock className="h-3.5 w-3.5 text-yellow-500" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-1.5 opacity-0 group-hover:opacity-100"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleValidate(issue);
|
||||||
|
}}
|
||||||
|
title="Validate with AI"
|
||||||
|
>
|
||||||
|
<Wand2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{/* Open in GitHub */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-1.5 opacity-0 group-hover:opacity-100"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleOpenInGitHub(issue.url);
|
||||||
|
}}
|
||||||
|
title="Open in GitHub"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
) : prs.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-4">No open pull requests</p>
|
||||||
|
) : (
|
||||||
|
prs.map((pr) => (
|
||||||
|
<div
|
||||||
|
key={pr.number}
|
||||||
|
className="flex items-start gap-2 p-2 rounded-md hover:bg-accent/50 cursor-pointer group"
|
||||||
|
onClick={() => handleOpenInGitHub(pr.url)}
|
||||||
|
>
|
||||||
|
<GitPullRequest className="h-3.5 w-3.5 mt-0.5 text-purple-500 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium truncate">{pr.title}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
#{pr.number} by {pr.author?.login}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ExternalLink className="h-3 w-3 opacity-0 group-hover:opacity-100 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Validation Dialog */}
|
||||||
|
<ValidationDialog
|
||||||
|
open={showValidationDialog}
|
||||||
|
onOpenChange={setShowValidationDialog}
|
||||||
|
issue={selectedIssue}
|
||||||
|
validationResult={validationResult}
|
||||||
|
onConvertToTask={() => {
|
||||||
|
// Task conversion not supported in dock panel - need to go to full view
|
||||||
|
toast.info('Open GitHub Issues view for task conversion');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
586
apps/ui/src/components/layout/bottom-dock/panels/spec-panel.tsx
Normal file
586
apps/ui/src/components/layout/bottom-dock/panels/spec-panel.tsx
Normal file
@@ -0,0 +1,586 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
Save,
|
||||||
|
Sparkles,
|
||||||
|
RefreshCw,
|
||||||
|
FilePlus2,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { XmlSyntaxEditor } from '@/components/ui/xml-syntax-editor';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import type { SpecRegenerationEvent } from '@/types/electron';
|
||||||
|
|
||||||
|
// Feature count options
|
||||||
|
type FeatureCount = 20 | 50 | 100;
|
||||||
|
|
||||||
|
const FEATURE_COUNT_OPTIONS: { value: FeatureCount; label: string; warning?: string }[] = [
|
||||||
|
{ value: 20, label: '20' },
|
||||||
|
{ value: 50, label: '50', warning: 'May take up to 5 minutes' },
|
||||||
|
{ value: 100, label: '100', warning: 'May take up to 5 minutes' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PHASE_LABELS: Record<string, string> = {
|
||||||
|
initialization: 'Initializing...',
|
||||||
|
setup: 'Setting up tools...',
|
||||||
|
analysis: 'Analyzing project...',
|
||||||
|
spec_complete: 'Spec created! Generating features...',
|
||||||
|
feature_generation: 'Creating features...',
|
||||||
|
complete: 'Complete!',
|
||||||
|
error: 'Error occurred',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SPEC_FILE_WRITE_DELAY = 500;
|
||||||
|
|
||||||
|
export function SpecPanel() {
|
||||||
|
const { currentProject, appSpec, setAppSpec } = useAppStore();
|
||||||
|
const [specContent, setSpecContent] = useState('');
|
||||||
|
const [originalContent, setOriginalContent] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [specExists, setSpecExists] = useState(false);
|
||||||
|
|
||||||
|
// Generation state
|
||||||
|
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||||
|
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false);
|
||||||
|
const [projectOverview, setProjectOverview] = useState('');
|
||||||
|
const [projectDefinition, setProjectDefinition] = useState('');
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [generateFeatures, setGenerateFeatures] = useState(true);
|
||||||
|
const [analyzeProject, setAnalyzeProject] = useState(true);
|
||||||
|
const [featureCount, setFeatureCount] = useState<FeatureCount>(50);
|
||||||
|
const [currentPhase, setCurrentPhase] = useState('');
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
|
const hasChanges = specContent !== originalContent;
|
||||||
|
|
||||||
|
// Load spec from file
|
||||||
|
const loadSpec = useCallback(async () => {
|
||||||
|
if (!currentProject?.path) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
|
||||||
|
// Check if generation is running
|
||||||
|
if (api.specRegeneration) {
|
||||||
|
const status = await api.specRegeneration.status();
|
||||||
|
if (status.success && status.isRunning) {
|
||||||
|
setIsGenerating(true);
|
||||||
|
if (status.currentPhase) {
|
||||||
|
setCurrentPhase(status.currentPhase);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the spec file using the correct API
|
||||||
|
const specPath = `${currentProject.path}/.automaker/app_spec.txt`;
|
||||||
|
const result = await api.readFile(specPath);
|
||||||
|
|
||||||
|
if (result.success && result.content) {
|
||||||
|
setSpecContent(result.content);
|
||||||
|
setOriginalContent(result.content);
|
||||||
|
setAppSpec(result.content);
|
||||||
|
setSpecExists(true);
|
||||||
|
} else {
|
||||||
|
setSpecContent('');
|
||||||
|
setOriginalContent('');
|
||||||
|
setSpecExists(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading spec:', error);
|
||||||
|
setSpecExists(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [currentProject?.path, setAppSpec]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSpec();
|
||||||
|
}, [loadSpec]);
|
||||||
|
|
||||||
|
// Sync with store
|
||||||
|
useEffect(() => {
|
||||||
|
if (appSpec && appSpec !== specContent && !hasChanges) {
|
||||||
|
setSpecContent(appSpec);
|
||||||
|
setOriginalContent(appSpec);
|
||||||
|
setSpecExists(true);
|
||||||
|
}
|
||||||
|
}, [appSpec, specContent, hasChanges]);
|
||||||
|
|
||||||
|
// Subscribe to spec regeneration events
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentProject?.path) return;
|
||||||
|
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.specRegeneration) return;
|
||||||
|
|
||||||
|
const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => {
|
||||||
|
if (event.projectPath !== currentProject.path) return;
|
||||||
|
|
||||||
|
if (event.type === 'spec_regeneration_progress') {
|
||||||
|
setIsGenerating(true);
|
||||||
|
const phaseMatch = event.content.match(/\[Phase:\s*([^\]]+)\]/);
|
||||||
|
if (phaseMatch) {
|
||||||
|
setCurrentPhase(phaseMatch[1]);
|
||||||
|
}
|
||||||
|
if (event.content.includes('All tasks completed')) {
|
||||||
|
setIsGenerating(false);
|
||||||
|
setCurrentPhase('');
|
||||||
|
setTimeout(() => loadSpec(), SPEC_FILE_WRITE_DELAY);
|
||||||
|
}
|
||||||
|
} else if (event.type === 'spec_regeneration_complete') {
|
||||||
|
const isFinal =
|
||||||
|
event.message?.includes('All tasks completed') ||
|
||||||
|
event.message === 'Spec regeneration complete!' ||
|
||||||
|
event.message === 'Initial spec creation complete!';
|
||||||
|
|
||||||
|
if (isFinal) {
|
||||||
|
setIsGenerating(false);
|
||||||
|
setCurrentPhase('');
|
||||||
|
setShowCreateDialog(false);
|
||||||
|
setShowRegenerateDialog(false);
|
||||||
|
setProjectOverview('');
|
||||||
|
setProjectDefinition('');
|
||||||
|
setErrorMessage('');
|
||||||
|
setTimeout(() => loadSpec(), SPEC_FILE_WRITE_DELAY);
|
||||||
|
toast.success('Spec Generation Complete', {
|
||||||
|
description: 'Your app specification has been saved.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (event.type === 'spec_regeneration_error') {
|
||||||
|
setIsGenerating(false);
|
||||||
|
setCurrentPhase('error');
|
||||||
|
setErrorMessage(event.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, [currentProject?.path, loadSpec]);
|
||||||
|
|
||||||
|
// Save spec
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!currentProject?.path || !hasChanges) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const specPath = `${currentProject.path}/.automaker/app_spec.txt`;
|
||||||
|
await api.writeFile(specPath, specContent);
|
||||||
|
setOriginalContent(specContent);
|
||||||
|
setAppSpec(specContent);
|
||||||
|
toast.success('Spec saved');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to save spec');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [currentProject?.path, specContent, hasChanges, setAppSpec]);
|
||||||
|
|
||||||
|
// Create spec
|
||||||
|
const handleCreateSpec = useCallback(async () => {
|
||||||
|
if (!currentProject?.path || !projectOverview.trim()) return;
|
||||||
|
|
||||||
|
setIsGenerating(true);
|
||||||
|
setShowCreateDialog(false);
|
||||||
|
setCurrentPhase('initialization');
|
||||||
|
setErrorMessage('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.specRegeneration) {
|
||||||
|
setIsGenerating(false);
|
||||||
|
toast.error('Spec generation not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.specRegeneration.create(
|
||||||
|
currentProject.path,
|
||||||
|
projectOverview.trim(),
|
||||||
|
generateFeatures,
|
||||||
|
analyzeProject,
|
||||||
|
generateFeatures ? featureCount : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
setIsGenerating(false);
|
||||||
|
setCurrentPhase('error');
|
||||||
|
setErrorMessage(result.error || 'Failed to create spec');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setIsGenerating(false);
|
||||||
|
setCurrentPhase('error');
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : 'Failed to create spec');
|
||||||
|
}
|
||||||
|
}, [currentProject?.path, projectOverview, generateFeatures, analyzeProject, featureCount]);
|
||||||
|
|
||||||
|
// Regenerate spec
|
||||||
|
const handleRegenerate = useCallback(async () => {
|
||||||
|
if (!currentProject?.path || !projectDefinition.trim()) return;
|
||||||
|
|
||||||
|
setIsGenerating(true);
|
||||||
|
setShowRegenerateDialog(false);
|
||||||
|
setCurrentPhase('initialization');
|
||||||
|
setErrorMessage('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.specRegeneration) {
|
||||||
|
setIsGenerating(false);
|
||||||
|
toast.error('Spec generation not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.specRegeneration.generate(
|
||||||
|
currentProject.path,
|
||||||
|
projectDefinition.trim(),
|
||||||
|
generateFeatures,
|
||||||
|
analyzeProject,
|
||||||
|
generateFeatures ? featureCount : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
setIsGenerating(false);
|
||||||
|
setCurrentPhase('error');
|
||||||
|
setErrorMessage(result.error || 'Failed to regenerate spec');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setIsGenerating(false);
|
||||||
|
setCurrentPhase('error');
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : 'Failed to regenerate spec');
|
||||||
|
}
|
||||||
|
}, [currentProject?.path, projectDefinition, generateFeatures, analyzeProject, featureCount]);
|
||||||
|
|
||||||
|
const selectedOption = FEATURE_COUNT_OPTIONS.find((o) => o.value === featureCount);
|
||||||
|
const phaseLabel = PHASE_LABELS[currentPhase] || currentPhase;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generation in progress view
|
||||||
|
if (isGenerating) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
|
||||||
|
<span className="text-xs font-medium">Generating Spec...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center justify-center p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="p-3 rounded-full bg-primary/10 inline-block">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium mb-2">
|
||||||
|
{currentPhase === 'feature_generation'
|
||||||
|
? 'Creating Features...'
|
||||||
|
: 'Generating Specification'}
|
||||||
|
</p>
|
||||||
|
{currentPhase && <p className="text-xs text-muted-foreground">{phaseLabel}</p>}
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="mt-3 p-2 rounded bg-destructive/10 border border-destructive/20">
|
||||||
|
<p className="text-xs text-destructive">{errorMessage}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state - no spec exists
|
||||||
|
if (!specExists) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-xs font-medium">App Specification</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center justify-center p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<FilePlus2 className="h-10 w-10 mx-auto text-muted-foreground/50 mb-3" />
|
||||||
|
<p className="text-sm font-medium mb-1">No Spec Found</p>
|
||||||
|
<p className="text-xs text-muted-foreground mb-4">
|
||||||
|
Create an app specification to help AI understand your project.
|
||||||
|
</p>
|
||||||
|
<Button size="sm" onClick={() => setShowCreateDialog(true)}>
|
||||||
|
<Sparkles className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Create Spec
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Spec Dialog */}
|
||||||
|
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create App Specification</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Describe your project and we'll generate a comprehensive specification.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Project Overview</label>
|
||||||
|
<Textarea
|
||||||
|
value={projectOverview}
|
||||||
|
onChange={(e) => setProjectOverview(e.target.value)}
|
||||||
|
placeholder="Describe what your project does and what features you want to build..."
|
||||||
|
className="h-32 resize-none font-mono text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<Checkbox
|
||||||
|
id="analyze-project"
|
||||||
|
checked={analyzeProject}
|
||||||
|
onCheckedChange={(checked) => setAnalyzeProject(checked === true)}
|
||||||
|
/>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<label htmlFor="analyze-project" className="text-sm font-medium cursor-pointer">
|
||||||
|
Analyze current project
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Research your codebase to understand the tech stack.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<Checkbox
|
||||||
|
id="generate-features"
|
||||||
|
checked={generateFeatures}
|
||||||
|
onCheckedChange={(checked) => setGenerateFeatures(checked === true)}
|
||||||
|
/>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<label htmlFor="generate-features" className="text-sm font-medium cursor-pointer">
|
||||||
|
Generate feature list
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Automatically create features from the spec.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{generateFeatures && (
|
||||||
|
<div className="space-y-2 pl-6">
|
||||||
|
<label className="text-sm font-medium">Number of Features</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{FEATURE_COUNT_OPTIONS.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
variant={featureCount === option.value ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFeatureCount(option.value)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{selectedOption?.warning && (
|
||||||
|
<p className="text-xs text-amber-500 flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{selectedOption.warning}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => setShowCreateDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreateSpec} disabled={!projectOverview.trim()}>
|
||||||
|
<Sparkles className="w-4 h-4 mr-2" />
|
||||||
|
Generate Spec
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main view - spec exists
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-xs font-medium">App Specification</span>
|
||||||
|
{hasChanges && <span className="text-[10px] text-amber-500">Unsaved</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => setShowRegenerateDialog(true)}
|
||||||
|
title="Regenerate spec"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
{hasChanges && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-xs"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin mr-1" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-3 w-3 mr-1" />
|
||||||
|
)}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-hidden bg-muted/30 rounded-md m-2">
|
||||||
|
<XmlSyntaxEditor
|
||||||
|
value={specContent}
|
||||||
|
onChange={(value) => setSpecContent(value)}
|
||||||
|
placeholder="Enter your app specification..."
|
||||||
|
className="h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Regenerate Spec Dialog */}
|
||||||
|
<Dialog open={showRegenerateDialog} onOpenChange={setShowRegenerateDialog}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Regenerate App Specification</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
We'll regenerate your spec based on your project description.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Describe your project</label>
|
||||||
|
<Textarea
|
||||||
|
value={projectDefinition}
|
||||||
|
onChange={(e) => setProjectDefinition(e.target.value)}
|
||||||
|
placeholder="Describe what your app should do..."
|
||||||
|
className="h-32 resize-none font-mono text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<Checkbox
|
||||||
|
id="regen-analyze-project"
|
||||||
|
checked={analyzeProject}
|
||||||
|
onCheckedChange={(checked) => setAnalyzeProject(checked === true)}
|
||||||
|
/>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<label
|
||||||
|
htmlFor="regen-analyze-project"
|
||||||
|
className="text-sm font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
Analyze current project
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Research your codebase to understand the tech stack.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<Checkbox
|
||||||
|
id="regen-generate-features"
|
||||||
|
checked={generateFeatures}
|
||||||
|
onCheckedChange={(checked) => setGenerateFeatures(checked === true)}
|
||||||
|
/>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<label
|
||||||
|
htmlFor="regen-generate-features"
|
||||||
|
className="text-sm font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
Generate feature list
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Automatically create features from the spec.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{generateFeatures && (
|
||||||
|
<div className="space-y-2 pl-6">
|
||||||
|
<label className="text-sm font-medium">Number of Features</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{FEATURE_COUNT_OPTIONS.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
variant={featureCount === option.value ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFeatureCount(option.value)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{selectedOption?.warning && (
|
||||||
|
<p className="text-xs text-amber-500 flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{selectedOption.warning}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => setShowRegenerateDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleRegenerate} disabled={!projectDefinition.trim()}>
|
||||||
|
<Sparkles className="w-4 h-4 mr-2" />
|
||||||
|
Regenerate Spec
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
Zap,
|
Zap,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
Brain,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { NavSection, NavItem } from '../types';
|
import type { NavSection, NavItem } from '../types';
|
||||||
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||||
@@ -27,7 +26,6 @@ interface UseNavigationProps {
|
|||||||
cycleNextProject: string;
|
cycleNextProject: string;
|
||||||
spec: string;
|
spec: string;
|
||||||
context: string;
|
context: string;
|
||||||
memory: string;
|
|
||||||
profiles: string;
|
profiles: string;
|
||||||
board: string;
|
board: string;
|
||||||
agent: string;
|
agent: string;
|
||||||
@@ -116,12 +114,6 @@ export function useNavigation({
|
|||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
shortcut: shortcuts.context,
|
shortcut: shortcuts.context,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'memory',
|
|
||||||
label: 'Memory',
|
|
||||||
icon: Brain,
|
|
||||||
shortcut: shortcuts.memory,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'profiles',
|
id: 'profiles',
|
||||||
label: 'AI Profiles',
|
label: 'AI Profiles',
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
@@ -87,7 +87,6 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
|
|||||||
agent: 'Agent Runner',
|
agent: 'Agent Runner',
|
||||||
spec: 'Spec Editor',
|
spec: 'Spec Editor',
|
||||||
context: 'Context',
|
context: 'Context',
|
||||||
memory: 'Memory',
|
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
profiles: 'AI Profiles',
|
profiles: 'AI Profiles',
|
||||||
terminal: 'Terminal',
|
terminal: 'Terminal',
|
||||||
@@ -116,7 +115,6 @@ const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, 'navigation' | 'ui' |
|
|||||||
agent: 'navigation',
|
agent: 'navigation',
|
||||||
spec: 'navigation',
|
spec: 'navigation',
|
||||||
context: 'navigation',
|
context: 'navigation',
|
||||||
memory: 'navigation',
|
|
||||||
settings: 'navigation',
|
settings: 'navigation',
|
||||||
profiles: 'navigation',
|
profiles: 'navigation',
|
||||||
terminal: 'navigation',
|
terminal: 'navigation',
|
||||||
|
|||||||
@@ -16,10 +16,24 @@ interface AgentModelSelectorProps {
|
|||||||
onChange: (entry: PhaseModelEntry) => void;
|
onChange: (entry: PhaseModelEntry) => void;
|
||||||
/** Disabled state */
|
/** Disabled state */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** Custom trigger class name */
|
||||||
|
triggerClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentModelSelector({ value, onChange, disabled }: AgentModelSelectorProps) {
|
export function AgentModelSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
triggerClassName,
|
||||||
|
}: AgentModelSelectorProps) {
|
||||||
return (
|
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 { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||||
import { useWindowState } from '@/hooks/use-window-state';
|
import { useWindowState } from '@/hooks/use-window-state';
|
||||||
// Board-view specific imports
|
// 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 { KanbanBoard } from './board-view/kanban-board';
|
||||||
import { GraphView } from './graph-view';
|
import { GraphView } from './graph-view';
|
||||||
import {
|
import {
|
||||||
@@ -172,8 +169,9 @@ export function BoardView() {
|
|||||||
} = useSelectionMode();
|
} = useSelectionMode();
|
||||||
const [showMassEditDialog, setShowMassEditDialog] = useState(false);
|
const [showMassEditDialog, setShowMassEditDialog] = useState(false);
|
||||||
|
|
||||||
// Search filter for Kanban cards
|
// Search filter for Kanban cards - using store state for top bar integration
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const searchQuery = useAppStore((state) => state.boardSearchQuery);
|
||||||
|
const setSearchQuery = useAppStore((state) => state.setBoardSearchQuery);
|
||||||
// Plan approval loading state
|
// Plan approval loading state
|
||||||
const [isPlanApprovalLoading, setIsPlanApprovalLoading] = useState(false);
|
const [isPlanApprovalLoading, setIsPlanApprovalLoading] = useState(false);
|
||||||
// Derive spec creation state from store - check if current project is the one being created
|
// Derive spec creation state from store - check if current project is the one being created
|
||||||
@@ -247,6 +245,26 @@ export function BoardView() {
|
|||||||
setIsMounted(true);
|
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(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
@@ -1138,30 +1156,6 @@ export function BoardView() {
|
|||||||
className="flex-1 flex flex-col overflow-hidden content-bg relative"
|
className="flex-1 flex flex-col overflow-hidden content-bg relative"
|
||||||
data-testid="board-view"
|
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 */}
|
{/* Worktree Panel */}
|
||||||
<WorktreePanel
|
<WorktreePanel
|
||||||
refreshTrigger={worktreeRefreshKey}
|
refreshTrigger={worktreeRefreshKey}
|
||||||
@@ -1196,28 +1190,6 @@ export function BoardView() {
|
|||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<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 */}
|
{/* View Content - Kanban or Graph */}
|
||||||
{boardViewMode === 'kanban' ? (
|
{boardViewMode === 'kanban' ? (
|
||||||
<KanbanBoard
|
<KanbanBoard
|
||||||
@@ -1257,6 +1229,7 @@ export function BoardView() {
|
|||||||
selectedFeatureIds={selectedFeatureIds}
|
selectedFeatureIds={selectedFeatureIds}
|
||||||
onToggleFeatureSelection={toggleFeatureSelection}
|
onToggleFeatureSelection={toggleFeatureSelection}
|
||||||
onToggleSelectionMode={toggleSelectionMode}
|
onToggleSelectionMode={toggleSelectionMode}
|
||||||
|
onAddFeature={() => setShowAddDialog(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<GraphView
|
<GraphView
|
||||||
|
|||||||
@@ -1,50 +1,26 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Plus, Wand2 } from 'lucide-react';
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Plus, Bot, Wand2, Settings2 } from 'lucide-react';
|
|
||||||
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||||
import { UsagePopover } from '@/components/usage-popover';
|
import { UsagePopover } from '@/components/usage-popover';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
|
|
||||||
|
|
||||||
interface BoardHeaderProps {
|
interface BoardHeaderProps {
|
||||||
projectName: string;
|
|
||||||
maxConcurrency: number;
|
|
||||||
runningAgentsCount: number;
|
|
||||||
onConcurrencyChange: (value: number) => void;
|
|
||||||
isAutoModeRunning: boolean;
|
|
||||||
onAutoModeToggle: (enabled: boolean) => void;
|
|
||||||
onAddFeature: () => void;
|
onAddFeature: () => void;
|
||||||
onOpenPlanDialog: () => void;
|
onOpenPlanDialog: () => void;
|
||||||
addFeatureShortcut: KeyboardShortcut;
|
addFeatureShortcut: KeyboardShortcut;
|
||||||
isMounted: boolean;
|
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({
|
export function BoardHeader({
|
||||||
projectName,
|
|
||||||
maxConcurrency,
|
|
||||||
runningAgentsCount,
|
|
||||||
onConcurrencyChange,
|
|
||||||
isAutoModeRunning,
|
|
||||||
onAutoModeToggle,
|
|
||||||
onAddFeature,
|
onAddFeature,
|
||||||
onOpenPlanDialog,
|
onOpenPlanDialog,
|
||||||
addFeatureShortcut,
|
addFeatureShortcut,
|
||||||
isMounted,
|
isMounted,
|
||||||
}: BoardHeaderProps) {
|
}: BoardHeaderProps) {
|
||||||
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
|
|
||||||
const apiKeys = useAppStore((state) => state.apiKeys);
|
const apiKeys = useAppStore((state) => state.apiKeys);
|
||||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||||
const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode);
|
|
||||||
const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode);
|
|
||||||
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
||||||
|
|
||||||
// Claude usage tracking visibility logic
|
// Claude usage tracking visibility logic
|
||||||
@@ -62,90 +38,30 @@ export function BoardHeader({
|
|||||||
const showCodexUsage = !!codexAuthStatus?.authenticated;
|
const showCodexUsage = !!codexAuthStatus?.authenticated;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
<div className="flex items-center justify-end gap-2 px-4 py-2 border-b border-border bg-glass backdrop-blur-md">
|
||||||
<div>
|
{/* Usage Popover - show if either provider is authenticated */}
|
||||||
<h1 className="text-xl font-bold">Kanban Board</h1>
|
{isMounted && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
||||||
<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 />}
|
|
||||||
|
|
||||||
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
|
<Button
|
||||||
{isMounted && (
|
size="sm"
|
||||||
<div className={controlContainerClass} data-testid="concurrency-slider-container">
|
variant="outline"
|
||||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
onClick={onOpenPlanDialog}
|
||||||
<span className="text-sm font-medium">Agents</span>
|
data-testid="plan-backlog-button"
|
||||||
<Slider
|
>
|
||||||
value={[maxConcurrency]}
|
<Wand2 className="w-4 h-4 mr-2" />
|
||||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
Plan
|
||||||
min={1}
|
</Button>
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
<HotkeyButton
|
||||||
{isMounted && (
|
size="sm"
|
||||||
<div className={controlContainerClass} data-testid="auto-mode-toggle-container">
|
onClick={onAddFeature}
|
||||||
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
|
hotkey={addFeatureShortcut}
|
||||||
Auto Mode
|
hotkeyActive={false}
|
||||||
</Label>
|
data-testid="add-feature-button"
|
||||||
<Switch
|
>
|
||||||
id="auto-mode-toggle"
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
checked={isAutoModeRunning}
|
Add Feature
|
||||||
onCheckedChange={onAutoModeToggle}
|
</HotkeyButton>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { KanbanColumn, KanbanCard } from './components';
|
import { KanbanColumn, KanbanCard } from './components';
|
||||||
import { Feature } from '@/store/app-store';
|
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 { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
|
||||||
import { getColumnsWithPipeline, type ColumnId } from './constants';
|
import { getColumnsWithPipeline, type ColumnId } from './constants';
|
||||||
import type { PipelineConfig } from '@automaker/types';
|
import type { PipelineConfig } from '@automaker/types';
|
||||||
|
import { useDockState } from '@/components/layout/bottom-dock/bottom-dock';
|
||||||
|
|
||||||
interface KanbanBoardProps {
|
interface KanbanBoardProps {
|
||||||
sensors: any;
|
sensors: any;
|
||||||
@@ -50,6 +51,8 @@ interface KanbanBoardProps {
|
|||||||
selectedFeatureIds?: Set<string>;
|
selectedFeatureIds?: Set<string>;
|
||||||
onToggleFeatureSelection?: (featureId: string) => void;
|
onToggleFeatureSelection?: (featureId: string) => void;
|
||||||
onToggleSelectionMode?: () => void;
|
onToggleSelectionMode?: () => void;
|
||||||
|
// Add feature action
|
||||||
|
onAddFeature?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KanbanBoard({
|
export function KanbanBoard({
|
||||||
@@ -84,6 +87,7 @@ export function KanbanBoard({
|
|||||||
selectedFeatureIds = new Set(),
|
selectedFeatureIds = new Set(),
|
||||||
onToggleFeatureSelection,
|
onToggleFeatureSelection,
|
||||||
onToggleSelectionMode,
|
onToggleSelectionMode,
|
||||||
|
onAddFeature,
|
||||||
}: KanbanBoardProps) {
|
}: KanbanBoardProps) {
|
||||||
// Generate columns including pipeline steps
|
// Generate columns including pipeline steps
|
||||||
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
|
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
|
||||||
@@ -92,15 +96,40 @@ export function KanbanBoard({
|
|||||||
// containerStyle handles centering and ensures columns fit without horizontal scroll in Electron
|
// containerStyle handles centering and ensures columns fit without horizontal scroll in Electron
|
||||||
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
|
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
|
||||||
|
|
||||||
|
// Get dock state to add padding when dock is expanded on the side
|
||||||
|
const {
|
||||||
|
position: dockPosition,
|
||||||
|
isExpanded: dockExpanded,
|
||||||
|
isMaximized: dockMaximized,
|
||||||
|
} = useDockState();
|
||||||
|
|
||||||
|
// Calculate padding based on dock state
|
||||||
|
// Dock widths: collapsed=w-10 (2.5rem), expanded=w-96 (24rem), maximized=w-[50vw]
|
||||||
|
const getSideDockPadding = () => {
|
||||||
|
if (!dockExpanded) return undefined;
|
||||||
|
if (dockMaximized) return '50vw';
|
||||||
|
return '25rem'; // 24rem dock width + 1rem breathing room
|
||||||
|
};
|
||||||
|
|
||||||
|
const sideDockPadding = getSideDockPadding();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-x-auto px-5 pb-4 relative" style={backgroundImageStyle}>
|
<div
|
||||||
|
className="flex-1 overflow-x-auto px-5 pb-4 relative transition-[padding] duration-300"
|
||||||
|
style={{
|
||||||
|
...backgroundImageStyle,
|
||||||
|
// Add padding when dock is expanded on the side so content can scroll past the overlay
|
||||||
|
paddingRight: dockPosition === 'right' ? sideDockPadding : undefined,
|
||||||
|
paddingLeft: dockPosition === 'left' ? sideDockPadding : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={collisionDetectionStrategy}
|
collisionDetection={collisionDetectionStrategy}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
>
|
>
|
||||||
<div className="h-full py-1" style={containerStyle}>
|
<div className="h-full pt-4 pb-1" style={containerStyle}>
|
||||||
{columns.map((column) => {
|
{columns.map((column) => {
|
||||||
const columnFeatures = getColumnFeatures(column.id as ColumnId);
|
const columnFeatures = getColumnFeatures(column.id as ColumnId);
|
||||||
return (
|
return (
|
||||||
@@ -127,26 +156,36 @@ export function KanbanBoard({
|
|||||||
Complete All
|
Complete All
|
||||||
</Button>
|
</Button>
|
||||||
) : column.id === 'backlog' ? (
|
) : column.id === 'backlog' ? (
|
||||||
<Button
|
<div className="flex items-center gap-1">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className={`h-6 px-2 text-xs ${isSelectionMode ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
|
className="h-6 px-2 text-xs gap-1"
|
||||||
onClick={onToggleSelectionMode}
|
onClick={onAddFeature}
|
||||||
title={isSelectionMode ? 'Switch to Drag Mode' : 'Select Multiple'}
|
data-testid="backlog-add-feature-button"
|
||||||
data-testid="selection-mode-button"
|
>
|
||||||
>
|
<Plus className="w-3 h-3" />
|
||||||
{isSelectionMode ? (
|
Add
|
||||||
<>
|
</Button>
|
||||||
<GripVertical className="w-3.5 h-3.5 mr-1" />
|
<Button
|
||||||
Drag
|
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}
|
||||||
<CheckSquare className="w-3.5 h-3.5 mr-1" />
|
data-testid="selection-mode-button"
|
||||||
Select
|
>
|
||||||
</>
|
{isSelectionMode ? (
|
||||||
)}
|
<>
|
||||||
</Button>
|
<GripVertical className="w-3 h-3" />
|
||||||
|
Drag
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckSquare className="w-3 h-3" />
|
||||||
|
Select
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : column.id === 'in_progress' ? (
|
) : column.id === 'in_progress' ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { cn, pathsEqual } from '@/lib/utils';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import type { WorktreePanelProps, WorktreeInfo } from './types';
|
import type { WorktreePanelProps, WorktreeInfo } from './types';
|
||||||
@@ -85,9 +85,6 @@ export function WorktreePanel({
|
|||||||
|
|
||||||
// Collapse state from store (synced via API)
|
// Collapse state from store (synced via API)
|
||||||
const isCollapsed = useAppStore((s) => s.worktreePanelCollapsed);
|
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
|
// 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
|
// 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 mainWorktree = worktrees.find((w) => w.isMain);
|
||||||
const nonMainWorktrees = worktrees.filter((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) {
|
if (isCollapsed) {
|
||||||
return (
|
return null;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expanded view - full worktree panel
|
// Expanded view - full worktree panel
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
|
<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" />
|
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||||
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
|
<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,627 +0,0 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card } from '@/components/ui/card';
|
|
||||||
import {
|
|
||||||
RefreshCw,
|
|
||||||
FileText,
|
|
||||||
Trash2,
|
|
||||||
Save,
|
|
||||||
Brain,
|
|
||||||
Eye,
|
|
||||||
Pencil,
|
|
||||||
FilePlus,
|
|
||||||
MoreVertical,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { Markdown } from '../ui/markdown';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
|
|
||||||
const logger = createLogger('MemoryView');
|
|
||||||
|
|
||||||
interface MemoryFile {
|
|
||||||
name: string;
|
|
||||||
content?: string;
|
|
||||||
path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MemoryView() {
|
|
||||||
const { currentProject } = useAppStore();
|
|
||||||
const [memoryFiles, setMemoryFiles] = useState<MemoryFile[]>([]);
|
|
||||||
const [selectedFile, setSelectedFile] = useState<MemoryFile | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
|
||||||
const [editedContent, setEditedContent] = useState('');
|
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
||||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
|
||||||
const [renameFileName, setRenameFileName] = useState('');
|
|
||||||
const [isPreviewMode, setIsPreviewMode] = useState(true);
|
|
||||||
|
|
||||||
// Create Memory file modal state
|
|
||||||
const [isCreateMemoryOpen, setIsCreateMemoryOpen] = useState(false);
|
|
||||||
const [newMemoryName, setNewMemoryName] = useState('');
|
|
||||||
const [newMemoryContent, setNewMemoryContent] = useState('');
|
|
||||||
|
|
||||||
// Get memory directory path
|
|
||||||
const getMemoryPath = useCallback(() => {
|
|
||||||
if (!currentProject) return null;
|
|
||||||
return `${currentProject.path}/.automaker/memory`;
|
|
||||||
}, [currentProject]);
|
|
||||||
|
|
||||||
const isMarkdownFile = (filename: string): boolean => {
|
|
||||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
|
||||||
return ext === '.md' || ext === '.markdown';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load memory files
|
|
||||||
const loadMemoryFiles = useCallback(async () => {
|
|
||||||
const memoryPath = getMemoryPath();
|
|
||||||
if (!memoryPath) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
|
|
||||||
// Ensure memory directory exists
|
|
||||||
await api.mkdir(memoryPath);
|
|
||||||
|
|
||||||
// Read directory contents
|
|
||||||
const result = await api.readdir(memoryPath);
|
|
||||||
if (result.success && result.entries) {
|
|
||||||
const files: MemoryFile[] = result.entries
|
|
||||||
.filter((entry) => entry.isFile && isMarkdownFile(entry.name))
|
|
||||||
.map((entry) => ({
|
|
||||||
name: entry.name,
|
|
||||||
path: `${memoryPath}/${entry.name}`,
|
|
||||||
}));
|
|
||||||
setMemoryFiles(files);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to load memory files:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [getMemoryPath]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadMemoryFiles();
|
|
||||||
}, [loadMemoryFiles]);
|
|
||||||
|
|
||||||
// Load selected file content
|
|
||||||
const loadFileContent = useCallback(async (file: MemoryFile) => {
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
const result = await api.readFile(file.path);
|
|
||||||
if (result.success && result.content !== undefined) {
|
|
||||||
setEditedContent(result.content);
|
|
||||||
setSelectedFile({ ...file, content: result.content });
|
|
||||||
setHasChanges(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to load file content:', error);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Select a file
|
|
||||||
const handleSelectFile = (file: MemoryFile) => {
|
|
||||||
if (hasChanges) {
|
|
||||||
// Could add a confirmation dialog here
|
|
||||||
}
|
|
||||||
loadFileContent(file);
|
|
||||||
setIsPreviewMode(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save current file
|
|
||||||
const saveFile = async () => {
|
|
||||||
if (!selectedFile) return;
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
await api.writeFile(selectedFile.path, editedContent);
|
|
||||||
setSelectedFile({ ...selectedFile, content: editedContent });
|
|
||||||
setHasChanges(false);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to save file:', error);
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle content change
|
|
||||||
const handleContentChange = (value: string) => {
|
|
||||||
setEditedContent(value);
|
|
||||||
setHasChanges(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle create memory file
|
|
||||||
const handleCreateMemory = async () => {
|
|
||||||
const memoryPath = getMemoryPath();
|
|
||||||
if (!memoryPath || !newMemoryName.trim()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
let filename = newMemoryName.trim();
|
|
||||||
|
|
||||||
// Add .md extension if not provided
|
|
||||||
if (!filename.includes('.')) {
|
|
||||||
filename += '.md';
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = `${memoryPath}/${filename}`;
|
|
||||||
|
|
||||||
// Write memory file
|
|
||||||
await api.writeFile(filePath, newMemoryContent);
|
|
||||||
|
|
||||||
// Reload files
|
|
||||||
await loadMemoryFiles();
|
|
||||||
|
|
||||||
// Reset and close modal
|
|
||||||
setIsCreateMemoryOpen(false);
|
|
||||||
setNewMemoryName('');
|
|
||||||
setNewMemoryContent('');
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to create memory file:', error);
|
|
||||||
setIsCreateMemoryOpen(false);
|
|
||||||
setNewMemoryName('');
|
|
||||||
setNewMemoryContent('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Delete selected file
|
|
||||||
const handleDeleteFile = async () => {
|
|
||||||
if (!selectedFile) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
await api.deleteFile(selectedFile.path);
|
|
||||||
|
|
||||||
setIsDeleteDialogOpen(false);
|
|
||||||
setSelectedFile(null);
|
|
||||||
setEditedContent('');
|
|
||||||
setHasChanges(false);
|
|
||||||
await loadMemoryFiles();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to delete file:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Rename selected file
|
|
||||||
const handleRenameFile = async () => {
|
|
||||||
const memoryPath = getMemoryPath();
|
|
||||||
if (!selectedFile || !memoryPath || !renameFileName.trim()) return;
|
|
||||||
|
|
||||||
let newName = renameFileName.trim();
|
|
||||||
// Add .md extension if not provided
|
|
||||||
if (!newName.includes('.')) {
|
|
||||||
newName += '.md';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newName === selectedFile.name) {
|
|
||||||
setIsRenameDialogOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
const newPath = `${memoryPath}/${newName}`;
|
|
||||||
|
|
||||||
// Check if file with new name already exists
|
|
||||||
const exists = await api.exists(newPath);
|
|
||||||
if (exists) {
|
|
||||||
logger.error('A file with this name already exists');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read current file content
|
|
||||||
const result = await api.readFile(selectedFile.path);
|
|
||||||
if (!result.success || result.content === undefined) {
|
|
||||||
logger.error('Failed to read file for rename');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write to new path
|
|
||||||
await api.writeFile(newPath, result.content);
|
|
||||||
|
|
||||||
// Delete old file
|
|
||||||
await api.deleteFile(selectedFile.path);
|
|
||||||
|
|
||||||
setIsRenameDialogOpen(false);
|
|
||||||
setRenameFileName('');
|
|
||||||
|
|
||||||
// Reload files and select the renamed file
|
|
||||||
await loadMemoryFiles();
|
|
||||||
|
|
||||||
// Update selected file with new name and path
|
|
||||||
const renamedFile: MemoryFile = {
|
|
||||||
name: newName,
|
|
||||||
path: newPath,
|
|
||||||
content: result.content,
|
|
||||||
};
|
|
||||||
setSelectedFile(renamedFile);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to rename file:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Delete file from list (used by dropdown)
|
|
||||||
const handleDeleteFromList = async (file: MemoryFile) => {
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
await api.deleteFile(file.path);
|
|
||||||
|
|
||||||
// Clear selection if this was the selected file
|
|
||||||
if (selectedFile?.path === file.path) {
|
|
||||||
setSelectedFile(null);
|
|
||||||
setEditedContent('');
|
|
||||||
setHasChanges(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadMemoryFiles();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to delete file:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!currentProject) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex-1 flex items-center justify-center"
|
|
||||||
data-testid="memory-view-no-project"
|
|
||||||
>
|
|
||||||
<p className="text-muted-foreground">No project selected</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex items-center justify-center" data-testid="memory-view-loading">
|
|
||||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="memory-view">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Brain className="w-5 h-5 text-muted-foreground" />
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold">Memory Layer</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
View and edit AI memory files for this project
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={loadMemoryFiles}
|
|
||||||
data-testid="refresh-memory-button"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsCreateMemoryOpen(true)}
|
|
||||||
data-testid="create-memory-button"
|
|
||||||
>
|
|
||||||
<FilePlus className="w-4 h-4 mr-2" />
|
|
||||||
Create Memory File
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main content area with file list and editor */}
|
|
||||||
<div className="flex-1 flex overflow-hidden">
|
|
||||||
{/* Left Panel - File List */}
|
|
||||||
<div className="w-64 border-r border-border flex flex-col overflow-hidden">
|
|
||||||
<div className="p-3 border-b border-border">
|
|
||||||
<h2 className="text-sm font-semibold text-muted-foreground">
|
|
||||||
Memory Files ({memoryFiles.length})
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto p-2" data-testid="memory-file-list">
|
|
||||||
{memoryFiles.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full text-center p-4">
|
|
||||||
<Brain className="w-8 h-8 text-muted-foreground mb-2" />
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No memory files yet.
|
|
||||||
<br />
|
|
||||||
Create a memory file to get started.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{memoryFiles.map((file) => (
|
|
||||||
<div
|
|
||||||
key={file.path}
|
|
||||||
onClick={() => handleSelectFile(file)}
|
|
||||||
className={cn(
|
|
||||||
'group w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors cursor-pointer',
|
|
||||||
selectedFile?.path === file.path
|
|
||||||
? 'bg-primary/20 text-foreground border border-primary/30'
|
|
||||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
|
||||||
)}
|
|
||||||
data-testid={`memory-file-${file.name}`}
|
|
||||||
>
|
|
||||||
<FileText className="w-4 h-4 flex-shrink-0" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<span className="truncate text-sm block">{file.name}</span>
|
|
||||||
</div>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-accent rounded transition-opacity"
|
|
||||||
data-testid={`memory-file-menu-${file.name}`}
|
|
||||||
>
|
|
||||||
<MoreVertical className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
setRenameFileName(file.name);
|
|
||||||
setSelectedFile(file);
|
|
||||||
setIsRenameDialogOpen(true);
|
|
||||||
}}
|
|
||||||
data-testid={`rename-memory-file-${file.name}`}
|
|
||||||
>
|
|
||||||
<Pencil className="w-4 h-4 mr-2" />
|
|
||||||
Rename
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleDeleteFromList(file)}
|
|
||||||
className="text-red-500 focus:text-red-500"
|
|
||||||
data-testid={`delete-memory-file-${file.name}`}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Panel - Editor/Preview */}
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
|
||||||
{selectedFile ? (
|
|
||||||
<>
|
|
||||||
{/* File toolbar */}
|
|
||||||
<div className="flex items-center justify-between p-3 border-b border-border bg-card">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
|
||||||
<span className="text-sm font-medium truncate">{selectedFile.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsPreviewMode(!isPreviewMode)}
|
|
||||||
data-testid="toggle-preview-mode"
|
|
||||||
>
|
|
||||||
{isPreviewMode ? (
|
|
||||||
<>
|
|
||||||
<Pencil className="w-4 h-4 mr-2" />
|
|
||||||
Edit
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Eye className="w-4 h-4 mr-2" />
|
|
||||||
Preview
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={saveFile}
|
|
||||||
disabled={!hasChanges || isSaving}
|
|
||||||
data-testid="save-memory-file"
|
|
||||||
>
|
|
||||||
<Save className="w-4 h-4 mr-2" />
|
|
||||||
{isSaving ? 'Saving...' : hasChanges ? 'Save' : 'Saved'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsDeleteDialogOpen(true)}
|
|
||||||
className="text-red-500 hover:text-red-400 hover:border-red-500/50"
|
|
||||||
data-testid="delete-memory-file"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content area */}
|
|
||||||
<div className="flex-1 overflow-hidden p-4">
|
|
||||||
{isPreviewMode ? (
|
|
||||||
<Card className="h-full overflow-auto p-4" data-testid="markdown-preview">
|
|
||||||
<Markdown>{editedContent}</Markdown>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<Card className="h-full overflow-hidden">
|
|
||||||
<textarea
|
|
||||||
className="w-full h-full p-4 font-mono text-sm bg-transparent resize-none focus:outline-none"
|
|
||||||
value={editedContent}
|
|
||||||
onChange={(e) => handleContentChange(e.target.value)}
|
|
||||||
placeholder="Enter memory content here..."
|
|
||||||
spellCheck={false}
|
|
||||||
data-testid="memory-editor"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1 flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<Brain className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
|
|
||||||
<p className="text-foreground-secondary">Select a file to view or edit</p>
|
|
||||||
<p className="text-muted-foreground text-sm mt-1">
|
|
||||||
Memory files help AI agents learn from past interactions
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Create Memory Dialog */}
|
|
||||||
<Dialog open={isCreateMemoryOpen} onOpenChange={setIsCreateMemoryOpen}>
|
|
||||||
<DialogContent
|
|
||||||
data-testid="create-memory-dialog"
|
|
||||||
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col"
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create Memory File</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Create a new memory file to store learnings and patterns for AI agents.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-4 flex-1 overflow-auto">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="memory-filename">File Name</Label>
|
|
||||||
<Input
|
|
||||||
id="memory-filename"
|
|
||||||
value={newMemoryName}
|
|
||||||
onChange={(e) => setNewMemoryName(e.target.value)}
|
|
||||||
placeholder="my-learnings.md"
|
|
||||||
data-testid="new-memory-name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="memory-content">Content</Label>
|
|
||||||
<textarea
|
|
||||||
id="memory-content"
|
|
||||||
value={newMemoryContent}
|
|
||||||
onChange={(e) => setNewMemoryContent(e.target.value)}
|
|
||||||
placeholder="Enter your memory content here..."
|
|
||||||
className="w-full h-60 p-3 font-mono text-sm bg-background border border-border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent"
|
|
||||||
spellCheck={false}
|
|
||||||
data-testid="new-memory-content"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setIsCreateMemoryOpen(false);
|
|
||||||
setNewMemoryName('');
|
|
||||||
setNewMemoryContent('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleCreateMemory}
|
|
||||||
disabled={!newMemoryName.trim()}
|
|
||||||
data-testid="confirm-create-memory"
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
|
||||||
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
|
||||||
<DialogContent data-testid="delete-memory-dialog">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete Memory File</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Are you sure you want to delete "{selectedFile?.name}"? This action cannot be undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleDeleteFile}
|
|
||||||
className="bg-red-600 hover:bg-red-700"
|
|
||||||
data-testid="confirm-delete-file"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Rename Dialog */}
|
|
||||||
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
|
|
||||||
<DialogContent data-testid="rename-memory-dialog">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Rename Memory File</DialogTitle>
|
|
||||||
<DialogDescription>Enter a new name for "{selectedFile?.name}".</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="rename-filename">File Name</Label>
|
|
||||||
<Input
|
|
||||||
id="rename-filename"
|
|
||||||
value={renameFileName}
|
|
||||||
onChange={(e) => setRenameFileName(e.target.value)}
|
|
||||||
placeholder="Enter new filename"
|
|
||||||
data-testid="rename-file-input"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' && renameFileName.trim()) {
|
|
||||||
handleRenameFile();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setIsRenameDialogOpen(false);
|
|
||||||
setRenameFileName('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleRenameFile}
|
|
||||||
disabled={!renameFileName.trim() || renameFileName === selectedFile?.name}
|
|
||||||
data-testid="confirm-rename-file"
|
|
||||||
>
|
|
||||||
Rename
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 { SettingsHeader } from './settings-view/components/settings-header';
|
||||||
import { KeyboardMapDialog } from './settings-view/components/keyboard-map-dialog';
|
import { SettingsContent } from './settings-view/settings-content';
|
||||||
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';
|
|
||||||
|
|
||||||
export function SettingsView() {
|
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 (
|
return (
|
||||||
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="settings-view">
|
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="settings-view">
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<SettingsHeader />
|
<SettingsHeader />
|
||||||
|
|
||||||
{/* Content Area with Sidebar */}
|
{/* Content Area with Sidebar */}
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<SettingsContent />
|
||||||
{/* 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}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface SettingsNavigationProps {
|
|||||||
activeSection: SettingsViewId;
|
activeSection: SettingsViewId;
|
||||||
currentProject: Project | null;
|
currentProject: Project | null;
|
||||||
onNavigate: (sectionId: SettingsViewId) => void;
|
onNavigate: (sectionId: SettingsViewId) => void;
|
||||||
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavButton({
|
function NavButton({
|
||||||
@@ -167,11 +168,13 @@ export function SettingsNavigation({
|
|||||||
activeSection,
|
activeSection,
|
||||||
currentProject,
|
currentProject,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
|
compact = false,
|
||||||
}: SettingsNavigationProps) {
|
}: SettingsNavigationProps) {
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className={cn(
|
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',
|
'border-r border-border/50',
|
||||||
'bg-gradient-to-b from-card/80 via-card/60 to-card/40 backdrop-blur-xl'
|
'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 { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
|
||||||
import { useEffect, useState, useCallback, useDeferredValue, useRef } from 'react';
|
import { useEffect, useState, useCallback, useDeferredValue, useRef } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
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 {
|
import {
|
||||||
FileBrowserProvider,
|
FileBrowserProvider,
|
||||||
useFileBrowser,
|
useFileBrowser,
|
||||||
@@ -73,9 +75,11 @@ function RootLayoutContent() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
|
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
|
||||||
|
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||||
const authChecked = useAuthStore((s) => s.authChecked);
|
const authChecked = useAuthStore((s) => s.authChecked);
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
const { openFileBrowser } = useFileBrowser();
|
const { openFileBrowser } = useFileBrowser();
|
||||||
|
const { position: dockPosition } = useDockState();
|
||||||
|
|
||||||
// Load project settings when switching projects
|
// Load project settings when switching projects
|
||||||
useProjectSettingsLoader();
|
useProjectSettingsLoader();
|
||||||
@@ -83,6 +87,7 @@ function RootLayoutContent() {
|
|||||||
const isSetupRoute = location.pathname === '/setup';
|
const isSetupRoute = location.pathname === '/setup';
|
||||||
const isLoginRoute = location.pathname === '/login';
|
const isLoginRoute = location.pathname === '/login';
|
||||||
const isLoggedOutRoute = location.pathname === '/logged-out';
|
const isLoggedOutRoute = location.pathname === '/logged-out';
|
||||||
|
const isDashboardRoute = location.pathname === '/dashboard';
|
||||||
|
|
||||||
// Sandbox environment check state
|
// Sandbox environment check state
|
||||||
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
|
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
|
||||||
@@ -128,6 +133,21 @@ function RootLayoutContent() {
|
|||||||
};
|
};
|
||||||
}, [handleStreamerPanelShortcut]);
|
}, [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();
|
const effectiveTheme = getEffectiveTheme();
|
||||||
// Defer the theme value to keep UI responsive during rapid hover changes
|
// Defer the theme value to keep UI responsive during rapid hover changes
|
||||||
const deferredTheme = useDeferredValue(effectiveTheme);
|
const deferredTheme = useDeferredValue(effectiveTheme);
|
||||||
@@ -264,13 +284,36 @@ function RootLayoutContent() {
|
|||||||
// Initialize API key for Electron mode
|
// Initialize API key for Electron mode
|
||||||
await initApiKey();
|
await initApiKey();
|
||||||
|
|
||||||
// 1. Verify session (Single Request, ALL modes)
|
// 1. Verify session with retry logic for server restarts
|
||||||
|
// verifySession() returns false for definitive auth failures (401/403)
|
||||||
|
// and throws for network/timeout errors (server might be restarting)
|
||||||
let isValid = false;
|
let isValid = false;
|
||||||
try {
|
const maxSessionAttempts = 8;
|
||||||
isValid = await verifySession();
|
const baseSessionDelayMs = 500;
|
||||||
} catch (error) {
|
|
||||||
logger.warn('Session verification failed (likely network/server issue):', error);
|
for (let attempt = 1; attempt <= maxSessionAttempts; attempt++) {
|
||||||
isValid = false;
|
try {
|
||||||
|
isValid = await verifySession();
|
||||||
|
// If we get here, verifySession completed (returned true or false)
|
||||||
|
// false = definitive auth failure (401/403), no point retrying
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
// Network/timeout error - server might be restarting
|
||||||
|
if (attempt === maxSessionAttempts) {
|
||||||
|
logger.warn(
|
||||||
|
`Session verification failed after ${maxSessionAttempts} attempts (server unavailable):`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
isValid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delayMs = Math.min(2000, baseSessionDelayMs * attempt);
|
||||||
|
logger.info(
|
||||||
|
`Session verification failed (attempt ${attempt}/${maxSessionAttempts}), server may be restarting. Retrying in ${delayMs}ms...`
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
@@ -424,10 +467,14 @@ function RootLayoutContent() {
|
|||||||
testConnection();
|
testConnection();
|
||||||
}, [setIpcConnected]);
|
}, [setIpcConnected]);
|
||||||
|
|
||||||
// Restore to board view if a project was previously open
|
// Navigate to appropriate view based on project state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMounted && currentProject && location.pathname === '/') {
|
if (isMounted && location.pathname === '/') {
|
||||||
navigate({ to: '/board' });
|
if (currentProject) {
|
||||||
|
navigate({ to: '/board' });
|
||||||
|
} else {
|
||||||
|
navigate({ to: '/dashboard' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [isMounted, currentProject, location.pathname, navigate]);
|
}, [isMounted, currentProject, location.pathname, navigate]);
|
||||||
|
|
||||||
@@ -501,7 +548,7 @@ function RootLayoutContent() {
|
|||||||
|
|
||||||
return (
|
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 */}
|
{/* Full-width titlebar drag region for Electron window dragging */}
|
||||||
{isElectron() && (
|
{isElectron() && (
|
||||||
<div
|
<div
|
||||||
@@ -509,13 +556,21 @@ function RootLayoutContent() {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Sidebar />
|
{!isDashboardRoute && <TopBar />}
|
||||||
<div
|
<div
|
||||||
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
|
className="flex-1 flex overflow-hidden transition-all duration-300"
|
||||||
style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}
|
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>
|
</div>
|
||||||
|
{/* Single BottomDock instance - handles its own positioning */}
|
||||||
|
{!isDashboardRoute && currentProject && <BottomDock />}
|
||||||
|
|
||||||
{/* Hidden streamer panel - opens with "\" key, pushes content */}
|
{/* Hidden streamer panel - opens with "\" key, pushes content */}
|
||||||
<div
|
<div
|
||||||
@@ -530,6 +585,7 @@ function RootLayoutContent() {
|
|||||||
onConfirm={handleSandboxConfirm}
|
onConfirm={handleSandboxConfirm}
|
||||||
onDeny={handleSandboxDeny}
|
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,
|
||||||
|
});
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
|
||||||
import { MemoryView } from '@/components/views/memory-view';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/memory')({
|
|
||||||
component: MemoryView,
|
|
||||||
});
|
|
||||||
@@ -217,7 +217,6 @@ export interface KeyboardShortcuts {
|
|||||||
agent: string;
|
agent: string;
|
||||||
spec: string;
|
spec: string;
|
||||||
context: string;
|
context: string;
|
||||||
memory: string;
|
|
||||||
settings: string;
|
settings: string;
|
||||||
profiles: string;
|
profiles: string;
|
||||||
terminal: string;
|
terminal: string;
|
||||||
@@ -253,7 +252,6 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
|
|||||||
agent: 'A',
|
agent: 'A',
|
||||||
spec: 'D',
|
spec: 'D',
|
||||||
context: 'C',
|
context: 'C',
|
||||||
memory: 'Y',
|
|
||||||
settings: 'S',
|
settings: 'S',
|
||||||
profiles: 'M',
|
profiles: 'M',
|
||||||
terminal: 'T',
|
terminal: 'T',
|
||||||
@@ -469,6 +467,21 @@ export interface PersistedTerminalSettings {
|
|||||||
maxSessions: number;
|
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 {
|
export interface AppState {
|
||||||
// Project state
|
// Project state
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
@@ -476,6 +489,7 @@ export interface AppState {
|
|||||||
trashedProjects: TrashedProject[];
|
trashedProjects: TrashedProject[];
|
||||||
projectHistory: string[]; // Array of project IDs in MRU order (most recent first)
|
projectHistory: string[]; // Array of project IDs in MRU order (most recent first)
|
||||||
projectHistoryIndex: number; // Current position in project history for cycling
|
projectHistoryIndex: number; // Current position in project history for cycling
|
||||||
|
pinnedProjectIds: string[]; // Array of project IDs that are pinned to the top bar
|
||||||
|
|
||||||
// View state
|
// View state
|
||||||
currentView: ViewMode;
|
currentView: ViewMode;
|
||||||
@@ -518,6 +532,7 @@ export interface AppState {
|
|||||||
// Kanban Card Display Settings
|
// Kanban Card Display Settings
|
||||||
kanbanCardDetailLevel: KanbanCardDetailLevel; // Level of detail shown on kanban cards
|
kanbanCardDetailLevel: KanbanCardDetailLevel; // Level of detail shown on kanban cards
|
||||||
boardViewMode: BoardViewMode; // Whether to show kanban or dependency graph view
|
boardViewMode: BoardViewMode; // Whether to show kanban or dependency graph view
|
||||||
|
boardSearchQuery: string; // Search query for filtering kanban cards
|
||||||
|
|
||||||
// Feature Default Settings
|
// Feature Default Settings
|
||||||
defaultSkipTests: boolean; // Default value for skip tests when creating new features
|
defaultSkipTests: boolean; // Default value for skip tests when creating new features
|
||||||
@@ -664,6 +679,17 @@ export interface AppState {
|
|||||||
lastProjectDir: string;
|
lastProjectDir: string;
|
||||||
/** Recently accessed folders for quick access */
|
/** Recently accessed folders for quick access */
|
||||||
recentFolders: string[];
|
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
|
// Claude Usage interface matching the server response
|
||||||
@@ -824,6 +850,8 @@ export interface AppActions {
|
|||||||
cyclePrevProject: () => void; // Cycle back through project history (Q)
|
cyclePrevProject: () => void; // Cycle back through project history (Q)
|
||||||
cycleNextProject: () => void; // Cycle forward through project history (E)
|
cycleNextProject: () => void; // Cycle forward through project history (E)
|
||||||
clearProjectHistory: () => void; // Clear history, keeping only current project
|
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
|
// View actions
|
||||||
setCurrentView: (view: ViewMode) => void;
|
setCurrentView: (view: ViewMode) => void;
|
||||||
@@ -879,6 +907,7 @@ export interface AppActions {
|
|||||||
// Kanban Card Settings actions
|
// Kanban Card Settings actions
|
||||||
setKanbanCardDetailLevel: (level: KanbanCardDetailLevel) => void;
|
setKanbanCardDetailLevel: (level: KanbanCardDetailLevel) => void;
|
||||||
setBoardViewMode: (mode: BoardViewMode) => void;
|
setBoardViewMode: (mode: BoardViewMode) => void;
|
||||||
|
setBoardSearchQuery: (query: string) => void;
|
||||||
|
|
||||||
// Feature Default Settings actions
|
// Feature Default Settings actions
|
||||||
setDefaultSkipTests: (skip: boolean) => void;
|
setDefaultSkipTests: (skip: boolean) => void;
|
||||||
@@ -1095,6 +1124,19 @@ export interface AppActions {
|
|||||||
// Codex Usage Tracking actions
|
// Codex Usage Tracking actions
|
||||||
setCodexUsage: (usage: CodexUsage | null) => void;
|
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
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
@@ -1151,6 +1193,7 @@ const initialState: AppState = {
|
|||||||
trashedProjects: [],
|
trashedProjects: [],
|
||||||
projectHistory: [],
|
projectHistory: [],
|
||||||
projectHistoryIndex: -1,
|
projectHistoryIndex: -1,
|
||||||
|
pinnedProjectIds: [],
|
||||||
currentView: 'welcome',
|
currentView: 'welcome',
|
||||||
sidebarOpen: true,
|
sidebarOpen: true,
|
||||||
lastSelectedSessionByProject: {},
|
lastSelectedSessionByProject: {},
|
||||||
@@ -1171,6 +1214,7 @@ const initialState: AppState = {
|
|||||||
maxConcurrency: 3, // Default to 3 concurrent agents
|
maxConcurrency: 3, // Default to 3 concurrent agents
|
||||||
kanbanCardDetailLevel: 'standard', // Default to standard detail level
|
kanbanCardDetailLevel: 'standard', // Default to standard detail level
|
||||||
boardViewMode: 'kanban', // Default to kanban view
|
boardViewMode: 'kanban', // Default to kanban view
|
||||||
|
boardSearchQuery: '', // Default to empty search
|
||||||
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
||||||
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
|
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
|
||||||
skipVerificationInAutoMode: false, // Default to disabled (require dependencies to be verified)
|
skipVerificationInAutoMode: false, // Default to disabled (require dependencies to be verified)
|
||||||
@@ -1240,6 +1284,8 @@ const initialState: AppState = {
|
|||||||
worktreePanelCollapsed: false,
|
worktreePanelCollapsed: false,
|
||||||
lastProjectDir: '',
|
lastProjectDir: '',
|
||||||
recentFolders: [],
|
recentFolders: [],
|
||||||
|
// GitHub Cache
|
||||||
|
gitHubCacheByProject: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||||
@@ -1479,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
|
// View actions
|
||||||
setCurrentView: (view) => set({ currentView: view }),
|
setCurrentView: (view) => set({ currentView: view }),
|
||||||
toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }),
|
toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }),
|
||||||
@@ -1756,6 +1815,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
// Kanban Card Settings actions
|
// Kanban Card Settings actions
|
||||||
setKanbanCardDetailLevel: (level) => set({ kanbanCardDetailLevel: level }),
|
setKanbanCardDetailLevel: (level) => set({ kanbanCardDetailLevel: level }),
|
||||||
setBoardViewMode: (mode) => set({ boardViewMode: mode }),
|
setBoardViewMode: (mode) => set({ boardViewMode: mode }),
|
||||||
|
setBoardSearchQuery: (query) => set({ boardSearchQuery: query }),
|
||||||
|
|
||||||
// Feature Default Settings actions
|
// Feature Default Settings actions
|
||||||
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
|
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
|
||||||
@@ -3018,6 +3078,43 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
codexUsageLastUpdated: usage ? Date.now() : null,
|
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
|
// Pipeline actions
|
||||||
setPipelineConfig: (projectPath, config) => {
|
setPipelineConfig: (projectPath, config) => {
|
||||||
set({
|
set({
|
||||||
|
|||||||
48
package-lock.json
generated
48
package-lock.json
generated
@@ -13,7 +13,9 @@
|
|||||||
"libs/*"
|
"libs/*"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"rehype-sanitize": "6.0.0",
|
"rehype-sanitize": "6.0.0",
|
||||||
"tree-kill": "1.2.2"
|
"tree-kill": "1.2.2"
|
||||||
},
|
},
|
||||||
@@ -40,7 +42,7 @@
|
|||||||
"@automaker/prompts": "1.0.0",
|
"@automaker/prompts": "1.0.0",
|
||||||
"@automaker/types": "1.0.0",
|
"@automaker/types": "1.0.0",
|
||||||
"@automaker/utils": "1.0.0",
|
"@automaker/utils": "1.0.0",
|
||||||
"@modelcontextprotocol/sdk": "1.25.2",
|
"@modelcontextprotocol/sdk": "1.25.1",
|
||||||
"@openai/codex-sdk": "^0.77.0",
|
"@openai/codex-sdk": "^0.77.0",
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
@@ -3723,9 +3725,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.25.2",
|
"version": "1.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz",
|
||||||
"integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==",
|
"integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.19.7",
|
"@hono/node-server": "^1.19.7",
|
||||||
@@ -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": {
|
"node_modules/@radix-ui/react-dialog": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
|
||||||
@@ -8720,6 +8750,16 @@
|
|||||||
"lodash": "^4.17.15"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|||||||
@@ -61,7 +61,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"rehype-sanitize": "6.0.0",
|
"rehype-sanitize": "6.0.0",
|
||||||
"tree-kill": "1.2.2"
|
"tree-kill": "1.2.2"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user