gadget/gadget-code/frontend/src/pages/ProjectManager.tsx
2026-05-01 08:13:22 -04:00

706 lines
27 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import type { User, Project } from '../lib/api';
import { projectApi, droneApi, chatSessionApi, type DroneRegistration, type ChatSession, type AiProvider, providerApi } from '../lib/api';
import { socketClient } from '../lib/socket';
interface ProjectManagerProps {
user: User | null;
}
function NewProjectForm({ onCancel, onSuccess }: { onCancel: () => void; onSuccess: () => void }) {
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [gitUrl, setGitUrl] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name || !slug) {
setError('Name and slug are required');
return;
}
setSubmitting(true);
setError('');
try {
await projectApi.create({ name, slug, gitUrl: gitUrl || undefined });
onSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create project');
} finally {
setSubmitting(false);
}
};
return (
<div className="max-w-lg">
<h2 className="text-xl font-semibold mb-6">Create New Project</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-text-secondary mb-1">Project Name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
placeholder="My Project"
/>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1">Project Slug *</label>
<input
type="text"
value={slug}
onChange={(e) => setSlug(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
placeholder="my-project"
/>
<p className="text-xs text-text-muted mt-1">Unique identifier for the project directory</p>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1">Git Repository URL</label>
<input
type="text"
value={gitUrl}
onChange={(e) => setGitUrl(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
placeholder="https://github.com/user/repo.git"
/>
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={submitting}
className="px-4 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50"
>
{submitting ? 'Creating...' : 'Create Project'}
</button>
<button
type="button"
onClick={onCancel}
className="px-4 py-2 border border-border-default text-text-secondary rounded hover:bg-bg-tertiary transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
);
}
interface ProjectInspectorProps {
project: Project;
onDelete: () => void;
}
function ProjectInspector({ project, onDelete }: ProjectInspectorProps) {
const [deleting, setDeleting] = useState(false);
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this project? This cannot be undone.')) {
return;
}
setDeleting(true);
try {
await projectApi.delete(project._id);
onDelete();
} catch (err) {
console.error('Failed to delete project', err);
} finally {
setDeleting(false);
}
};
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-3xl">
<h2 className="text-xl font-semibold mb-6">Project Inspector</h2>
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-bg-secondary border border-border-default rounded">
<div className="text-sm text-text-muted mb-1">Name</div>
<div className="text-text-primary font-medium">{project.name}</div>
</div>
<div className="p-4 bg-bg-secondary border border-border-default rounded">
<div className="text-sm text-text-muted mb-1">Slug</div>
<div className="font-mono text-text-primary">{project.slug}</div>
</div>
</div>
<div className="p-4 bg-bg-secondary border border-border-default rounded">
<div className="text-sm text-text-muted mb-1">Git URL</div>
<div className="text-text-primary font-mono text-sm break-all">
{project.gitUrl || <span className="text-text-muted">Not configured</span>}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-bg-secondary border border-border-default rounded">
<div className="text-sm text-text-muted mb-1">Status</div>
<div className="text-text-primary capitalize">
<span className={`inline-block w-2 h-2 rounded-full mr-2 ${
project.status === 'active' ? 'bg-green-500' :
project.status === 'inactive' ? 'bg-yellow-500' : 'bg-gray-500'
}`} />
{project.status}
</div>
</div>
<div className="p-4 bg-bg-secondary border border-border-default rounded">
<div className="text-sm text-text-muted mb-1">Created</div>
<div className="text-text-primary">
{new Date(project.createdAt).toLocaleDateString()}
</div>
</div>
</div>
<div className="pt-6 border-t border-border-subtle">
<button
onClick={handleDelete}
disabled={deleting}
className="px-4 py-2 border border-red-600 text-red-500 rounded hover:bg-red-900/20 transition-colors disabled:opacity-50"
>
{deleting ? 'Deleting...' : 'Delete Project'}
</button>
</div>
</div>
</div>
</div>
);
}
interface RightSidebarProps {
project: Project;
onOpenChatSession: (sessionId: string) => void;
}
function RightSidebar({ project, onOpenChatSession }: RightSidebarProps) {
const [drones, setDrones] = useState<DroneRegistration[]>([]);
const [chatSessions, setChatSessions] = useState<ChatSession[]>([]);
const [providers, setProviders] = useState<AiProvider[]>([]);
const [selectedDrone, setSelectedDrone] = useState<DroneRegistration | null>(null);
const [showNewChatModal, setShowNewChatModal] = useState(false);
const [loading, setLoading] = useState(true);
const [deletingSessions, setDeletingSessions] = useState<Set<string>>(new Set());
useEffect(() => {
loadData();
}, [project]);
const loadData = async () => {
setLoading(true);
try {
// Load available drones (backend filters offline)
const availableDrones = await droneApi.getAll();
setDrones(availableDrones);
// Load chat sessions for this project
const sessions = await chatSessionApi.getAll(project._id);
setChatSessions(sessions);
// Load providers for new chat session
const allProviders = await providerApi.getAll();
setProviders(allProviders);
} catch (err) {
console.error('Failed to load sidebar data', err);
} finally {
setLoading(false);
}
};
const handleSelectDrone = (drone: DroneRegistration) => {
setSelectedDrone(drone);
};
const handleCreateChatSession = async (data: {
providerId: string;
selectedModel: string;
mode: string;
name?: string;
}) => {
try {
const session = await chatSessionApi.create({
projectId: project._id,
providerId: data.providerId,
selectedModel: data.selectedModel,
mode: data.mode as any,
name: data.name,
});
setShowNewChatModal(false);
// Lock the drone to this session BEFORE navigating
if (selectedDrone) {
const success = await socketClient.requestSessionLock(selectedDrone, project, session);
if (!success) {
console.error('Failed to lock drone session');
}
}
onOpenChatSession(session._id);
await loadData(); // Refresh list
} catch (err) {
console.error('Failed to create chat session', err);
}
};
const handleDeleteChatSession = async (sessionId: string) => {
if (!confirm('Delete this chat session?')) return;
setDeletingSessions(prev => new Set(prev).add(sessionId));
try {
await chatSessionApi.delete(sessionId);
await loadData();
} catch (err) {
console.error('Failed to delete chat session', err);
} finally {
setDeletingSessions(prev => {
const next = new Set(prev);
next.delete(sessionId);
return next;
});
}
};
return (
<>
<aside className="w-80 border-l border-border-subtle bg-bg-secondary flex flex-col overflow-hidden">
{/* Available Drones Section - 40% of available space (excluding button row) */}
<div className="flex flex-col min-h-0 flex-[2]" style={{ minHeight: 0 }}>
<div className="p-3 border-b border-border-subtle flex-shrink-0">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
Available Drones ({drones.length})
</h3>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-2 min-h-0">
{loading ? (
<p className="text-sm text-text-muted p-2">Loading...</p>
) : drones.length === 0 ? (
<div className="text-text-muted text-sm p-2">
No available drones. Start a gadget-drone instance to begin working.
</div>
) : (
drones.map((drone) => (
<div
key={drone._id}
className={`p-3 border rounded transition-colors ${
selectedDrone?._id === drone._id
? 'border-brand bg-bg-tertiary'
: 'border-border-default bg-bg-tertiary hover:bg-bg-elevated'
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${
drone.status === 'available'
? 'bg-green-500'
: drone.status === 'busy'
? 'bg-yellow-500'
: 'bg-gray-500'
}`}
/>
<span className="font-mono text-sm font-medium">{drone.hostname}</span>
</div>
<button
onClick={() => handleSelectDrone(drone)}
disabled={drone.status === 'busy'}
className={`px-2 py-1 text-xs rounded ${
selectedDrone?._id === drone._id
? 'bg-brand text-white'
: drone.status === 'busy'
? 'bg-bg-tertiary text-text-muted cursor-not-allowed'
: 'border border-border-default hover:bg-bg-elevated'
}`}
>
{selectedDrone?._id === drone._id ? 'Selected' : 'Select'}
</button>
</div>
<div className="text-xs text-text-muted truncate">
{drone.workspaceDir}
</div>
<div className="text-xs text-text-muted mt-1">
Registered: {new Date(drone.createdAt).toLocaleDateString()}
</div>
</div>
))
)}
</div>
</div>
{/* Chat Sessions Section - 60% of available space (excluding button row) */}
<div className="flex flex-col min-h-0 flex-[3]" style={{ minHeight: 0 }}>
<div className="p-3 border-b border-border-subtle flex-shrink-0 flex items-center justify-between">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
Chat Sessions ({chatSessions.length})
</h3>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-2 min-h-0">
{loading ? (
<p className="text-sm text-text-muted p-2">Loading...</p>
) : chatSessions.length === 0 ? (
<div className="text-text-muted text-sm p-2">
No chat sessions yet. Create a new session to start working.
</div>
) : (
chatSessions.map((session) => (
<div
key={session._id}
className="p-3 border border-border-default rounded bg-bg-tertiary hover:bg-bg-elevated transition-colors cursor-pointer group relative"
onClick={() => onOpenChatSession(session._id)}
>
<div className="font-medium text-text-primary text-sm mb-1">
{session.name}
</div>
<div className="text-xs text-text-muted flex items-center gap-2">
<span className="capitalize">{session.mode}</span>
<span></span>
<span className="truncate">{session.selectedModel}</span>
</div>
<div className="text-xs text-text-muted mt-1">
{new Date(session.createdAt).toLocaleDateString()}
</div>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteChatSession(session._id);
}}
disabled={deletingSessions.has(session._id)}
className="absolute top-2 right-2 p-1.5 rounded opacity-0 group-hover:opacity-100 hover:bg-red-900/50 text-text-muted hover:text-red-400 transition-all disabled:opacity-50"
title="Delete session"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
))
)}
</div>
</div>
{/* New Chat Session Button - Fixed at bottom */}
<div className="p-3 border-t border-border-subtle bg-bg-secondary flex-shrink-0">
<button
onClick={() => setShowNewChatModal(true)}
disabled={!selectedDrone}
className="w-full px-4 py-2.5 bg-brand text-white rounded hover:bg-red-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
title={!selectedDrone ? 'Select a drone first' : 'Create new chat session'}
>
[New Chat Session]
</button>
</div>
</aside>
{/* New Chat Session Modal */}
{showNewChatModal && (
<NewChatSessionModal
providers={providers}
selectedDrone={selectedDrone}
onCancel={() => setShowNewChatModal(false)}
onCreate={handleCreateChatSession}
/>
)}
</>
);
}
interface NewChatSessionModalProps {
providers: AiProvider[];
selectedDrone: DroneRegistration | null;
onCancel: () => void;
onCreate: (data: { providerId: string; selectedModel: string; mode: string; name?: string }) => void;
}
function NewChatSessionModal({ providers, selectedDrone, onCancel, onCreate }: NewChatSessionModalProps) {
const [selectedProviderId, setSelectedProviderId] = useState('');
const [selectedModel, setSelectedModel] = useState('');
const [mode, setMode] = useState('build');
const [name, setName] = useState('');
const [creating, setCreating] = useState(false);
const selectedProvider = providers.find(p => p._id === selectedProviderId);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedProviderId || !selectedModel) {
return;
}
setCreating(true);
try {
await onCreate({
providerId: selectedProviderId,
selectedModel,
mode,
name: name || undefined,
});
} catch (err) {
console.error('Failed to create chat session', err);
} finally {
setCreating(false);
}
};
return (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
<div className="bg-bg-secondary border border-border-default rounded p-6 w-full max-w-md">
<h3 className="text-lg font-semibold mb-4">New Chat Session</h3>
{selectedDrone && (
<div className="mb-4 p-3 bg-bg-tertiary border border-border-default rounded">
<div className="text-xs text-text-muted mb-1">Selected Drone</div>
<div className="font-mono text-sm text-text-primary">{selectedDrone.hostname}</div>
<div className="text-xs text-text-muted truncate">{selectedDrone.workspaceDir}</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-text-secondary mb-1">Session Name (optional)</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
placeholder="Auto-generated from first prompt"
/>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1">AI Provider *</label>
<select
value={selectedProviderId}
onChange={(e) => {
setSelectedProviderId(e.target.value);
setSelectedModel('');
}}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
>
<option value="">Select a provider</option>
{providers.map((provider) => (
<option key={provider._id} value={provider._id}>
{provider.name} ({provider.apiType})
</option>
))}
</select>
</div>
{selectedProvider && (
<div>
<label className="block text-sm text-text-secondary mb-1">Model *</label>
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
disabled={selectedProvider.models.length === 0}
>
<option value="">Select a model</option>
{selectedProvider.models.map((model) => (
<option key={model.id} value={model.id}>
{model.name} {model.parameterLabel ? `(${model.parameterLabel})` : ''}
</option>
))}
</select>
{selectedProvider.models.length === 0 && (
<p className="text-xs text-text-muted mt-1">
No models discovered. Run `pnpm cli provider probe {selectedProvider._id}` to discover models.
</p>
)}
</div>
)}
<div>
<label className="block text-sm text-text-secondary mb-1">Mode</label>
<select
value={mode}
onChange={(e) => setMode(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
>
<option value="plan">Plan - Planning and brainstorming</option>
<option value="build">Build - Building and coding</option>
<option value="test">Test - Testing and debugging</option>
<option value="ship">Ship - Finalizing and shipping</option>
<option value="dev">Dev - Working on Gadget Code itself</option>
</select>
</div>
<div className="flex gap-3 pt-4">
<button
type="submit"
disabled={creating || !selectedProviderId || !selectedModel}
className="px-4 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50 flex-1"
>
{creating ? 'Creating...' : 'Create Session'}
</button>
<button
type="button"
onClick={onCancel}
className="px-4 py-2 border border-border-default text-text-secondary rounded hover:bg-bg-tertiary transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
export default function ProjectManager({ user }: ProjectManagerProps) {
const navigate = useNavigate();
const { slug } = useParams();
const [projects, setProjects] = useState<Project[]>([]);
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
const [showNewForm, setShowNewForm] = useState(false);
useEffect(() => {
loadProjects();
}, []);
useEffect(() => {
if (slug && projects.length > 0) {
const found = projects.find((p) => p.slug === slug);
setSelectedProject(found || null);
} else if (!slug) {
setSelectedProject(null);
}
}, [slug, projects]);
const loadProjects = async () => {
try {
const data = await projectApi.getAll();
setProjects(data);
} catch (err) {
console.error('Failed to load projects', err);
} finally {
setLoading(false);
}
};
const handleSelectProject = (project: Project) => {
navigate(`/projects/${project.slug}`);
};
const handleProjectCreated = () => {
setShowNewForm(false);
loadProjects();
};
const handleProjectDeleted = () => {
setSelectedProject(null);
loadProjects();
navigate('/projects');
};
const handleOpenChatSession = (sessionId: string) => {
if (selectedProject) {
navigate(`/projects/${selectedProject._id}/chat-session/${sessionId}`);
}
};
if (!user) {
return (
<div className="flex-1 flex items-center justify-center bg-bg-primary">
<p className="text-text-muted">Please sign in to view projects.</p>
</div>
);
}
if (loading) {
return (
<div className="flex-1 flex items-center justify-center bg-bg-primary">
<p className="text-text-muted">Loading projects...</p>
</div>
);
}
return (
<div className="flex-1 flex bg-bg-primary overflow-hidden">
{/* Left Sidebar - Project List */}
<aside className="w-64 border-r border-border-subtle bg-bg-secondary flex flex-col overflow-hidden">
<div className="p-3 border-b border-border-subtle flex-shrink-0">
<button
onClick={() => setShowNewForm(true)}
className="w-full px-3 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors text-sm font-medium"
>
[New Project]
</button>
</div>
<div className="flex-1 overflow-y-auto p-2">
{showNewForm ? (
<div className="p-2">
<p className="text-sm text-text-muted mb-2">Creating new project...</p>
<button
onClick={() => setShowNewForm(false)}
className="text-sm text-text-secondary hover:text-text-primary transition-colors"
>
Cancel
</button>
</div>
) : (
<>
<div className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2 px-2">
Projects ({projects.length})
</div>
{projects.length === 0 ? (
<p className="text-sm text-text-muted px-2">No projects yet.</p>
) : (
<div className="space-y-1">
{projects.map((project) => (
<button
key={project._id}
onClick={() => handleSelectProject(project)}
className={`w-full text-left px-2 py-1.5 text-sm rounded truncate transition-colors ${
selectedProject?.slug === project.slug
? 'bg-brand text-white'
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
}`}
>
{project.name}
</button>
))}
</div>
)}
</>
)}
</div>
</aside>
{/* Main Content Area */}
<div className="flex-1 flex overflow-hidden">
{showNewForm ? (
<div className="flex-1 overflow-y-auto p-6">
<NewProjectForm
onCancel={() => setShowNewForm(false)}
onSuccess={handleProjectCreated}
/>
</div>
) : selectedProject ? (
<>
{/* Center - Project Inspector */}
<ProjectInspector
project={selectedProject}
onDelete={handleProjectDeleted}
/>
{/* Right Sidebar - Drones & Chat Sessions */}
<RightSidebar
project={selectedProject}
onOpenChatSession={handleOpenChatSession}
/>
</>
) : (
<div className="flex-1 flex items-center justify-center bg-bg-primary">
<div className="text-center text-text-muted">
<p className="mb-4">Select a project to view details</p>
<p className="text-sm">or create a new project to get started</p>
</div>
</div>
)}
</div>
</div>
);
}