From c14bfa7016f72b6d227876010d563b5093917ea5 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 11 Aug 2026 09:41:35 +0100 Subject: [PATCH] feat(webapp,cli,database): track real dev onboarding progress The dev environment "Get set up" panel now reflects actual progress instead of static instructions. Running trigger init records the project as initialized (via a new project-scoped endpoint the CLI calls), so step 1 ticks off, and the panel updates live as the dev server connects and tasks register. Adds a "Copy AI agent prompt" button that copies a ready-to-paste setup prompt pre-filled with the project reference, and updates the init scaffold to import from @trigger.dev/sdk instead of the deprecated /v3 subpath. --- .changeset/dev-onboarding-progress.md | 7 ++ .../app/components/BlankStatePanels.tsx | 90 ++++++++++++--- apps/webapp/app/components/SetupCommands.tsx | 104 ++++++++++++++++-- .../v3/TasksStreamPresenter.server.ts | 4 + .../route.tsx | 18 ++- .../api.v1.projects.$projectRef.init.ts | 67 +++++++++++ .../app/v3/services/projectPubSub.server.ts | 3 + .../migration.sql | 2 + .../database/prisma/schema.prisma | 3 + packages/cli-v3/src/apiClient.ts | 23 ++++ packages/cli-v3/src/commands/init.ts | 9 ++ .../templates/examples/schedule.mjs.template | 2 +- .../templates/examples/schedule.ts.template | 2 +- .../templates/examples/simple.mjs.template | 2 +- .../templates/examples/simple.ts.template | 2 +- .../templates/trigger.config.mjs.template | 2 +- .../templates/trigger.config.ts.template | 2 +- 17 files changed, 306 insertions(+), 36 deletions(-) create mode 100644 .changeset/dev-onboarding-progress.md create mode 100644 apps/webapp/app/routes/api.v1.projects.$projectRef.init.ts create mode 100644 internal-packages/database/prisma/migrations/20260811065646_add_project_initialized_at/migration.sql diff --git a/.changeset/dev-onboarding-progress.md b/.changeset/dev-onboarding-progress.md new file mode 100644 index 00000000000..6755cac9160 --- /dev/null +++ b/.changeset/dev-onboarding-progress.md @@ -0,0 +1,7 @@ +--- +"trigger.dev": patch +--- + +The dev environment onboarding now tracks real progress. After you run `init`, the setup checklist marks your project as initialized, and it updates live as your dev server connects and your tasks register. The blank state also adds a "Copy AI agent prompt" button that copies a ready-to-paste setup prompt (pre-filled with your project reference) for Claude Code, Cursor, or any coding agent. + +The `init` scaffold now imports from `@trigger.dev/sdk` instead of the deprecated `@trigger.dev/sdk/v3` subpath. diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index 53b552155be..83936ae9574 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -8,6 +8,7 @@ import { QuestionMarkCircleIcon, RectangleGroupIcon, RectangleStackIcon, + SparklesIcon, Squares2X2Icon, } from "@heroicons/react/20/solid"; import { useLocation } from "react-use"; @@ -36,6 +37,7 @@ import { } from "~/utils/pathBuilder"; import { AskAI } from "./AskAI"; import { CodeBlock } from "./code/CodeBlock"; +import { useDevPresence } from "./DevPresence"; import { InlineCode } from "./code/InlineCode"; import { environmentFullTitle, EnvironmentIcon } from "./environments/EnvironmentLabel"; import { Feedback } from "./Feedback"; @@ -54,6 +56,7 @@ import { StepNumber } from "./primitives/StepNumber"; import { TextLink } from "./primitives/TextLink"; import { SimpleTooltip } from "./primitives/Tooltip"; import { + InitAgentPromptV3, InitCommandV3, PackageManagerProvider, TriggerDeployStep, @@ -62,12 +65,16 @@ import { import { StepContentContainer } from "./StepContentContainer"; import { V4Badge } from "./V4Badge"; -export function HasNoTasksDev() { +export function HasNoTasksDev({ initializedAt }: { initializedAt: Date | string | null }) { + const { isConnected } = useDevPresence(); + const initialized = !!initializedAt; + const devConnected = isConnected === true; + return (
- Get setup in 3 minutes + Get set up in 2 minutes
- - - - - You'll notice a new folder in your project called{" "} - trigger. We've added a few simple example tasks - in there to help you get started. - - - + {!initialized && ( + <> +
+ + + +
+ Set it up with your AI agent + + Copy a ready-to-paste prompt for Claude Code, Cursor, or any coding agent. It + includes your project reference. + +
+
+ +
+
+
+
+ + or set it up yourself + +
+
+ + )} + - + {initialized ? ( + + Your project is initialized. Your tasks live in the{" "} + trigger directory. + + ) : ( + <> + + + Run this in an existing project. You'll notice a new folder called{" "} + trigger with a few example tasks to help + you get started. + + + )} - + - This page will automatically refresh. + {devConnected ? ( + + Your dev server is connected. Your tasks will appear here automatically as soon as + they register. + + ) : ( + <> + + + Keep this running while you develop. Once your tasks register, this page updates + automatically. + + + )}
diff --git a/apps/webapp/app/components/SetupCommands.tsx b/apps/webapp/app/components/SetupCommands.tsx index d219b0be721..9b13a506deb 100644 --- a/apps/webapp/app/components/SetupCommands.tsx +++ b/apps/webapp/app/components/SetupCommands.tsx @@ -1,7 +1,9 @@ -import { createContext, useContext, useState } from "react"; +import { CheckIcon, SparklesIcon } from "@heroicons/react/20/solid"; +import { createContext, useContext, useRef, useState } from "react"; import { useAppOrigin } from "~/hooks/useAppOrigin"; import { useProject } from "~/hooks/useProject"; import { useTriggerCliTag } from "~/hooks/useTriggerCliTag"; +import { Button } from "./primitives/Buttons"; import { ClientTabs, ClientTabsContent, @@ -10,6 +12,7 @@ import { } from "./primitives/ClientTabs"; import { ClipboardField } from "./primitives/ClipboardField"; import { Header3 } from "./primitives/Headers"; +import { SimpleTooltip } from "./primitives/Tooltip"; type PackageManagerContextType = { activePackageManager: string; @@ -36,26 +39,23 @@ function usePackageManager() { return context; } -function getApiUrlArg() { +function useApiUrl() { const appOrigin = useAppOrigin(); - let apiUrl: string | undefined = undefined; - switch (appOrigin) { case "https://cloud.trigger.dev": - // don't display the arg, use the CLI default - break; + return undefined; case "https://test-cloud.trigger.dev": - apiUrl = "https://test-api.trigger.dev"; - break; + return "https://test-api.trigger.dev"; case "https://internal.trigger.dev": - apiUrl = "https://internal-api.trigger.dev"; - break; + return "https://internal-api.trigger.dev"; default: - apiUrl = appOrigin; - break; + return appOrigin; } +} +function getApiUrlArg() { + const apiUrl = useApiUrl(); return apiUrl ? `-a ${apiUrl}` : undefined; } @@ -117,6 +117,86 @@ export function InitCommandV3({ title }: TabsProps) { ); } +function buildAgentSetupPrompt({ + projectRef, + apiUrl, + cliTag, +}: { + projectRef: string; + apiUrl: string | undefined; + cliTag: string; +}) { + const apiUrlArg = apiUrl ? ` -a ${apiUrl}` : ""; + const apiUrlLine = apiUrl ? `\nTrigger.dev API URL: ${apiUrl}` : ""; + + return `Set up Trigger.dev in this project. + +Trigger.dev runs your background tasks. This is an existing codebase — add Trigger.dev to it and get one task running in the development environment. + +Project reference: ${projectRef}${apiUrlLine} + +How to do it: +1. If you have the Trigger.dev MCP server available, use its "initialize_project" tool with the project reference above. +2. Otherwise run this and follow its output: + npx trigger.dev@${cliTag} init -p ${projectRef}${apiUrlArg} +3. If you set it up by hand, follow https://trigger.dev/docs/manual-setup and make sure you end up with: + - "@trigger.dev/sdk" installed (latest) and "@trigger.dev/build" as a dev dependency + - a trigger.config.ts with: import { defineConfig } from "@trigger.dev/sdk", project: "${projectRef}", dirs: ["./src/trigger"], and a maxDuration + - a src/trigger/ directory with at least one exported task created with task() from "@trigger.dev/sdk" + - trigger.config.ts added to tsconfig "include", and ".trigger" added to .gitignore + +Golden rules: +- Import from "@trigger.dev/sdk". Never "@trigger.dev/sdk/v3" or the deprecated client.defineJob. +- Export every task, including subtasks. +- Use the built-in fetch, not node-fetch. +- Never wrap wait.*, triggerAndWait, or batchTriggerAndWait in Promise.all. + +Two steps I have to do myself — ask me when you need them: +- Running "npx trigger.dev@${cliTag} login" (it opens a browser). +- Giving you the development TRIGGER_SECRET_KEY from the dashboard to put in .env. + +When you're done, run "npx trigger.dev@${cliTag} dev" and confirm the task shows up in the Trigger.dev dashboard.`; +} + +export function InitAgentPromptV3() { + const project = useProject(); + const apiUrl = useApiUrl(); + const cliTag = useTriggerCliTag(); + const [copied, setCopied] = useState(false); + const timeoutRef = useRef | null>(null); + + const onCopy = () => { + const prompt = buildAgentSetupPrompt({ + projectRef: project.externalRef, + apiUrl, + cliTag, + }); + setCopied(true); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setCopied(false), 2000); + void navigator.clipboard.writeText(prompt).catch(() => {}); + }; + + return ( + + {copied ? "Copied prompt" : "Copy AI agent prompt"} + + } + content="Copies a setup prompt to paste into Claude Code, Cursor, or any coding agent" + /> + ); +} + export function TriggerDevStepV3({ title }: TabsProps) { const triggerCliTag = useTriggerCliTag(); const { activePackageManager, setActivePackageManager } = usePackageManager(); diff --git a/apps/webapp/app/presenters/v3/TasksStreamPresenter.server.ts b/apps/webapp/app/presenters/v3/TasksStreamPresenter.server.ts index d6436560b10..b30d668cb5a 100644 --- a/apps/webapp/app/presenters/v3/TasksStreamPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TasksStreamPresenter.server.ts @@ -88,6 +88,10 @@ export class TasksStreamPresenter { safeSend({ data: message.createdAt.toISOString() }); }); + subscriber.on("PROJECT_INITIALIZED", async (message) => { + safeSend({ data: message.initializedAt.toISOString() }); + }); + pinger = setInterval(() => { if (signal.aborted) { return close(); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx index d49d5c83398..e8e4463ef02 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx @@ -71,6 +71,7 @@ import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useSearchParams } from "~/hooks/useSearchParam"; import { useShortcutKeys } from "~/hooks/useShortcutKeys"; +import { prisma } from "~/db.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { @@ -128,7 +129,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const usefulLinksPreference = await getUsefulLinksPreference(request); - return typeddefer({ items, hourlyActivity, runningStates, usefulLinksPreference }); + const initialized = await prisma.project.findFirst({ + where: { id: project.id }, + select: { initializedAt: true }, + }); + + return typeddefer({ + items, + hourlyActivity, + runningStates, + usefulLinksPreference, + projectInitializedAt: initialized?.initializedAt ?? null, + }); } catch (error) { console.error(error); throw new Response(undefined, { @@ -195,7 +207,7 @@ export default function Page() { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); - const { items, hourlyActivity, runningStates, usefulLinksPreference } = + const { items, hourlyActivity, runningStates, usefulLinksPreference, projectInitializedAt } = useTypedLoaderData(); const { value, values } = useSearchParams(); @@ -355,7 +367,7 @@ export default function Page() {
) : environment.type === "DEVELOPMENT" ? ( - + ) : ( diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.init.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.init.ts new file mode 100644 index 00000000000..bb90362b1a9 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.init.ts @@ -0,0 +1,67 @@ +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { logger } from "~/services/logger.server"; +import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { projectPubSub } from "~/v3/services/projectPubSub.server"; + +const ParamsSchema = z.object({ + projectRef: z.string(), +}); + +export const action = createActionPATApiRoute( + { + method: "POST", + params: ParamsSchema, + context: async ({ projectRef }) => { + const project = await prisma.project.findFirst({ + where: { externalRef: projectRef, deletedAt: null }, + select: { organizationId: true }, + }); + return project ? { organizationId: project.organizationId } : {}; + }, + authorization: { action: "manage", resource: () => ({ type: "project" }) }, + }, + async ({ params, authentication }) => { + const project = await prisma.project.findFirst({ + where: { + externalRef: params.projectRef, + organization: { + deletedAt: null, + members: { some: { userId: authentication.userId } }, + }, + deletedAt: null, + }, + select: { id: true, initializedAt: true }, + }); + + if (!project) { + return json({ error: "Project not found" }, { status: 404 }); + } + + if (!project.initializedAt) { + await prisma.project.updateMany({ + where: { id: project.id, initializedAt: null }, + data: { initializedAt: new Date() }, + }); + } + + const { initializedAt } = + (await prisma.project.findFirst({ + where: { id: project.id }, + select: { initializedAt: true }, + })) ?? {}; + + if (initializedAt) { + try { + await projectPubSub.publish(`project:${project.id}:initialized`, "PROJECT_INITIALIZED", { + initializedAt, + }); + } catch (error) { + logger.debug("Failed to publish PROJECT_INITIALIZED", { error }); + } + } + + return json({ id: project.id, initializedAt: initializedAt ?? null }); + } +); diff --git a/apps/webapp/app/v3/services/projectPubSub.server.ts b/apps/webapp/app/v3/services/projectPubSub.server.ts index b3c7a6b769c..0d9004fee14 100644 --- a/apps/webapp/app/v3/services/projectPubSub.server.ts +++ b/apps/webapp/app/v3/services/projectPubSub.server.ts @@ -14,6 +14,9 @@ const messageCatalog = { taskCount: z.number(), type: z.union([z.literal("local"), z.literal("deployed")]), }), + PROJECT_INITIALIZED: z.object({ + initializedAt: z.coerce.date(), + }), }; export type ProjectSubscriber = ZodSubscriber; diff --git a/internal-packages/database/prisma/migrations/20260811065646_add_project_initialized_at/migration.sql b/internal-packages/database/prisma/migrations/20260811065646_add_project_initialized_at/migration.sql new file mode 100644 index 00000000000..0d69acb6792 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260811065646_add_project_initialized_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "initializedAt" TIMESTAMP(3); diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 3cf141fe0a8..d5984de16f8 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -469,6 +469,9 @@ model Project { updatedAt DateTime @updatedAt deletedAt DateTime? + /// Set the first time the CLI `init` command completes against this project. Drives the dev onboarding progress. + initializedAt DateTime? + version ProjectVersion @default(V2) engine RunEngineVersion @default(V1) diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index c972802f4d2..4107134da2d 100644 --- a/packages/cli-v3/src/apiClient.ts +++ b/packages/cli-v3/src/apiClient.ts @@ -95,6 +95,11 @@ const CliPlatformNotificationResponseSchema = z.object({ .nullable(), }); +const MarkProjectInitializedResponseBody = z.object({ + id: z.string(), + initializedAt: z.string().nullable(), +}); + export class CliApiClient { private engineURL: string; private source: "cli" | "mcp"; @@ -174,6 +179,24 @@ export class CliApiClient { }); } + async markProjectInitialized(projectRef: string) { + if (!this.accessToken) { + throw new Error("markProjectInitialized: No access token"); + } + + return wrapZodFetch( + MarkProjectInitializedResponseBody, + `${this.apiURL}/api/v1/projects/${projectRef}/init`, + { + method: "POST", + headers: { + Authorization: `Bearer ${this.accessToken}`, + "Content-Type": "application/json", + }, + } + ); + } + async getProjects() { if (!this.accessToken) { throw new Error("getProjects: No access token"); diff --git a/packages/cli-v3/src/commands/init.ts b/packages/cli-v3/src/commands/init.ts index 8721e620501..a6ae5af60cb 100644 --- a/packages/cli-v3/src/commands/init.ts +++ b/packages/cli-v3/src/commands/init.ts @@ -339,6 +339,15 @@ async function _initCommand(dir: string, options: InitCommandOptions) { // Ignore .trigger dir await gitIgnoreDotTriggerDir(dir, options); + try { + const result = await apiClient.markProjectInitialized(selectedProject.externalRef); + if (!result.success) { + logger.debug("Failed to mark project as initialized", { error: result.error }); + } + } catch (error) { + logger.debug("Failed to mark project as initialized", { error }); + } + const projectDashboard = cliLink( "project dashboard", `${authorization.dashboardUrl}/projects/v3/${selectedProject.externalRef}` diff --git a/packages/cli-v3/templates/examples/schedule.mjs.template b/packages/cli-v3/templates/examples/schedule.mjs.template index 5622b37a91e..9c37aa1d9ba 100644 --- a/packages/cli-v3/templates/examples/schedule.mjs.template +++ b/packages/cli-v3/templates/examples/schedule.mjs.template @@ -1,4 +1,4 @@ -import { logger, schedules, wait } from "@trigger.dev/sdk/v3"; +import { logger, schedules, wait } from "@trigger.dev/sdk"; export const firstScheduledTask = schedules.task({ id: "first-scheduled-task", diff --git a/packages/cli-v3/templates/examples/schedule.ts.template b/packages/cli-v3/templates/examples/schedule.ts.template index 5622b37a91e..9c37aa1d9ba 100644 --- a/packages/cli-v3/templates/examples/schedule.ts.template +++ b/packages/cli-v3/templates/examples/schedule.ts.template @@ -1,4 +1,4 @@ -import { logger, schedules, wait } from "@trigger.dev/sdk/v3"; +import { logger, schedules, wait } from "@trigger.dev/sdk"; export const firstScheduledTask = schedules.task({ id: "first-scheduled-task", diff --git a/packages/cli-v3/templates/examples/simple.mjs.template b/packages/cli-v3/templates/examples/simple.mjs.template index 8b1bf12d4f1..81e9064565e 100644 --- a/packages/cli-v3/templates/examples/simple.mjs.template +++ b/packages/cli-v3/templates/examples/simple.mjs.template @@ -1,4 +1,4 @@ -import { logger, task, wait } from "@trigger.dev/sdk/v3"; +import { logger, task, wait } from "@trigger.dev/sdk"; export const helloWorldTask = task({ id: "hello-world", diff --git a/packages/cli-v3/templates/examples/simple.ts.template b/packages/cli-v3/templates/examples/simple.ts.template index 9ef3d529f3a..5b07b687cad 100644 --- a/packages/cli-v3/templates/examples/simple.ts.template +++ b/packages/cli-v3/templates/examples/simple.ts.template @@ -1,4 +1,4 @@ -import { logger, task, wait } from "@trigger.dev/sdk/v3"; +import { logger, task, wait } from "@trigger.dev/sdk"; export const helloWorldTask = task({ id: "hello-world", diff --git a/packages/cli-v3/templates/trigger.config.mjs.template b/packages/cli-v3/templates/trigger.config.mjs.template index d50033647e2..ef2fa0357a9 100644 --- a/packages/cli-v3/templates/trigger.config.mjs.template +++ b/packages/cli-v3/templates/trigger.config.mjs.template @@ -1,4 +1,4 @@ -import { defineConfig } from "@trigger.dev/sdk/v3"; +import { defineConfig } from "@trigger.dev/sdk"; export default defineConfig({ project: "${projectRef}", diff --git a/packages/cli-v3/templates/trigger.config.ts.template b/packages/cli-v3/templates/trigger.config.ts.template index d50033647e2..ef2fa0357a9 100644 --- a/packages/cli-v3/templates/trigger.config.ts.template +++ b/packages/cli-v3/templates/trigger.config.ts.template @@ -1,4 +1,4 @@ -import { defineConfig } from "@trigger.dev/sdk/v3"; +import { defineConfig } from "@trigger.dev/sdk"; export default defineConfig({ project: "${projectRef}",