SPB Git

Repository Public

page.tsx 8.0 KB 135 lines tsx

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
Files main
Menu

Theme