446 lines
14 KiB
TypeScript
446 lines
14 KiB
TypeScript
import type { Doc, Id } from "@code/backend/convex/_generated/dataModel";
|
|
import { Button } from "@code/ui/components/button";
|
|
import { Input } from "@code/ui/components/input";
|
|
import { Textarea } from "@code/ui/components/textarea";
|
|
import {
|
|
Bot,
|
|
ExternalLink,
|
|
FileText,
|
|
GitFork,
|
|
GitPullRequest,
|
|
Play,
|
|
Plus,
|
|
} from "lucide-react";
|
|
|
|
import UserMenu from "@/components/user-menu";
|
|
import { useProjectWorkspace } from "@/hooks/use-project-workspace";
|
|
|
|
const statusStyle: Record<Doc<"projectIssues">["status"], string> = {
|
|
completed:
|
|
"border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
|
failed: "border-destructive/40 bg-destructive/10 text-destructive",
|
|
"needs-input":
|
|
"border-orange-500/40 bg-orange-500/10 text-orange-700 dark:text-orange-300",
|
|
open: "border-border text-muted-foreground",
|
|
queued:
|
|
"border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
|
working: "border-blue-500/40 bg-blue-500/10 text-blue-700 dark:text-blue-300",
|
|
};
|
|
|
|
interface RepositoryFormProps {
|
|
readonly busy: boolean;
|
|
readonly onChange: (value: string) => void;
|
|
readonly onSubmit: () => Promise<void>;
|
|
readonly value: string;
|
|
}
|
|
|
|
const RepositoryForm = ({
|
|
busy,
|
|
onChange,
|
|
onSubmit,
|
|
value,
|
|
}: RepositoryFormProps) => (
|
|
<form
|
|
className="grid gap-3 border-b p-4"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
void onSubmit();
|
|
}}
|
|
>
|
|
<div className="space-y-1">
|
|
<label className="text-xs font-medium" htmlFor="github-repository">
|
|
Public GitHub repository
|
|
</label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Connect with an owner/name slug or repository URL.
|
|
</p>
|
|
</div>
|
|
<Input
|
|
id="github-repository"
|
|
onChange={(event) => onChange(event.target.value)}
|
|
placeholder="rivet-dev/rivet"
|
|
required
|
|
value={value}
|
|
/>
|
|
<Button disabled={busy || value.trim().length === 0} type="submit">
|
|
<GitFork data-icon="inline-start" />
|
|
{busy ? "Connecting" : "Connect repository"}
|
|
</Button>
|
|
</form>
|
|
);
|
|
|
|
interface ProjectListProps {
|
|
readonly projects: readonly Doc<"projects">[] | undefined;
|
|
readonly selectedId: Id<"projects"> | null;
|
|
readonly onSelect: (projectId: Id<"projects">) => void;
|
|
}
|
|
|
|
const ProjectList = ({ projects, selectedId, onSelect }: ProjectListProps) => {
|
|
const renderContent = () => {
|
|
if (projects === undefined) {
|
|
return (
|
|
<p className="p-3 text-xs text-muted-foreground">Loading projects...</p>
|
|
);
|
|
}
|
|
if (projects.length === 0) {
|
|
return (
|
|
<p className="p-3 text-xs leading-relaxed text-muted-foreground">
|
|
Connect a repository to create its project artifacts and issue queue.
|
|
</p>
|
|
);
|
|
}
|
|
return (
|
|
<div className="grid gap-1">
|
|
{projects.map((project) => (
|
|
<button
|
|
className={`w-full border px-3 py-2 text-left transition-colors ${
|
|
selectedId === project._id
|
|
? "border-foreground/30 bg-muted"
|
|
: "border-transparent hover:bg-muted/60"
|
|
}`}
|
|
key={project._id}
|
|
onClick={() => onSelect(project._id)}
|
|
type="button"
|
|
>
|
|
<span className="block truncate text-sm font-medium">
|
|
{project.name}
|
|
</span>
|
|
<span className="block truncate text-xs text-muted-foreground">
|
|
{project.repoOwner}/{project.repoName}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<nav
|
|
aria-label="Connected projects"
|
|
className="min-h-0 flex-1 overflow-y-auto p-2"
|
|
>
|
|
{renderContent()}
|
|
</nav>
|
|
);
|
|
};
|
|
|
|
interface ArtifactGridProps {
|
|
readonly artifacts: readonly Doc<"projectArtifacts">[] | undefined;
|
|
}
|
|
|
|
const ArtifactGrid = ({ artifacts }: ArtifactGridProps) => (
|
|
<section aria-labelledby="artifact-heading" className="space-y-3">
|
|
<div>
|
|
<h2 className="text-lg font-semibold" id="artifact-heading">
|
|
Project artifacts
|
|
</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
Canonical context staged into every issue workspace.
|
|
</p>
|
|
</div>
|
|
{artifacts === undefined ? (
|
|
<p className="text-sm text-muted-foreground">Loading artifacts...</p>
|
|
) : (
|
|
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
|
{artifacts.map((artifact) => (
|
|
<article className="min-w-0 border bg-card p-3" key={artifact._id}>
|
|
<div className="mb-2 flex items-center justify-between gap-3">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
|
<h3 className="truncate font-mono text-xs font-medium">
|
|
{artifact.path}
|
|
</h3>
|
|
</div>
|
|
<span className="shrink-0 text-[11px] text-muted-foreground">
|
|
r{artifact.revision}
|
|
</span>
|
|
</div>
|
|
<p className="line-clamp-4 whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground">
|
|
{artifact.content}
|
|
</p>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
|
|
interface IssueComposerProps {
|
|
readonly body: string;
|
|
readonly busy: boolean;
|
|
readonly onBodyChange: (value: string) => void;
|
|
readonly onSubmit: () => Promise<void>;
|
|
readonly onTitleChange: (value: string) => void;
|
|
readonly title: string;
|
|
}
|
|
|
|
const IssueComposer = ({
|
|
body,
|
|
busy,
|
|
onBodyChange,
|
|
onSubmit,
|
|
onTitleChange,
|
|
title,
|
|
}: IssueComposerProps) => (
|
|
<form
|
|
className="grid gap-3 border p-4"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
void onSubmit();
|
|
}}
|
|
>
|
|
<div>
|
|
<h2 className="font-semibold">Raise an issue</h2>
|
|
<p className="text-xs text-muted-foreground">
|
|
The issue becomes the identity for one Flue agent and AgentOS workspace.
|
|
</p>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium" htmlFor="issue-title">
|
|
Title
|
|
</label>
|
|
<Input
|
|
id="issue-title"
|
|
maxLength={160}
|
|
onChange={(event) => onTitleChange(event.target.value)}
|
|
required
|
|
value={title}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium" htmlFor="issue-body">
|
|
Description
|
|
</label>
|
|
<Textarea
|
|
id="issue-body"
|
|
maxLength={10_000}
|
|
onChange={(event) => onBodyChange(event.target.value)}
|
|
required
|
|
rows={5}
|
|
value={body}
|
|
/>
|
|
</div>
|
|
<Button
|
|
disabled={busy || title.trim().length < 3 || body.trim().length < 10}
|
|
type="submit"
|
|
>
|
|
<Plus data-icon="inline-start" />
|
|
{busy ? "Creating" : "Create issue"}
|
|
</Button>
|
|
</form>
|
|
);
|
|
|
|
interface IssueListProps {
|
|
readonly issues: readonly Doc<"projectIssues">[] | undefined;
|
|
readonly pendingAction: string | null;
|
|
readonly onStart: (
|
|
issueId: Id<"projectIssues">,
|
|
issueNumber: number,
|
|
title: string
|
|
) => Promise<void>;
|
|
}
|
|
|
|
const IssueList = ({ issues, pendingAction, onStart }: IssueListProps) => {
|
|
const renderContent = () => {
|
|
if (issues === undefined) {
|
|
return <p className="text-sm text-muted-foreground">Loading issues...</p>;
|
|
}
|
|
if (issues.length === 0) {
|
|
return (
|
|
<p className="border p-4 text-sm text-muted-foreground">
|
|
No issues yet. Describe a concrete change to start the workflow.
|
|
</p>
|
|
);
|
|
}
|
|
return (
|
|
<div className="grid gap-2">
|
|
{issues.map((issue) => {
|
|
const canStart = ["open", "needs-input", "failed"].includes(
|
|
issue.status
|
|
);
|
|
const busy = pendingAction === `issue:${issue._id}`;
|
|
let actionLabel: string = issue.status;
|
|
if (canStart) {
|
|
actionLabel = "Start agent";
|
|
}
|
|
if (busy) {
|
|
actionLabel = "Dispatching";
|
|
}
|
|
return (
|
|
<article
|
|
className="grid gap-3 border bg-card p-4 md:grid-cols-[1fr_auto] md:items-center"
|
|
key={issue._id}
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="mb-1 flex flex-wrap items-center gap-2">
|
|
<span className="font-mono text-xs text-muted-foreground">
|
|
#{issue.number}
|
|
</span>
|
|
<span
|
|
className={`border px-1.5 py-0.5 text-[11px] font-medium ${statusStyle[issue.status]}`}
|
|
>
|
|
{issue.status}
|
|
</span>
|
|
</div>
|
|
<h3 className="font-medium">{issue.title}</h3>
|
|
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
|
{issue.body}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
disabled={!canStart || busy}
|
|
onClick={() =>
|
|
void onStart(issue._id, issue.number, issue.title)
|
|
}
|
|
size="sm"
|
|
type="button"
|
|
variant={canStart ? "default" : "outline"}
|
|
>
|
|
{canStart ? (
|
|
<Play data-icon="inline-start" />
|
|
) : (
|
|
<Bot data-icon="inline-start" />
|
|
)}
|
|
{actionLabel}
|
|
</Button>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<section aria-labelledby="issue-heading" className="space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<GitPullRequest className="size-4 text-muted-foreground" />
|
|
<h2 className="text-lg font-semibold" id="issue-heading">
|
|
Issue queue
|
|
</h2>
|
|
</div>
|
|
{renderContent()}
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export const ProjectWorkspacePage = () => {
|
|
const workspace = useProjectWorkspace();
|
|
const handleRepositoryChange = workspace.setRepository;
|
|
const handleRepositorySubmit = workspace.connectRepository;
|
|
const handleProjectSelect = workspace.setSelectedProjectId;
|
|
const handleIssueBodyChange = workspace.setIssueBody;
|
|
const handleIssueSubmit = workspace.raiseIssue;
|
|
const handleIssueTitleChange = workspace.setIssueTitle;
|
|
const handleIssueStart = workspace.startIssue;
|
|
|
|
return (
|
|
<main className="min-h-svh bg-background text-foreground">
|
|
<header className="border-b">
|
|
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-4 py-3 md:px-6">
|
|
<div className="flex items-center gap-3">
|
|
<div className="grid size-8 place-items-center border bg-muted">
|
|
<Bot className="size-4" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-semibold">Project manager</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
GitHub, Flue, AgentOS
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<UserMenu />
|
|
</div>
|
|
</header>
|
|
|
|
<div className="mx-auto grid min-h-[calc(100svh-57px)] max-w-[1400px] md:grid-cols-[260px_1fr]">
|
|
<aside className="flex min-h-0 flex-col border-b md:border-r md:border-b-0">
|
|
<RepositoryForm
|
|
busy={workspace.pendingAction === "connect"}
|
|
onChange={handleRepositoryChange}
|
|
onSubmit={handleRepositorySubmit}
|
|
value={workspace.repository}
|
|
/>
|
|
<ProjectList
|
|
onSelect={handleProjectSelect}
|
|
projects={workspace.projects}
|
|
selectedId={workspace.selectedProjectId}
|
|
/>
|
|
</aside>
|
|
|
|
<div className="min-w-0 p-4 md:p-6 lg:p-8">
|
|
{workspace.error ? (
|
|
<div className="mb-4 border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
|
{workspace.error}
|
|
</div>
|
|
) : null}
|
|
|
|
{workspace.selectedProject ? (
|
|
<div className="grid gap-8">
|
|
<div className="flex flex-col gap-3 border-b pb-5 sm:flex-row sm:items-end sm:justify-between">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Connected GitHub project
|
|
</p>
|
|
<h1 className="text-2xl font-semibold tracking-tight">
|
|
{workspace.selectedProject.repoOwner}/
|
|
{workspace.selectedProject.repoName}
|
|
</h1>
|
|
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
|
{workspace.selectedProject.description ??
|
|
"No repository description is available."}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
onClick={() =>
|
|
window.open(
|
|
workspace.selectedProject?.repoUrl,
|
|
"_blank",
|
|
"noopener,noreferrer"
|
|
)
|
|
}
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
<ExternalLink data-icon="inline-start" />
|
|
Open GitHub
|
|
</Button>
|
|
</div>
|
|
|
|
<ArtifactGrid artifacts={workspace.artifacts} />
|
|
|
|
<div className="grid items-start gap-5 lg:grid-cols-[minmax(280px,380px)_1fr]">
|
|
<IssueComposer
|
|
body={workspace.issueBody}
|
|
busy={workspace.pendingAction === "issue"}
|
|
onBodyChange={handleIssueBodyChange}
|
|
onSubmit={handleIssueSubmit}
|
|
onTitleChange={handleIssueTitleChange}
|
|
title={workspace.issueTitle}
|
|
/>
|
|
<IssueList
|
|
issues={workspace.issues}
|
|
onStart={handleIssueStart}
|
|
pendingAction={workspace.pendingAction}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="grid min-h-[60svh] place-items-center text-center">
|
|
<div className="max-w-sm space-y-2">
|
|
<GitFork className="mx-auto size-7 text-muted-foreground" />
|
|
<h1 className="text-xl font-semibold">
|
|
Connect your first repository
|
|
</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Zopu creates the project artifact set and opens an issue queue
|
|
for agent work.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
);
|
|
};
|