feat: ship agent onboarding runtime

This commit is contained in:
-Puter
2026-08-04 01:38:48 +05:30
parent 48205a5e19
commit b02200b3dc
68 changed files with 10522 additions and 4156 deletions
@@ -0,0 +1,103 @@
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { Button } from "@code/ui/components/button";
import { ArrowRight, LoaderCircle, X } from "lucide-react";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { useProjectSetup } from "@/hooks/use-project-setup";
import { ProjectSetupStatus } from "./project-setup-status";
const handleRetry = () => {
// The setup query is reactive — a retry on the backend will append new
// events and the phase derivation updates automatically. No client-side
// refetch is needed; this is a no-op signal for assistive tech.
};
export interface ProjectSetupPanelProps {
readonly onClose: () => void;
readonly onEnterProject: () => void;
readonly projectId: Id<"projects">;
readonly projectName: string;
}
/**
* Modal surface shown after a project is created. Subscribes to the project
* setup lifecycle and renders thin progress states. On "ready" it offers a CTA
* to the global timeline / project home. Never surfaces raw runtime logs.
*/
export const ProjectSetupPanel = ({
onClose,
onEnterProject,
projectId,
projectName,
}: ProjectSetupPanelProps) => {
const dialogRef = useRef<HTMLDialogElement>(null);
const setup = useProjectSetup(projectId);
useEffect(() => {
const dialog = dialogRef.current;
dialog?.showModal();
return () => dialog?.close();
}, []);
return createPortal(
<dialog
aria-labelledby="project-setup-heading"
className="fixed inset-0 z-50 m-0 flex h-full w-full max-w-none items-center justify-center bg-[#20201d]/45 p-2 backdrop-blur-[2px] sm:p-6"
onCancel={(event) => {
event.preventDefault();
onClose();
}}
ref={dialogRef}
>
<section className="relative flex h-[calc(100svh-1rem)] w-full max-w-lg flex-col overflow-hidden rounded bg-[#faf9f4] text-[#20201d] shadow-2xl sm:h-[min(40rem,calc(100svh-3rem))]">
<div className="flex shrink-0 items-center justify-between gap-5 border-b border-[#d7d3c7] px-5 py-4">
<div className="min-w-0">
<p className="text-[10px] tracking-[0.16em] text-[#858277] uppercase">
Project setup
</p>
<h2
className="truncate text-base font-semibold tracking-tight text-[#20201d]"
id="project-setup-heading"
>
{projectName}
</h2>
</div>
<button
aria-label="Close project setup"
className="grid size-8 shrink-0 place-items-center rounded text-[#747168] transition-colors hover:bg-[#f2f0e7] hover:text-[#20201d]"
onClick={onClose}
type="button"
>
<X className="size-4" />
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-5">
{setup.kind === "loading" ? (
<div className="flex flex-1 items-center justify-center">
<LoaderCircle className="size-5 animate-spin text-[#747168]" />
</div>
) : (
<ProjectSetupStatus onRetry={handleRetry} phase={setup.phase} />
)}
{setup.kind === "ready" && setup.phase === "ready" ? (
<div className="mt-auto border-t border-[#e4e0d4] pt-4">
<Button
className="w-full bg-[#20201d] text-[#fffefa] hover:bg-[#3a3933]"
onClick={onEnterProject}
type="button"
>
<ArrowRight className="size-4" />
Go to project
</Button>
</div>
) : null}
</div>
</section>
</dialog>,
document.body
);
};
@@ -0,0 +1,157 @@
import { Button } from "@code/ui/components/button";
import {
CheckCircle2,
FolderGit2,
LoaderCircle,
PackageOpen,
ShieldCheck,
TriangleAlert,
XCircle,
} from "lucide-react";
import type { ProjectSetupPhase } from "@/hooks/use-project-setup";
interface PhaseConfig {
readonly description: string;
readonly icon: typeof FolderGit2;
readonly label: string;
}
const PHASE_CONFIG: Record<ProjectSetupPhase, PhaseConfig> = {
blocked: {
description: "Setup could not complete.",
icon: XCircle,
label: "Setup blocked",
},
checking_repository: {
description: "Verifying the repository is readable.",
icon: ShieldCheck,
label: "Checking repository",
},
cloning_repository: {
description: "Cloning the repository into the workspace.",
icon: PackageOpen,
label: "Cloning repository",
},
creating_workspace: {
description: "Spinning up an isolated workspace for this project.",
icon: FolderGit2,
label: "Creating workspace",
},
ready: {
description: "Project is set up and ready to explore.",
icon: CheckCircle2,
label: "Ready",
},
};
const ORDER: readonly ProjectSetupPhase[] = [
"creating_workspace",
"cloning_repository",
"checking_repository",
"ready",
];
export interface ProjectSetupStatusProps {
readonly onRetry?: () => void;
readonly phase: ProjectSetupPhase;
}
const resolveStepClassName = (
isComplete: boolean,
isActive: boolean
): string => {
if (isComplete) {
return "grid size-7 shrink-0 place-items-center rounded-full bg-[#20201d] text-[#fffefa]";
}
if (isActive) {
return "grid size-7 shrink-0 place-items-center rounded-full border border-[#20201d] text-[#20201d]";
}
return "grid size-7 shrink-0 place-items-center rounded-full border border-[#c9c5b9] text-[#858277]";
};
/**
* Presentational rendering of the project setup lifecycle. Renders thin,
* user-meaningful states with no raw runtime logs. The `blocked` phase renders
* an actionable retry control when a handler is supplied.
*/
export const ProjectSetupStatus = ({
onRetry,
phase,
}: ProjectSetupStatusProps) => {
if (phase === "blocked") {
return (
<div className="flex items-start gap-3 border border-red-300 bg-red-50 p-4">
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-red-600" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-red-800">
Setup hit a problem
</p>
<p className="mt-1 text-xs leading-5 text-red-700">
We could not finish preparing this project. You can retry now or
close and try again later.
</p>
{onRetry ? (
<Button
className="mt-3 h-8 border-red-300 bg-white text-xs text-red-700 hover:bg-red-100"
onClick={onRetry}
size="sm"
type="button"
variant="outline"
>
Retry setup
</Button>
) : null}
</div>
</div>
);
}
const activeIndex = ORDER.indexOf(phase);
return (
<ol className="flex flex-col gap-1">
{ORDER.map((stepPhase, index) => {
const config = PHASE_CONFIG[stepPhase];
const isComplete = activeIndex > index;
const isActive = stepPhase === phase;
const Icon = isComplete ? CheckCircle2 : config.icon;
const stepClassName = resolveStepClassName(isComplete, isActive);
return (
<li
aria-current={isActive ? "step" : undefined}
className="flex items-center gap-3 px-1 py-2"
key={stepPhase}
>
<span className={stepClassName}>
{isActive ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<Icon className="size-3.5" />
)}
</span>
<div className="min-w-0">
<p
className={
isActive || isComplete
? "text-sm font-medium text-[#20201d]"
: "text-sm font-medium text-[#858277]"
}
>
{config.label}
</p>
{isActive ? (
<p className="mt-0.5 text-xs leading-5 text-[#68665e]">
{config.description}
</p>
) : null}
</div>
</li>
);
})}
</ol>
);
};
+168
View File
@@ -0,0 +1,168 @@
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useQuery } from "convex/react";
import { makeFunctionReference } from "convex/server";
/**
* Setup event types emitted by the backend project-setup coordinator. The
* lifecycle types (`project.setup.*`, `project.ready`, `project.setup.failed`)
* are emitted by `projectSetup.runSetup`; the remaining types mirror the
* literals accepted by `projects.appendSetupEvent` and returned by
* `projects.getSetup`.
*/
export type ProjectSetupEventType =
// Lifecycle event types emitted by the project-setup coordinator.
| "project.setup.creating_vm"
| "project.setup.cloning"
| "project.setup.checking_repository"
| "project.ready"
| "project.setup.failed"
// Legacy / environment / preview event types (compatible).
| "project.setup.started"
| "project.setup.environment_required"
| "project.setup.environment_updated"
| "project.setup.ready"
| "project.setup.blocked"
| "project.preview.ready"
| "project.preview.blocked";
/**
* User-facing lifecycle phases derived from the raw setup event stream. The
* component layer never renders raw event types — it renders these phases.
*/
export type ProjectSetupPhase =
| "creating_workspace"
| "cloning_repository"
| "checking_repository"
| "ready"
| "blocked";
export interface ProjectSetupEvent {
readonly occurredAt: number;
readonly payload: unknown;
readonly type: ProjectSetupEventType;
}
export interface ProjectSetupState {
readonly events: readonly ProjectSetupEvent[];
readonly phase: ProjectSetupPhase;
}
export type ProjectSetupResult =
| { readonly kind: "loading" }
| ({ readonly kind: "ready" } & ProjectSetupState);
/**
* Minimal structural shape of the `projects.getSetup` return value. We define
* this locally rather than importing from generated code so the hook compiles
* before the backend regenerates its API manifest.
*/
interface SetupQueryResult {
readonly events: readonly {
readonly occurredAt: number;
readonly payload: unknown;
readonly type: ProjectSetupEventType;
}[];
}
const getSetupRef = makeFunctionReference<
"query",
{ projectId: Id<"projects"> },
SetupQueryResult
>("projects:getSetup");
const ORDERED_PHASES: readonly ProjectSetupPhase[] = [
"creating_workspace",
"cloning_repository",
"checking_repository",
"ready",
];
const typeToPhase = (type: ProjectSetupEventType): ProjectSetupPhase => {
switch (type) {
case "project.setup.creating_vm":
case "project.setup.started": {
return "creating_workspace";
}
case "project.setup.cloning": {
return "cloning_repository";
}
case "project.setup.checking_repository":
case "project.setup.environment_required":
case "project.setup.environment_updated": {
return "checking_repository";
}
default: {
return "creating_workspace";
}
}
};
/**
* Derive the user-facing phase from the raw event stream.
*
* - A `blocked` event always wins (terminal error state).
* - A `ready` event wins over any in-progress phase.
* - Otherwise we map observed events to the furthest in-progress phase.
*/
const derivePhase = (
events: readonly ProjectSetupEvent[]
): ProjectSetupPhase => {
if (events.length === 0) {
return "creating_workspace";
}
const hasBlocked = events.some(
(event) =>
event.type === "project.setup.failed" ||
event.type === "project.setup.blocked" ||
event.type === "project.preview.blocked"
);
if (hasBlocked) {
return "blocked";
}
const hasReady = events.some(
(event) =>
event.type === "project.ready" ||
event.type === "project.setup.ready" ||
event.type === "project.preview.ready"
);
if (hasReady) {
return "ready";
}
let furthestIndex = 0;
for (const event of events) {
const index = ORDERED_PHASES.indexOf(typeToPhase(event.type));
if (index > furthestIndex) {
furthestIndex = index;
}
}
return ORDERED_PHASES[furthestIndex] ?? "creating_workspace";
};
/**
* Subscribe to a project's setup lifecycle via the `projects.getSetup` query.
* Returns a loading sentinel while the first fetch is in flight, then a
* derived `{ phase, events }` state that components can render directly.
*/
export const useProjectSetup = (
projectId: Id<"projects"> | undefined
): ProjectSetupResult => {
const setup = useQuery(
getSetupRef,
projectId === undefined ? "skip" : { projectId }
);
if (setup === undefined) {
return { kind: "loading" };
}
const events: readonly ProjectSetupEvent[] = setup.events.map((event) => ({
occurredAt: event.occurredAt,
payload: event.payload,
type: event.type,
}));
const phase = derivePhase(events);
return { events, kind: "ready", phase };
};
+26 -2
View File
@@ -3,16 +3,22 @@ import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useAction, useQuery } from "convex/react";
import { makeFunctionReference } from "convex/server";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router";
import { useNavigate, useSearchParams } from "react-router";
import { AddProjectPanel } from "@/components/projects/add-project-panel";
import { LoadingState } from "@/components/projects/loading-state";
import { ProjectSetupPanel } from "@/components/projects/project-setup-panel";
import { ProjectsGrid } from "@/components/projects/projects-grid";
import { ProjectsHeader } from "@/components/projects/projects-header";
import type { GitProviderAccountOption } from "@/components/projects/provider-chips";
import { useFilteredProjects } from "@/hooks/use-filtered-projects";
import { useProjectsPageState } from "@/hooks/use-projects-page-state";
interface SetupTarget {
readonly id: Id<"projects">;
readonly name: string;
}
const connectGithubRef = makeFunctionReference<
"action",
Record<string, never>,
@@ -20,6 +26,7 @@ const connectGithubRef = makeFunctionReference<
>("gitConnections:connectGithub");
export default function ProjectsRoute() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const projects = useQuery(api.projects.list, {});
const providerAccounts = useQuery(
@@ -28,6 +35,7 @@ export default function ProjectsRoute() {
) as readonly GitProviderAccountOption[] | undefined;
const connectGithubAction = useAction(connectGithubRef);
const [resumeError, setResumeError] = useState<string>();
const [setupTarget, setSetupTarget] = useState<SetupTarget>();
const pageState = useProjectsPageState({
hasProjects: projects === undefined ? undefined : projects.length > 0,
});
@@ -67,8 +75,13 @@ export default function ProjectsRoute() {
project.sources.map((source) => source.url)
);
const hasProjects = projects.length > 0;
const handleProjectCreated = () => {
const handleProjectCreated = (projectId: string) => {
pageState.closeAddProject();
const project = projects.find((item) => item.id === projectId);
setSetupTarget({
id: projectId as Id<"projects">,
name: project?.name ?? "Project",
});
};
const handleCloseAddProject = () => {
pageState.closeAddProject();
@@ -115,6 +128,17 @@ export default function ProjectsRoute() {
onProjectCreated={handleProjectCreated}
/>
) : null}
{setupTarget ? (
<ProjectSetupPanel
onClose={() => setSetupTarget(undefined)}
onEnterProject={() => {
setSetupTarget(undefined);
navigate("/");
}}
projectId={setupTarget.id}
projectName={setupTarget.name}
/>
) : null}
</main>
);
}