From de6ddc912f1ec174200c03d1362fc7923fa2cd59 Mon Sep 17 00:00:00 2001 From: Nicholas Kissel Date: Wed, 2 Sep 2026 00:17:33 -0700 Subject: [PATCH 1/4] feat(frontend): expand onboarding product options --- frontend/public/images/brand/actors-mark.svg | 21 ++ frontend/public/images/brand/agentos-mark.svg | 13 + .../public/images/brand/dynamic-apps-mark.svg | 9 + .../public/images/brand/workflows-mark.svg | 9 + frontend/src/app/compute-deploy.tsx | 20 +- frontend/src/app/forms/stepper-form.tsx | 21 +- frontend/src/app/getting-started.tsx | 208 +++++++++----- frontend/src/content/agent-prompts.test.ts | 132 +++++++++ frontend/src/content/agent-prompts.ts | 269 +++++++++++++++++- 9 files changed, 604 insertions(+), 98 deletions(-) create mode 100644 frontend/public/images/brand/actors-mark.svg create mode 100644 frontend/public/images/brand/agentos-mark.svg create mode 100644 frontend/public/images/brand/dynamic-apps-mark.svg create mode 100644 frontend/public/images/brand/workflows-mark.svg create mode 100644 frontend/src/content/agent-prompts.test.ts 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/compute-deploy.tsx b/frontend/src/app/compute-deploy.tsx index 7f48df2296..d8ef112127 100644 --- a/frontend/src/app/compute-deploy.tsx +++ b/frontend/src/app/compute-deploy.tsx @@ -10,6 +10,7 @@ import { import { getAgentInstructionsPrompt, getComputeAddendum, + type OnboardingTarget, } from "@/content/agent-prompts"; import { cloudEnv, getRivetRunUrl } from "@/lib/env"; import { usePublishableToken } from "@/queries/accessors"; @@ -40,11 +41,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) @@ -75,14 +78,18 @@ export function useAgentInstructionsCode({ // The `--namespace` deploy flag only applies to Rivet Compute's // `@rivetkit/cli deploy` flow. cliDeploy: provider === "rivet", + target, }); } // 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" }), @@ -97,6 +104,7 @@ export function useComputeInstructionsCode() { apiUrl: cloudEnv().VITE_APP_API_URL, cloudApiUrl: cloudEnv().VITE_APP_CLOUD_API_URL, rivetRunUrl: getRivetRunUrl(namespace), + target, }); return { @@ -143,7 +151,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 +166,11 @@ export function AgentPromptBanner({

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

+ Follow the quickstart guide +

+

+ {copy.quickstartDescription} +

+
+ +
); } @@ -978,6 +983,12 @@ 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. +// 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 ( @@ -985,7 +996,7 @@ function RunLocallyComputeBanner({ target }: { target: OnboardingTarget }) { code={code} containsSecret title="Use your coding agent" - description={`Copy a prompt that scaffolds, runs, and deploys ${getOnboardingTargetCopy(target).promptObject} for you.`} + description={`Copy a prompt that scaffolds, runs, and deploys ${getOnboardingTargetCopy(target).promptObject} for you.${mcpSuffix}`} /> ); } @@ -996,7 +1007,7 @@ function RunLocallyGenericBanner({ target }: { target: OnboardingTarget }) { ); } @@ -1033,7 +1044,7 @@ function ComputeCopyAgentInstructionsButton({ ); } @@ -1062,7 +1073,7 @@ function GenericCopyAgentInstructionsButton({ ); } 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/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/content/agent-prompts.test.ts b/frontend/src/content/agent-prompts.test.ts index 6646ce58c6..70f4ddc9d4 100644 --- a/frontend/src/content/agent-prompts.test.ts +++ b/frontend/src/content/agent-prompts.test.ts @@ -27,7 +27,7 @@ const computePromptOptions = { describe("onboarding product prompts", () => { it.each([ - ["actor", "https://rivet.dev/actors/docs/quickstart/backend/"], + ["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) => { @@ -129,4 +129,73 @@ describe("onboarding product prompts", () => { 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 5f650c9d61..3be81ebe07 100644 --- a/frontend/src/content/agent-prompts.ts +++ b/frontend/src/content/agent-prompts.ts @@ -16,7 +16,7 @@ const onboardingTargetCopy: Record< promptObject: "your first Rivet Actor", quickstartDescription: "Build a Rivet Actor project by hand, step by step.", - quickstartUrl: "https://rivet.dev/actors/docs/quickstart/backend/", + quickstartUrl: "https://rivet.dev/docs/actors/quickstart/backend", }, "agent-os": { promptObject: "an agentOS project", @@ -49,13 +49,17 @@ type ComputePromptOptions = { cloudApiUrl: string; rivetRunUrl: string; target?: OnboardingTarget; + mcp?: McpSetup; }; function getDynamicAppsComputeAddendum({ cloudToken, namespace, rivetRunUrl, -}: Pick) { + mcpSection, +}: Pick & { + mcpSection: string; +}) { return `# Dynamic Apps Compute Deployment Steps ## Step 1: Follow the Dynamic Apps host architecture @@ -100,12 +104,12 @@ npx @rivetkit/cli deploy --token "${cloudToken}" --namespace ${namespace} --env Keep the token server-side. Do not expose it to generated app code or browser bundles. -## Step 4: Verify the host and a deployed app +${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\` and fix the host before retrying. +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.`; } @@ -114,7 +118,10 @@ function getWorkflowsComputeAddendum({ cloudToken, namespace, rivetRunUrl, -}: Pick) { + mcpSection, +}: Pick & { + mcpSection: string; +}) { return `# Rivet Workflows Compute Deployment Steps ## Step 1: Preserve the Workflows application @@ -155,16 +162,44 @@ npx @rivetkit/cli deploy --token "${cloudToken}" --namespace ${namespace} --env 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. -## Step 4: Verify the workflow end-to-end +${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\` and consult https://rivet.dev/workflows/docs/failure-and-recovery/ before retrying. +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, @@ -173,12 +208,15 @@ export function getComputeAddendum({ cloudApiUrl, rivetRunUrl, target = "actor", + mcp, }: ComputePromptOptions) { + const mcpSection = mcp ? getMcpSection(mcp) : ""; if (target === "dynamic-apps") { return getDynamicAppsComputeAddendum({ cloudToken, namespace, rivetRunUrl, + mcpSection, }); } @@ -187,11 +225,22 @@ export function getComputeAddendum({ 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). @@ -212,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 @@ -245,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. @@ -283,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: @@ -297,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({ @@ -331,6 +389,7 @@ export function getAgentInstructionsPrompt({ namespace, cliDeploy, target = "actor", + mcp, }: { providerStr: string; publishableToken: string; @@ -343,9 +402,13 @@ export function getAgentInstructionsPrompt({ // 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 @@ -358,13 +421,23 @@ export function getAgentInstructionsPrompt({ : ""; 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} @@ -374,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.`; @@ -410,7 +487,7 @@ Run the project, start one workflow instance, and verify its steps complete in o ${deploySteps} -After deployment, run the same workflow operation against the deployed environment and confirm the expected state. For troubleshooting, use https://rivet.dev/actors/docs/troubleshooting/ and include the workflow name, failed step name, runtime, and package version in the report.`; +${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") { @@ -442,14 +519,18 @@ Use the supported Dynamic Apps skills when generating the file tree, then call \ 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 to ${providerStr} +## Step 4: Deploy the host${computeOwnsDeploy ? "" : ` to ${providerStr}`} -1. ${docLine} +${ + 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. +4. Deploy a test app and verify it at the public \`/apps//\` URL.` +} -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.`; +${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 @@ -488,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: @@ -566,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 From fe989d778cfdd1a0119413db4d76c46527bc1e99 Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:15:17 +0200 Subject: [PATCH 3/4] fix(frontend): show onboarding skeleton while a namespace headed to onboarding loads --- frontend/src/app/data-providers/cache.ts | 10 +++ .../src/app/onboarding-skeleton.stories.tsx | 27 +++++++ frontend/src/app/onboarding-skeleton.tsx | 78 +++++++++++++++++++ frontend/src/lib/data.ts | 36 +++++++++ .../src/routes/_context/ns.$namespace.tsx | 28 +++++++ .../projects.$project/ns.$namespace.tsx | 32 +++++++- 6 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 frontend/src/app/onboarding-skeleton.stories.tsx create mode 100644 frontend/src/app/onboarding-skeleton.tsx diff --git a/frontend/src/app/data-providers/cache.ts b/frontend/src/app/data-providers/cache.ts index c98ce179cf..579e989559 100644 --- a/frontend/src/app/data-providers/cache.ts +++ b/frontend/src/app/data-providers/cache.ts @@ -133,3 +133,13 @@ export function getOrCreateEngineNamespaceContext( engineNamespaceContextCache.set(key, context); return context; } + +export function peekCloudNamespaceContext( + organization: string, + project: string, + namespace: string, +): CloudNamespaceContext | undefined { + return cloudNamespaceContextCache.get( + `${organization}:${project}:${namespace}`, + ); +} 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/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, From 98603b5ed8628e1fb348f3f0a0a149714019f9c0 Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:15:17 +0200 Subject: [PATCH 4/4] feat(frontend): replace actors create button with add-component picker --- frontend/src/app/actors-grid.tsx | 71 +-------- frontend/src/app/add-component-card.tsx | 64 ++++++++ .../src/app/dialogs/add-component-frame.tsx | 35 +++++ frontend/src/app/engine-namespace-landing.tsx | 32 +--- frontend/src/app/getting-started.tsx | 125 ++------------- frontend/src/app/use-dialog.tsx | 3 + .../products/product-picker.stories.tsx | 35 +++++ .../components/products/product-picker.tsx | 143 ++++++++++++++++++ 8 files changed, 304 insertions(+), 204 deletions(-) create mode 100644 frontend/src/app/add-component-card.tsx create mode 100644 frontend/src/app/dialogs/add-component-frame.tsx create mode 100644 frontend/src/components/products/product-picker.stories.tsx create mode 100644 frontend/src/components/products/product-picker.tsx 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/dialogs/add-component-frame.tsx b/frontend/src/app/dialogs/add-component-frame.tsx new file mode 100644 index 0000000000..b3546d3b4b --- /dev/null +++ b/frontend/src/app/dialogs/add-component-frame.tsx @@ -0,0 +1,35 @@ +import { Frame } from "@/components"; +import { + getProductDocsUrl, + ProductPicker, +} from "@/components/products/product-picker"; + +export default function AddComponentFrameContent({ + onClose, +}: { + onClose?: () => void; +}) { + return ( + <> + + Add a component + + Pick what you want to add to this project. + + + + { + window.open( + getProductDocsUrl(target), + "_blank", + "noopener,noreferrer", + ); + onClose?.(); + }} + /> + + + ); +} diff --git a/frontend/src/app/engine-namespace-landing.tsx b/frontend/src/app/engine-namespace-landing.tsx index 7f5c21b93a..1054419f7a 100644 --- a/frontend/src/app/engine-namespace-landing.tsx +++ b/frontend/src/app/engine-namespace-landing.tsx @@ -1,10 +1,11 @@ -import { faGear, faPlus, Icon } from "@rivet-gg/icons"; +import { faGear, Icon } from "@rivet-gg/icons"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { Button, H1, ScrollArea, SmallText, WithTooltip } from "@/components"; import { useEngineNamespaceDataProvider } from "@/components/actors"; import { NoProvidersAlert } from "@/components/actors/no-providers-alert"; import { VisibilitySensor } from "@/components/visibility-sensor"; +import { AddComponentButton, AddComponentCard } from "./add-component-card"; import { ActorBuildCard, ActorGridCardSkeleton } from "./actors-grid"; // Engine (OSS / enterprise) namespace landing shown when no Actor name is @@ -43,15 +44,6 @@ export function EngineNamespaceLanding() { const sorted = [...builds].sort((a, b) => a.id.localeCompare(b.id)); - const openCreateActor = () => - navigate({ - to: ".", - search: (old) => ({ - ...(old as Record), - modal: "create-actor", - }), - }); - return (
@@ -89,16 +81,6 @@ export function EngineNamespaceLanding() {

Actors

- {builds.length > 0 ? ( - - ) : 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/getting-started.tsx b/frontend/src/app/getting-started.tsx index 5930a22278..e2d3a38d74 100644 --- a/frontend/src/app/getting-started.tsx +++ b/frontend/src/app/getting-started.tsx @@ -40,8 +40,8 @@ import { } 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 { publicUrl } from "@/lib/utils"; import { queryClient } from "@/queries/global"; import { cn } from "../components/lib/utils"; import { Badge } from "../components/ui/badge"; @@ -669,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) => ( @@ -692,7 +691,7 @@ function OnboardingProgress({ action }: { action?: ReactNode }) {
- Step {currentIndex + 1} of {total} · {groupLabel} + Step {currentIndex + 1} of {total}
{action}
@@ -741,119 +740,27 @@ function AgentOsKeyNotice() { ); } -function ProductMark({ fileName }: { fileName: string }) { - return ( - - ); -} - -function BuildTargetCard({ - icon, - label, - description, - badge, - onSelect, -}: { - icon: ReactNode; - label: string; - description: string; - badge?: string; - onSelect: () => void; -}) { - return ( - - ); -} - -// Product selector shown atop the first step. The common products are -// available in every flavor; only the agentOS card is gated. +// 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(); - // Selecting a product is the whole step, so the choice advances the wizard - // instead of parking the user in front of a Continue button. - const select = (template: OnboardingTarget) => { - setValue("template", template, { - shouldDirty: true, - shouldTouch: true, - shouldValidate: true, - }); - submitForm?.(); - }; return ( ( -
-
- } - label="Actors" - description="The primitive for realtime, stateful workloads" - onSelect={() => select("actor")} - /> - {features.agentOs ? ( - - } - label="agentOS" - description="Hand every agent a computer of its own" - onSelect={() => select("agent-os")} - /> - ) : null} - } - label="Workflows" - description="Write multi-step operations that survive restarts" - onSelect={() => select("workflows")} - /> - - } - label="Dynamic Apps" - badge="Preview" - description="Deploy AI-generated apps for your users" - onSelect={() => select("dynamic-apps")} - /> -
-

- Rivet is composable. Start with one product and add the - rest to the same project whenever you need them. -

-
+ { + setValue("template", template, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }); + submitForm?.(); + }} + /> )} /> ); 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/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} +

+
+ ); +}