diff --git a/frontend/public/images/brand/actors-mark.svg b/frontend/public/images/brand/actors-mark.svg new file mode 100644 index 0000000000..3a94d564fa --- /dev/null +++ b/frontend/public/images/brand/actors-mark.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/images/brand/agentos-mark.svg b/frontend/public/images/brand/agentos-mark.svg new file mode 100644 index 0000000000..d5e05f022a --- /dev/null +++ b/frontend/public/images/brand/agentos-mark.svg @@ -0,0 +1,13 @@ + + + + + + + +OS + + + + + diff --git a/frontend/public/images/brand/dynamic-apps-mark.svg b/frontend/public/images/brand/dynamic-apps-mark.svg new file mode 100644 index 0000000000..c89025cc3c --- /dev/null +++ b/frontend/public/images/brand/dynamic-apps-mark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/images/brand/workflows-mark.svg b/frontend/public/images/brand/workflows-mark.svg new file mode 100644 index 0000000000..8f4f7b2229 --- /dev/null +++ b/frontend/public/images/brand/workflows-mark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/app/actors-grid.tsx b/frontend/src/app/actors-grid.tsx index 4f59f65964..a0ab9a8d3d 100644 --- a/frontend/src/app/actors-grid.tsx +++ b/frontend/src/app/actors-grid.tsx @@ -1,4 +1,4 @@ -import { faChevronDown, faGear, faLogs, faPlus, Icon } from "@rivet-gg/icons"; +import { faGear, faLogs, Icon } from "@rivet-gg/icons"; import { queryOptions, useInfiniteQuery, @@ -22,18 +22,12 @@ import { useCloudNamespaceDataProvider, useDataProvider, } from "@/components/actors"; -import { Badge } from "@/components/ui/badge"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { NoProvidersAlert } from "@/components/actors/no-providers-alert"; import { ActorIcon } from "@/components/lazy-icon"; import { VisibilitySensor } from "@/components/visibility-sensor"; import { features } from "@/lib/features"; import { getRivetRunUrl } from "../lib/env"; +import { AddComponentButton, AddComponentCard } from "./add-component-card"; import { RouteLayout } from "./route-layout"; function _GridCard({ @@ -65,61 +59,6 @@ function _GridCard({ ); } -// Header create affordance. With the agentOS feature flag on, the single -// "Create Actor" button becomes a "Create" menu offering Actor or agentOS; -// both open the same create dialog, the latter with agentOS-tailored copy. -function CreateMenu({ - buttonVariant, -}: { - buttonVariant: "outline" | "default"; -}) { - const navigate = useNavigate(); - const openModal = (modal: string) => - navigate({ to: ".", search: (old) => ({ ...old, modal }) }); - - if (!features.agentOs) { - return ( - - ); - } - - return ( - - - - - - openModal("create-actor")}> - Actor - - openModal("create-agent-os")}> - agentOS - - Beta - - - - - ); -} - export function ActorGridCardSkeleton() { return (
@@ -269,9 +208,6 @@ export function ActorsGrid({ namespaceLabel }: { namespaceLabel?: string }) {

Actors

- {builds.length > 0 ? ( - - ) : null} {isLoading ? ( @@ -293,7 +229,7 @@ export function ActorsGrid({ namespaceLabel }: { namespaceLabel?: string }) { Deploy code that registers an actor to see it here. - +
) ) : ( @@ -305,6 +241,7 @@ export function ActorsGrid({ namespaceLabel }: { namespaceLabel?: string }) { build={build} /> ))} + {isFetchingNextPage ? Array.from({ length: 4 }).map( (_, i) => ( diff --git a/frontend/src/app/add-component-card.tsx b/frontend/src/app/add-component-card.tsx new file mode 100644 index 0000000000..0bd816d47b --- /dev/null +++ b/frontend/src/app/add-component-card.tsx @@ -0,0 +1,64 @@ +import { faPlus, Icon } from "@rivet-gg/icons"; +import { type ReactNode, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/components/lib/utils"; +import { useDialog } from "./use-dialog"; + +function AddComponentDialogTrigger({ + children, +}: { + children: (open: () => void) => ReactNode; +}) { + const [isOpen, setOpen] = useState(false); + const Dialog = useDialog.AddComponent.Dialog; + return ( + <> + {children(() => setOpen(true))} + + + ); +} + +export function AddComponentButton() { + return ( + + {(open) => ( + + )} + + ); +} + +// Matches ActorBuildCard's shape so it reads as the last tile in the grid +// rather than a control that happens to sit next to it. +export function AddComponentCard() { + return ( + + {(open) => ( + + )} + + ); +} diff --git a/frontend/src/app/compute-deploy.tsx b/frontend/src/app/compute-deploy.tsx index 7f48df2296..3e14538f75 100644 --- a/frontend/src/app/compute-deploy.tsx +++ b/frontend/src/app/compute-deploy.tsx @@ -1,17 +1,26 @@ import { faCopy, Icon } from "@rivet-gg/icons"; import { deployOptions, type Provider } from "@rivetkit/shared-data"; import { useSuspenseQuery } from "@tanstack/react-query"; +import { useParams } from "@tanstack/react-router"; import { toast } from "sonner"; -import { Badge, Button, CodeFrame, CodePreview } from "@/components"; +import { Badge, Button, CodeFrame, CodePreview, getConfig } from "@/components"; import { useCloudNamespaceDataProvider, useEngineCompatDataProvider, } from "@/components/actors"; +import { + hostedMcpCommand, + localMcpCommand, +} from "@/components/mcp/client-tabs"; +import { type HostedTarget, hostedUrl } from "@/components/mcp/scope"; import { getAgentInstructionsPrompt, getComputeAddendum, + type McpSetup, + type OnboardingTarget, } from "@/content/agent-prompts"; -import { cloudEnv, getRivetRunUrl } from "@/lib/env"; +import { cloudEnv, getMcpUrl, getRivetRunUrl } from "@/lib/env"; +import { features } from "@/lib/features"; import { usePublishableToken } from "@/queries/accessors"; import { useRivetDsn } from "./env-variables"; @@ -40,11 +49,13 @@ export function useAgentInstructionsCode({ runnerName = "default", endpoint, mode, + target = "actor", }: { provider?: Provider; runnerName?: string; endpoint?: string; mode?: "serverless" | "serverful"; + target?: OnboardingTarget; } = {}) { const providerDetails = provider ? deployOptions.find((p) => p.name === provider) @@ -62,6 +73,7 @@ export function useAgentInstructionsCode({ (mode ?? defaultRuntimeModeForProvider(provider)) === "serverless"; const publishableToken = useRivetDsn({ kind: "publishable", endpoint }); const secretToken = useRivetDsn({ kind: "secret", endpoint }); + const mcp = useMcpSetup(); const namespace = useEngineCompatDataProvider().engineNamespace; return getAgentInstructionsPrompt({ @@ -75,20 +87,63 @@ export function useAgentInstructionsCode({ // The `--namespace` deploy flag only applies to Rivet Compute's // `@rivetkit/cli deploy` flow. cliDeploy: provider === "rivet", + target, + mcp, }); } +// The MCP setup the copy-prompt should instruct the agent to perform. The hosted +// connection needs the user to approve an OAuth window, so the agent has to hand +// that step back; the local stdio server it can wire up itself. +function useMcpSetup(): McpSetup | undefined { + const params = useParams({ strict: false }) as Partial; + const dataProvider = useEngineCompatDataProvider(); + + if (!features.mcp) return undefined; + + if (features.platform) { + if (!params.organization || !params.project || !params.namespace) { + return undefined; + } + const url = hostedUrl( + getMcpUrl(), + { + organization: params.organization, + project: params.project, + namespace: params.namespace, + }, + "namespace", + ); + return { + command: hostedMcpCommand(url), + requiresUserApproval: true, + }; + } + + return { + command: localMcpCommand( + getConfig().apiUrl, + dataProvider.engineNamespace, + ), + requiresUserApproval: false, + }; +} + // Builds the Rivet Compute copy-prompt (generic instructions + compute addendum) // and exposes the cloud token and namespace so callers can also render a manual // `@rivetkit/cli deploy` command. -export function useComputeInstructionsCode() { - const agentInstructions = useAgentInstructionsCode({ provider: "rivet" }); +export function useComputeInstructionsCode(target: OnboardingTarget = "actor") { + const agentInstructions = useAgentInstructionsCode({ + provider: "rivet", + target, + }); const dataProvider = useCloudNamespaceDataProvider(); const { data: cloudToken } = useSuspenseQuery( dataProvider.createApiTokenQueryOptions({ name: "Onboarding" }), ); const publishableRawToken = usePublishableToken(); const namespace = dataProvider.engineNamespace; + const mcp = useMcpSetup(); const computeAddendum = getComputeAddendum({ cloudToken, @@ -97,6 +152,8 @@ export function useComputeInstructionsCode() { apiUrl: cloudEnv().VITE_APP_API_URL, cloudApiUrl: cloudEnv().VITE_APP_CLOUD_API_URL, rivetRunUrl: getRivetRunUrl(namespace), + target, + mcp, }); return { @@ -143,7 +200,7 @@ export function AgentPromptBanner({ : "Copied to clipboard", ); }} - className="relative w-full flex items-center justify-between gap-4 rounded-lg px-4 py-4 border border-primary group cursor-pointer text-left" + className="relative w-full flex flex-col items-stretch justify-between gap-4 rounded-lg px-4 py-4 border border-primary group cursor-pointer text-left sm:flex-row sm:items-center" > Recommended @@ -158,7 +215,11 @@ export function AgentPromptBanner({

) : null} - - ) : null} {isLoading ? ( @@ -120,14 +102,7 @@ export function EngineNamespaceLanding() { Deploy code that registers an actor to see it here. - + ) ) : ( @@ -139,6 +114,7 @@ export function EngineNamespaceLanding() { build={build} /> ))} + {isFetchingNextPage ? Array.from({ length: 4 }).map( (_, i) => ( diff --git a/frontend/src/app/forms/stepper-form.tsx b/frontend/src/app/forms/stepper-form.tsx index 7e081243f1..c760b6b24d 100644 --- a/frontend/src/app/forms/stepper-form.tsx +++ b/frontend/src/app/forms/stepper-form.tsx @@ -297,20 +297,19 @@ function Content({ : {}), }); + // Step visibility can depend on form values. Keep the visibility provider + // subscribed to the live form state so sequential changes (including + // dirty-to-dirty changes) update progress and the visible step sequence. + const liveValues = (useWatch({ control: form.control }) ?? {}) as Record< + string, + unknown + >; + const ref = useRef> | null>({}); const formRef = useRef(null); - const getValues = () => { - const allLive = form.getValues() as Record; - const dirtyFields = form.formState.dirtyFields as Record< - string, - unknown - >; - const live = Object.fromEntries( - Object.entries(allLive).filter(([k]) => k in dirtyFields), - ); - return { ...ref.current, ...live } as Record; - }; + const getValues = () => + ({ ...ref.current, ...liveValues }) as Record; const isLastVisible = (currentId: string) => getNextVisibleStepId(allSteps as Step[], currentId, getValues()) === @@ -330,10 +329,14 @@ function Content({ return onSubmit?.({ values: ref.current, form, stepper }); } await onPartialSubmit?.({ values: ref.current, form, stepper }); + // `liveValues` is the previous render's useWatch snapshot, so it still + // holds the pre-submit value for anything just changed. Let the + // accumulated values win here or a step gated on a field submitted by + // this step resolves its visibility against the stale choice. const nextId = getNextVisibleStepId( allSteps as Step[], stepper.current.id, - getValues(), + { ...getValues(), ...ref.current }, ); if (nextId) stepper.goTo(nextId as Parameters[0]); form.reset(undefined, { diff --git a/frontend/src/app/getting-started.tsx b/frontend/src/app/getting-started.tsx index 15079f0f46..e2d3a38d74 100644 --- a/frontend/src/app/getting-started.tsx +++ b/frontend/src/app/getting-started.tsx @@ -1,9 +1,9 @@ import { - faActors, faArrowRight, faCheck, faChevronDown, faKey, + faRivet, Icon, } from "@rivet-gg/icons"; import { deployOptions, type Provider } from "@rivetkit/shared-data"; @@ -34,8 +34,13 @@ import { useEngineCompatDataProvider, } from "@/components/actors"; import { defineStepper } from "@/components/ui/stepper"; +import { + getOnboardingTargetCopy, + type OnboardingTarget, +} from "@/content/agent-prompts"; import { deriveProviderFromMetadata } from "@/lib/data"; import { engineEnv } from "@/lib/env"; +import { ProductPicker } from "@/components/products/product-picker"; import { features } from "@/lib/features"; import { queryClient } from "@/queries/global"; import { cn } from "../components/lib/utils"; @@ -56,7 +61,11 @@ import { ConfigurationAccordion, } from "./dialogs/connect-manual-serverless-frame"; import { EnvVariables } from "./env-variables"; -import { StepperForm, StepVisibilityContext } from "./forms/stepper-form"; +import { + StepperForm, + StepVisibilityContext, + useStepperFormSubmit, +} from "./forms/stepper-form"; import { Content } from "./layout"; import { AgentSelectStep } from "@/components/onboarding/agent-os/agent-select-step"; import { buildAgentOsSetup } from "@/components/onboarding/agent-os/build-agent-os-setup"; @@ -82,22 +91,31 @@ function platformTitle(provider: unknown): string { const stepper = defineStepper( { - id: "local", - title: "Run locally", - titleFor: (values: Record) => - values.template === "agent-os" - ? "What are you building?" - : "Run locally", - description: "Get your first Rivet Actor running on your machine.", - next: "Continue", + id: "select", + title: "Select a product", + // Selecting a card submits the step, so there is no Continue button. + showNext: false, // `template` is carried in the step schema so the stepper accumulates it - // into its running values. The agentOS steps below gate on it via - // isVisible, so it must survive navigation past this step. + // into its running values. The steps below gate on it via isVisible, so + // it must survive navigation past this step. schema: z.object({ - template: z.enum(["actor", "agent-os"]).optional(), + template: z + .enum(["actor", "agent-os", "workflows", "dynamic-apps"]) + .optional(), }), group: "local", }, + { + id: "local", + title: "Run locally", + next: "Continue", + previous: "Back", + schema: z.object({}), + group: "local", + // agentOS gets the dedicated agent/handoff steps below instead. + isVisible: (values: Record) => + values.template !== "agent-os", + }, // agentOS-only steps. Hidden for the actor path via isVisible, so the // stepper skips them and the wizard stays a two-step local -> deploy flow. { @@ -105,6 +123,7 @@ const stepper = defineStepper( title: "Choose your agent", description: "Pick the coding agent to run inside agentOS.", next: "Continue", + previous: "Back", schema: z.object({ agent: z.string().nonempty() }), group: "local", isVisible: (values: Record) => @@ -236,7 +255,7 @@ export function GettingStarted({ : defaultRuntimeModeForProvider(defaultProvider)) as | "serverless" | "serverful", - template: "actor" as "actor" | "agent-os", + template: "actor" as OnboardingTarget, agent: DEFAULT_AGENT, packages: DEFAULT_PACKAGES, sandbox: { enabled: false, provider: DEFAULT_SANDBOX_PROVIDER } as { @@ -323,6 +342,11 @@ export function GettingStarted({ } defaultValues={defaultValues} content={{ + select: () => ( + + + + ), local: () => ( @@ -385,7 +409,7 @@ export function GettingStarted({ // The managed pool is created by the Rivet CLI // during deploy, not by the dashboard, so we only // prefetch the data the deploy step renders here. - if (stepper.current.id === "local") { + if (stepper.current.id === "select") { await Promise.all([ ...(features.auth && "publishableTokenQueryOptions" in @@ -438,15 +462,25 @@ function OnboardingHeader() { ); } +// `deployOptions` only covers self-host platforms, so Rivet Compute has no +// entry there and has to be prepended for the switcher to offer it. +const RIVET_DEPLOY_OPTION = { + name: "rivet", + displayName: "Rivet Compute", + description: "Deploy to Rivet's managed compute with the Rivet CLI", + icon: faRivet, + badge: "Recommended", +}; + // Platform switcher pinned top-right on the deploy screen. Defaults to Rivet // Compute; selecting another option updates the `provider` form field, which // re-tunes the deploy screen. function SwitchPlatform() { const { setValue } = useFormContext(); const provider = (useWatch({ name: "provider" }) as string) || "rivet"; - const options = deployOptions.filter( - (o) => features.compute || o.name !== "rivet", - ); + const options = features.compute + ? [RIVET_DEPLOY_OPTION, ...deployOptions] + : deployOptions; const otherOptions = options.filter((o) => o.name !== provider); return ( @@ -485,23 +519,23 @@ function SwitchPlatform() { key={option.name} className="items-start gap-3 py-2" onClick={() => { - setValue("provider", option.name, { + const opts = { shouldDirty: true, shouldTouch: true, shouldValidate: true, - }); + }; + setValue("provider", option.name, opts); // Reset the runner mode to the new provider's default // so switching to a container platform lands on the // runner and a function platform lands on serverless. setValue( "mode", defaultRuntimeModeForProvider(option.name), - { - shouldDirty: true, - shouldTouch: true, - shouldValidate: true, - }, + opts, ); + setValue("runnerName", "default", opts); + setValue("customName", "", opts); + setValue("customIcon", "", opts); }} > "}`; - const isAgentOs = useWatch({ name: "template" }) === "agent-os"; + const target = useOnboardingTarget(); + const token = cloudToken ?? ""; + // `deploy` defaults to the `production` namespace, so the onboarding + // namespace has to be passed explicitly or the app lands somewhere the rest + // of the flow is not watching. + const deployCommand = `npx @rivetkit/cli deploy --token "${token}" --namespace ${dataProvider.engineNamespace} --env PORT=3000${ + target === "dynamic-apps" ? ` --env RIVET_CLOUD_TOKEN="${token}"` : "" + }`; + const isAgentOs = target === "agent-os"; return (
{isAgentOs ? : null} @@ -565,8 +606,11 @@ function RivetDeploy() {

Run this from your project root. The CLI builds and pushes - your image and provisions Rivet Compute. The token is saved - to{" "} + your image and provisions Rivet Compute. + {target === "dynamic-apps" + ? " The Cloud API token is also passed to the Dynamic Apps host so it can provision an isolated namespace for each app." + : ""}{" "} + The token is saved to{" "} ~/.rivet/credentials {" "} @@ -625,7 +669,6 @@ function OnboardingProgress({ action }: { action?: ReactNode }) { const steps = s.all.filter((step) => isStepVisible(step.id)); const currentIndex = Math.max(0, visibleStepIndex(s.current.id)); const total = visibleStepCount; - const groupLabel = s.current.group === "local" ? "Local setup" : "Deploy"; return (

{steps.map((step, i) => ( @@ -648,7 +691,7 @@ function OnboardingProgress({ action }: { action?: ReactNode }) {
- Step {currentIndex + 1} of {total} · {groupLabel} + Step {currentIndex + 1} of {total}
{action}
@@ -697,170 +740,78 @@ function AgentOsKeyNotice() { ); } -// agentOS brand mark (rounded square + "OS") drawn in currentColor so it adapts -// to the theme, unlike the white-only marketing SVG. -function AgentOsLogo({ className }: { className?: string }) { - return ( - - ); -} - -function BuildTargetCard({ - icon, - label, - description, - badge, - isSelected, - onSelect, -}: { - icon: ReactNode; - label: string; - description: string; - badge?: string; - isSelected: boolean; - onSelect: () => void; -}) { - return ( - - ); -} - -// "What are you building?" selector shown atop the first step when the agentOS -// feature flag is on. Picking agentOS reveals the agent/software/sandbox/handoff -// steps (gated by `template === "agent-os"` via the stepper's isVisible). +// Product selector shown atop the first step. Selecting a product is the whole +// step, so the choice advances the wizard instead of parking the user in front +// of a Continue button. function BuildTargetSelector() { const { control, setValue } = useFormContext(); + const submitForm = useStepperFormSubmit(); return ( ( -
-

What are you building?

-
- } - label="Rivet Actors" - description="Realtime, state, and multiplayer for any app" - isSelected={field.value !== "agent-os"} - onSelect={() => - setValue("template", "actor", { - shouldDirty: true, - shouldTouch: true, - shouldValidate: true, - }) - } - /> - } - label="agentOS" - badge="Beta" - description="An open-source OS for agents. Runs in-process with ~6 ms cold starts." - isSelected={field.value === "agent-os"} - onSelect={() => - setValue("template", "agent-os", { - shouldDirty: true, - shouldTouch: true, - shouldValidate: true, - }) - } - /> -
-
+ render={() => ( + { + setValue("template", template, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }); + submitForm?.(); + }} + /> )} /> ); } +function useOnboardingTarget(): OnboardingTarget { + return ( + (useWatch({ name: "template" }) as OnboardingTarget | undefined) ?? + "actor" + ); +} + +function SelectProductStep() { + return ; +} + function RunLocallyStep() { - const isAgentOs = useWatch({ name: "template" }) === "agent-os"; + const target = useOnboardingTarget(); + const copy = getOnboardingTargetCopy(target); return (
- {features.agentOs ? : null} - {isAgentOs ? null : ( - <> - {features.compute ? ( - - ) : ( - - )} - -
-
-

- Follow the quickstart guide -

-

- Build a Rivet Actor project by hand, step by - step. -

-
- -
- + {features.compute ? ( + + ) : ( + )} + +
+
+

+ Follow the quickstart guide +

+

+ {copy.quickstartDescription} +

+
+ +
); } @@ -939,25 +890,31 @@ function AgentOsHandoff() { // Compute is the default deploy target, so the run-locally prompt ships the // compute deployment addendum alongside the onboarding instructions. Copying it // gives the agent everything it needs to scaffold, run, and deploy in one paste. -function RunLocallyComputeBanner() { - const { code } = useComputeInstructionsCode(); +// The prompt sets up the Rivet MCP server as part of the deploy, so the banner +// says so rather than the flow growing a second agent-shaped affordance. +const mcpSuffix = features.mcp + ? " The prompt also connects Rivet to your editor over MCP." + : ""; + +function RunLocallyComputeBanner({ target }: { target: OnboardingTarget }) { + const { code } = useComputeInstructionsCode(target); return ( ); } -function RunLocallyGenericBanner() { - const code = useAgentInstructionsCode(); +function RunLocallyGenericBanner({ target }: { target: OnboardingTarget }) { + const code = useAgentInstructionsCode({ target }); return ( ); } @@ -971,22 +928,40 @@ function StepNumber({ n }: { n: number }) { } function CopyAgentInstructionsButton({ provider }: { provider?: Provider }) { + const target = useOnboardingTarget(); // The compute prompt reads cloud-namespace data; only available with compute. if (provider === "rivet" && features.compute) { - return ; + return ; } - return ; + return ( + + ); } -function ComputeCopyAgentInstructionsButton() { - const { code } = useComputeInstructionsCode(); - return ; +function ComputeCopyAgentInstructionsButton({ + target, +}: { + target: OnboardingTarget; +}) { + const { code } = useComputeInstructionsCode(target); + return ( + + ); } function GenericCopyAgentInstructionsButton({ provider, + target, }: { provider?: Provider; + target: OnboardingTarget; }) { const endpoint = useEndpoint(); const runnerName = useWatch({ name: "runnerName" }) as string; @@ -999,12 +974,13 @@ function GenericCopyAgentInstructionsButton({ runnerName, endpoint, mode, + target, }); return ( ); } diff --git a/frontend/src/app/onboarding-skeleton.stories.tsx b/frontend/src/app/onboarding-skeleton.stories.tsx new file mode 100644 index 0000000000..324c4793fc --- /dev/null +++ b/frontend/src/app/onboarding-skeleton.stories.tsx @@ -0,0 +1,27 @@ +import type { Story } from "@ladle/react"; +import "../../.ladle/ladle.css"; +import { OnboardingSkeleton } from "./onboarding-skeleton"; + +export const Default: Story = () => ( +
+ +
+); + +// The route pending components pass the real `SidebarlessHeader` so the header +// does not swap when the wizard mounts; the header needs a router, so this +// stands in for it. +export const WithCustomHeader: Story = () => ( +
+ +
+ + acme / production + + + } + /> +
+); diff --git a/frontend/src/app/onboarding-skeleton.tsx b/frontend/src/app/onboarding-skeleton.tsx new file mode 100644 index 0000000000..c0b810c088 --- /dev/null +++ b/frontend/src/app/onboarding-skeleton.tsx @@ -0,0 +1,78 @@ +import type { ReactNode } from "react"; +import { Skeleton } from "@/components"; + +// Matches the non-agentOS path (select -> local -> deploy); agentOS adds steps +// only after a product is picked, which is past this skeleton. +const STEP_COUNT = 3; +const PRODUCT_CARD_COUNT = 4; + +function HeaderSkeleton() { + return ( +
+ + +
+ ); +} + +function ProductCardSkeleton() { + return ( +
+ +
+ + +
+
+ ); +} + +// Mirrors the `GettingStarted` wizard layout (centered card, stepper progress, +// step heading, product grid) so the pending UI matches the screen it resolves +// to instead of flashing the Actors grid skeleton. +export function OnboardingSkeleton({ header }: { header?: ReactNode }) { + return ( +
+ {header ?? } +
+
+
+
+
+ {Array.from({ length: STEP_COUNT }).map( + (_, i) => ( +
+ ), + )} +
+
+ +
+
+ + + + +
+ {Array.from({ length: PRODUCT_CARD_COUNT }).map( + (_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton cards + + ), + )} +
+ +
+
+
+
+ ); +} diff --git a/frontend/src/app/settings-pages/mcp-connection.tsx b/frontend/src/app/settings-pages/mcp-connection.tsx index 3d07eaf49b..806ed6723d 100644 --- a/frontend/src/app/settings-pages/mcp-connection.tsx +++ b/frontend/src/app/settings-pages/mcp-connection.tsx @@ -1,19 +1,6 @@ -import { - faChevronRight, - faClaude, - faCursor, - faGemini, - faPlug, - faVscode, - Icon, - type IconProp, -} from "@rivet-gg/icons"; import { useParams } from "@tanstack/react-router"; import { useState } from "react"; import { - CodeFrame, - CodeGroup, - CodePreview, getConfig, Select, SelectContent, @@ -22,143 +9,23 @@ import { SelectValue, } from "@/components"; import { useEngineCompatDataProvider } from "@/components/actors"; -import { getMcpUrl } from "@/lib/env"; -import { features } from "@/lib/features"; +import { + ClientTabs, + hostedTabs, + localTabs, + MCP_DESCRIPTION, +} from "@/components/mcp/client-tabs"; import { type HostedTarget, hostedUrl, SCOPE_ORDER, SCOPES, type Scope, -} from "./mcp-scope"; +} from "@/components/mcp/scope"; +import { getMcpUrl } from "@/lib/env"; +import { features } from "@/lib/features"; import { SettingsCard } from "./settings-card"; -const DOCS_URL = "https://rivet.dev/mcp"; - -const DESCRIPTION = - "Let AI tools like Claude Code and Cursor read and manage your actors."; - -type Language = "json" | "bash"; - -interface ClientTab { - title: string; - icon: IconProp; - language: Language; - code: string; -} - -function json(value: unknown) { - return JSON.stringify(value, null, 2); -} - -function hostedTabs(url: string): ClientTab[] { - return [ - { - title: "Claude Code", - icon: faClaude, - language: "bash", - code: `claude mcp add --transport http rivet "${url}"`, - }, - { - title: "Cursor", - icon: faCursor, - language: "json", - code: json({ mcpServers: { rivet: { url } } }), - }, - { - title: "VS Code", - icon: faVscode, - language: "bash", - code: `code --add-mcp '${JSON.stringify({ name: "rivet", type: "http", url })}'`, - }, - { - title: "Gemini CLI", - icon: faGemini, - language: "json", - code: json({ mcpServers: { rivet: { httpUrl: url } } }), - }, - { - title: "Other", - icon: faPlug, - language: "json", - code: json({ mcpServers: { rivet: { type: "http", url } } }), - }, - ]; -} - -function localTabs(endpoint: string, namespace: string): ClientTab[] { - const command = "npx"; - const args = ["-y", "@rivet-dev/mcp", "--target", "local"]; - const env = { RIVET_ENDPOINT: endpoint, RIVET_NAMESPACE: namespace }; - const server = { command, args, env }; - - return [ - { - title: "Claude Code", - icon: faClaude, - language: "bash", - code: `claude mcp add rivet \\ - --env RIVET_ENDPOINT=${endpoint} \\ - --env RIVET_NAMESPACE=${namespace} \\ - -- ${command} ${args.join(" ")}`, - }, - { - title: "Cursor", - icon: faCursor, - language: "json", - code: json({ mcpServers: { rivet: server } }), - }, - { - title: "VS Code", - icon: faVscode, - language: "bash", - code: `code --add-mcp '${JSON.stringify({ name: "rivet", ...server })}'`, - }, - { - title: "Gemini CLI", - icon: faGemini, - language: "json", - code: json({ mcpServers: { rivet: server } }), - }, - { - title: "Other", - icon: faPlug, - language: "json", - code: json({ mcpServers: { rivet: server } }), - }, - ]; -} - -function DocsFooter() { - return ( - - - See MCP Documentation{" "} - - - - ); -} - -function ClientTabs({ tabs }: { tabs: ClientTab[] }) { - return ( - - {tabs.map((tab) => ( - tab.code} - footer={} - > - - - ))} - - ); -} - function ScopeSelect({ value, onValueChange, @@ -201,7 +68,7 @@ function HostedMcp() { return ( } > diff --git a/frontend/src/app/use-dialog.tsx b/frontend/src/app/use-dialog.tsx index e7bc652105..fa5ca38b3c 100644 --- a/frontend/src/app/use-dialog.tsx +++ b/frontend/src/app/use-dialog.tsx @@ -2,6 +2,9 @@ import { useDialog as baseUseDialog, createDialogHook } from "@/components"; export const useDialog = { ...baseUseDialog, + AddComponent: createDialogHook( + () => import("@/app/dialogs/add-component-frame"), + ), CreateNamespace: createDialogHook( () => import("@/app/dialogs/create-namespace-frame"), ), diff --git a/frontend/src/components/mcp/client-tabs.tsx b/frontend/src/components/mcp/client-tabs.tsx new file mode 100644 index 0000000000..71b9104da7 --- /dev/null +++ b/frontend/src/components/mcp/client-tabs.tsx @@ -0,0 +1,148 @@ +import { + faChevronRight, + faClaude, + faCursor, + faGemini, + faPlug, + faVscode, + Icon, + type IconProp, +} from "@rivet-gg/icons"; +import { CodeFrame, CodeGroup, CodePreview } from "@/components"; + +export const MCP_DOCS_URL = "https://rivet.dev/mcp"; + +export const MCP_DESCRIPTION = + "Let AI tools like Claude Code and Cursor read and manage your actors."; + +type Language = "json" | "bash"; + +export interface ClientTab { + title: string; + icon: IconProp; + language: Language; + code: string; +} + +function json(value: unknown) { + return JSON.stringify(value, null, 2); +} + +export function hostedMcpCommand(url: string) { + return `claude mcp add --transport http rivet "${url}"`; +} + +export function hostedTabs(url: string): ClientTab[] { + return [ + { + title: "Claude Code", + icon: faClaude, + language: "bash", + code: hostedMcpCommand(url), + }, + { + title: "Cursor", + icon: faCursor, + language: "json", + code: json({ mcpServers: { rivet: { url } } }), + }, + { + title: "VS Code", + icon: faVscode, + language: "bash", + code: `code --add-mcp '${JSON.stringify({ name: "rivet", type: "http", url })}'`, + }, + { + title: "Gemini CLI", + icon: faGemini, + language: "json", + code: json({ mcpServers: { rivet: { httpUrl: url } } }), + }, + { + title: "Other", + icon: faPlug, + language: "json", + code: json({ mcpServers: { rivet: { type: "http", url } } }), + }, + ]; +} + +const LOCAL_COMMAND = "npx"; +const LOCAL_ARGS = ["-y", "@rivet-dev/mcp", "--target", "local"]; + +export function localMcpCommand(endpoint: string, namespace: string) { + return `claude mcp add rivet \\ + --env RIVET_ENDPOINT=${endpoint} \\ + --env RIVET_NAMESPACE=${namespace} \\ + -- ${LOCAL_COMMAND} ${LOCAL_ARGS.join(" ")}`; +} + +export function localTabs(endpoint: string, namespace: string): ClientTab[] { + const command = LOCAL_COMMAND; + const args = LOCAL_ARGS; + const env = { RIVET_ENDPOINT: endpoint, RIVET_NAMESPACE: namespace }; + const server = { command, args, env }; + + return [ + { + title: "Claude Code", + icon: faClaude, + language: "bash", + code: localMcpCommand(endpoint, namespace), + }, + { + title: "Cursor", + icon: faCursor, + language: "json", + code: json({ mcpServers: { rivet: server } }), + }, + { + title: "VS Code", + icon: faVscode, + language: "bash", + code: `code --add-mcp '${JSON.stringify({ name: "rivet", ...server })}'`, + }, + { + title: "Gemini CLI", + icon: faGemini, + language: "json", + code: json({ mcpServers: { rivet: server } }), + }, + { + title: "Other", + icon: faPlug, + language: "json", + code: json({ mcpServers: { rivet: server } }), + }, + ]; +} + +function DocsFooter() { + return ( + + + See MCP Documentation{" "} + + + + ); +} + +export function ClientTabs({ tabs }: { tabs: ClientTab[] }) { + return ( + + {tabs.map((tab) => ( + tab.code} + footer={} + > + + + ))} + + ); +} diff --git a/frontend/src/app/settings-pages/mcp-scope.test.ts b/frontend/src/components/mcp/scope.test.ts similarity index 94% rename from frontend/src/app/settings-pages/mcp-scope.test.ts rename to frontend/src/components/mcp/scope.test.ts index 7601e14b8e..f7c544109c 100644 --- a/frontend/src/app/settings-pages/mcp-scope.test.ts +++ b/frontend/src/components/mcp/scope.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { hostedUrl, type Scope } from "./mcp-scope"; +import { hostedUrl, type Scope } from "./scope"; const BASE = "https://mcp.rivet.dev/mcp"; const TARGET = { diff --git a/frontend/src/app/settings-pages/mcp-scope.ts b/frontend/src/components/mcp/scope.ts similarity index 100% rename from frontend/src/app/settings-pages/mcp-scope.ts rename to frontend/src/components/mcp/scope.ts diff --git a/frontend/src/components/products/product-picker.stories.tsx b/frontend/src/components/products/product-picker.stories.tsx new file mode 100644 index 0000000000..2b8417e915 --- /dev/null +++ b/frontend/src/components/products/product-picker.stories.tsx @@ -0,0 +1,35 @@ +import type { Story } from "@ladle/react"; +import "../../../.ladle/ladle.css"; +import { ProductPicker } from "./product-picker"; + +// The picker is rendered at two very different widths: full-bleed inside the +// onboarding step, and constrained inside the "Add a component" dialog. The +// two-column grid has to survive both. +export const InOnboardingStep: Story = () => ( +
+
+

Select a product

+ {}} /> +
+
+); + +export const InDialog: Story = () => ( +
+
+

Add a component

+

+ Pick what you want to add to this project. +

+ {}} /> +
+
+); + +export const Narrow: Story = () => ( +
+
+ {}} /> +
+
+); diff --git a/frontend/src/components/products/product-picker.tsx b/frontend/src/components/products/product-picker.tsx new file mode 100644 index 0000000000..e360ade462 --- /dev/null +++ b/frontend/src/components/products/product-picker.tsx @@ -0,0 +1,143 @@ +import type { ReactNode } from "react"; +import { Badge } from "@/components/ui/badge"; +import { + getOnboardingTargetCopy, + type OnboardingTarget, +} from "@/content/agent-prompts"; +import { features } from "@/lib/features"; +import { publicUrl } from "@/lib/utils"; + +type Product = { + target: OnboardingTarget; + label: string; + description: string; + markFileName: string; + badge?: string; + isAvailable: () => boolean; +}; + +const PRODUCTS: Product[] = [ + { + target: "actor", + label: "Actors", + description: "The primitive for realtime, stateful workloads", + markFileName: "actors-mark.svg", + isAvailable: () => true, + }, + { + target: "agent-os", + label: "agentOS", + description: "Hand every agent a computer of its own", + markFileName: "agentos-mark.svg", + isAvailable: () => features.agentOs, + }, + { + target: "workflows", + label: "Workflows", + description: "Write multi-step operations that survive restarts", + markFileName: "workflows-mark.svg", + isAvailable: () => true, + }, + { + target: "dynamic-apps", + label: "Dynamic Apps", + description: "Deploy AI-generated apps for your users", + markFileName: "dynamic-apps-mark.svg", + badge: "Preview", + isAvailable: () => true, + }, +]; + +export function getAvailableProducts() { + return PRODUCTS.filter((p) => p.isAvailable()); +} + +export function getProductDocsUrl(target: OnboardingTarget) { + return getOnboardingTargetCopy(target).quickstartUrl; +} + +export function ProductMark({ fileName }: { fileName: string }) { + return ( + + ); +} + +export function ProductCard({ + icon, + label, + description, + badge, + onSelect, +}: { + icon: ReactNode; + label: string; + description: string; + badge?: string; + onSelect: () => void; +}) { + return ( + + ); +} + +export const PRODUCT_COMPOSABILITY_NOTE = + "Rivet is composable. Start with one product and add the rest to the same project whenever you need them."; + +export function ProductPicker({ + onSelect, + ariaLabel = "Select a product", +}: { + onSelect: (target: OnboardingTarget) => void; + ariaLabel?: string; +}) { + return ( +
+
+ {getAvailableProducts().map((product) => ( + } + label={product.label} + description={product.description} + badge={product.badge} + onSelect={() => onSelect(product.target)} + /> + ))} +
+

+ {PRODUCT_COMPOSABILITY_NOTE} +

+
+ ); +} diff --git a/frontend/src/content/agent-prompts.test.ts b/frontend/src/content/agent-prompts.test.ts new file mode 100644 index 0000000000..70f4ddc9d4 --- /dev/null +++ b/frontend/src/content/agent-prompts.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; +import { + getAgentInstructionsPrompt, + getComputeAddendum, + getOnboardingTargetCopy, +} from "./agent-prompts"; + +const agentPromptOptions = { + providerStr: "Rivet Compute", + publishableToken: "pk_test", + secretToken: "secret_test", + runnerName: "default", + serverless: true, + providerDocUrl: "https://rivet.dev/docs/deploy/rivet-compute", + namespace: "onboarding-test", + cliDeploy: true, +} as const; + +const computePromptOptions = { + cloudToken: "cloud_api_test", + publishableToken: "pk_test", + namespace: "onboarding-test", + apiUrl: "https://api.staging.rivet.dev", + cloudApiUrl: "https://cloud-api.staging.rivet.dev", + rivetRunUrl: "https://onboarding-test.staging.rivet.run/", +} as const; + +describe("onboarding product prompts", () => { + it.each([ + ["actor", "https://rivet.dev/docs/actors/quickstart/backend"], + ["workflows", "https://rivet.dev/workflows/docs/quickstart/"], + ["dynamic-apps", "https://rivet.dev/dynamic-apps/docs/quickstart/"], + ] as const)("selects the %s quickstart", (target, expectedUrl) => { + expect(getOnboardingTargetCopy(target).quickstartUrl).toBe(expectedUrl); + }); + + it("preserves the Rivet Actor prompt as the default", () => { + const prompt = getAgentInstructionsPrompt(agentPromptOptions); + + expect(prompt).toContain("# RivetKit Setup & Deploy"); + expect(prompt).toContain("npm install rivetkit"); + expect(prompt).toContain("registry.listen"); + }); + + it("preserves the existing Rivet Actor setup prompt for agentOS", () => { + const prompt = getAgentInstructionsPrompt({ + ...agentPromptOptions, + target: "agent-os", + }); + + expect(prompt).toContain("# RivetKit Setup & Deploy"); + expect(prompt).toContain("npm install rivetkit"); + expect(prompt).toContain("registry.listen"); + }); + + it("uses Workflows-specific setup guidance", () => { + const prompt = getAgentInstructionsPrompt({ + ...agentPromptOptions, + target: "workflows", + }); + + expect(prompt).toContain("# Rivet Workflows Setup & Deploy"); + expect(prompt).toContain("@rivet-dev/workflows"); + expect(prompt).toContain( + "https://rivet.dev/workflows/docs/quickstart/", + ); + expect(prompt).toContain('ctx.step("stable-step-name"'); + }); + + it("uses the Dynamic Apps host and deployment APIs", () => { + const prompt = getAgentInstructionsPrompt({ + ...agentPromptOptions, + target: "dynamic-apps", + }); + + expect(prompt).toContain("@rivet-dev/dynamic-apps"); + expect(prompt).toContain("appsRouter.fetch"); + expect(prompt).toContain("deployApp({ appId, files })"); + expect(prompt).toContain( + "https://rivet.dev/dynamic-apps/docs/quickstart/", + ); + expect(prompt).not.toContain("Start the app with `registry.start()`"); + expect(prompt).not.toContain("Drive actors via the inspector HTTP API"); + }); + + it("replaces Actor verification with an app URL for Dynamic Apps on Compute", () => { + const prompt = getComputeAddendum({ + ...computePromptOptions, + target: "dynamic-apps", + }); + + expect(prompt).toContain("RIVET_CLOUD_TOKEN"); + expect(prompt).toContain("apps/onboarding/"); + expect(prompt).toContain("deployApp"); + expect(prompt).not.toContain("Keep registry.start()"); + expect(prompt).not.toContain("/actors?namespace="); + }); + + it("preserves Rivet Actor Compute deployment guidance by default", () => { + const prompt = getComputeAddendum(computePromptOptions); + + expect(prompt).toContain("Keep registry.start()"); + expect(prompt).toContain("/actors?namespace="); + expect(prompt).toContain("Verify actors work end-to-end"); + }); + + it("uses Workflows-specific Compute deployment and verification", () => { + const prompt = getComputeAddendum({ + ...computePromptOptions, + target: "workflows", + }); + + expect(prompt).toContain("# Rivet Workflows Compute Deployment Steps"); + expect(prompt).toContain("@rivet-dev/workflows"); + expect(prompt).toContain( + "https://rivet.dev/workflows/docs/quickstart/", + ); + expect(prompt).toContain("ctx.step"); + expect(prompt).toContain( + '--token "cloud_api_test" --namespace onboarding-test --env PORT=3000', + ); + expect(prompt).toContain("rivetkit/client"); + expect(prompt).toContain("getOrCreate"); + expect(prompt).toContain( + "https://onboarding-test.staging.rivet.run/api/rivet", + ); + expect(prompt).not.toContain("Keep registry.start()"); + expect(prompt).not.toContain("/actors?namespace="); + expect(prompt).not.toContain("/gateway//health"); + expect(prompt).not.toContain("Verify actors work end-to-end"); + }); + it.each([ + "actor", + "workflows", + "dynamic-apps", + ] as const)("includes the MCP connection section for %s when MCP is available", (target) => { + const prompt = getComputeAddendum({ + ...computePromptOptions, + target, + mcp: { + command: + 'claude mcp add --transport http rivet "https://mcp.rivet.dev/mcp?organization=acme"', + requiresUserApproval: true, + }, + }); + + expect(prompt).toContain("## Connect the Rivet MCP server"); + expect(prompt).toContain( + 'claude mcp add --transport http rivet "https://mcp.rivet.dev/mcp?organization=acme"', + ); + expect(prompt).toContain("Ask the user to run this"); + }); + + it("tells the agent to run the local MCP server itself", () => { + const prompt = getComputeAddendum({ + ...computePromptOptions, + mcp: { + command: "claude mcp add rivet -- npx -y @rivet-dev/mcp", + requiresUserApproval: false, + }, + }); + + expect(prompt).toContain("Run this in the project root"); + expect(prompt).not.toContain("Ask the user to run this"); + }); + + // Mirrors how `useComputeInstructionsCode` concatenates the two prompts. + const composeComputePrompt = ( + target: "actor" | "workflows" | "dynamic-apps", + ) => + `${getAgentInstructionsPrompt({ ...agentPromptOptions, target })}\n\n---\n\n${getComputeAddendum({ ...computePromptOptions, target })}`; + + it.each([ + "actor", + "workflows", + "dynamic-apps", + ] as const)("defers to the Compute addendum for the %s deploy step", (target) => { + const prompt = composeComputePrompt(target); + + expect(prompt).toContain("Compute Deployment Steps"); + expect(prompt).toContain( + "Follow that section instead of deploying by hand", + ); + expect(prompt).not.toContain("Deploy the Hono host as an HTTP service"); + expect(prompt).not.toContain("paste their deployment's public URL"); + }); + + it.each([ + "actor", + "workflows", + "dynamic-apps", + ] as const)("omits the MCP connection section for %s when MCP is unavailable", (target) => { + const prompt = getComputeAddendum({ + ...computePromptOptions, + target, + }); + + expect(prompt).not.toContain("Connect the Rivet MCP server"); + expect(prompt).not.toContain("claude mcp add"); + }); +}); diff --git a/frontend/src/content/agent-prompts.ts b/frontend/src/content/agent-prompts.ts index dc35702def..3be81ebe07 100644 --- a/frontend/src/content/agent-prompts.ts +++ b/frontend/src/content/agent-prompts.ts @@ -1,19 +1,246 @@ -export function getComputeAddendum({ - cloudToken, - publishableToken, - namespace, - apiUrl, - cloudApiUrl, - rivetRunUrl, -}: { +export type OnboardingTarget = + | "actor" + | "agent-os" + | "workflows" + | "dynamic-apps"; + +const onboardingTargetCopy: Record< + OnboardingTarget, + { + promptObject: string; + quickstartDescription: string; + quickstartUrl: string; + } +> = { + actor: { + promptObject: "your first Rivet Actor", + quickstartDescription: + "Build a Rivet Actor project by hand, step by step.", + quickstartUrl: "https://rivet.dev/docs/actors/quickstart/backend", + }, + "agent-os": { + promptObject: "an agentOS project", + quickstartDescription: "Set up agentOS by hand, step by step.", + quickstartUrl: "https://rivet.dev/agentos/", + }, + workflows: { + promptObject: "your first durable workflow", + quickstartDescription: + "Build a durable workflow project by hand, step by step.", + quickstartUrl: "https://rivet.dev/workflows/docs/quickstart/", + }, + "dynamic-apps": { + promptObject: "a Dynamic Apps host and sample app", + quickstartDescription: + "Build a Dynamic Apps host and deploy a sample app by hand.", + quickstartUrl: "https://rivet.dev/dynamic-apps/docs/quickstart/", + }, +}; + +export function getOnboardingTargetCopy(target: OnboardingTarget) { + return onboardingTargetCopy[target]; +} + +type ComputePromptOptions = { cloudToken: string; publishableToken: string; namespace: string; apiUrl: string; cloudApiUrl: string; rivetRunUrl: string; + target?: OnboardingTarget; + mcp?: McpSetup; +}; + +function getDynamicAppsComputeAddendum({ + cloudToken, + namespace, + rivetRunUrl, + mcpSection, +}: Pick & { + mcpSection: string; +}) { + return `# Dynamic Apps Compute Deployment Steps + +## Step 1: Follow the Dynamic Apps host architecture + +Read the Dynamic Apps quickstart and deployment guidance before changing the project: + +- https://rivet.dev/dynamic-apps/docs/quickstart/ +- https://rivet.dev/dynamic-apps/docs/deploy/ +- https://rivet.dev/dynamic-apps/docs/connect/ + +The host is a normal Hono server. Mount the private Rivet callback with \`appsRouter.fetch\`, route deployed applications under \`/apps\`, and call \`deployApp()\` only from trusted server-side code. Generated applications default-export a Fetch handler; they must not call \`serve()\`, \`listen()\`, or \`registry.start()\`. + +## Step 2: Create a production Dockerfile + +If the project does not already have a Dockerfile, add one that installs dependencies, builds the host, exposes port 3000, and starts the Hono server. Adjust the package manager, build output, and entrypoint to match the project: + +\`\`\`dockerfile +FROM node:24-alpine + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . +RUN npm run build --if-present + +EXPOSE 3000 + +CMD ["node", "dist/server.js"] +\`\`\` + +If the project does not already have a \`.dockerignore\`, create one that excludes \`node_modules/\`, \`dist/\`, \`.env\`, and \`.git/\`. + +## Step 3: Deploy the Dynamic Apps host + +Deploy to the \`${namespace}\` namespace and pass the Cloud API token to the running host. \`deployApp()\` needs \`RIVET_CLOUD_TOKEN\` to create and manage the isolated namespace for each generated app: + +\`\`\`bash +npx @rivetkit/cli deploy --token "${cloudToken}" --namespace ${namespace} --env PORT=3000 --env RIVET_CLOUD_TOKEN="${cloudToken}" +\`\`\` + +Keep the token server-side. Do not expose it to generated app code or browser bundles. + +${mcpSection}## Step 4: Verify the host and a deployed app + +1. Confirm the host is live at \`${rivetRunUrl}\`. +2. Deploy a small generated app with \`deployApp({ appId: "onboarding", files })\`. +3. Open \`${rivetRunUrl}apps/onboarding/\` and verify the app responds successfully. Preserve the trailing slash. +4. If deployment fails, run \`npx @rivetkit/cli logs --namespace ${namespace}\` and fix the host before retrying. + +Report the host URL, app URL, commands run, and any remaining setup the user must complete.`; +} + +function getWorkflowsComputeAddendum({ + cloudToken, + namespace, + rivetRunUrl, + mcpSection, +}: Pick & { + mcpSection: string; }) { + return `# Rivet Workflows Compute Deployment Steps + +## Step 1: Preserve the Workflows application + +Read the Workflows quickstart before changing the project: https://rivet.dev/workflows/docs/quickstart/ + +Keep the project's \`@rivet-dev/workflows\` workflow definitions, \`setup({ use: { ... } })\` registry, stable \`ctx.step(...)\` names, and existing workflow host entrypoint. External side effects and nondeterministic work must stay inside named steps so retries remain durable. Do not rewrite the project as a generic Rivet Actor example. + +## Step 2: Create a production Dockerfile + +\`npx @rivetkit/cli deploy\` builds the project from a \`Dockerfile\`. If the project does not already have one, add one that installs dependencies, builds the application, exposes port 3000, and starts the project's existing workflow host. Adjust the package manager, build output, and entrypoint to match the project: + +\`\`\`dockerfile +FROM node:24-alpine + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . +RUN npm run build --if-present + +EXPOSE 3000 + +CMD ["node", "dist/index.js"] +\`\`\` + +If the project does not already have a \`.dockerignore\`, create one that excludes \`node_modules/\`, \`dist/\`, \`.env\`, and \`.git/\`. + +## Step 3: Deploy the workflow host + +Deploy to the \`${namespace}\` namespace: + +\`\`\`bash +npx @rivetkit/cli deploy --token "${cloudToken}" --namespace ${namespace} --env PORT=3000 +\`\`\` + +The CLI caches the Cloud API token in \`~/.rivet/credentials\`, so later deploy and logs commands can omit \`--token\`. Keep the token out of source files and browser bundles. + +${mcpSection}## Step 4: Verify the workflow end-to-end + +1. Confirm the workflow host is live with \`curl ${rivetRunUrl}api/rivet/health\` (expects a 200). +2. Point the project's existing typed \`rivetkit/client\` client at \`${rivetRunUrl}api/rivet\`, then use \`getOrCreate\` with a workflow key. +3. Invoke the workflow's real action or queue and confirm its expected state or named step result. Do not replace this with a generic actor creation check. +4. If deployment or execution fails, run \`npx @rivetkit/cli logs --namespace ${namespace}\` and consult https://rivet.dev/workflows/docs/failure-and-recovery/ before retrying. + +Report the workflow host URL, command used, action or queue invoked, observed result, and any remaining setup the user must complete.`; +} + +// The hosted connection authorizes against the user's Rivet account through a +// browser window, so the agent has to hand that step back. The local server is +// plain stdio and the agent can run it itself. +export interface McpSetup { + command: string; + requiresUserApproval: boolean; +} + +function getMcpSection({ command, requiresUserApproval }: McpSetup) { + const run = requiresUserApproval + ? `Ask the user to run this in their project, then approve the browser window it opens. It authorizes against their Rivet account, so you cannot complete it for them:` + : `Run this in the project root to connect the local Rivet MCP server:`; + + return `## Connect the Rivet MCP server + +${run} + +\`\`\`bash +${command} +\`\`\` + +Once connected, use the Rivet MCP tools to list actors, read actor state, and pull logs. Prefer them over the raw HTTP calls elsewhere in this prompt, which exist for when MCP is unavailable. + +If the connection is declined or fails, continue without it and say that MCP was skipped. + +`; +} + +export function getComputeAddendum({ + cloudToken, + publishableToken, + namespace, + apiUrl, + cloudApiUrl, + rivetRunUrl, + target = "actor", + mcp, +}: ComputePromptOptions) { + const mcpSection = mcp ? getMcpSection(mcp) : ""; + if (target === "dynamic-apps") { + return getDynamicAppsComputeAddendum({ + cloudToken, + namespace, + rivetRunUrl, + mcpSection, + }); + } + + if (target === "workflows") { + return getWorkflowsComputeAddendum({ + cloudToken, + namespace, + rivetRunUrl, + mcpSection, + }); + } + return `# Compute Deployment Steps + +## Prerequisites + +\`@rivetkit/cli deploy\` builds the image with \`docker buildx\`, so Docker is required. Check it first: + +\`\`\`bash +docker buildx version +\`\`\` + +If that fails, stop and tell the user to install Docker Desktop (or the Docker engine with the buildx plugin) before continuing. Do not attempt the deploy without it. + ## Step 1: Load the RivetKit docs Read https://rivet.dev/llms.txt to understand how RivetKit works (actors, state, events, actions, connections, clients). @@ -34,7 +261,7 @@ Once deployed, the app is publicly reachable at its Rivet Run URL, \`${rivetRunU **Serving a frontend:** \`registry.start()\` serves static files automatically. Put the frontend build output in a \`public/\` directory and it is served with zero extra wiring. If the build outputs somewhere else (e.g. \`dist/\`), set \`RIVETKIT_PUBLIC_DIR\` to that directory. -See https://rivet.dev/docs/general/runtime-modes for local vs. serverless modes and https://rivet.dev/docs/deploy/rivet-compute for the full Compute integration guide. +See https://rivet.dev/docs/general/runtime-modes for local vs. serverless modes and https://rivet.dev/docs/connect/rivet-compute for the full Compute integration guide. ## Step 3: Create Dockerfile @@ -67,36 +294,42 @@ dist/ .git/ \`\`\` -If Docker is installed, build and run the image to verify it works before proceeding. Pass \`-e RIVETKIT_RUNTIME_MODE=serverless\` to simulate how Compute runs it (otherwise the container defaults to engine/envoy mode and the check is not representative): +Build and run the image to verify it works before deploying. Pass \`-e RIVETKIT_RUNTIME_MODE=serverless\` to simulate how Compute runs it (otherwise the container defaults to engine/envoy mode and the check is not representative). Run it detached so the check does not block on a foreground container: \`\`\`bash -docker build -t rivet-test . && docker run --rm -p 3000:3000 -e RIVETKIT_RUNTIME_MODE=serverless rivet-test +docker build -t rivet-test . +docker run -d --name rivet-test -p 3000:3000 -e RIVETKIT_RUNTIME_MODE=serverless rivet-test +for i in $(seq 1 30); do curl -sf http://localhost:3000/api/rivet/health && break; sleep 1; done +docker logs rivet-test +docker rm -f rivet-test \`\`\` -Verify the container starts and is connectable (e.g. \`curl http://localhost:3000/api/rivet/health\` should return 200). If Docker is not installed, skip this and proceed. +If the health check never succeeds, read \`docker logs rivet-test\` and fix the image before deploying. Always remove the container afterwards so the port is free. ## Step 4: Deploy with the Rivet CLI Deploy the project with a single command. \`@rivetkit/cli\` builds the \`Dockerfile\`, pushes the image to Rivet's registry, and creates/updates the \`default\` managed pool. Always pass \`--namespace ${namespace}\` so the deploy targets this namespace and not the default \`production\` namespace. The project and organization are auto-detected from the token: \`\`\`bash -npx @rivetkit/cli deploy --token "${cloudToken}" --namespace ${namespace} --env PORT=3000 +npx -y @rivetkit/cli deploy --yes --token "${cloudToken}" --namespace ${namespace} --env PORT=3000 \`\`\` Notes: - The image is built for \`linux/amd64\`. \`--env PORT=3000\` tells Rivet Compute which port to route to. \`registry.start()\` binds the port from \`RIVET_PORT\` (default 3000), so the two line up by default. To use a different port, set both \`--env PORT=\` and \`--env RIVET_PORT=\` to the same value and update the \`EXPOSE\` line to match. Setting \`PORT\` alone does not change the port the app listens on. - \`--token\` is the \`cloud_api_*\` Cloud API token. The command also caches it to \`~/.rivet/credentials\`, so later \`deploy\` calls can omit \`--token\`. -- Pass \`--yes\` to skip interactive prompts in non-interactive environments. +- \`--yes\` skips the deploy confirmation prompt and \`npx -y\` skips npx's install prompt. Both are required when running non-interactively. When the command finishes successfully, proceed to Step 5 to verify the deployment is live. -## Step 5: Verify Deployment +${mcpSection}## Step 5: Verify Deployment **Token types used in this step:** - \`cloud_api_*\` is the \`--token\` passed to \`@rivetkit/cli deploy\`, cached in \`~/.rivet/credentials\`. It is a management token scoped to the Cloud API (cloud-api.rivet.dev). The CLI uses it for logs. - \`pk_*\` is the publishable token below, a public key scoped to the Rivet Engine API (api.rivet.dev). Use this for creating actors and calling gateway endpoints. -These are different tokens with different scopes. Do not mix them up. +These are different tokens with different scopes. Do not mix them up. A 401 in this step is almost always a swapped token type, not a wrong URL. + +If the publishable token below reads literally \`\`, no token was available when this prompt was generated. Stop and ask the user to create a publishable token in the Rivet dashboard before running these checks. \`@rivetkit/cli deploy\` waits for the managed pool to become ready before it exits, so a successful deploy means the deployment is already live. You do not need to poll deployment status separately. @@ -105,7 +338,7 @@ The deployed app is served at its Rivet Run URL: \`${rivetRunUrl}\`. Open it in If the deploy fails or you need to debug, read the deployment logs with the CLI (it resolves the token from \`~/.rivet/credentials\`): \`\`\`bash -npx @rivetkit/cli logs +npx @rivetkit/cli logs --namespace ${namespace} \`\`\` Verify actors work end-to-end: @@ -119,28 +352,31 @@ Verify actors work end-to-end: \`\`\` Replace \`\` with a valid actor name from the registry and \`\` with an appropriate key string (e.g. "general"). Note the \`actor_id\` from the response. -2. Wait ~10 seconds for the actor to start, then hit its health endpoint through the gateway using the public token: +2. Poll the actor's health endpoint through the gateway using the public token. Cold pools can take a while to start, so retry rather than sleeping a fixed amount: \`\`\`bash - curl "${apiUrl}/gateway//health" \\ - -H "x-rivet-token: ${publishableToken}" + for i in $(seq 1 30); do + curl -sf "${apiUrl}/gateway//health" \\ + -H "x-rivet-token: ${publishableToken}" && break + sleep 2 + done \`\`\` - This should return ok with a 200 status. + A successful run prints ok. If the loop finishes without output, treat it as a failure and move to step 3. 3. If the health check returns actor_runner_failed, check the logs to diagnose: \`\`\`bash - npx @rivetkit/cli logs + npx @rivetkit/cli logs --namespace ${namespace} \`\`\` 4. Common issues: - "actor should have a key": The key field was missing from the create request. - - Token 401: Make sure you're using the correct API URLs (${apiUrl}, ${cloudApiUrl}). + - Token 401: You are almost certainly using the \`cloud_api_*\` token where a \`pk_*\` token belongs, or the reverse. Also confirm the API URLs (${apiUrl}, ${cloudApiUrl}). - "Failed to start container: Please ensure your container starts successfully on the specified port (3000 if unspecified). Make sure your image was built for linux/amd64.": Ensure the container listens on \`RIVET_PORT\` (3000 by default) and that the \`--env PORT\` value passed to \`@rivetkit/cli deploy\` matches it. ## Troubleshooting -- Deployment and logs are done with \`npx @rivetkit/cli deploy\` and \`npx @rivetkit/cli logs\`. Actor creation and health checks are done via HTTP APIs (curl) as shown in Step 5. +- Deployment and logs are done with \`npx @rivetkit/cli deploy\` and \`npx @rivetkit/cli logs\`. Both default to the \`production\` namespace, so always pass \`--namespace ${namespace}\`. Actor creation and health checks are done via HTTP APIs (curl) as shown in Step 5. - Architecture: \`@rivetkit/cli deploy\` builds your Docker image and pushes it to Rivet. Rivet runs the container serverlessly. When you create an actor, Rivet communicates with the \`/api/rivet/*\` endpoint inside the container to manage its lifecycle. -- For more troubleshooting help, see: https://rivet.dev/docs/actors/troubleshooting/`; +- For more troubleshooting help, see: https://rivet.dev/docs/actors/troubleshooting`; } export function getAgentInstructionsPrompt({ @@ -152,6 +388,8 @@ export function getAgentInstructionsPrompt({ providerDocUrl, namespace, cliDeploy, + target = "actor", + mcp, }: { providerStr: string; publishableToken: string; @@ -163,9 +401,14 @@ export function getAgentInstructionsPrompt({ // Whether this deploy uses `@rivetkit/cli deploy` (Rivet Compute only). Only // then does the `--namespace` flag apply; other providers deploy differently. cliDeploy?: boolean; + target?: OnboardingTarget; + mcp?: McpSetup; }) { const poolLine = runnerName !== "default" ? `\n RIVET_POOL=${runnerName}` : ""; + // Compute appends its own addendum with the same section; emitting it twice + // in one copy-paste prompt is worse than not mentioning it here. + const mcpSection = mcp && cliDeploy !== true ? getMcpSection(mcp) : ""; const namespaceNote = namespace ? `> **Important:** Run every step below against the \`${namespace}\` namespace only${ cliDeploy @@ -173,15 +416,28 @@ export function getAgentInstructionsPrompt({ : "" }. Do not deploy to or modify any other namespace (for example the default \`production\` namespace).\n\n` : ""; + const dynamicAppsNamespaceNote = namespace + ? `> **Host deployment:** Deploy the Dynamic Apps host itself to the \`${namespace}\` namespace. \`deployApp()\` is expected to create a separate isolated namespace for each generated app.\n\n` + : ""; const docLine = providerDocUrl ? `Review the deploy guide for ${providerStr}: ${providerDocUrl}` - : `Review the deploy guide for ${providerStr} at https://rivet.dev/docs/deploy/`; - const deployEnv = ` RIVET_PUBLIC_ENDPOINT=${publishableToken}\n RIVET_ENDPOINT=${secretToken}${poolLine}`; + : `Review the deploy guide for ${providerStr} at https://rivet.dev/docs/connect/`; + // `RIVET_ENDPOINT` embeds the namespace admin token, so it needs the same + // handling discipline the Compute addendum applies to `RIVET_CLOUD_TOKEN`. + const deployEnv = ` RIVET_PUBLIC_ENDPOINT=${publishableToken}\n RIVET_ENDPOINT=${secretToken}${poolLine} + + \`RIVET_ENDPOINT\` contains a secret admin credential. Write it to the platform's secret store or a local \`.env\` that is listed in \`.gitignore\`. Never commit it, never pass it on a command line where it lands in shell history, and never expose it to browser code. \`RIVET_PUBLIC_ENDPOINT\` is the public counterpart and is safe to ship to clients.`; + + // Rivet Compute appends `getComputeAddendum` below this prompt, and that + // addendum owns the whole deploy (CLI build, push, pool, verification). The + // generic steps would contradict it, most visibly by asking the user to + // register a serverless URL the CLI registers for them. + const computeOwnsDeploy = cliDeploy === true; // Deploy instructions differ by runtime mode. Runner is the default: the app // connects out to Rivet, so nothing is registered in the dashboard. // Serverless registers a public URL that Rivet calls into. - const deploySteps = serverless + const genericDeploySteps = serverless ? `1. ${docLine} 2. Configure and deploy using the following environment variables: ${deployEnv} @@ -191,6 +447,10 @@ ${deployEnv} ${deployEnv} 3. Start the app with \`registry.start()\`. It runs as a Runner and connects out to Rivet, so there is no URL to paste into the dashboard and no HTTP endpoint to expose. It appears under Runners in the dashboard once connected.`; + const deploySteps = computeOwnsDeploy + ? `Deployment is covered by the **Compute Deployment Steps** section below. Follow that section instead of deploying by hand, and treat it as authoritative wherever the two disagree.` + : genericDeploySteps; + const integrateStep = serverless ? `- Mount on the existing server: \`app.all("/api/rivet/*", (c) => registry.handler(c.req.raw))\` (or the equivalent for the project's framework).` : `- Start the Rivet runner from the app entrypoint: \`registry.start()\` (runs as a Runner and connects out to Rivet). There is no HTTP route to mount.`; @@ -199,6 +459,80 @@ ${deployEnv} ? `Verify with \`/api/rivet/metadata\` and the inspector API (https://rivet.dev/docs/actors/debugging).` : `Verify the runner appears under Runners in the Rivet dashboard, then drive actors via the inspector API (https://rivet.dev/docs/actors/debugging).`; + if (target === "workflows") { + return `# Rivet Workflows Setup & Deploy + +${namespaceNote}Read the Workflows quickstart before changing the project: https://rivet.dev/workflows/docs/quickstart/ + +Use \`@rivet-dev/workflows\` for durable, replayable multi-step operations. Keep external side effects inside named \`ctx.step(...)\` calls, use stable and unique step names, and model long-running work with workflow loops and queues where appropriate. + +## Step 1: Understand the project + +Determine whether the user wants a new workflow project or wants to add a workflow to the existing application. Inspect the current package manager, runtime, entrypoint, and deployment setup before editing. + +## Step 2: Build the workflow + +- Install \`@rivet-dev/workflows\` with the project's package manager. +- Define the workflow with \`workflow(...)\` and export a registry with \`setup({ use: { ... } })\`. +- Put retryable side effects in \`ctx.step("stable-step-name", ...)\` calls. +- Expose only the actions, queues, and state needed by the caller. +- Use \`rivetkit/client\` to create a typed client and exercise the workflow. +- Follow the current quickstart instead of substituting a generic Rivet Actor implementation. + +## Step 3: Verify locally + +Run the project, start one workflow instance, and verify its steps complete in order. Confirm the resulting state or action response, and exercise a queue when the workflow uses one. Report the commands run and the observed result. + +## Step 4: Deploy + +${deploySteps} + +${mcpSection}After deployment, run the same workflow operation against the deployed environment and confirm the expected state. For troubleshooting, use https://rivet.dev/docs/actors/troubleshooting and include the workflow name, failed step name, runtime, and package version in the report.`; + } + + if (target === "dynamic-apps") { + return `# Dynamic Apps Setup & Deploy + +${dynamicAppsNamespaceNote}Read the current Dynamic Apps documentation before changing the project: + +- Quickstart: https://rivet.dev/dynamic-apps/docs/quickstart/ +- App deployment: https://rivet.dev/dynamic-apps/docs/deploy/ +- Rivet connection: https://rivet.dev/dynamic-apps/docs/connect/ + +## Step 1: Understand the host + +Determine whether the user wants a new Dynamic Apps host or wants to integrate Dynamic Apps into the existing server. Inspect the package manager, runtime, HTTP framework, authentication, and existing routes first. + +## Step 2: Build the host + +- Use Node.js 22 or newer. +- Install \`@rivet-dev/dynamic-apps\`, \`@hono/node-server\`, and \`hono\`. +- Create a Hono host that mounts the private Rivet callback with \`server.all("/api/rivet/*", (c) => appsRouter.fetch(c.req.raw))\`. +- Mount deployed applications with \`server.route("/apps", appsRouter)\` and serve the host on port 3000. +- Keep authentication and trusted control-plane routes in the host, outside generated application code. + +Generated applications default-export a Fetch handler. They do not own an HTTP listener and must not call \`serve()\`, \`listen()\`, or \`registry.start()\`. + +## Step 3: Generate and deploy an app + +Use the supported Dynamic Apps skills when generating the file tree, then call \`deployApp({ appId, files })\` from trusted server-side code. For Rivet Cloud, keep \`RIVET_CLOUD_TOKEN\` server-side so \`deployApp()\` can create each app's isolated namespace. + +Run the host, deploy a small app, and open \`http://localhost:3000/apps//\`. Preserve the trailing slash and verify the generated app responds successfully. Do not replace this check with raw Rivet Actor creation or inspector calls. + +## Step 4: Deploy the host${computeOwnsDeploy ? "" : ` to ${providerStr}`} + +${ + computeOwnsDeploy + ? deploySteps + : `1. ${docLine} +2. Deploy the Hono host as an HTTP service on port 3000, preserving both \`/api/rivet/*\` and \`/apps/*\` routes. +3. Set \`RIVET_CLOUD_TOKEN\` as a server-side secret when \`deployApp()\` will provision Rivet Cloud namespaces. For self-hosting, use the host's existing Rivet admin configuration instead. +4. Deploy a test app and verify it at the public \`/apps//\` URL.` +} + +${mcpSection}Report the host URL, deployed app URL, commands run, and any remaining secret or DNS configuration. Never expose management tokens to generated apps or browser code.`; + } + return `# RivetKit Setup & Deploy ${namespaceNote}Read https://rivet.dev/llms.txt to understand how RivetKit works (actors, state, events, actions, connections, clients). @@ -235,7 +569,7 @@ Scaffold a minimal project with RivetKit: - \`npm install rivetkit\` (or pnpm/yarn/whatever is being used) - Add a frontend (plain HTML/JS or React via \`@rivetkit/react\` — keep it small). - Define actors + registry (see https://rivet.dev/docs/actors). -- Serve via \`registry.listen({ port: 3001, publicDir: "" })\` so one command serves both API and frontend. +- Serve via \`registry.listen({ port: Number(process.env.RIVET_PORT ?? 3000), publicDir: "" })\` so one command serves both API and frontend. Use 3000; the Dockerfile, \`--env PORT\`, and every health check below assume it. - Add a local dev script (e.g. \`npm run dev\`) that builds the frontend and starts the server. Reference quickstarts: @@ -313,7 +647,7 @@ Link docs: --- -## If you get stuck +${mcpSection}## If you get stuck Check https://rivet.dev/docs/actors/troubleshooting. If that doesn't help, point the user at: - Discord: https://rivet.dev/discord diff --git a/frontend/src/lib/data.ts b/frontend/src/lib/data.ts index b47480837e..05347a787c 100644 --- a/frontend/src/lib/data.ts +++ b/frontend/src/lib/data.ts @@ -1,3 +1,4 @@ +import type { QueryClient, QueryKey } from "@tanstack/react-query"; import z from "zod"; const providerMetadataSchema = z @@ -133,3 +134,38 @@ const _safeJsonParse = (str: unknown): unknown => { return str; } }; + +type OnboardingPeekProvider = { + currentNamespaceQueryOptions(): { queryKey: QueryKey }; + actorsCountQueryOptions(): { queryKey: QueryKey }; +}; + +// Synchronous best-effort guess of the destination screen, for pending UI that +// must pick a skeleton before the loader resolves. Onboarding is shown exactly +// when an onboarding-eligible namespace has no actors, so an uncached actor +// count returns false instead of guessing. +export function peekDisplaysOnboarding(opts: { + queryClient: QueryClient; + dataProvider: OnboardingPeekProvider | undefined; + onboardingDisplayName: string; + isSkipped: boolean; +}): boolean { + const { queryClient, dataProvider, onboardingDisplayName, isSkipped } = + opts; + if (isSkipped || !dataProvider) { + return false; + } + + const namespace = queryClient.getQueryData<{ displayName?: string }>( + dataProvider.currentNamespaceQueryOptions().queryKey, + ); + if (namespace?.displayName !== onboardingDisplayName) { + return false; + } + + return ( + queryClient.getQueryData( + dataProvider.actorsCountQueryOptions().queryKey, + ) === 0 + ); +} diff --git a/frontend/src/routes/_context/ns.$namespace.tsx b/frontend/src/routes/_context/ns.$namespace.tsx index d436ced4b7..5860c9dab5 100644 --- a/frontend/src/routes/_context/ns.$namespace.tsx +++ b/frontend/src/routes/_context/ns.$namespace.tsx @@ -1,5 +1,6 @@ import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; import { match } from "ts-pattern"; +import { NamespaceLandingPending } from "@/app/actors-grid"; import { ConnectProviderSheet, isConnectProviderModal, @@ -8,12 +9,14 @@ import { EditRunnerConfigSheet } from "@/app/dialogs/edit-runner-config-sheet"; import { GettingStarted } from "@/app/getting-started"; import { SidebarlessHeader } from "@/app/layout"; import { NotFoundCard } from "@/app/not-found-card"; +import { OnboardingSkeleton } from "@/app/onboarding-skeleton"; import { RouteLayout } from "@/app/route-layout"; import { useDialog } from "@/app/use-dialog"; import { ls } from "@/components"; import { CreateActorSheet } from "@/components/actors/dialogs/create-actor-sheet"; import { deriveOnboardingState, + peekDisplaysOnboarding, type RunnerConfigsInfiniteData, type RunnerNamesInfiniteData, } from "@/lib/data"; @@ -22,6 +25,7 @@ import { RECENT_NAMESPACES_KEY, recordRecentVisit, } from "@/lib/recently-visited"; +import { queryClient } from "@/queries/global"; export const Route = createFileRoute("/_context/ns/$namespace")({ context: ({ context, params }) => @@ -144,8 +148,32 @@ export const Route = createFileRoute("/_context/ns/$namespace")({ }, component: RouteComponent, notFoundComponent: () => , + pendingMinMs: 0, + pendingMs: 0, + pendingComponent: NamespacePending, }); +function NamespacePending() { + const { namespace } = Route.useParams(); + const { dataProvider } = Route.useRouteContext(); + const search = Route.useSearch() as { skipOnboarding?: boolean }; + + const displaysOnboarding = peekDisplaysOnboarding({ + queryClient, + dataProvider, + onboardingDisplayName: "Default", + isSkipped: + ls.onboarding.getSkipWelcomeEngine(namespace) || + search.skipOnboarding === true, + }); + + if (displaysOnboarding) { + return } />; + } + + return ; +} + function RouteComponent() { const { displayOnboarding, diff --git a/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace.tsx b/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace.tsx index e2cd34604c..9e83a459a6 100644 --- a/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace.tsx +++ b/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace.tsx @@ -5,15 +5,17 @@ import { useNavigate, useSearch, } from "@tanstack/react-router"; +import { NamespaceLandingPending } from "@/app/actors-grid"; +import { peekCloudNamespaceContext } from "@/app/data-providers/cache"; import { ConnectProviderSheet, isConnectProviderModal, } from "@/app/dialogs/connect-provider-sheet"; import { EditRunnerConfigSheet } from "@/app/dialogs/edit-runner-config-sheet"; -import { NamespaceLandingPending } from "@/app/actors-grid"; import { GettingStarted } from "@/app/getting-started"; import { SidebarlessHeader } from "@/app/layout"; import { NotFoundCard } from "@/app/not-found-card"; +import { OnboardingSkeleton } from "@/app/onboarding-skeleton"; import { RouteError } from "@/app/route-error"; import { RouteLayout } from "@/app/route-layout"; import { useDialog } from "@/app/use-dialog"; @@ -21,6 +23,7 @@ import { ls } from "@/components"; import { CreateActorSheet } from "@/components/actors/dialogs/create-actor-sheet"; import { deriveOnboardingState, + peekDisplaysOnboarding, type RunnerConfigsInfiniteData, type RunnerNamesInfiniteData, } from "@/lib/data"; @@ -29,6 +32,7 @@ import { RECENT_NAMESPACES_KEY, recordRecentVisit, } from "@/lib/recently-visited"; +import { queryClient } from "@/queries/global"; export const Route = createFileRoute( "/_context/orgs/$organization/projects/$project/ns/$namespace", @@ -171,9 +175,33 @@ export const Route = createFileRoute( errorComponent: RouteError, pendingMinMs: 0, pendingMs: 0, - pendingComponent: NamespaceLandingPending, + pendingComponent: NamespacePending, }); +function NamespacePending() { + const { organization, project, namespace } = Route.useParams(); + const { skipOnboarding } = Route.useSearch(); + + const displaysOnboarding = peekDisplaysOnboarding({ + queryClient, + dataProvider: peekCloudNamespaceContext( + organization, + project, + namespace, + ), + onboardingDisplayName: "Production", + isSkipped: + ls.onboarding.getSkipWelcome(project, namespace) || + skipOnboarding === true, + }); + + if (displaysOnboarding) { + return } />; + } + + return ; +} + function RouteComponent() { const { displayOnboarding,