957 lines
33 KiB
TypeScript
957 lines
33 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
import slug from "slug";
|
|
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 EditProjectFormProps {
|
|
project: Project;
|
|
onCancel: () => void;
|
|
onSuccess: () => void;
|
|
}
|
|
|
|
function EditProjectForm({
|
|
project,
|
|
onCancel,
|
|
onSuccess,
|
|
}: EditProjectFormProps) {
|
|
const [name, setName] = useState(project.name);
|
|
const [slugValue, setSlugValue] = useState(project.slug);
|
|
const [gitUrl, setGitUrl] = useState(project.gitUrl || "");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState("");
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!name || !slugValue) {
|
|
setError("Name and slug are required");
|
|
return;
|
|
}
|
|
|
|
setSubmitting(true);
|
|
setError("");
|
|
|
|
try {
|
|
await projectApi.update(project._id, {
|
|
name,
|
|
slug: slug(slugValue),
|
|
gitUrl: gitUrl || undefined,
|
|
});
|
|
onSuccess();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to update project");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-lg">
|
|
<h2 className="text-xl font-semibold mb-6">Edit 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"
|
|
/>
|
|
<p className="text-xs text-text-muted mt-1">
|
|
Used for display purposes only
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm text-text-secondary mb-1">
|
|
Project Slug *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={slugValue}
|
|
onChange={(e) => setSlugValue(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. Changing this will
|
|
affect the workspace directory name on drones.
|
|
</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 ? "Updating..." : "Save Changes"}
|
|
</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;
|
|
onUpdate: () => void;
|
|
}
|
|
|
|
function ProjectInspector({ project, onDelete, onUpdate }: ProjectInspectorProps) {
|
|
const [deleting, setDeleting] = useState(false);
|
|
const [editing, setEditing] = 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>
|
|
{editing ? (
|
|
<EditProjectForm
|
|
project={project}
|
|
onCancel={() => setEditing(false)}
|
|
onSuccess={() => {
|
|
setEditing(false);
|
|
onUpdate();
|
|
}}
|
|
/>
|
|
) : (
|
|
<>
|
|
<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 flex gap-3">
|
|
<button
|
|
onClick={() => setEditing(true)}
|
|
className="px-4 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors"
|
|
>
|
|
Edit Project
|
|
</button>
|
|
<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;
|
|
selectedDrone: DroneRegistration | null;
|
|
onSelectDrone: (drone: DroneRegistration) => void;
|
|
onOpenChatSession: (sessionId: string) => void;
|
|
}
|
|
|
|
function RightSidebar({
|
|
project,
|
|
selectedDrone,
|
|
onSelectDrone,
|
|
onOpenChatSession,
|
|
}: RightSidebarProps) {
|
|
const [drones, setDrones] = useState<DroneRegistration[]>([]);
|
|
const [chatSessions, setChatSessions] = useState<ChatSession[]>([]);
|
|
const [providers, setProviders] = useState<AiProvider[]>([]);
|
|
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.sort((a, b) => a.name.localeCompare(b.name)));
|
|
} catch (err) {
|
|
console.error("Failed to load sidebar data", err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
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={() => onSelectDrone(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 {
|
|
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].sort((a, b) => a.name.localeCompare(b.name)).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 [selectedDrone, setSelectedDrone] = useState<DroneRegistration | 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 handleProjectUpdated = () => {
|
|
loadProjects();
|
|
};
|
|
|
|
const handleSelectDrone = (drone: DroneRegistration) => {
|
|
setSelectedDrone(drone);
|
|
};
|
|
|
|
const handleOpenChatSession = async (sessionId: string) => {
|
|
if (!selectedProject || !selectedDrone) return;
|
|
|
|
try {
|
|
const session = await chatSessionApi.get(sessionId);
|
|
const success = await socketClient.requestSessionLock(
|
|
selectedDrone,
|
|
selectedProject,
|
|
session,
|
|
);
|
|
if (!success) {
|
|
console.error("Failed to lock drone session");
|
|
return;
|
|
}
|
|
localStorage.setItem('dtp_drone_registration', JSON.stringify(selectedDrone));
|
|
navigate(`/projects/${selectedProject._id}/chat-session/${sessionId}`);
|
|
} catch (err) {
|
|
console.error("Failed to open chat session", err);
|
|
}
|
|
};
|
|
|
|
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}
|
|
onUpdate={handleProjectUpdated}
|
|
/>
|
|
|
|
{/* Right Sidebar - Drones & Chat Sessions */}
|
|
<RightSidebar
|
|
project={selectedProject}
|
|
selectedDrone={selectedDrone}
|
|
onSelectDrone={handleSelectDrone}
|
|
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>
|
|
);
|
|
}
|