UI update with sidebar and settings tabs (#3288)
Co-authored-by: Nahiyan Khan <nahiyan@squareup.com> Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: Lily Delalande <119957291+lily-de@users.noreply.github.com> Co-authored-by: Spence <spencrmartin@gmail.com> Co-authored-by: spencrmartin <spencermartin@squareup.com> Co-authored-by: Judson Stephenson <Jud@users.noreply.github.com> Co-authored-by: Max Novich <mnovich@squareup.com> Co-authored-by: Best Codes <106822363+The-Best-Codes@users.noreply.github.com> Co-authored-by: caroline-a-mckenzie <cmckenzie@squareup.com> Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
import { Button } from '../ui/button';
|
||||
import { Session } from '../../sessions';
|
||||
import { Project } from '../../projects';
|
||||
import { addSessionToProject } from '../../projects';
|
||||
import { toastError, toastSuccess } from '../../toasts';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { Checkbox } from '../ui/checkbox';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface AddSessionToProjectModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
project: Project;
|
||||
availableSessions: Session[];
|
||||
onSessionsAdded: () => void;
|
||||
}
|
||||
|
||||
const AddSessionToProjectModal: React.FC<AddSessionToProjectModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
project,
|
||||
availableSessions,
|
||||
onSessionsAdded,
|
||||
}) => {
|
||||
const [selectedSessions, setSelectedSessions] = useState<string[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleToggleSession = (sessionId: string) => {
|
||||
setSelectedSessions((prev) => {
|
||||
if (prev.includes(sessionId)) {
|
||||
return prev.filter((id) => id !== sessionId);
|
||||
} else {
|
||||
return [...prev, sessionId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedSessions([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (selectedSessions.length === 0) {
|
||||
toastError({ title: 'Error', msg: 'Please select at least one session' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Add each selected session to the project
|
||||
const promises = selectedSessions.map((sessionId) =>
|
||||
addSessionToProject(project.id, sessionId)
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
toastSuccess({
|
||||
title: 'Success',
|
||||
msg: `Added ${selectedSessions.length} ${selectedSessions.length === 1 ? 'session' : 'sessions'} to project`,
|
||||
});
|
||||
onSessionsAdded();
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to add sessions to project:', err);
|
||||
toastError({ title: 'Error', msg: 'Failed to add sessions to project' });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Sessions to Project</DialogTitle>
|
||||
<DialogDescription>Select sessions to add to "{project.name}"</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{availableSessions.length === 0 ? (
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
No available sessions to add. All sessions are already part of this project.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[300px] mt-4 pr-4">
|
||||
<div className="space-y-2">
|
||||
{availableSessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-center space-x-3 py-2 px-3 rounded-md hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
id={`session-${session.id}`}
|
||||
checked={selectedSessions.includes(session.id)}
|
||||
onCheckedChange={() => handleToggleSession(session.id)}
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<label
|
||||
htmlFor={`session-${session.id}`}
|
||||
className="text-sm font-medium leading-none cursor-pointer flex justify-between w-full"
|
||||
>
|
||||
<span className="truncate">{session.metadata.description}</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatDistanceToNow(new Date(session.modified))} ago
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground truncate mt-1">
|
||||
{session.metadata.working_dir}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button variant="outline" onClick={handleClose} type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || selectedSessions.length === 0}>
|
||||
{isSubmitting ? 'Adding...' : 'Add Sessions'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddSessionToProjectModal;
|
||||
@@ -0,0 +1,151 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { FolderSearch } from 'lucide-react';
|
||||
import { toastError } from '../../toasts';
|
||||
|
||||
interface CreateProjectModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (name: string, description: string, defaultDirectory: string) => void;
|
||||
defaultDirectory?: string;
|
||||
}
|
||||
|
||||
const CreateProjectModal: React.FC<CreateProjectModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onCreate,
|
||||
defaultDirectory: defaultDirectoryProp,
|
||||
}) => {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [defaultDirectory, setDefaultDirectory] = useState(defaultDirectoryProp || '');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setDefaultDirectory(defaultDirectoryProp || '');
|
||||
}
|
||||
}, [defaultDirectoryProp, isOpen]);
|
||||
|
||||
const resetForm = () => {
|
||||
setName('');
|
||||
setDescription('');
|
||||
setDefaultDirectory(defaultDirectoryProp || '');
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!name.trim()) {
|
||||
toastError({ title: 'Error', msg: 'Project name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Pass data to parent component
|
||||
onCreate(name, description, defaultDirectory);
|
||||
|
||||
// Form will be reset when the modal is closed by the parent
|
||||
// after successful creation
|
||||
};
|
||||
|
||||
const handlePickDirectory = async () => {
|
||||
try {
|
||||
// Use Electron's dialog to pick a directory
|
||||
const directory = await window.electron.directoryChooser();
|
||||
|
||||
if (!directory.canceled && directory.filePaths.length > 0) {
|
||||
setDefaultDirectory(directory.filePaths[0]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to pick directory:', err);
|
||||
toastError({ title: 'Error', msg: 'Failed to pick directory' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create new project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a project to group related sessions together
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Name*</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Project"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="resize-none"
|
||||
placeholder="Optional description"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="directory">Directory</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="directory"
|
||||
value={defaultDirectory}
|
||||
onChange={(e) => setDefaultDirectory(e.target.value)}
|
||||
className="flex-grow"
|
||||
placeholder="Default working directory for sessions"
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={handlePickDirectory}>
|
||||
<FolderSearch className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button variant="outline" onClick={handleClose} type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateProjectModal;
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { ProjectMetadata } from '../../projects';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '../ui/card';
|
||||
import { Folder, Calendar } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: ProjectMetadata;
|
||||
onClick: () => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const ProjectCard: React.FC<ProjectCardProps> = ({ project, onClick }) => {
|
||||
return (
|
||||
<Card
|
||||
className="transition-all duration-200 hover:shadow-default hover:cursor-pointer min-h-[140px] flex flex-col"
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Folder className="w-4 h-4 text-text-muted flex-shrink-0" />
|
||||
{project.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-4 text-sm flex-grow flex flex-col justify-between">
|
||||
{project.description && (
|
||||
<div className="mb-2">
|
||||
<span className="text-text-muted line-clamp-2">{project.description}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-text-muted mt-auto">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
|
||||
<span>{formatDistanceToNow(new Date(project.updatedAt))} ago</span>
|
||||
</div>
|
||||
<span>
|
||||
{project.sessionCount} {project.sessionCount === 1 ? 'session' : 'sessions'}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectCard;
|
||||
@@ -0,0 +1,498 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Project } from '../../projects';
|
||||
import { Session, fetchSessions } from '../../sessions';
|
||||
import {
|
||||
getProject as fetchProject,
|
||||
removeSessionFromProject,
|
||||
deleteProject,
|
||||
addSessionToProject,
|
||||
} from '../../projects';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader,
|
||||
RefreshCcw,
|
||||
Edit,
|
||||
Trash2,
|
||||
Folder,
|
||||
MessageSquareText,
|
||||
ChevronLeft,
|
||||
LoaderCircle,
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
Target,
|
||||
} from 'lucide-react';
|
||||
import { toastError, toastSuccess } from '../../toasts';
|
||||
import { formatMessageTimestamp } from '../../utils/timeUtils';
|
||||
import AddSessionToProjectModal from './AddSessionToProjectModal';
|
||||
import UpdateProjectModal from './UpdateProjectModal';
|
||||
import { MainPanelLayout } from '../Layout/MainPanelLayout';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '../ui/alert-dialog';
|
||||
import { ChatSmart } from '../icons';
|
||||
import { View, ViewOptions } from '../../App';
|
||||
import { Card } from '../ui/card';
|
||||
|
||||
interface ProjectDetailsViewProps {
|
||||
projectId: string;
|
||||
onBack: () => void;
|
||||
setView: (view: View, viewOptions?: ViewOptions) => void;
|
||||
}
|
||||
|
||||
// Custom ProjectHeader component similar to SessionHistoryView style
|
||||
const ProjectHeader: React.FC<{
|
||||
onBack: () => void;
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
actionButtons?: React.ReactNode;
|
||||
}> = ({ onBack, children, title, actionButtons }) => {
|
||||
return (
|
||||
<div className="flex flex-col pb-8">
|
||||
<div className="flex items-center pt-13 pb-2">
|
||||
<Button onClick={onBack} size="xs" variant="outline">
|
||||
<ChevronLeft />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="text-4xl font-light mb-4">{title}</h1>
|
||||
<div className="flex items-center">{children}</div>
|
||||
{actionButtons && <div className="flex items-center space-x-3 mt-4">{actionButtons}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// New component for displaying project sessions with consistent styling
|
||||
const ProjectSessions: React.FC<{
|
||||
sessions: Session[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
onRetry: () => void;
|
||||
onRemoveSession: (sessionId: string) => void;
|
||||
onAddSession: () => void;
|
||||
}> = ({ sessions, isLoading, error, onRetry }) => {
|
||||
return (
|
||||
<ScrollArea className="h-full w-full">
|
||||
<div className="pb-16">
|
||||
<div className="flex flex-col space-y-6">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<LoaderCircle className="animate-spin h-8 w-8 text-textStandard" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-textSubtle">
|
||||
<div className="text-red-500 mb-4">
|
||||
<AlertCircle size={32} />
|
||||
</div>
|
||||
<p className="text-md mb-2">Error Loading Project Details</p>
|
||||
<p className="text-sm text-center mb-4">{error}</p>
|
||||
<Button onClick={onRetry} variant="default">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
) : sessions?.length > 0 ? (
|
||||
<div className="w-full">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{sessions.map((session) => (
|
||||
<Card
|
||||
key={session.id}
|
||||
className="h-full py-3 px-4 hover:shadow-default cursor-pointer transition-all duration-150 flex flex-col justify-between"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base truncate mb-1">
|
||||
{session.metadata.description || session.id}
|
||||
</h3>
|
||||
<div className="flex items-center text-text-muted text-xs mb-1">
|
||||
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
|
||||
<span>{formatMessageTimestamp(Date.parse(session.modified) / 1000)}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-text-muted text-xs mb-1">
|
||||
<Folder className="w-3 h-3 mr-1 flex-shrink-0" />
|
||||
<span className="truncate">{session.metadata.working_dir}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-1 pt-2">
|
||||
<div className="flex items-center space-x-3 text-xs text-text-muted">
|
||||
<div className="flex items-center">
|
||||
<MessageSquareText className="w-3 h-3 mr-1" />
|
||||
<span className="font-mono">{session.metadata.message_count}</span>
|
||||
</div>
|
||||
{session.metadata.total_tokens !== null && (
|
||||
<div className="flex items-center">
|
||||
<Target className="w-3 h-3 mr-1" />
|
||||
<span className="font-mono">
|
||||
{session.metadata.total_tokens.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* <Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveSession(session.id);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
Remove
|
||||
</Button> */}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col justify-center text-textSubtle">
|
||||
<p className="text-lg mb-2">No sessions in this project</p>
|
||||
<p className="text-sm mb-4 text-text-muted">
|
||||
Add sessions to this project to keep your work organized
|
||||
</p>
|
||||
{/* <Button onClick={onAddSession}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Session
|
||||
</Button> */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
const ProjectDetailsView: React.FC<ProjectDetailsViewProps> = ({ projectId, onBack, setView }) => {
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [allSessions, setAllSessions] = useState<Session[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isAddSessionModalOpen, setIsAddSessionModalOpen] = useState(false);
|
||||
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const loadProjectData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Fetch the project details
|
||||
const projectData = await fetchProject(projectId);
|
||||
setProject(projectData);
|
||||
|
||||
// Fetch all sessions
|
||||
const allSessionsData = await fetchSessions();
|
||||
setAllSessions(allSessionsData);
|
||||
|
||||
// Filter sessions that belong to this project
|
||||
const projectSessions = allSessionsData.filter((session: Session) =>
|
||||
projectData.sessionIds.includes(session.id)
|
||||
);
|
||||
|
||||
setSessions(projectSessions);
|
||||
} catch (err) {
|
||||
console.error('Failed to load project data:', err);
|
||||
setError('Failed to load project data');
|
||||
toastError({ title: 'Error', msg: 'Failed to load project data' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// Fetch project details and associated sessions
|
||||
useEffect(() => {
|
||||
loadProjectData();
|
||||
}, [projectId, loadProjectData]);
|
||||
|
||||
// Set up session creation listener to automatically associate new sessions with this project
|
||||
useEffect(() => {
|
||||
if (!project) return;
|
||||
|
||||
const handleSessionCreated = async () => {
|
||||
console.log(
|
||||
'ProjectDetailsView: Session created event received, checking for new sessions...'
|
||||
);
|
||||
|
||||
// Wait a bit for the session to be fully created
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// Fetch all sessions to find the newest one
|
||||
const allSessionsData = await fetchSessions();
|
||||
|
||||
// Find sessions that are not in this project but were created recently
|
||||
const recentSessions = allSessionsData.filter((session: Session) => {
|
||||
const sessionDate = new Date(session.modified);
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
||||
const isRecent = sessionDate > fiveMinutesAgo;
|
||||
const isNotInProject = !project.sessionIds.includes(session.id);
|
||||
const isInProjectDirectory = session.metadata.working_dir === project.defaultDirectory;
|
||||
|
||||
return isRecent && isNotInProject && isInProjectDirectory;
|
||||
});
|
||||
|
||||
// Add recent sessions to this project
|
||||
for (const session of recentSessions) {
|
||||
try {
|
||||
await addSessionToProject(project.id, session.id);
|
||||
console.log(`Automatically added session ${session.id} to project ${project.id}`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to add session ${session.id} to project:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh project data if we added any sessions
|
||||
if (recentSessions.length > 0) {
|
||||
loadProjectData();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking for new sessions:', err);
|
||||
}
|
||||
}, 2000); // Wait 2 seconds for session to be created
|
||||
};
|
||||
|
||||
// Listen for session creation events
|
||||
window.addEventListener('session-created', handleSessionCreated);
|
||||
window.addEventListener('message-stream-finished', handleSessionCreated);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('session-created', handleSessionCreated);
|
||||
window.removeEventListener('message-stream-finished', handleSessionCreated);
|
||||
};
|
||||
}, [project, loadProjectData]);
|
||||
|
||||
const handleRemoveSession = async (sessionId: string) => {
|
||||
if (!project) return;
|
||||
|
||||
try {
|
||||
await removeSessionFromProject(project.id, sessionId);
|
||||
|
||||
// Update local state
|
||||
setProject((prev) => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
sessionIds: prev.sessionIds.filter((id) => id !== sessionId),
|
||||
};
|
||||
});
|
||||
|
||||
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
||||
toastSuccess({ title: 'Success', msg: 'Session removed from project' });
|
||||
} catch (err) {
|
||||
console.error('Failed to remove session from project:', err);
|
||||
toastError({ title: 'Error', msg: 'Failed to remove session from project' });
|
||||
}
|
||||
};
|
||||
|
||||
const getSessionsNotInProject = () => {
|
||||
if (!project) return [];
|
||||
|
||||
return allSessions.filter((session) => !project.sessionIds.includes(session.id));
|
||||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
if (!project) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteProject(project.id);
|
||||
toastSuccess({ title: 'Success', msg: `Project "${project.name}" deleted successfully` });
|
||||
onBack(); // Go back to projects list
|
||||
} catch (err) {
|
||||
console.error('Failed to delete project:', err);
|
||||
toastError({ title: 'Error', msg: 'Failed to delete project' });
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewSession = () => {
|
||||
if (!project) return;
|
||||
|
||||
console.log(`Navigating to chat page for project: ${project.name}`);
|
||||
|
||||
// Update the working directory in localStorage to the project's directory
|
||||
try {
|
||||
const currentConfig = JSON.parse(localStorage.getItem('gooseConfig') || '{}');
|
||||
const updatedConfig = {
|
||||
...currentConfig,
|
||||
GOOSE_WORKING_DIR: project.defaultDirectory,
|
||||
};
|
||||
localStorage.setItem('gooseConfig', JSON.stringify(updatedConfig));
|
||||
} catch (error) {
|
||||
console.error('Failed to update working directory in localStorage:', error);
|
||||
}
|
||||
|
||||
// Navigate to the pair page
|
||||
setView('pair');
|
||||
|
||||
toastSuccess({
|
||||
title: 'New Session',
|
||||
msg: `Starting new session in ${project.name}`,
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<MainPanelLayout>
|
||||
<div className="flex flex-col h-full w-full items-center justify-center">
|
||||
<Loader className="h-10 w-10 animate-spin opacity-70 mb-4" />
|
||||
<p className="text-muted-foreground">Loading project...</p>
|
||||
</div>
|
||||
</MainPanelLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !project) {
|
||||
return (
|
||||
<MainPanelLayout>
|
||||
<div className="flex flex-col h-full w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">{error || 'Project not found'}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onBack} variant="outline">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back
|
||||
</Button>
|
||||
<Button onClick={loadProjectData}>
|
||||
<RefreshCcw className="mr-2 h-4 w-4" /> Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainPanelLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Define action buttons
|
||||
const actionButtons = (
|
||||
<>
|
||||
<Button onClick={handleNewSession} size="sm" className="flex items-center gap-1">
|
||||
<ChatSmart className="h-4 w-4" />
|
||||
<span>New session</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsUpdateModalOpen(true)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<span>Edit</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span>Delete</span>
|
||||
</Button>
|
||||
{/* <Button
|
||||
onClick={() => setIsAddSessionModalOpen(true)}
|
||||
size="sm"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Add Session</span>
|
||||
</Button> */}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MainPanelLayout>
|
||||
<div className="flex-1 flex flex-col min-h-0 px-8">
|
||||
<ProjectHeader onBack={onBack} title={project.name} actionButtons={actionButtons}>
|
||||
<div className="flex flex-col">
|
||||
{!loading && (
|
||||
<>
|
||||
<div className="flex items-center text-text-muted text-sm space-x-5 font-mono">
|
||||
<span className="flex items-center">
|
||||
<MessageSquareText className="w-4 h-4 mr-1" />
|
||||
{sessions.length} {sessions.length === 1 ? 'session' : 'sessions'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center text-text-muted text-sm mt-1 font-mono">
|
||||
<span className="flex items-center">
|
||||
<Folder className="w-4 h-4 mr-1" />
|
||||
{project.defaultDirectory}
|
||||
</span>
|
||||
</div>
|
||||
{project.description && (
|
||||
<div className="flex items-center text-text-muted text-sm mt-1">
|
||||
<span>{project.description}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ProjectHeader>
|
||||
|
||||
<ProjectSessions
|
||||
sessions={sessions}
|
||||
isLoading={loading}
|
||||
error={error}
|
||||
onRetry={loadProjectData}
|
||||
onRemoveSession={handleRemoveSession}
|
||||
onAddSession={() => setIsAddSessionModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</MainPanelLayout>
|
||||
|
||||
<AddSessionToProjectModal
|
||||
isOpen={isAddSessionModalOpen}
|
||||
onClose={() => setIsAddSessionModalOpen(false)}
|
||||
project={project}
|
||||
availableSessions={getSessionsNotInProject()}
|
||||
onSessionsAdded={loadProjectData}
|
||||
/>
|
||||
|
||||
<UpdateProjectModal
|
||||
isOpen={isUpdateModalOpen}
|
||||
onClose={() => setIsUpdateModalOpen(false)}
|
||||
project={{
|
||||
...project,
|
||||
sessionCount: sessions.length,
|
||||
}}
|
||||
onRefresh={loadProjectData}
|
||||
/>
|
||||
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure you want to delete this project?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will delete the project "{project.name}". The sessions within this project won't
|
||||
be deleted, but they will no longer be part of this project.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDeleteProject();
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectDetailsView;
|
||||
@@ -0,0 +1,33 @@
|
||||
import React, { useState } from 'react';
|
||||
import ProjectsView from './ProjectsView';
|
||||
import ProjectDetailsView from './ProjectDetailsView';
|
||||
import { View, ViewOptions } from '../../App';
|
||||
|
||||
interface ProjectsContainerProps {
|
||||
setView: (view: View, viewOptions?: ViewOptions) => void;
|
||||
}
|
||||
|
||||
const ProjectsContainer: React.FC<ProjectsContainerProps> = ({ setView }) => {
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
const handleSelectProject = (projectId: string) => {
|
||||
setSelectedProjectId(projectId);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setSelectedProjectId(null);
|
||||
// Trigger a refresh of the projects list when returning from details
|
||||
setRefreshTrigger((prev) => prev + 1);
|
||||
};
|
||||
|
||||
if (selectedProjectId) {
|
||||
return (
|
||||
<ProjectDetailsView projectId={selectedProjectId} onBack={handleBack} setView={setView} />
|
||||
);
|
||||
}
|
||||
|
||||
return <ProjectsView onSelectProject={handleSelectProject} refreshTrigger={refreshTrigger} />;
|
||||
};
|
||||
|
||||
export default ProjectsContainer;
|
||||
@@ -0,0 +1,214 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ProjectMetadata } from '../../projects';
|
||||
import { fetchProjects, createProject } from '../../projects';
|
||||
import ProjectCard from './ProjectCard';
|
||||
import CreateProjectModal from './CreateProjectModal';
|
||||
import { Button } from '../ui/button';
|
||||
import { FolderPlus, AlertCircle } from 'lucide-react';
|
||||
import { toastError, toastSuccess } from '../../toasts';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { MainPanelLayout } from '../Layout/MainPanelLayout';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
|
||||
interface ProjectsViewProps {
|
||||
onSelectProject: (projectId: string) => void;
|
||||
refreshTrigger?: number;
|
||||
}
|
||||
|
||||
const ProjectsView: React.FC<ProjectsViewProps> = ({ onSelectProject, refreshTrigger = 0 }) => {
|
||||
const [projects, setProjects] = useState<ProjectMetadata[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [showSkeleton, setShowSkeleton] = useState(true);
|
||||
const [showContent, setShowContent] = useState(false);
|
||||
|
||||
// Load projects on component mount and when refreshTrigger changes
|
||||
useEffect(() => {
|
||||
loadProjects();
|
||||
}, [refreshTrigger]);
|
||||
|
||||
// Minimum loading time to prevent skeleton flash
|
||||
useEffect(() => {
|
||||
if (!loading && showSkeleton) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowSkeleton(false);
|
||||
// Add a small delay before showing content for fade-in effect
|
||||
setTimeout(() => {
|
||||
setShowContent(true);
|
||||
}, 50);
|
||||
}, 300); // Show skeleton for at least 300ms
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return () => void 0;
|
||||
}, [loading, showSkeleton]);
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setShowSkeleton(true);
|
||||
setShowContent(false);
|
||||
setError(null);
|
||||
|
||||
const projectsList = await fetchProjects();
|
||||
setProjects(projectsList);
|
||||
} catch (err) {
|
||||
console.error('Failed to load projects:', err);
|
||||
setError('Failed to load projects. Please try again.');
|
||||
toastError({ title: 'Error', msg: 'Failed to load projects' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get the current working directory or fallback to home
|
||||
const getDefaultDirectory = () => {
|
||||
if (window.appConfig && typeof window.appConfig.get === 'function') {
|
||||
const dir = window.appConfig.get('GOOSE_WORKING_DIR');
|
||||
return typeof dir === 'string' ? dir : '';
|
||||
}
|
||||
return typeof process !== 'undefined' && process.env && typeof process.env.HOME === 'string'
|
||||
? process.env.HOME
|
||||
: '';
|
||||
};
|
||||
|
||||
const handleCreateProject = async (
|
||||
name: string,
|
||||
description: string,
|
||||
defaultDirectory?: string
|
||||
) => {
|
||||
try {
|
||||
await createProject({
|
||||
name,
|
||||
description: description.trim() === '' ? undefined : description,
|
||||
defaultDirectory: defaultDirectory || getDefaultDirectory(),
|
||||
});
|
||||
|
||||
setIsCreateModalOpen(false);
|
||||
toastSuccess({ title: 'Success', msg: `Project "${name}" created successfully` });
|
||||
|
||||
// Refresh the projects list to get the updated data from the server
|
||||
await loadProjects();
|
||||
} catch (err) {
|
||||
console.error('Failed to create project:', err);
|
||||
toastError({ title: 'Error', msg: 'Failed to create project' });
|
||||
}
|
||||
};
|
||||
|
||||
// Render skeleton loader for project items
|
||||
const ProjectSkeleton = () => (
|
||||
<div className="p-2 mb-2 bg-background-default border border-border-subtle rounded-lg">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Skeleton className="h-5 w-3/4 mb-2" />
|
||||
<Skeleton className="h-4 w-full mb-2" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading || showSkeleton) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<div className="space-y-2">
|
||||
<ProjectSkeleton />
|
||||
<ProjectSkeleton />
|
||||
<ProjectSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-text-muted">
|
||||
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
|
||||
<p className="text-lg mb-2">Error Loading Projects</p>
|
||||
<p className="text-sm text-center mb-4">{error}</p>
|
||||
<Button onClick={loadProjects} variant="default">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col justify-center h-full">
|
||||
<p className="text-lg">No projects yet</p>
|
||||
<p className="text-sm mb-4 text-text-muted">
|
||||
Create your first project to organize related sessions together
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{projects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
onClick={() => onSelectProject(project.id)}
|
||||
onRefresh={loadProjects}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<MainPanelLayout>
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="bg-background-default px-8 pb-8 pt-16">
|
||||
<div className="flex flex-col page-transition">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<h1 className="text-4xl font-light">Projects</h1>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
Create and manage your projects to organize related sessions together.
|
||||
</p>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Button onClick={() => setIsCreateModalOpen(true)} className="self-start">
|
||||
<FolderPlus className="h-4 w-4 mr-2" />
|
||||
New project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 relative px-8">
|
||||
<ScrollArea className="h-full">
|
||||
<div
|
||||
className={`h-full relative transition-all duration-300 ${
|
||||
showContent ? 'opacity-100 animate-in fade-in' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateProjectModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onCreate={handleCreateProject}
|
||||
defaultDirectory={getDefaultDirectory()}
|
||||
/>
|
||||
</MainPanelLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectsView;
|
||||
@@ -0,0 +1,181 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { FolderSearch } from 'lucide-react';
|
||||
import { toastError, toastSuccess } from '../../toasts';
|
||||
import { ProjectMetadata, updateProject, UpdateProjectRequest } from '../../projects';
|
||||
|
||||
interface UpdateProjectModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
project: ProjectMetadata;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const UpdateProjectModal: React.FC<UpdateProjectModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
project,
|
||||
onRefresh,
|
||||
}) => {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [defaultDirectory, setDefaultDirectory] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Initialize form with project data
|
||||
useEffect(() => {
|
||||
if (isOpen && project) {
|
||||
setName(project.name);
|
||||
setDescription(project.description || '');
|
||||
setDefaultDirectory(project.defaultDirectory);
|
||||
}
|
||||
}, [isOpen, project]);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!name.trim()) {
|
||||
toastError({ title: 'Error', msg: 'Project name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!defaultDirectory.trim()) {
|
||||
toastError({ title: 'Error', msg: 'Default directory is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Create update object, only include changed fields
|
||||
const updateData: UpdateProjectRequest = {};
|
||||
|
||||
if (name !== project.name) {
|
||||
updateData.name = name;
|
||||
}
|
||||
|
||||
if (description !== (project.description || '')) {
|
||||
updateData.description = description || null;
|
||||
}
|
||||
|
||||
if (defaultDirectory !== project.defaultDirectory) {
|
||||
updateData.defaultDirectory = defaultDirectory;
|
||||
}
|
||||
|
||||
// Only make the API call if there are changes
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await updateProject(project.id, updateData);
|
||||
toastSuccess({ title: 'Success', msg: 'Project updated successfully' });
|
||||
onRefresh();
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to update project:', err);
|
||||
toastError({ title: 'Error', msg: 'Failed to update project' });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePickDirectory = async () => {
|
||||
try {
|
||||
// Use Electron's dialog to pick a directory
|
||||
const directory = await window.electron.directoryChooser();
|
||||
|
||||
if (!directory.canceled && directory.filePaths.length > 0) {
|
||||
setDefaultDirectory(directory.filePaths[0]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to pick directory:', err);
|
||||
toastError({ title: 'Error', msg: 'Failed to pick directory' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Project</DialogTitle>
|
||||
<DialogDescription>Update project information</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-2">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
Name*
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="col-span-3"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-start gap-2">
|
||||
<Label htmlFor="description" className="text-right pt-2">
|
||||
Description
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="col-span-3 resize-none"
|
||||
placeholder="Optional description"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-2">
|
||||
<Label htmlFor="directory" className="text-right">
|
||||
Directory*
|
||||
</Label>
|
||||
<div className="col-span-3 flex gap-2">
|
||||
<Input
|
||||
id="directory"
|
||||
value={defaultDirectory}
|
||||
onChange={(e) => setDefaultDirectory(e.target.value)}
|
||||
className="flex-grow"
|
||||
required
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={handlePickDirectory}>
|
||||
<FolderSearch className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateProjectModal;
|
||||
Reference in New Issue
Block a user