Apply the bracketed Convex API module references required by the kebab-case module rename

This commit is contained in:
sai karthik
2026-07-23 00:50:08 +05:30
parent 74a209a807
commit b0aef54249
24 changed files with 1330 additions and 239 deletions

View File

@@ -0,0 +1,445 @@
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 = 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>
);
};

View File

@@ -0,0 +1,113 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useAction, useMutation, useQuery } from "convex/react";
import { useState } from "react";
import { flueClient } from "@/root";
const errorMessage = (error: unknown) =>
error instanceof Error ? error.message : String(error);
export const useProjectWorkspace = () => {
const projects = useQuery(api.projects.list);
const [selectedProjectId, setSelectedProjectId] =
useState<Id<"projects"> | null>(null);
const [repository, setRepository] = useState("");
const [issueTitle, setIssueTitle] = useState("");
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 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 artifacts = useQuery(
api.projectArtifacts.list,
activeProjectId ? { projectId: activeProjectId } : "skip"
);
const issues = useQuery(
api.projectIssues.list,
activeProjectId ? { projectId: activeProjectId } : "skip"
);
const connectRepository = async () => {
setPendingAction("connect");
setError(null);
try {
const projectId = await connectGitHub({ repository });
setSelectedProjectId(projectId);
setRepository("");
} catch (caughtError) {
setError(errorMessage(caughtError));
} finally {
setPendingAction(null);
}
};
const raiseIssue = async () => {
if (!activeProjectId) {
return;
}
setPendingAction("issue");
setError(null);
try {
await createIssue({
body: issueBody,
projectId: activeProjectId,
title: issueTitle,
});
setIssueTitle("");
setIssueBody("");
} catch (caughtError) {
setError(errorMessage(caughtError));
} finally {
setPendingAction(null);
}
};
const startIssue = async (
issueId: Id<"projectIssues">,
issueNumber: number,
title: string
) => {
const actionKey = `issue:${issueId}`;
setPendingAction(actionKey);
setError(null);
try {
await beginIssue({ issueId });
await flueClient.agents.send("project-manager", String(issueId), {
message: `Start project issue ${issueNumber}: ${title}. Read the bound context and complete the workflow.`,
});
} catch (caughtError) {
const message = errorMessage(caughtError);
await markDispatchFailed({ error: message, issueId });
setError(message);
} finally {
setPendingAction(null);
}
};
const selectedProject =
projects?.find((project) => project._id === activeProjectId) ?? null;
return {
artifacts,
connectRepository,
error,
issueBody,
issueTitle,
issues,
pendingAction,
projects,
raiseIssue,
repository,
selectedProject,
selectedProjectId: activeProjectId,
setIssueBody,
setIssueTitle,
setRepository,
setSelectedProjectId,
startIssue,
} as const;
};

View File

@@ -1,5 +1,5 @@
import { env } from "@code/env/web";
import { WebAuthProvider } from "@code/auth/web";
import { env } from "@code/env/web";
import { Toaster } from "@code/ui/components/sonner";
import { FlueProvider } from "@flue/react";
@@ -14,7 +14,6 @@ import {
ScrollRestoration,
} from "react-router";
import type { Route } from "./+types/root";
import { ThemeProvider } from "./components/theme-provider";
@@ -65,7 +64,7 @@ const flueFetch: typeof fetch = async (input, init) => {
}
);
};
const flueClient = createFlueClient({
export const flueClient = createFlueClient({
baseUrl: flueBaseUrl.toString(),
fetch: flueFetch,
});

View File

@@ -1,24 +1,5 @@
import { api } from "@code/backend/convex/_generated/api";
import { useQuery } from "convex/react";
import UserMenu from "@/components/user-menu";
import { ProjectWorkspacePage } from "@/components/projects/project-workspace-page";
export default function Dashboard() {
const privateData = useQuery(api.privateData.get);
return (
<main className="mx-auto flex min-h-svh max-w-3xl flex-col gap-6 p-6 md:p-10">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Authenticated workspace</p>
<h1 className="text-3xl font-semibold tracking-tight">Dashboard</h1>
</div>
<UserMenu />
</div>
<section className="rounded-xl border bg-card p-6 text-card-foreground">
<p>{privateData?.message ?? "Loading private data…"}</p>
</section>
</main>
);
return <ProjectWorkspacePage />;
}