feat: generic project UI, branding fix, context/artifact separation
Web: - Replace GitHub-specific import with generic public Git URL import - Update project workspace page to use ProjectView shape (name, sources) - Migrate use-project-workspace hook to importPublicGit action - Fix status key needsInput → needs-input Branding: - Correct Zopo/zopo → Zopu/zopu in docs/PRODUCT.md, docs/DESIGN.md, docs/TECH.md Artifacts: - Cut to 8 operational artifacts (removed project/business/design.md) - Update artifact seeds with generic context scaffolding
This commit is contained in:
@@ -18,19 +18,20 @@ 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",
|
||||
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",
|
||||
open: "border-border bg-muted/40 text-muted-foreground",
|
||||
queued: "border-blue-500/40 bg-blue-500/10 text-blue-700 dark:text-blue-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 onSubmit: () => void;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
@@ -41,36 +42,44 @@ const RepositoryForm = ({
|
||||
value,
|
||||
}: RepositoryFormProps) => (
|
||||
<form
|
||||
className="grid gap-3 border-b p-4"
|
||||
className="space-y-2 border-b p-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSubmit();
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium" htmlFor="github-repository">
|
||||
Public GitHub repository
|
||||
<label className="text-xs font-medium" htmlFor="git-repository">
|
||||
Public Git repository
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connect with an owner/name slug or repository URL.
|
||||
Import with a public HTTP(S) Git URL.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
id="github-repository"
|
||||
id="git-repository"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder="rivet-dev/rivet"
|
||||
placeholder="https://github.com/owner/repo"
|
||||
required
|
||||
value={value}
|
||||
/>
|
||||
<Button disabled={busy || value.trim().length === 0} type="submit">
|
||||
<Button className="w-full" disabled={busy} type="submit">
|
||||
<GitFork data-icon="inline-start" />
|
||||
{busy ? "Connecting" : "Connect repository"}
|
||||
{busy ? "Importing" : "Import repository"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
||||
interface ProjectListProps {
|
||||
readonly projects: readonly Doc<"projects">[] | undefined;
|
||||
readonly projects: readonly {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly sources: readonly {
|
||||
readonly host: string;
|
||||
readonly repositoryPath: string;
|
||||
readonly url: string;
|
||||
}[];
|
||||
}[] | undefined;
|
||||
readonly selectedId: Id<"projects"> | null;
|
||||
readonly onSelect: (projectId: Id<"projects">) => void;
|
||||
}
|
||||
@@ -85,7 +94,7 @@ const ProjectList = ({ projects, selectedId, onSelect }: ProjectListProps) => {
|
||||
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.
|
||||
Import a repository to create its project artifacts and issue queue.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -94,19 +103,20 @@ const ProjectList = ({ projects, selectedId, onSelect }: ProjectListProps) => {
|
||||
{projects.map((project) => (
|
||||
<button
|
||||
className={`w-full border px-3 py-2 text-left transition-colors ${
|
||||
selectedId === project._id
|
||||
selectedId === project.id
|
||||
? "border-foreground/30 bg-muted"
|
||||
: "border-transparent hover:bg-muted/60"
|
||||
}`}
|
||||
key={project._id}
|
||||
onClick={() => onSelect(project._id)}
|
||||
key={project.id}
|
||||
onClick={() => onSelect(project.id as Id<"projects">)}
|
||||
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}
|
||||
{project.sources[0]?.host}
|
||||
{project.sources[0]?.repositoryPath}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -130,35 +140,26 @@ interface ArtifactGridProps {
|
||||
|
||||
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>
|
||||
<h2 id="artifact-heading" className="text-sm font-semibold">
|
||||
Project artifacts
|
||||
</h2>
|
||||
{artifacts === undefined ? (
|
||||
<p className="text-sm text-muted-foreground">Loading artifacts...</p>
|
||||
<p className="text-xs text-muted-foreground">Loading artifacts...</p>
|
||||
) : artifacts.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No artifacts yet.</p>
|
||||
) : (
|
||||
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{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}
|
||||
<div
|
||||
className="border p-3"
|
||||
key={artifact._id}
|
||||
>
|
||||
<FileText className="mb-2 size-4 text-muted-foreground" />
|
||||
<p className="truncate text-xs font-medium">{artifact.path}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
rev {artifact.revision}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -169,7 +170,7 @@ interface IssueComposerProps {
|
||||
readonly body: string;
|
||||
readonly busy: boolean;
|
||||
readonly onBodyChange: (value: string) => void;
|
||||
readonly onSubmit: () => Promise<void>;
|
||||
readonly onSubmit: () => void;
|
||||
readonly onTitleChange: (value: string) => void;
|
||||
readonly title: string;
|
||||
}
|
||||
@@ -183,47 +184,27 @@ const IssueComposer = ({
|
||||
title,
|
||||
}: IssueComposerProps) => (
|
||||
<form
|
||||
className="grid gap-3 border p-4"
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSubmit();
|
||||
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"
|
||||
>
|
||||
<h2 className="text-sm font-semibold">New issue</h2>
|
||||
<Input
|
||||
onChange={(event) => onTitleChange(event.target.value)}
|
||||
placeholder="Issue title"
|
||||
required
|
||||
value={title}
|
||||
/>
|
||||
<Textarea
|
||||
onChange={(event) => onBodyChange(event.target.value)}
|
||||
placeholder="Describe the issue..."
|
||||
required
|
||||
rows={4}
|
||||
value={body}
|
||||
/>
|
||||
<Button className="w-full" disabled={busy} type="submit">
|
||||
<Plus data-icon="inline-start" />
|
||||
{busy ? "Creating" : "Create issue"}
|
||||
</Button>
|
||||
@@ -237,89 +218,55 @@ interface IssueListProps {
|
||||
issueId: Id<"projectIssues">,
|
||||
issueNumber: number,
|
||||
title: string
|
||||
) => Promise<void>;
|
||||
) => 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>
|
||||
);
|
||||
}
|
||||
if (issues === undefined) {
|
||||
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>
|
||||
<p className="text-xs text-muted-foreground">Loading issues...</p>
|
||||
);
|
||||
};
|
||||
|
||||
}
|
||||
if (issues.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No issues yet. Create one to start agent work.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
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>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-sm font-semibold">Issues</h2>
|
||||
{issues.map((issue) => (
|
||||
<div className="border p-3" key={issue._id}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
#{issue.number} · {issue.title}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded border px-2 py-0.5 text-[10px] font-medium ${statusStyle[issue.status]}`}
|
||||
>
|
||||
{issue.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{issue.body}
|
||||
</p>
|
||||
{(issue.status === "open" || issue.status === "failed") && (
|
||||
<Button
|
||||
className="mt-2"
|
||||
disabled={pendingAction === `issue:${issue._id}`}
|
||||
onClick={() => onStart(issue._id, issue.number, issue.title)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Play data-icon="inline-start" />
|
||||
{pendingAction === `issue:${issue._id}` ? "Starting" : "Start"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -332,6 +279,7 @@ export const ProjectWorkspacePage = () => {
|
||||
const handleIssueSubmit = workspace.raiseIssue;
|
||||
const handleIssueTitleChange = workspace.setIssueTitle;
|
||||
const handleIssueStart = workspace.startIssue;
|
||||
const projectSource = workspace.selectedProject?.sources[0];
|
||||
|
||||
return (
|
||||
<main className="min-h-svh bg-background text-foreground">
|
||||
@@ -344,7 +292,7 @@ export const ProjectWorkspacePage = () => {
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Project manager</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
GitHub, Flue, AgentOS
|
||||
Git · Flue · AgentOS
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -379,21 +327,19 @@ export const ProjectWorkspacePage = () => {
|
||||
<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
|
||||
Connected repository
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{workspace.selectedProject.repoOwner}/
|
||||
{workspace.selectedProject.repoName}
|
||||
{workspace.selectedProject.name}
|
||||
</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
{workspace.selectedProject.description ??
|
||||
"No repository description is available."}
|
||||
{projectSource?.host}/{projectSource?.repositoryPath}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.open(
|
||||
workspace.selectedProject?.repoUrl,
|
||||
projectSource?.url,
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
)
|
||||
@@ -402,7 +348,7 @@ export const ProjectWorkspacePage = () => {
|
||||
variant="outline"
|
||||
>
|
||||
<ExternalLink data-icon="inline-start" />
|
||||
Open GitHub
|
||||
Open repository
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -429,7 +375,7 @@ export const ProjectWorkspacePage = () => {
|
||||
<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
|
||||
Import your first repository
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Zopu creates the project artifact set and opens an issue queue
|
||||
|
||||
@@ -17,11 +17,12 @@ export const useProjectWorkspace = () => {
|
||||
const [issueBody, setIssueBody] = useState("");
|
||||
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const connectGitHub = useAction(api.projects.connectGitHub);
|
||||
const importPublicGit = useAction(api.projects.importPublicGit);
|
||||
const createIssue = useMutation(api.projectIssues.create);
|
||||
const beginIssue = useMutation(api.projectIssues.begin);
|
||||
const markDispatchFailed = useMutation(api.projectIssues.markDispatchFailed);
|
||||
const activeProjectId = selectedProjectId ?? projects?.[0]?._id ?? null;
|
||||
const activeProjectId =
|
||||
(selectedProjectId ?? (projects?.[0]?.id as unknown as Id<"projects">)) ?? null;
|
||||
const artifacts = useQuery(
|
||||
api.projectArtifacts.list,
|
||||
activeProjectId ? { projectId: activeProjectId } : "skip"
|
||||
@@ -35,8 +36,8 @@ export const useProjectWorkspace = () => {
|
||||
setPendingAction("connect");
|
||||
setError(null);
|
||||
try {
|
||||
const projectId = await connectGitHub({ repository });
|
||||
setSelectedProjectId(projectId);
|
||||
const outcome = await importPublicGit({ repositoryUrl: repository });
|
||||
setSelectedProjectId(outcome.id as unknown as Id<"projects">);
|
||||
setRepository("");
|
||||
} catch (caughtError) {
|
||||
setError(errorMessage(caughtError));
|
||||
@@ -87,9 +88,8 @@ export const useProjectWorkspace = () => {
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedProject =
|
||||
projects?.find((project) => project._id === activeProjectId) ?? null;
|
||||
projects?.find((project) => project.id === (activeProjectId as unknown as string)) ?? null;
|
||||
|
||||
return {
|
||||
artifacts,
|
||||
|
||||
Reference in New Issue
Block a user