Repository Public
1'use client';2import { useState } from 'react';3import Link from 'next/link';4import { useQueryClient } from '@tanstack/react-query';5import { FolderGit2, GitBranch, Pencil, Plus, Rocket, Trash2 } from 'lucide-react';6import type { ProjectDto } from '@aos/shared';7import { api, errorMessage } from '@/lib/api';8import { qk, useProjects } from '@/lib/queries';9import { useUi } from '@/stores/ui';10import { toast } from '@/stores/toasts';11import { useNow } from '@/hooks/use-viewport';12import { fmtRelative } from '@/lib/format';13import { Button, IconButton } from '@/components/ui/button';14import { ConfirmDialog, Dialog } from '@/components/ui/dialog';15import { Field, Input, Textarea } from '@/components/ui/input';16import { EmptyState, ErrorState, Skeleton } from '@/components/ui/misc';17import { IllusFolder } from '@/components/ui/illustrations';18import { NewProjectFields } from '@/components/missions/new-mission';1920function EditProject({ project, onClose }: { project: ProjectDto; onClose: () => void }) {21 const qc = useQueryClient();22 const [name, setName] = useState(project.name);23 const [description, setDescription] = useState(project.description);24 const [instructions, setInstructions] = useState(project.instructions);25 const [busy, setBusy] = useState(false);26 const [error, setError] = useState<string | null>(null);27 const save = async () => {28 setBusy(true);29 setError(null);30 try {31 await api.patch(`/projects/${project.id}`, { name: name.trim(), description, instructions });32 await qc.invalidateQueries({ queryKey: qk.projects });33 toast.success('Project saved');34 onClose();35 } catch (e) {36 setError(errorMessage(e));37 } finally {38 setBusy(false);39 }40 };41 return (42 <Dialog open onOpenChange={(o) => !o && onClose()} title={`Edit ${project.name}`} size="lg" footer={<><Button variant="ghost" onClick={onClose}>Cancel</Button><Button variant="primary" loading={busy} disabled={!name.trim()} onClick={save} data-testid="project-save">Save</Button></>}>43 <div className="flex flex-col gap-4">44 <Field label="Name"><Input value={name} onChange={(e) => setName(e.target.value)} maxLength={80} /></Field>45 <Field label="Description"><Input value={description} onChange={(e) => setDescription(e.target.value)} maxLength={2000} /></Field>46 <Field label="Instructions for agents" hint="Added to the system prompt of every mission in this project, after AGENTS.md / CLAUDE.md from the repository.">47 <Textarea value={instructions} onChange={(e) => setInstructions(e.target.value)} rows={8} maxLength={8000} className="min-h-40 font-mono text-[12.5px]" data-testid="project-instructions" />48 </Field>49 {error && <p role="alert" className="text-xs text-error">{error}</p>}50 </div>51 </Dialog>52 );53}5455export default function ProjectsPage() {56 const q = useProjects();57 const qc = useQueryClient();58 const now = useNow(60_000);59 const openNew = useUi((s) => s.openNewMission);60 const [creating, setCreating] = useState(false);61 const [editing, setEditing] = useState<ProjectDto | null>(null);62 const [removing, setRemoving] = useState<ProjectDto | null>(null);63 const list = q.data ?? [];64 return (65 <div className="h-full overflow-y-auto" data-testid="projects-page">66 <div className="mx-auto flex w-full max-w-5xl flex-col gap-5 px-4 pb-32 pt-1 md:px-8 md:pb-12 md:pt-6">67 <div className="flex items-end gap-3">68 <div className="min-w-0 flex-1">69 <h1 className="hidden text-xl font-bold tracking-tight md:block">Projects</h1>70 <p className="text-[13px] text-text-2">Repositories agents work in. Each lives under the workspace root on the host.</p>71 </div>72 <Button variant="brand" className="h-8 shrink-0 rounded-full px-3.5 font-semibold pointer-coarse:h-12 pointer-coarse:px-4" onClick={() => setCreating(true)} data-testid="project-new"><Plus className="size-4" strokeWidth={2.6} /> New project</Button>73 </div>74 {q.isError ? (75 <div className="card"><ErrorState error={q.error} onRetry={() => q.refetch()} /></div>76 ) : q.isPending ? (77 <div className="grid gap-3 md:grid-cols-2"><Skeleton className="h-32 rounded-md" /><Skeleton className="h-32 rounded-md" /></div>78 ) : list.length === 0 ? (79 <div className="card"><EmptyState illustration={<IllusFolder />} title="No project yet" description="Create an empty repository, register an existing folder of the workspace, or clone one." action={<Button variant="brand" className="h-9 rounded-full px-5 font-semibold pointer-coarse:h-12" onClick={() => setCreating(true)}><Plus className="size-4" strokeWidth={2.6} /> New project</Button>} /></div>80 ) : (81 <ul className="grid gap-3 md:grid-cols-2" data-testid="projects-list">82 {list.map((p) => (83 <li key={p.id} className="card flex min-w-0 flex-col gap-3 p-4" data-testid="project-card">84 <div className="flex items-start gap-3">85 <span className="flex size-9 shrink-0 items-center justify-center rounded-[11px] bg-accent/10 text-accent"><FolderGit2 className="size-[18px]" aria-hidden /></span>86 <div className="min-w-0 flex-1">87 <h2 className="truncate text-[15px] font-semibold tracking-tight">{p.name}</h2>88 <p className="truncate font-mono text-[11.5px] text-muted">{p.path}</p>89 </div>90 <IconButton label={`Edit ${p.name}`} onClick={() => setEditing(p)} data-testid="project-edit"><Pencil className="size-4" /></IconButton>91 <IconButton label={`Unregister ${p.name}`} onClick={() => setRemoving(p)}><Trash2 className="size-4" /></IconButton>92 </div>93 {p.description && <p className="line-clamp-2 text-[13px] leading-relaxed text-text-2">{p.description}</p>}94 <p className="tnum flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">95 <span className="inline-flex items-center gap-1"><GitBranch className="size-3" aria-hidden />{p.gitBranch ?? 'no branch'}</span>96 <span className="max-w-full truncate">{p.gitRemote ?? 'no remote'}</span>97 <span>created {fmtRelative(p.createdAt, now)}</span>98 </p>99 <div className="mt-auto flex items-center gap-2 border-t border-line pt-3">100 <Link href="/missions/" className="tnum flex-1 text-xs font-medium text-text-2 hover:text-text">{p.missions} mission{p.missions === 1 ? '' : 's'}</Link>101 <span className="text-[11px] text-muted">{p.instructions.trim() ? 'has instructions' : 'no instructions'}</span>102 <Button size="sm" variant="secondary" onClick={() => openNew(p.id)} data-testid="project-mission"><Rocket className="size-3.5" /> New mission</Button>103 </div>104 </li>105 ))}106 </ul>107 )}108 </div>109 <Dialog open={creating} onOpenChange={setCreating} title="New project" description="The folder is created or validated inside the workspace root on the host.">110 <NewProjectFields onCreated={(p) => { setCreating(false); toast.success('Project created', p.name); }} onCancel={() => setCreating(false)} />111 </Dialog>112 {editing && <EditProject project={editing} onClose={() => setEditing(null)} />}113 <ConfirmDialog114 open={!!removing}115 onOpenChange={(o) => !o && setRemoving(null)}116 title={`Unregister ${removing?.name ?? ''}?`}117 description="The project disappears from Agent OS. The folder and its git history stay on disk; nothing is deleted."118 confirmLabel="Unregister"119 danger120 onConfirm={async () => {121 if (!removing) return;122 try {123 await api.delete(`/projects/${removing.id}`);124 await qc.invalidateQueries({ queryKey: qk.projects });125 toast.success('Project unregistered');126 } catch (e) {127 toast.error('Could not unregister', errorMessage(e));128 throw e;129 }130 }}131 />132 </div>133 );134}135