feat: ship agent onboarding runtime
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user