feat: Projects backend slice — auth, primitives, backend, UI (#3)

This commit was merged in pull request #3.
This commit is contained in:
2026-07-23 14:09:25 +00:00
parent ab9426b8fc
commit 609badc4ed
32 changed files with 1925 additions and 663 deletions
@@ -7,7 +7,6 @@ import {
ExternalLink,
FileText,
GitFork,
GitPullRequest,
Play,
Plus,
} from "lucide-react";
@@ -20,17 +19,16 @@ const statusStyle: Record<Doc<"projectIssues">["status"], string> = {
"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",
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 +39,46 @@ 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 +93,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 +102,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>
))}
@@ -128,40 +137,34 @@ interface ArtifactGridProps {
readonly artifacts: readonly Doc<"projectArtifacts">[] | undefined;
}
const renderArtifacts = (artifacts: ArtifactGridProps["artifacts"]) => {
if (artifacts === undefined) {
return <p className="text-xs text-muted-foreground">Loading artifacts...</p>;
}
if (artifacts.length === 0) {
return <p className="text-xs text-muted-foreground">No artifacts yet.</p>;
}
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{artifacts.map((artifact) => (
<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>
</div>
))}
</div>
);
};
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>
)}
<h2 id="artifact-heading" className="text-sm font-semibold">
Project artifacts
</h2>
{renderArtifacts(artifacts)}
</section>
);
@@ -169,7 +172,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 +186,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 +220,53 @@ 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 <p className="text-xs text-muted-foreground">Loading issues...</p>;
}
if (issues.length === 0) {
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">
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
+7 -12
View File
@@ -1,5 +1,4 @@
import { authClient } from "@code/auth/web";
import { api } from "@code/backend/convex/_generated/api";
import { signOutWeb, useWebAuth } from "@code/auth/web";
import { Button } from "@code/ui/components/button";
import {
DropdownMenu,
@@ -10,12 +9,13 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@code/ui/components/dropdown-menu";
import { useQuery } from "convex/react";
import { useNavigate } from "react-router";
export default function UserMenu() {
const navigate = useNavigate();
const user = useQuery(api.auth.getCurrentUser);
const auth = useWebAuth();
const user = auth.status === "authenticated" ? auth.user : null;
return (
<DropdownMenu>
@@ -29,14 +29,9 @@ export default function UserMenu() {
<DropdownMenuItem>{user?.email}</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => {
authClient.signOut({
fetchOptions: {
onSuccess: () => {
navigate("/login", { replace: true });
},
},
});
onClick={async () => {
await signOutWeb();
navigate("/login", { replace: true });
}}
>
Sign Out
@@ -1,77 +0,0 @@
import type { EffectCallback } from "react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { usePersonalOrganization } from "./use-personal-organization";
const mocks = vi.hoisted(() => ({
cleanup: undefined as (() => void) | undefined,
ensurePersonalOrganization: vi.fn(),
session: {
data: { session: { id: "session-a" } },
} as { data: { session: { id: string } } | null },
state: null as {
error?: Error;
organizationId?: string;
sessionId: string;
} | null,
}));
vi.mock("@code/auth/web", () => ({
authClient: { useSession: () => mocks.session },
}));
vi.mock("@code/backend/convex/_generated/api", () => ({
api: { organizations: { ensurePersonalOrganization: "ensure-org" } },
}));
vi.mock("convex/react", () => ({
useMutation: () => mocks.ensurePersonalOrganization,
}));
vi.mock("react", () => ({
useEffect: (effect: EffectCallback) => {
mocks.cleanup?.();
const cleanup = effect();
mocks.cleanup = typeof cleanup === "function" ? cleanup : undefined;
},
useState: () => [
mocks.state,
(next: { error?: Error; organizationId?: string; sessionId: string }) => {
mocks.state = next;
},
],
}));
describe("usePersonalOrganization", () => {
beforeEach(() => {
mocks.cleanup?.();
mocks.cleanup = undefined;
mocks.ensurePersonalOrganization.mockReset();
mocks.session = { data: { session: { id: "session-a" } } };
mocks.state = null;
});
test("an authenticated session ensures its org before exposing the id", async () => {
const ensured = Promise.withResolvers<{ _id: string }>();
mocks.ensurePersonalOrganization.mockReturnValue(ensured.promise);
expect(usePersonalOrganization()).toEqual({});
expect(mocks.ensurePersonalOrganization).toHaveBeenCalledTimes(1);
ensured.resolve({ _id: "org-a" });
await ensured.promise;
await Promise.resolve();
expect(usePersonalOrganization()).toEqual({
error: undefined,
organizationId: "org-a",
});
});
test("logout cannot expose the prior session organization", async () => {
mocks.ensurePersonalOrganization.mockResolvedValue({ _id: "org-a" });
usePersonalOrganization();
await Promise.resolve();
await Promise.resolve();
mocks.session = { data: null };
expect(usePersonalOrganization()).toEqual({});
});
});
+20 -31
View File
@@ -1,33 +1,28 @@
import { authClient } from "@code/auth/web";
import { useWebAuth } from "@code/auth/web";
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useMutation } from "convex/react";
import { useEffect, useState } from "react";
interface OrganizationBootstrapState {
readonly error?: Error;
readonly organizationId?: Id<"organizations">;
readonly sessionId: string;
}
export interface PersonalOrganizationState {
readonly error?: Error;
readonly organizationId?: Id<"organizations">;
}
/** Ensure the authenticated session has its personal tenancy boundary. */
/** Ensure the authenticated user has its personal tenancy boundary. */
export const usePersonalOrganization = (): PersonalOrganizationState => {
const { data: session } = authClient.useSession();
const sessionId = session?.session.id;
const auth = useWebAuth();
const userId = auth.status === "authenticated" ? auth.user.id : null;
const ensurePersonalOrganization = useMutation(
api.organizations.ensurePersonalOrganization
);
const [bootstrap, setBootstrap] = useState<OrganizationBootstrapState | null>(
null
);
const [organizationId, setOrganizationId] = useState<
Id<"organizations"> | undefined
>();
const [error, setError] = useState<Error | undefined>();
useEffect(() => {
if (!sessionId) {
if (!userId) {
return;
}
@@ -36,20 +31,16 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
try {
const organization = await ensurePersonalOrganization();
if (active) {
setBootstrap({
organizationId: organization._id,
sessionId,
});
setOrganizationId(organization._id);
setError(undefined);
}
} catch (caughtError: unknown) {
if (active) {
setBootstrap({
error:
caughtError instanceof Error
? caughtError
: new Error(String(caughtError)),
sessionId,
});
setError(
caughtError instanceof Error
? caughtError
: new Error(String(caughtError))
);
}
}
};
@@ -58,13 +49,11 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
return () => {
active = false;
};
}, [ensurePersonalOrganization, sessionId]);
}, [ensurePersonalOrganization, userId]);
if (!sessionId || bootstrap?.sessionId !== sessionId) {
if (!userId) {
return {};
}
return {
error: bootstrap.error,
organizationId: bootstrap.organizationId,
};
return { error, organizationId };
};
+10 -6
View File
@@ -17,11 +17,14 @@ 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 +38,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 +90,10 @@ 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,
+17 -5
View File
@@ -1,15 +1,27 @@
import { useConvexAuth } from "convex/react";
import { useWebAuth } from "@code/auth/web";
import { Navigate, Outlet, useLocation } from "react-router";
export default function AppLayout() {
const { isAuthenticated, isLoading } = useConvexAuth();
const auth = useWebAuth();
const location = useLocation();
if (isLoading) {
return <div className="grid min-h-svh place-items-center text-sm text-muted-foreground">Loading…</div>;
if (auth.status === "loading") {
return (
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
Loading…
</div>
);
}
if (!isAuthenticated) {
if (auth.status === "inconsistent") {
return (
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
Your session could not be verified
</div>
);
}
if (auth.status === "unauthenticated") {
return <Navigate replace state={{ from: location.pathname }} to="/login" />;
}
+11 -5
View File
@@ -1,14 +1,20 @@
import { useConvexAuth } from "convex/react";
import { useWebAuth } from "@code/auth/web";
import { Navigate, Outlet } from "react-router";
export default function AuthLayout() {
const { isAuthenticated, isLoading } = useConvexAuth();
const auth = useWebAuth();
if (isLoading) {
return <div className="grid min-h-svh place-items-center text-sm text-muted-foreground">Loading…</div>;
if (auth.status === "loading") {
return (
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
Loading…
</div>
);
}
if (isAuthenticated) return <Navigate replace to="/" />;
if (auth.status === "authenticated") {
return <Navigate replace to="/" />;
}
return (
<main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10">