From a6800deb2b37c1a732b0cfadfd72d5118dce7c7b Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 5 Aug 2026 23:50:42 +0000 Subject: [PATCH 01/10] feat(webapp): shared page title mechanism for tab titles --- apps/webapp/app/root.tsx | 5 +- apps/webapp/app/utils/pageTitle.test.ts | 130 +++++++++++++++++++++++ apps/webapp/app/utils/pageTitle.ts | 131 ++++++++++++++++++++++++ 3 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 apps/webapp/app/utils/pageTitle.test.ts create mode 100644 apps/webapp/app/utils/pageTitle.ts diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index d7ad416af2..4e4fd1a90d 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -30,7 +30,7 @@ import { } from "~/utils/themePreference"; import { cachedFlag } from "~/v3/featureFlags.server"; import { getTimezonePreference } from "./services/preferences/uiPreferences.server"; -import { appEnvTitleTag } from "./utils"; +import { appTitle } from "./utils/pageTitle"; // Derived here (not inside StaleAssetRecovery) so the shared component takes // the flag as a prop. NODE_ENV is statically replaced in browser bundles, and @@ -51,7 +51,8 @@ export const headers = () => ({ export const meta: MetaFunction = ({ data }) => { const typedData = data as UseDataFunctionReturn; return [ - { title: typedData?.appEnv ? `Trigger.dev${appEnvTitleTag(typedData.appEnv)}` : "Trigger.dev" }, + // Pages declare their own title with `pageMeta`; this is the fallback. + { title: appTitle(typedData?.appEnv) }, { name: "viewport", content: "width=1024, initial-scale=1", diff --git a/apps/webapp/app/utils/pageTitle.test.ts b/apps/webapp/app/utils/pageTitle.test.ts new file mode 100644 index 0000000000..ba477647dc --- /dev/null +++ b/apps/webapp/app/utils/pageTitle.test.ts @@ -0,0 +1,130 @@ +import type { MetaDescriptor, MetaFunction } from "@remix-run/node"; +import { describe, expect, it } from "vitest"; +import { appTitle, pageMeta } from "./pageTitle"; + +const ORGANIZATION_MATCH_ID = "routes/_app.orgs.$organizationSlug"; + +type Route = { id: string; data?: unknown; meta?: MetaFunction }; + +/** + * Mirrors how Remix v2 resolves meta (see @remix-run/react `Meta`): each match is visited from the + * root down, a match without a meta export inherits the nearest ancestor's array, and the last + * match's array is what renders. It also only passes the matches visited *so far*, which is why + * titles are declared per route rather than centrally. + */ +function renderTitle(routes: Route[], params: Record = {}): string | undefined { + const matches: Array<{ id: string; data: unknown; meta: MetaDescriptor[]; params: any }> = []; + let leafMeta: MetaDescriptor[] = []; + + routes.forEach((route, index) => { + matches[index] = { id: route.id, data: route.data, meta: [], params }; + const routeMeta = route.meta + ? route.meta({ data: route.data, params, matches, location: {} as any } as any) + : [...leafMeta]; + matches[index].meta = routeMeta as MetaDescriptor[]; + leafMeta = routeMeta as MetaDescriptor[]; + }); + + const rendered = leafMeta.find((descriptor) => "title" in descriptor) as + | { title: string } + | undefined; + return rendered?.title; +} + +const rootRoute: Route = { + id: "root", + data: { appEnv: "production" }, + meta: () => [{ title: appTitle("production") }, { name: "viewport", content: "width=1024" }], +}; + +const orgRoute: Route = { + id: ORGANIZATION_MATCH_ID, + data: { + organization: { title: "Acme" }, + project: { name: "my-project" }, + environment: { type: "PRODUCTION" }, + }, +}; + +describe("pageMeta", () => { + it("adds the project and environment scope to an environment page", () => { + const title = renderTitle([rootRoute, orgRoute, { id: "routes/runs", meta: pageMeta("Runs") }]); + + expect(title).toBe("Runs | my-project (Prod) | Trigger.dev"); + }); + + it("uses the deepest declared title", () => { + const title = renderTitle([ + rootRoute, + orgRoute, + { id: "routes/runs", meta: pageMeta("Runs") }, + { id: "routes/runs.$runParam", meta: pageMeta(["run_abc", "Runs"]) }, + ]); + + expect(title).toBe("run_abc | Runs | my-project (Prod) | Trigger.dev"); + }); + + it("falls back to the nearest declared title, then the app title", () => { + expect( + renderTitle([ + rootRoute, + orgRoute, + { id: "routes/runs", meta: pageMeta("Runs") }, + { id: "routes/runs._index" }, + ]) + ).toBe("Runs | my-project (Prod) | Trigger.dev"); + + expect(renderTitle([rootRoute, orgRoute, { id: "routes/runs._index" }])).toBe("Trigger.dev"); + }); + + it("renders an entity title from loader data, with a fallback when it is missing", () => { + const meta = pageMeta<() => { task?: { slug: string } }>(({ data, params }) => [ + data?.task?.slug ?? params.taskParam ?? "Task", + "Tasks", + ]); + + expect( + renderTitle([ + rootRoute, + orgRoute, + { id: "routes/task", data: { task: { slug: "my-task" } }, meta }, + ]) + ).toBe("my-task | Tasks | my-project (Prod) | Trigger.dev"); + + expect( + renderTitle([rootRoute, orgRoute, { id: "routes/task", meta }], { taskParam: "other" }) + ).toBe("other | Tasks | my-project (Prod) | Trigger.dev"); + }); + + it("keeps the org name for org-scoped pages and the app env tag", () => { + const stagingRoot: Route = { + id: "root", + data: { appEnv: "staging" }, + meta: () => [{ title: appTitle("staging") }], + }; + + const title = renderTitle([ + stagingRoot, + { id: ORGANIZATION_MATCH_ID, data: { organization: { title: "Acme" } } }, + { id: "routes/team", meta: pageMeta("Team") }, + ]); + + expect(title).toBe("Team | Acme | Trigger.dev (staging)"); + }); + + it("carries the root's non-title tags through", () => { + const routes = [rootRoute, orgRoute, { id: "routes/runs", meta: pageMeta("Runs") }]; + const descriptors = pageMeta("Runs")({ + data: undefined, + params: {}, + matches: [ + { id: "root", data: rootRoute.data, meta: [{ name: "viewport", content: "width=1024" }] }, + { id: "routes/runs", data: undefined, meta: [] }, + ], + location: {} as any, + } as any) as MetaDescriptor[]; + + expect(descriptors).toContainEqual({ name: "viewport", content: "width=1024" }); + expect(renderTitle(routes)).toBe("Runs | my-project (Prod) | Trigger.dev"); + }); +}); diff --git a/apps/webapp/app/utils/pageTitle.ts b/apps/webapp/app/utils/pageTitle.ts new file mode 100644 index 0000000000..2943e9bcc5 --- /dev/null +++ b/apps/webapp/app/utils/pageTitle.ts @@ -0,0 +1,131 @@ +import type { MetaDescriptor, MetaFunction } from "@remix-run/node"; +import type { UseDataFunctionReturn } from "remix-typedjson"; +import { appEnvTitleTag } from "~/utils"; + +/** + * Tab titles. Every page exports `export const meta = pageMeta(...)` and only says what the page + * is; the shared scope suffix is added here. + * + * Shape (leading words carry the information, so a narrow tab still reads): + * env-scoped page: `Runs | my-project (Prod) | Trigger.dev` + * env-scoped entity: `run_abc | Runs | my-project (Prod) | Trigger.dev` + * org-scoped page: `Team | Acme | Trigger.dev` + * everything else: `Login to Trigger.dev` etc. (no scope to add) + * + * Remix v2 picks the meta of the deepest route that exports one; a route without a meta export + * inherits its nearest ancestor's. So a layout's `pageMeta` is the fallback for children that + * don't declare their own, and non-title tags from the root (viewport, robots) are carried over + * here rather than re-declared per route. + */ + +const APP_NAME = "Trigger.dev"; + +/** The org route holds the project/environment the URL resolved to. */ +const ORGANIZATION_MATCH_ID = "routes/_app.orgs.$organizationSlug"; + +/** One or more title segments, most specific first: `["run_abc", "Runs"]`. */ +export type TitleSegments = string | string[]; + +type MetaArgs = Parameters[0]; +type Matches = MetaArgs["matches"]; + +type PageInput = + | TitleSegments + | ((args: { + data: UseDataFunctionReturn | undefined; + params: Record; + }) => TitleSegments | undefined); + +/** The title used when no page declares one. */ +export function appTitle(appEnv?: string): string { + return `${APP_NAME}${appEnvTitleTag(appEnv)}`; +} + +/** + * Declares this page's tab title. Pass a string (or segments), or a function of the route's + * loader data and params for entity pages. + */ +export function pageMeta(page: PageInput): MetaFunction { + return ({ data, params, matches }) => { + const segments = resolveSegments( + page, + data as UseDataFunctionReturn | undefined, + params + ); + return [...inheritedMeta(matches), { title: composePageTitle(segments, matches) }]; + }; +} + +/** Builds the full title from the page segments plus the scope found in `matches`. */ +export function composePageTitle(segments: string[], matches: Matches): string { + return [...segments, scopeFromMatches(matches), appTitle(appEnvFromMatches(matches))] + .filter((segment): segment is string => Boolean(segment)) + .join(" | "); +} + +export function scopeFromMatches(matches: Matches): string | undefined { + const data = matches.find((match) => match.id === ORGANIZATION_MATCH_ID)?.data as + | { + organization?: { title?: string | null }; + project?: { name?: string | null }; + environment?: { type?: string | null; branchName?: string | null } | null; + } + | undefined; + + if (!data) return undefined; + + const project = data.project?.name; + if (project) { + const environment = data.environment ? environmentLabel(data.environment) : undefined; + return environment ? `${project} (${environment})` : project; + } + + return data.organization?.title ?? undefined; +} + +function environmentLabel(environment: { type?: string | null; branchName?: string | null }) { + if (environment.branchName) return environment.branchName; + + switch (environment.type) { + case "PRODUCTION": + return "Prod"; + case "STAGING": + return "Staging"; + case "DEVELOPMENT": + return "Dev"; + case "PREVIEW": + return "Preview"; + default: + return undefined; + } +} + +function appEnvFromMatches(matches: Matches): string | undefined { + const rootData = matches[0]?.data as { appEnv?: string } | undefined; + return rootData?.appEnv; +} + +/** Non-title tags (viewport, robots) from the nearest ancestor, which Remix would otherwise drop. */ +function inheritedMeta(matches: Matches): MetaDescriptor[] { + const parent = matches[matches.length - 2]; + return (parent?.meta ?? []).filter((descriptor) => !("title" in descriptor)); +} + +function resolveSegments( + page: PageInput, + data: UseDataFunctionReturn | undefined, + params: Record +): string[] { + const resolved = typeof page === "function" ? safeResolve(() => page({ data, params })) : page; + if (!resolved) return []; + return (Array.isArray(resolved) ? resolved : [resolved]).filter(Boolean); +} + +// Loader data can be missing (error boundaries, redirects), so a bad lookup must not break the page. +function safeResolve(resolve: () => TitleSegments | undefined): TitleSegments | undefined { + try { + return resolve(); + } catch { + return undefined; + } +} From 497927b5a08e6bd5349f27258b66fc9b579fbabf Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 5 Aug 2026 23:56:23 +0000 Subject: [PATCH 02/10] feat(webapp): tab titles for environment pages --- .../route.tsx | 7 +++---- .../route.tsx | 10 +++++----- .../route.tsx | 3 +++ .../route.tsx | 11 +++-------- .../route.tsx | 10 ++-------- .../route.tsx | 3 +++ .../route.tsx | 11 +++-------- .../route.tsx | 3 +++ .../route.tsx | 6 ++++++ .../route.tsx | 11 +++-------- .../route.tsx | 17 +++-------------- .../route.tsx | 6 ++++++ .../route.tsx | 7 +++---- .../route.tsx | 6 ++++++ .../route.tsx | 6 ++++++ .../route.tsx | 19 +++---------------- .../route.tsx | 3 +++ .../route.tsx | 3 +++ .../route.tsx | 10 ++-------- .../route.tsx | 11 +++-------- .../route.tsx | 11 +++-------- .../route.tsx | 10 ++-------- .../route.tsx | 11 +++-------- .../route.tsx | 9 +++++---- .../route.tsx | 12 +++--------- .../route.tsx | 6 ++---- .../route.tsx | 6 ++---- .../route.tsx | 7 +++---- .../route.tsx | 10 ++++++---- .../route.tsx | 6 ++---- .../route.tsx | 3 +++ .../route.tsx | 11 +++-------- .../route.tsx | 7 +++++-- .../route.tsx | 3 +++ .../route.tsx | 3 +++ .../route.tsx | 11 +++-------- .../route.tsx | 3 +++ .../route.tsx | 3 +++ .../route.tsx | 3 +++ .../route.tsx | 6 ++---- .../route.tsx | 10 ++-------- .../route.tsx | 3 +++ .../route.tsx | 11 +++-------- .../route.tsx | 6 ++---- .../route.tsx | 13 ++++++------- .../route.tsx | 10 +++++----- .../route.tsx | 3 +++ .../route.tsx | 11 +++-------- .../route.tsx | 6 ++++++ .../route.tsx | 11 +++-------- 50 files changed, 170 insertions(+), 218 deletions(-) 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 5a30d931c5..d49d5c8339 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 @@ -1,5 +1,5 @@ import { BookOpenIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid"; -import { json, type MetaFunction } from "@remix-run/node"; +import { json } from "@remix-run/node"; import { useFetcher, useRevalidator } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import type { TaskRunStatus } from "@trigger.dev/database"; @@ -100,10 +100,9 @@ import { v3TasksStreamingPath, v3TestTaskPath, } from "~/utils/pathBuilder"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [{ title: `Tasks | Trigger.dev` }]; -}; +export const meta = pageMeta("Tasks"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx index 5d9a86e4a2..04cc057b77 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx @@ -1,5 +1,4 @@ import { BookOpenIcon } from "@heroicons/react/24/solid"; -import { type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { Suspense, useMemo, useState } from "react"; import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; @@ -50,11 +49,12 @@ import { v3PlaygroundAgentPath, } from "~/utils/pathBuilder"; import { parseFiniteInt } from "~/utils/searchParams"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = ({ data }) => { - const slug = (data as { agent?: AgentDetail | null } | undefined)?.agent?.slug; - return [{ title: slug ? `${slug} | Agents | Trigger.dev` : "Agent | Trigger.dev" }]; -}; +export const meta = pageMeta(({ data, params }) => [ + data?.agent?.slug ?? params.agentParam ?? "Agent", + "Agents", +]); const AgentParamSchema = EnvironmentParamSchema.extend({ agentParam: z.string(), diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new/route.tsx index f121900e90..e0ee26255b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new/route.tsx @@ -46,6 +46,9 @@ import { assertSafeWebhookUrl, UnsafeWebhookUrlError, } from "~/v3/services/alerts/safeWebhookUrl.server"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta("New alert"); const FormSchema = z .object({ diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx index 62ecec6890..bb53dd94dc 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx @@ -10,7 +10,7 @@ import { PlusIcon, TrashIcon, } from "@heroicons/react/20/solid"; -import { Form, type MetaFunction, Outlet, useActionData, useNavigation } from "@remix-run/react"; +import { Form, Outlet, useActionData, useNavigation } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { SlackIcon } from "@trigger.dev/companyicons"; import type { ProjectAlertChannelType, ProjectAlertType } from "@trigger.dev/database"; @@ -66,14 +66,9 @@ import { v3ProjectAlertsPath, } from "~/utils/pathBuilder"; import { alertsWorker } from "~/v3/alertsWorker.server"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [ - { - title: `Alerts | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Alerts"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx index cb6df26557..7702c3b1b7 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx @@ -1,5 +1,4 @@ import { BookOpenIcon } from "@heroicons/react/20/solid"; -import { type MetaFunction } from "@remix-run/react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { CopyableText } from "~/components/primitives/CopyableText"; @@ -38,14 +37,9 @@ import { ApiKeysPresenter } from "~/presenters/v3/ApiKeysPresenter.server"; import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { cn } from "~/utils/cn"; import { docsPath, EnvironmentParamSchema } from "~/utils/pathBuilder"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [ - { - title: `API keys | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("API keys"); export const loader = dashboardLoader( { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches.$batchParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches.$batchParam/route.tsx index 3722472071..756b4cfcce 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches.$batchParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches.$batchParam/route.tsx @@ -24,6 +24,9 @@ import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { formatNumber } from "~/utils/numberFormatter"; import { EnvironmentParamSchema, v3BatchesPath, v3BatchRunsPath } from "~/utils/pathBuilder"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta(({ params }) => [params.batchParam ?? "Batch", "Batches"]); const BatchParamSchema = EnvironmentParamSchema.extend({ batchParam: z.string(), diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx index 9543e80243..4507f528ad 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx @@ -1,6 +1,6 @@ import { ExclamationCircleIcon } from "@heroicons/react/20/solid"; import { BookOpenIcon } from "@heroicons/react/24/solid"; -import { type MetaFunction, Outlet, useLocation, useNavigation, useParams } from "@remix-run/react"; +import { Outlet, useLocation, useNavigation, useParams } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { formatDuration } from "@trigger.dev/core/v3/utils/durations"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -62,14 +62,9 @@ import { v3BatchRunsPath, } from "~/utils/pathBuilder"; import { throwNotFound } from "~/utils/httpErrors"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [ - { - title: `Batches | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Batches"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx index 909a8bb638..22c9dc7035 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -79,6 +79,9 @@ import { ArchiveButton } from "../resources.branches.archive"; import { NewBranchPanel } from "~/routes/resources.branches.create"; import { BranchesOptions } from "~/utils/branches"; import { IconArrowBearRight2 } from "@tabler/icons-react"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta("Preview branches"); const PurchaseSchema = z.discriminatedUnion("action", [ z.object({ diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx index 15728fec69..2a4816e5a6 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx @@ -42,6 +42,12 @@ import { v3RunsPath, } from "~/utils/pathBuilder"; import { BulkActionService } from "~/v3/services/bulk/BulkActionV2.server"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta(({ data, params }) => [ + data?.bulkAction?.name || params.bulkActionParam || "Bulk action", + "Bulk actions", +]); const BulkActionParamSchema = EnvironmentParamSchema.extend({ bulkActionParam: z.string(), diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx index 1fa646dac6..6dbc22bb86 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx @@ -1,6 +1,6 @@ import { BookOpenIcon, PlusIcon } from "@heroicons/react/20/solid"; import { NoSymbolIcon } from "@heroicons/react/24/solid"; -import { Outlet, useParams, type MetaFunction } from "@remix-run/react"; +import { Outlet, useParams } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -54,14 +54,9 @@ import { v3BulkActionPath, v3CreateBulkActionPath, } from "~/utils/pathBuilder"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [ - { - title: `Bulk actions | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Bulk actions"); const SearchParamsSchema = z.object({ page: z.coerce.number().optional(), diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx index e9542b5c8f..18dc4b3409 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx @@ -7,13 +7,7 @@ import { InformationCircleIcon, } from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; -import { - Form, - useActionData, - useNavigation, - useSearchParams, - type MetaFunction, -} from "@remix-run/react"; +import { Form, useActionData, useNavigation, useSearchParams } from "@remix-run/react"; import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; import { useEffect, useState } from "react"; @@ -74,14 +68,9 @@ import { concurrencyPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/ import { AllocateConcurrencyService } from "~/v3/services/allocateConcurrency.server"; import { SetConcurrencyAddOnService } from "~/v3/services/setConcurrencyAddOn.server"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [ - { - title: `Manage concurrency | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Manage concurrency"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route.tsx index 170a0f49a4..91004be828 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route.tsx @@ -42,6 +42,12 @@ import { canAccessQueueMetricsUi } from "~/v3/canAccessQueueMetricsUi.server"; import { QueryScopeSchema } from "~/v3/querySchemas"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { MetricWidget } from "../resources.metric"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta(({ data }) => [ + data?.title ?? "Dashboard", + "Dashboards", +]); const ParamSchema = EnvironmentParamSchema.extend({ dashboardKey: z.string(), diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards._index/route.tsx index 1cee0dde8e..bad9010a1e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards._index/route.tsx @@ -1,5 +1,5 @@ import { PlusIcon } from "@heroicons/react/20/solid"; -import { Link, type MetaFunction } from "@remix-run/react"; +import { Link } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -18,10 +18,9 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { EnvironmentParamSchema, v3BuiltInDashboardPath } from "~/utils/pathBuilder"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [{ title: "Dashboards | Trigger.dev" }]; -}; +export const meta = pageMeta("Dashboards"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx index 6f7c1d2609..0a1b448564 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx @@ -58,6 +58,12 @@ import { } from "~/utils/pathBuilder"; import { MetricDashboard } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta(({ data }) => [ + data?.title ?? "Dashboard", + "Dashboards", +]); const ParamSchema = EnvironmentParamSchema.extend({ dashboardId: z.string(), diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx index c495027728..73e1752715 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx @@ -52,6 +52,12 @@ import { v3DeploymentParams, v3DeploymentsPath, v3RunsPath } from "~/utils/pathB import { capitalizeWord } from "~/utils/string"; import { UserTag } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route"; import { DeploymentEventFromString } from "@trigger.dev/core/v3/schemas"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta(({ params }) => [ + params.deploymentParam ?? "Deployment", + "Deployments", +]); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx index b9a5085183..822050f19f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx @@ -4,15 +4,7 @@ import { BookOpenIcon, NoSymbolIcon, } from "@heroicons/react/20/solid"; -import { - Form, - type MetaFunction, - Outlet, - useLocation, - useNavigate, - useNavigation, - useParams, -} from "@remix-run/react"; +import { Form, Outlet, useLocation, useNavigate, useNavigation, useParams } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { CogIcon, GitBranchIcon } from "lucide-react"; import { useEffect } from "react"; @@ -87,14 +79,9 @@ import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions"; import { useAutoRevalidate } from "~/hooks/useAutoRevalidate"; import { env } from "~/env.server"; import { DialogClose } from "@radix-ui/react-dialog"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [ - { - title: `Deployments | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Deployments"); const SearchParams = z.object({ page: z.coerce.number().optional(), diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dev-branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dev-branches/route.tsx index dd2679085e..8580cecead 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dev-branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dev-branches/route.tsx @@ -46,6 +46,9 @@ import { NewBranchPanel } from "~/routes/resources.branches.create"; import { BranchesOptions } from "~/utils/branches"; import { IconArrowBearRight2 } from "@tabler/icons-react"; import { useAutoRevalidate } from "~/hooks/useAutoRevalidate"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta("Dev branches"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx index 02d6ef00c4..6904815fc5 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx @@ -56,6 +56,9 @@ import { import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; import { EnvironmentVariableKey } from "~/v3/environmentVariables/repository"; import { findUnauthorizedEnvironmentId } from "~/v3/writableEnvironments"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta("New environment variable"); const Variable = z.object({ key: EnvironmentVariableKey, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index bd152e5803..e783cbbdfa 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -16,7 +16,6 @@ import { useFetcher, useNavigation, useRevalidator, - type MetaFunction, } from "@remix-run/react"; import { json } from "@remix-run/server-runtime"; import { useVirtualizer } from "@tanstack/react-virtual"; @@ -88,14 +87,9 @@ import { shouldSyncEnvVar, type TriggerEnvironmentType, } from "~/v3/vercel/vercelProjectIntegrationSchema"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [ - { - title: `Environment variables | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Environment variables"); type PageVercelIntegration = NonNullable< Awaited>["vercelIntegration"] diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx index 6ec1aa7fce..0bea5b63ca 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx @@ -1,6 +1,6 @@ import { parseWithZod } from "@conform-to/zod"; import { BellAlertIcon } from "@heroicons/react/20/solid"; -import { type MetaFunction, useFetcher, useRevalidator } from "@remix-run/react"; +import { useFetcher, useRevalidator } from "@remix-run/react"; import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { IconAlarmSnooze as IconAlarmSnoozeBase, IconCircleDotted } from "@tabler/icons-react"; import { ErrorId } from "@trigger.dev/core/v3/isomorphic"; @@ -83,14 +83,9 @@ import { } from "~/utils/pathBuilder"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { ErrorGroupActions } from "~/v3/services/errorGroupActions.server"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = ({ data }) => { - return [ - { - title: `Error Details | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Error Details"); const emptyStringToUndefined = z.preprocess( (v) => (v === "" ? undefined : v), diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx index 5243ab3d46..4aed224952 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx @@ -1,6 +1,6 @@ import * as Ariakit from "@ariakit/react"; import { BellAlertIcon, XMarkIcon } from "@heroicons/react/20/solid"; -import { Form, useFetcher, useRevalidator, type MetaFunction } from "@remix-run/react"; +import { Form, useFetcher, useRevalidator } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { ErrorId } from "@trigger.dev/core/v3/isomorphic"; import { type ErrorGroupStatus } from "@trigger.dev/database"; @@ -76,14 +76,9 @@ import { requireUser } from "~/services/session.server"; import { formatNumberCompact } from "~/utils/numberFormatter"; import { EnvironmentParamSchema, v3ErrorPath } from "~/utils/pathBuilder"; import { ServiceValidationError } from "~/v3/services/baseService.server"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [ - { - title: `Errors | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Errors"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const user = await requireUser(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index 40eb8356f7..833dffdec6 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -1,5 +1,4 @@ import { CheckIcon, BookOpenIcon } from "@heroicons/react/20/solid"; -import { type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { IconCardsFilled, IconDiamondFilled, IconTallymark4 } from "@tabler/icons-react"; import { tryCatch } from "@trigger.dev/core"; @@ -48,14 +47,9 @@ import { EnvironmentParamSchema, organizationBillingPath, } from "~/utils/pathBuilder"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [ - { - title: `Limits | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Limits"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx index a963fc0210..78ab68276f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx @@ -1,5 +1,5 @@ import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; -import { type MetaFunction, useFetcher, useNavigation, useLocation, Form } from "@remix-run/react"; +import { useFetcher, useNavigation, useLocation, Form } from "@remix-run/react"; import { XMarkIcon } from "@heroicons/react/20/solid"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { @@ -42,6 +42,7 @@ import { } from "~/components/primitives/Resizable"; import { Button } from "~/components/primitives/Buttons"; import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags"; +import { pageMeta } from "~/utils/pageTitle"; // Valid log levels for filtering const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]; @@ -52,13 +53,7 @@ function parseLevelsFromUrl(url: URL): LogLevel[] | undefined { return levelParams.filter((l): l is LogLevel => validLevels.includes(l as LogLevel)); } -export const meta: MetaFunction = () => { - return [ - { - title: `Logs | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Logs"); // TODO: Move this to a more appropriate shared location async function hasLogsPageAccess( diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models.$modelId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models.$modelId/route.tsx index e27f63a7e2..6ea0713dfb 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models.$modelId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models.$modelId/route.tsx @@ -1,5 +1,4 @@ import { ArrowsRightLeftIcon } from "@heroicons/react/20/solid"; -import { type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -33,14 +32,16 @@ import { formatTokenCount, } from "~/utils/modelFormatters"; import { EnvironmentParamSchema, v3ModelComparePath, v3ModelsPath } from "~/utils/pathBuilder"; +import { pageMeta } from "~/utils/pageTitle"; const ParamSchema = EnvironmentParamSchema.extend({ modelId: z.string(), }); -export const meta: MetaFunction = () => { - return [{ title: "Model Detail | Trigger.dev" }]; -}; +export const meta = pageMeta(({ data, params }) => [ + data?.model?.modelName ?? params.modelId ?? "Model", + "Models", +]); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models._index/route.tsx index 744c29235a..24f08ee695 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models._index/route.tsx @@ -6,12 +6,7 @@ import { CubeIcon, XMarkIcon, } from "@heroicons/react/20/solid"; -import { - Form, - type MetaFunction, - type ShouldRevalidateFunctionArgs, - useFetcher, -} from "@remix-run/react"; +import { Form, type ShouldRevalidateFunctionArgs, useFetcher } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { AnimatePresence, motion } from "framer-motion"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -110,10 +105,9 @@ import { parseFiniteInt } from "~/utils/searchParams"; import { IconColumns3 } from "@tabler/icons-react"; import { type loader as compareLoader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models.compare/route"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [{ title: "Models | Trigger.dev" }]; -}; +export const meta = pageMeta("Models"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models.compare/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models.compare/route.tsx index 41f1d43009..c286a7f466 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models.compare/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models.compare/route.tsx @@ -1,5 +1,4 @@ import { ArrowsRightLeftIcon } from "@heroicons/react/20/solid"; -import { type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; @@ -28,10 +27,9 @@ import { useEnvironment } from "~/hooks/useEnvironment"; import { EnvironmentParamSchema, v3ModelsPath } from "~/utils/pathBuilder"; import { formatModelCost } from "~/utils/modelFormatters"; import { formatNumberCompact } from "~/utils/numberFormatter"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [{ title: "Compare Models | Trigger.dev" }]; -}; +export const meta = pageMeta("Compare Models"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.$agentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.$agentParam/route.tsx index 581c52c211..64692acb58 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.$agentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.$agentParam/route.tsx @@ -2,7 +2,6 @@ import type { UIMessage } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react"; import { BoltIcon, CheckIcon, StopIcon } from "@heroicons/react/20/solid"; import { ClipboardIcon, TrashIcon } from "@heroicons/react/24/outline"; -import { type MetaFunction } from "@remix-run/node"; import { Link, useFetcher, useNavigate, useRouteLoaderData } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { generateJWT as internal_generateJWT, MachinePresetName } from "@trigger.dev/core/v3"; @@ -59,10 +58,9 @@ import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { EnvironmentParamSchema } from "~/utils/pathBuilder"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [{ title: "Playground | Trigger.dev" }]; -}; +export const meta = pageMeta(({ params }) => [params.agentParam ?? "Agent", "Playground"]); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground/route.tsx index ddb9f2a63e..e45e7218f4 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground/route.tsx @@ -1,5 +1,5 @@ import { BookOpenIcon, ChevronUpDownIcon, CpuChipIcon } from "@heroicons/react/20/solid"; -import { json, type MetaFunction } from "@remix-run/node"; +import { json } from "@remix-run/node"; import { Outlet, useLoaderData, useNavigate, useParams } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon"; @@ -24,10 +24,9 @@ import { playgroundPresenter } from "~/presenters/v3/PlaygroundPresenter.server" import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server"; import { requireUser } from "~/services/session.server"; import { docsPath, EnvironmentParamSchema, v3PlaygroundAgentPath } from "~/utils/pathBuilder"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [{ title: "Playground | Trigger.dev" }]; -}; +export const meta = pageMeta("Playground"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const user = await requireUser(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts.$promptSlug/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts.$promptSlug/route.tsx index cfdfb44fec..96a86ba300 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts.$promptSlug/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts.$promptSlug/route.tsx @@ -1,7 +1,7 @@ import * as Ariakit from "@ariakit/react"; import { ArrowPathIcon, ChevronUpDownIcon } from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; -import { type MetaFunction, useFetcher } from "@remix-run/react"; +import { useFetcher } from "@remix-run/react"; import { json, type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; import { AnimatePresence, motion } from "framer-motion"; @@ -85,14 +85,16 @@ import { MetricWidget } from "~/routes/resources.metric"; import { cn } from "~/utils/cn"; import { EnvironmentParamSchema, v3PromptsPath, v3RunSpanPath } from "~/utils/pathBuilder"; import { parsePeriodToMs } from "~/utils/periods"; +import { pageMeta } from "~/utils/pageTitle"; const ParamSchema = EnvironmentParamSchema.extend({ promptSlug: z.string(), }); -export const meta: MetaFunction = ({ data }) => { - return [{ title: `${(data as any)?.prompt.slug ?? "Prompt"} | Trigger.dev` }]; -}; +export const meta = pageMeta(({ data, params }) => [ + data?.prompt?.slug ?? params.promptSlug ?? "Prompt", + "Prompts", +]); // ─── Action ────────────────────────────────────────────── diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts._index/route.tsx index 651ce47c78..f8725bc4b3 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts._index/route.tsx @@ -1,4 +1,3 @@ -import { type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { Bar, @@ -39,10 +38,9 @@ import { requireUserId } from "~/services/session.server"; import { docsPath, EnvironmentParamSchema, v3PromptsPath } from "~/utils/pathBuilder"; import { LinkButton } from "~/components/primitives/Buttons"; import { BookOpenIcon } from "@heroicons/react/24/solid"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [{ title: "Prompts | Trigger.dev" }]; -}; +export const meta = pageMeta("Prompts"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx index 4f5a2c1e52..402345aaa0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx @@ -18,6 +18,9 @@ import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useEnvironment } from "~/hooks/useEnvironment"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta("Query"); /** Convert a Date or ISO string to ISO string format */ function toISOString(value: Date | string): string { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index d128f53a89..0b15bf66c0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -7,7 +7,7 @@ import { RectangleStackIcon, } from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; -import { Form, useNavigation, type MetaFunction } from "@remix-run/react"; +import { Form, useNavigation } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import type { RuntimeEnvironmentType } from "@trigger.dev/database"; import { useEffect, useMemo, useState, type ReactNode } from "react"; @@ -112,6 +112,7 @@ import { useRememberQueueMetricsPeriod, } from "~/components/queues/queueMetricsPeriod"; import { queueMetricsMaxPeriodDays } from "~/components/queues/queueMetricsPeriod.server"; +import { pageMeta } from "~/utils/pageTitle"; const SearchParamsSchema = z.object({ query: z.string().optional(), @@ -134,13 +135,7 @@ const QUEUE_LIVE_BLOCKS_QUERY = // inspector). const LIVE_GAUGE_FRESH_MS = 90_000; -export const meta: MetaFunction = () => { - return [ - { - title: `Queues | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Queues"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx index 55494258f7..866add305a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx @@ -1,4 +1,3 @@ -import { type MetaFunction } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { QueueItem } from "@trigger.dev/core/v3/schemas"; @@ -79,8 +78,12 @@ import { Paragraph } from "~/components/primitives/Paragraph"; import { InlineCode } from "~/components/code/InlineCode"; import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; import { BookOpenIcon } from "@heroicons/react/20/solid"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => [{ title: `Queue metrics | Trigger.dev` }]; +export const meta = pageMeta(({ data, params }) => [ + data?.queue?.name ?? params.queueParam ?? "Queue", + "Queues", +]); const ParamsSchema = EnvironmentParamSchema.extend({ queueParam: z.string() }); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx index c4d28ded12..d1a64ad1d5 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx @@ -64,6 +64,9 @@ import { v3BillingPath, } from "~/utils/pathBuilder"; import { SetDefaultRegionService } from "~/v3/services/setDefaultRegion.server"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta("Regions"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const user = await requireUser(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx index 085fa8f23a..6c8a955f9d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx @@ -121,6 +121,9 @@ import { import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta(({ params }) => [params.runParam ?? "Run", "Runs"]); const resizableSettings = { parent: { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index dfb921e269..7fb751c4b8 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -1,5 +1,5 @@ import { BeakerIcon, BookOpenIcon } from "@heroicons/react/24/solid"; -import { type MetaFunction, useLocation, useNavigation, useRevalidator } from "@remix-run/react"; +import { useLocation, useNavigation, useRevalidator } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { Suspense, useState } from "react"; import { @@ -76,16 +76,11 @@ import { shouldRevalidateRunsList, } from "./shouldRevalidateRunsList"; import { useRunsLiveReload } from "./useRunsLiveReload"; +import { pageMeta } from "~/utils/pageTitle"; export { shouldRevalidateRunsList as shouldRevalidate }; -export const meta: MetaFunction = () => { - return [ - { - title: `Runs metrics | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Runs metrics"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam/route.tsx index a3a0ef0547..529bec93ae 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam/route.tsx @@ -17,6 +17,9 @@ import { requireUserId } from "~/services/session.server"; import { v3EnvironmentPath, v3ScheduleParams, v3SchedulePath } from "~/utils/pathBuilder"; import { DeleteTaskScheduleService } from "~/v3/services/deleteTaskSchedule.server"; import { SetActiveOnTaskScheduleService } from "~/v3/services/setActiveOnTaskSchedule.server"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta(({ params }) => [params.scheduleParam ?? "Schedule", "Schedules"]); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.edit.$scheduleParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.edit.$scheduleParam/route.tsx index b01b9a7d93..645fcb1c5f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.edit.$scheduleParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.edit.$scheduleParam/route.tsx @@ -5,6 +5,9 @@ import { requireUserId } from "~/services/session.server"; import { v3EnvironmentPath, v3ScheduleParams } from "~/utils/pathBuilder"; import { humanToCronSupported } from "~/v3/humanToCron.server"; import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta("Edit schedule"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx index b254988e7d..272b87b305 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx @@ -5,6 +5,9 @@ import { requireUserId } from "~/services/session.server"; import { EnvironmentParamSchema } from "~/utils/pathBuilder"; import { humanToCronSupported } from "~/v3/humanToCron.server"; import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta("New schedule"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx index 19773257d2..1de8d7fcbf 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx @@ -1,6 +1,5 @@ import { BoltIcon, BoltSlashIcon } from "@heroicons/react/20/solid"; import { BookOpenIcon, CheckIcon } from "@heroicons/react/24/solid"; -import { type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { useVirtualizer } from "@tanstack/react-virtual"; import { Clipboard, ClipboardCheck } from "lucide-react"; @@ -72,14 +71,13 @@ import { v3RunsPath, v3SessionsPath, } from "~/utils/pathBuilder"; +import { pageMeta } from "~/utils/pageTitle"; const ParamsSchema = EnvironmentParamSchema.extend({ sessionParam: z.string(), }); -export const meta: MetaFunction = () => { - return [{ title: `Session | Trigger.dev` }]; -}; +export const meta = pageMeta(({ params }) => [params.sessionParam ?? "Session", "Sessions"]); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index/route.tsx index 2f05fb2e83..0bb25715db 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index/route.tsx @@ -1,5 +1,4 @@ import { BookOpenIcon } from "@heroicons/react/24/solid"; -import { type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon"; @@ -24,14 +23,9 @@ import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstan import { requireUserId } from "~/services/session.server"; import { docsPath, EnvironmentParamSchema } from "~/utils/pathBuilder"; import { throwNotFound } from "~/utils/httpErrors"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [ - { - title: `Sessions | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Sessions"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx index 9bc54b19bf..3aca7f06ac 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx @@ -40,6 +40,9 @@ import { VercelOnboardingModal, VercelSettingsPanel, } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta("Integrations"); export const handle = { pageTitle: "Integrations" }; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx index 65d7ab1427..7ce5cbd2dd 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx @@ -1,4 +1,4 @@ -import { Outlet, useMatches, type MetaFunction } from "@remix-run/react"; +import { Outlet, useMatches } from "@remix-run/react"; import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; @@ -12,14 +12,9 @@ import { v3ProjectSettingsGeneralPath, v3ProjectSettingsIntegrationsPath, } from "~/utils/pathBuilder"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [ - { - title: `Project settings | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Project settings"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.dashboard/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.dashboard/route.tsx index e6fcb09bbc..fc51404320 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.dashboard/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.dashboard/route.tsx @@ -1,5 +1,4 @@ import { BookOpenIcon } from "@heroicons/react/20/solid"; -import { type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { Suspense, useMemo } from "react"; import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; @@ -28,10 +27,9 @@ import { v3EnvironmentPath, v3RunsPath, } from "~/utils/pathBuilder"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [{ title: "Tasks | Trigger.dev" }]; -}; +export const meta = pageMeta("Tasks"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx index b3a2b852ed..3191cd4f8c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx @@ -1,5 +1,5 @@ import { BookOpenIcon, PlusIcon } from "@heroicons/react/20/solid"; -import { useFetcher, useRevalidator, type MetaFunction } from "@remix-run/react"; +import { useFetcher, useRevalidator } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { TypedAwait, typeddefer, useTypedFetcher, useTypedLoaderData } from "remix-typedjson"; @@ -105,13 +105,12 @@ import type { loader as scheduleNewLoader } from "../_app.orgs.$organizationSlug import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route"; import { NewRunsButton, TaskRunsList } from "~/components/runs/v3/TaskRunsList"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = ({ data }) => { - const slug = (data as { task?: TaskDetail | null } | undefined)?.task?.slug; - return [ - { title: slug ? `${slug} | Scheduled tasks | Trigger.dev` : "Scheduled task | Trigger.dev" }, - ]; -}; +export const meta = pageMeta(({ data, params }) => [ + data?.task?.slug ?? params.taskParam ?? "Task", + "Scheduled tasks", +]); const ParamsSchema = EnvironmentParamSchema.extend({ taskParam: z.string(), diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx index 15df31370d..3263febe24 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx @@ -1,4 +1,3 @@ -import { type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { formatDurationMilliseconds } from "@trigger.dev/core/v3"; import { Suspense, useMemo, useRef, useState } from "react"; @@ -65,11 +64,12 @@ import { parseFiniteInt } from "~/utils/searchParams"; import { NewRunsButton, TaskRunsList } from "~/components/runs/v3/TaskRunsList"; import { canAccessQueueMetricsUi } from "~/v3/canAccessQueueMetricsUi.server"; import { engine } from "~/v3/runEngine.server"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = ({ data }) => { - const slug = (data as { task?: TaskDetail | null } | undefined)?.task?.slug; - return [{ title: slug ? `${slug} | Tasks | Trigger.dev` : "Task | Trigger.dev" }]; -}; +export const meta = pageMeta(({ data, params }) => [ + data?.task?.slug ?? params.taskParam ?? "Task", + "Tasks", +]); const ParamsSchema = EnvironmentParamSchema.extend({ taskParam: z.string(), diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx index ffe6dbcd03..eca0f7686c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx @@ -80,6 +80,9 @@ import { AIPayloadTabContent } from "./AIPayloadTabContent"; import { SchemaTabContent } from "./SchemaTabContent"; import { TestSidebarTabs } from "./TestSidebarTabs"; import { Header2 } from "~/components/primitives/Headers"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta(({ params }) => [params.taskParam ?? "Task", "Test"]); type FormAction = "create-template" | "delete-template" | "run-scheduled" | "run-standard"; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test/route.tsx index 2c29521fef..f3a39546a9 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test/route.tsx @@ -1,5 +1,5 @@ import { BookOpenIcon, MagnifyingGlassIcon } from "@heroicons/react/20/solid"; -import { type MetaFunction, Outlet, useParams } from "@remix-run/react"; +import { Outlet, useParams } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { TestHasNoTasks } from "~/components/BlankStatePanels"; @@ -36,14 +36,9 @@ import { type TaskListItem, TestPresenter } from "~/presenters/v3/TestPresenter. import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { docsPath, EnvironmentParamSchema, v3TestTaskPath } from "~/utils/pathBuilder"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [ - { - title: `Test | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Test"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens.$waitpointParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens.$waitpointParam/route.tsx index d584d5f970..879e815567 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens.$waitpointParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens.$waitpointParam/route.tsx @@ -25,6 +25,12 @@ import { WaitpointDetailTable } from "~/components/runs/v3/WaitpointDetails"; import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable"; import { InfoIconTooltip } from "~/components/primitives/Tooltip"; import { logger } from "~/services/logger.server"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta(({ params }) => [ + params.waitpointParam ?? "Waitpoint", + "Waitpoint tokens", +]); const Params = EnvironmentParamSchema.extend({ waitpointParam: z.string(), diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx index 011ad8a368..ae80228e3e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx @@ -1,5 +1,5 @@ import { BookOpenIcon } from "@heroicons/react/20/solid"; -import { Outlet, useParams, type MetaFunction } from "@remix-run/react"; +import { Outlet, useParams } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; @@ -49,14 +49,9 @@ import { type PrismaClientOrTransaction, } from "~/db.server"; import { docsPath, EnvironmentParamSchema, v3WaitpointTokenPath } from "~/utils/pathBuilder"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [ - { - title: `Waitpoint tokens | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Waitpoint tokens"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); From 442f5a3fb96cc0ee7dc25d967c658fbcc58953d5 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 5 Aug 2026 23:57:13 +0000 Subject: [PATCH 03/10] feat(webapp): tab titles for organization, account and onboarding pages --- .../route.tsx | 3 +++ .../route.tsx | 11 +++-------- .../route.tsx | 6 ++---- .../route.tsx | 10 ++-------- ...nizationSlug.settings.integrations.slack.tsx | 3 +++ ...izationSlug.settings.integrations.vercel.tsx | 3 +++ .../route.tsx | 7 +++---- .../route.tsx | 7 +++---- .../route.tsx | 10 ++-------- .../route.tsx | 4 ++-- .../route.tsx | 17 +++-------------- .../route.tsx | 11 +++-------- .../route.tsx | 3 +++ .../route.tsx | 3 +++ apps/webapp/app/routes/_app.orgs.new/route.tsx | 3 +++ apps/webapp/app/routes/_app.timezones/route.tsx | 3 +++ apps/webapp/app/routes/account._index/route.tsx | 17 +++-------------- .../route.tsx | 3 +++ .../app/routes/account.security/route.tsx | 10 ++-------- apps/webapp/app/routes/account.tokens/route.tsx | 11 +++-------- .../webapp/app/routes/confirm-basic-details.tsx | 3 +++ apps/webapp/app/routes/invites.tsx | 3 +++ .../app/routes/unsubscribe.$userId.$token.tsx | 3 +++ apps/webapp/app/routes/vercel.onboarding.tsx | 3 +++ 24 files changed, 67 insertions(+), 90 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx index b6fe0b09c6..5d016e96bc 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx @@ -38,6 +38,9 @@ import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashb import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder"; import { isAtOrBelow } from "~/utils/inviteRoleLadder"; import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta("Invite team members"); const Params = z.object({ organizationSlug: z.string(), diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx index 348658ba9e..5830f599a6 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx @@ -7,7 +7,7 @@ import { FolderIcon, TrashIcon, } from "@heroicons/react/20/solid"; -import { Form, type MetaFunction, useActionData, useNavigation, useSubmit } from "@remix-run/react"; +import { Form, useActionData, useNavigation, useSubmit } from "@remix-run/react"; import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { useEffect, useRef, useState } from "react"; import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -54,14 +54,9 @@ import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { cn } from "~/utils/cn"; import { extractDomain, faviconUrl as buildFaviconUrl } from "~/utils/favicon"; import { OrganizationParamsSchema, organizationSettingsPath, rootPath } from "~/utils/pathBuilder"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [ - { - title: `Organization settings | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Organization settings"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-limits/route.tsx index a7790c4ee4..3dc613bded 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-limits/route.tsx @@ -1,5 +1,4 @@ import { parseWithZod } from "@conform-to/zod"; -import type { MetaFunction } from "@remix-run/react"; import { json, redirect } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -74,15 +73,14 @@ import { v3BillingLimitsPath, v3BillingPath, } from "~/utils/pathBuilder"; +import { pageMeta } from "~/utils/pageTitle"; const billingLimitsAuthorization = { action: "manage" as const, resource: { type: "billing-limits" as const }, }; -export const meta: MetaFunction = () => { - return [{ title: `Billing limits | Trigger.dev` }]; -}; +export const meta = pageMeta("Billing limits"); export const loader = dashboardLoader( { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx index 28737236bc..2c98feadf6 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx @@ -1,5 +1,4 @@ import { CalendarDaysIcon, CreditCardIcon, StarIcon } from "@heroicons/react/20/solid"; -import { type MetaFunction } from "@remix-run/react"; import { type PlanDefinition } from "@trigger.dev/platform"; import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; import { Feedback } from "~/components/Feedback"; @@ -21,14 +20,9 @@ import { v3StripePortalPath, } from "~/utils/pathBuilder"; import { PricingPlans } from "../resources.orgs.$organizationSlug.select-plan"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [ - { - title: `Billing | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Billing"); export const loader = dashboardLoader( { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.slack.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.slack.tsx index a7950a41e3..ca51044da8 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.slack.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.slack.tsx @@ -40,6 +40,9 @@ import { $transaction, prisma } from "~/db.server"; import { requireOrganization } from "~/services/org.server"; import { OrganizationParamsSchema, organizationSlackIntegrationPath } from "~/utils/pathBuilder"; import { logger } from "~/services/logger.server"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta("Slack integration"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const { organizationSlug } = OrganizationParamsSchema.parse(params); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx index a8412a0765..15241df2fb 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx @@ -35,6 +35,9 @@ import { requireOrganization } from "~/services/org.server"; import { rbac } from "~/services/rbac.server"; import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { OrganizationParamsSchema, v3ProjectSettingsIntegrationsPath } from "~/utils/pathBuilder"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta("Vercel integration"); function formatDate(date: Date): string { return new Intl.DateTimeFormat("en-US", { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections._index/route.tsx index 914e9ceb23..cb2480c170 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections._index/route.tsx @@ -4,7 +4,7 @@ import { PlusIcon, TrashIcon, } from "@heroicons/react/20/solid"; -import { Form, useRevalidator, type MetaFunction } from "@remix-run/react"; +import { Form, useRevalidator } from "@remix-run/react"; import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core/utils"; import type { PrivateLinkConnectionStatus } from "@trigger.dev/platform"; @@ -33,10 +33,9 @@ import { } from "~/utils/pathBuilder"; import { canAccessPrivateConnections } from "~/v3/canAccessPrivateConnections.server"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [{ title: `Private Connections | Trigger.dev` }]; -}; +export const meta = pageMeta("Private Connections"); export async function loader({ params, request }: LoaderFunctionArgs) { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections.new/route.tsx index 1dc9753ac1..23355edb0f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections.new/route.tsx @@ -1,6 +1,6 @@ import { getFormProps, getInputProps, getSelectProps, useForm } from "@conform-to/react"; import { parseWithZod } from "@conform-to/zod"; -import { Form, useActionData, useParams, type MetaFunction } from "@remix-run/react"; +import { Form, useActionData, useParams } from "@remix-run/react"; import { json, type ActionFunction, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core/utils"; import { useState } from "react"; @@ -45,10 +45,9 @@ import { SparklesIcon, TrashIcon, } from "@heroicons/react/20/solid"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [{ title: `Add Private Connection | Trigger.dev` }]; -}; +export const meta = pageMeta("Add Private Connection"); export async function loader({ params, request }: LoaderFunctionArgs) { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx index d334fb50fe..d8e39bcadd 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx @@ -1,5 +1,4 @@ import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid"; -import { type MetaFunction } from "@remix-run/react"; import { useState } from "react"; import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; @@ -27,14 +26,9 @@ import { useShowSelfServe } from "~/hooks/useShowSelfServe"; import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { rbac } from "~/services/rbac.server"; import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [ - { - title: `Roles | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Roles"); const Params = z.object({ organizationSlug: z.string(), diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx index e24fceb332..84bf36e37c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx @@ -4,7 +4,6 @@ import { ClockIcon, ExclamationCircleIcon, } from "@heroicons/react/20/solid"; -import { type MetaFunction } from "@remix-run/react"; import { redirect } from "@remix-run/server-runtime"; import { useEffect, useState } from "react"; import { useFetcher, useRevalidator } from "@remix-run/react"; @@ -48,8 +47,9 @@ import { FEATURE_FLAG } from "~/v3/featureFlags"; import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { cn } from "~/utils/cn"; import { throwPermissionDenied } from "~/utils/permissionDenied"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => [{ title: "SSO & Directory Sync | Trigger.dev" }]; +export const meta = pageMeta("SSO & Directory Sync"); const Params = z.object({ organizationSlug: z.string() }); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index cc2a95571d..868985b205 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -2,13 +2,7 @@ import { getFormProps, getInputProps, useForm } from "@conform-to/react"; import { parseWithZod } from "@conform-to/zod"; import { EnvelopeIcon, NoSymbolIcon, UserPlusIcon } from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; -import { - Form, - type MetaFunction, - useActionData, - useFetcher, - useNavigation, -} from "@remix-run/react"; +import { Form, useActionData, useFetcher, useNavigation } from "@remix-run/react"; import { json } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core/utils"; import { cloneElement, useEffect, useRef, useState } from "react"; @@ -61,14 +55,9 @@ import { } from "~/utils/pathBuilder"; import { SetSeatsAddOnService } from "~/v3/services/setSeatsAddOn.server"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [ - { - title: `Team | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Team"); const Params = z.object({ organizationSlug: z.string(), diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx index 03636f285d..05e7fef886 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx @@ -1,5 +1,5 @@ import { InformationCircleIcon } from "@heroicons/react/20/solid"; -import { Await, type MetaFunction } from "@remix-run/react"; +import { Await } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { formatDurationMilliseconds } from "@trigger.dev/core/v3"; import { Suspense, useMemo } from "react"; @@ -36,14 +36,9 @@ import { formatCurrency, formatCurrencyAccurate, formatNumber } from "~/utils/nu import { useBillingLimit } from "~/hooks/useOrganizations"; import { OrganizationParamsSchema, organizationPath } from "~/utils/pathBuilder"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [ - { - title: `Usage | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Usage"); export async function loader({ params, request }: LoaderFunctionArgs) { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx index 70c07a6dfc..6f44617d35 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx @@ -36,6 +36,9 @@ import { v3ProjectPath, } from "~/utils/pathBuilder"; import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta("New project"); const WORKING_ON_OTHER = "Other/not sure yet"; const GOALS_OTHER = "Other/not sure yet"; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.select-plan/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.select-plan/route.tsx index a01b079361..29839ecc10 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.select-plan/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.select-plan/route.tsx @@ -9,6 +9,9 @@ import { getCurrentPlan, getPlans } from "~/services/platform.v3.server"; import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { OrganizationParamsSchema, organizationPath } from "~/utils/pathBuilder"; import { PricingPlans } from "../resources.orgs.$organizationSlug.select-plan"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta("Choose a plan"); export const loader = dashboardLoader( { diff --git a/apps/webapp/app/routes/_app.orgs.new/route.tsx b/apps/webapp/app/routes/_app.orgs.new/route.tsx index b06d9bafd9..f87d398159 100644 --- a/apps/webapp/app/routes/_app.orgs.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.new/route.tsx @@ -27,6 +27,9 @@ import { NewOrganizationPresenter } from "~/presenters/NewOrganizationPresenter. import { requireUser, requireUserId } from "~/services/session.server"; import { extractDomain, faviconUrl } from "~/utils/favicon"; import { organizationPath, rootPath } from "~/utils/pathBuilder"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta("New organization"); const schema = z.object({ orgName: z.string().min(3).max(50), diff --git a/apps/webapp/app/routes/_app.timezones/route.tsx b/apps/webapp/app/routes/_app.timezones/route.tsx index 39e3b7b6e8..702c4487c8 100644 --- a/apps/webapp/app/routes/_app.timezones/route.tsx +++ b/apps/webapp/app/routes/_app.timezones/route.tsx @@ -5,6 +5,9 @@ import { LogoIcon } from "~/components/LogoIcon"; import { Header1 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; import { getTimezones } from "~/utils/timezones.server"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta("Supported timezones"); export const loader = async ({ request }: LoaderFunctionArgs) => { return typedjson({ diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index 4ecccb33d6..39dbf4b4e6 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -2,13 +2,7 @@ import { getFormProps, getInputProps, useForm } from "@conform-to/react"; import { useEffect, useState } from "react"; import { conformZodMessage, parseWithZod } from "@conform-to/zod"; import { ComputerDesktopIcon, MoonIcon, SunIcon, SwatchIcon } from "@heroicons/react/20/solid"; -import { - Form, - type MetaFunction, - useActionData, - useFetcher, - useLoaderData, -} from "@remix-run/react"; +import { Form, useActionData, useFetcher, useLoaderData } from "@remix-run/react"; import { type ActionFunction, json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; import { UserProfilePhoto } from "~/components/UserProfilePhoto"; @@ -44,6 +38,7 @@ import { cachedFlag } from "~/v3/featureFlags.server"; import { requireUser, requireUserId } from "~/services/session.server"; import { emailSchema, MAX_EMAIL_LENGTH } from "~/utils/emailValidation"; import { accountPath } from "~/utils/pathBuilder"; +import { pageMeta } from "~/utils/pageTitle"; const THEME_LABELS: Record = { classic: "Classic", @@ -75,13 +70,7 @@ function themeIcon(value: ThemePreference) { } } -export const meta: MetaFunction = () => { - return [ - { - title: `Your profile | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Your profile"); function createSchema( constraints: { diff --git a/apps/webapp/app/routes/account.authorization-code.$authorizationCode/route.tsx b/apps/webapp/app/routes/account.authorization-code.$authorizationCode/route.tsx index 067f6af446..32eb853b71 100644 --- a/apps/webapp/app/routes/account.authorization-code.$authorizationCode/route.tsx +++ b/apps/webapp/app/routes/account.authorization-code.$authorizationCode/route.tsx @@ -15,6 +15,9 @@ import { isAuthorizationCodeMintable, } from "~/services/personalAccessToken.server"; import { requireUserId } from "~/services/session.server"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta("Authorize login"); const ParamsSchema = z.object({ authorizationCode: z.string(), diff --git a/apps/webapp/app/routes/account.security/route.tsx b/apps/webapp/app/routes/account.security/route.tsx index 4dc6fa76f7..62f08f6e08 100644 --- a/apps/webapp/app/routes/account.security/route.tsx +++ b/apps/webapp/app/routes/account.security/route.tsx @@ -1,4 +1,3 @@ -import { type MetaFunction } from "@remix-run/react"; import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { @@ -16,14 +15,9 @@ import { } from "~/services/sessionDuration.server"; import { MfaSetup } from "../resources.account.mfa.setup/route"; import { SessionDurationSetting } from "../resources.account.session-duration/SessionDurationSetting"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [ - { - title: `Security | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Security"); export async function loader({ request }: LoaderFunctionArgs) { const user = await requireUser(request); diff --git a/apps/webapp/app/routes/account.tokens/route.tsx b/apps/webapp/app/routes/account.tokens/route.tsx index 1179d070ea..0b33a2a046 100644 --- a/apps/webapp/app/routes/account.tokens/route.tsx +++ b/apps/webapp/app/routes/account.tokens/route.tsx @@ -3,7 +3,7 @@ import { parseWithZod } from "@conform-to/zod"; import { BookOpenIcon, ShieldCheckIcon, TrashIcon } from "@heroicons/react/20/solid"; import { ShieldExclamationIcon } from "@heroicons/react/24/solid"; import { DialogClose } from "@radix-ui/react-dialog"; -import { Form, type MetaFunction, useActionData, useFetcher } from "@remix-run/react"; +import { Form, useActionData, useFetcher } from "@remix-run/react"; import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -47,14 +47,9 @@ import { } from "~/services/personalAccessToken.server"; import { requireUserId } from "~/services/session.server"; import { docsPath, personalAccessTokensPath } from "~/utils/pathBuilder"; +import { pageMeta } from "~/utils/pageTitle"; -export const meta: MetaFunction = () => { - return [ - { - title: `Personal Access Tokens | Trigger.dev`, - }, - ]; -}; +export const meta = pageMeta("Personal Access Tokens"); // Shared between the create-token panel hint and the listing column // header tooltip so the cap is explained identically in both places. diff --git a/apps/webapp/app/routes/confirm-basic-details.tsx b/apps/webapp/app/routes/confirm-basic-details.tsx index 4e37091324..6af3da7bd7 100644 --- a/apps/webapp/app/routes/confirm-basic-details.tsx +++ b/apps/webapp/app/routes/confirm-basic-details.tsx @@ -30,6 +30,9 @@ import { requireUserId } from "~/services/session.server"; import { emailSchema, MAX_EMAIL_LENGTH } from "~/utils/emailValidation"; import { rootPath } from "~/utils/pathBuilder"; import { getVercelInstallParams } from "~/v3/vercel"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta("Confirm your details"); const referralSourceOptions = [ "Search engine", diff --git a/apps/webapp/app/routes/invites.tsx b/apps/webapp/app/routes/invites.tsx index 6cf667c867..527a284213 100644 --- a/apps/webapp/app/routes/invites.tsx +++ b/apps/webapp/app/routes/invites.tsx @@ -25,6 +25,9 @@ import { import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { requireUser } from "~/services/session.server"; import { invitesPath, rootPath } from "~/utils/pathBuilder"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta("Your invitations"); export const loader = async ({ request }: LoaderFunctionArgs) => { const user = await requireUser(request); diff --git a/apps/webapp/app/routes/unsubscribe.$userId.$token.tsx b/apps/webapp/app/routes/unsubscribe.$userId.$token.tsx index 076a6155a4..e342171b68 100644 --- a/apps/webapp/app/routes/unsubscribe.$userId.$token.tsx +++ b/apps/webapp/app/routes/unsubscribe.$userId.$token.tsx @@ -11,6 +11,9 @@ import { TextLink } from "~/components/primitives/TextLink"; import { prisma } from "~/db.server"; import { env } from "~/env.server"; import { rootPath } from "~/utils/pathBuilder"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta("Unsubscribe"); export const ParamsSchema = z.object({ userId: z.string(), diff --git a/apps/webapp/app/routes/vercel.onboarding.tsx b/apps/webapp/app/routes/vercel.onboarding.tsx index fb4d223978..f30f22cb81 100644 --- a/apps/webapp/app/routes/vercel.onboarding.tsx +++ b/apps/webapp/app/routes/vercel.onboarding.tsx @@ -24,6 +24,9 @@ import { ssoRedirectForEmail } from "~/services/ssoAutoDiscovery.server"; import { confirmBasicDetailsPath, newProjectPath } from "~/utils/pathBuilder"; import { redirectWithErrorMessage } from "~/models/message.server"; import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta("Vercel setup"); const LoaderParamsSchema = z.object({ organizationId: z.string().optional().nullable(), From bca18f3507a8774adb959e62f5d5649432fe9def Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 5 Aug 2026 23:58:31 +0000 Subject: [PATCH 04/10] chore: add server-changes note for page tab titles --- .server-changes/page-tab-titles.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .server-changes/page-tab-titles.md diff --git a/.server-changes/page-tab-titles.md b/.server-changes/page-tab-titles.md new file mode 100644 index 0000000000..ad8c8af0f4 --- /dev/null +++ b/.server-changes/page-tab-titles.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Browser tabs now show the page you're on, plus the project and environment, so several open dashboard tabs are easy to tell apart. From 9219b4913879b4667504846d6f0365bab4a9088c Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 6 Aug 2026 00:07:16 +0000 Subject: [PATCH 05/10] fix(webapp): title the runs page Runs, matching its header --- .../route.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index 7fb751c4b8..8cbd02a910 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -80,7 +80,7 @@ import { pageMeta } from "~/utils/pageTitle"; export { shouldRevalidateRunsList as shouldRevalidate }; -export const meta = pageMeta("Runs metrics"); +export const meta = pageMeta("Runs"); export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); From c20ef4f6ad45b51ee55a7d693b1c17d04c199f62 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 6 Aug 2026 00:43:07 +0000 Subject: [PATCH 06/10] fix(webapp): title org pages with the org, and name the error group in its tab --- .../route.tsx | 5 ++- apps/webapp/app/utils/pageTitle.test.ts | 36 +++++++++++++++---- apps/webapp/app/utils/pageTitle.ts | 7 ++-- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx index 0bea5b63ca..dbf3233c23 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx @@ -85,7 +85,10 @@ import { ServiceValidationError } from "~/v3/services/baseService.server"; import { ErrorGroupActions } from "~/v3/services/errorGroupActions.server"; import { pageMeta } from "~/utils/pageTitle"; -export const meta = pageMeta("Error Details"); +export const meta = pageMeta(({ params }) => [ + params.fingerprint ? ErrorId.toFriendlyId(params.fingerprint) : "Error", + "Errors", +]); const emptyStringToUndefined = z.preprocess( (v) => (v === "" ? undefined : v), diff --git a/apps/webapp/app/utils/pageTitle.test.ts b/apps/webapp/app/utils/pageTitle.test.ts index ba477647dc..b7b1b790a6 100644 --- a/apps/webapp/app/utils/pageTitle.test.ts +++ b/apps/webapp/app/utils/pageTitle.test.ts @@ -12,7 +12,13 @@ type Route = { id: string; data?: unknown; meta?: MetaFunction }; * match's array is what renders. It also only passes the matches visited *so far*, which is why * titles are declared per route rather than centrally. */ -function renderTitle(routes: Route[], params: Record = {}): string | undefined { +/** The URL params an environment-scoped page carries; the org scope is only used when they exist. */ +const ENV_PARAMS = { organizationSlug: "acme", projectParam: "my-project", envParam: "prod" }; + +function renderTitle( + routes: Route[], + params: Record = ENV_PARAMS +): string | undefined { const matches: Array<{ id: string; data: unknown; meta: MetaDescriptor[]; params: any }> = []; let leafMeta: MetaDescriptor[] = []; @@ -92,7 +98,10 @@ describe("pageMeta", () => { ).toBe("my-task | Tasks | my-project (Prod) | Trigger.dev"); expect( - renderTitle([rootRoute, orgRoute, { id: "routes/task", meta }], { taskParam: "other" }) + renderTitle([rootRoute, orgRoute, { id: "routes/task", meta }], { + ...ENV_PARAMS, + taskParam: "other", + }) ).toBe("other | Tasks | my-project (Prod) | Trigger.dev"); }); @@ -103,15 +112,28 @@ describe("pageMeta", () => { meta: () => [{ title: appTitle("staging") }], }; - const title = renderTitle([ - stagingRoot, - { id: ORGANIZATION_MATCH_ID, data: { organization: { title: "Acme" } } }, - { id: "routes/team", meta: pageMeta("Team") }, - ]); + const title = renderTitle( + [ + stagingRoot, + { id: ORGANIZATION_MATCH_ID, data: { organization: { title: "Acme" } } }, + { id: "routes/team", meta: pageMeta("Team") }, + ], + { organizationSlug: "acme" } + ); expect(title).toBe("Team | Acme | Trigger.dev (staging)"); }); + it("ignores the project the org loader resolved when the URL names none", () => { + // The org loader always resolves a "best" project, so an org page would otherwise + // advertise a project the user is not looking at. + const title = renderTitle([rootRoute, orgRoute, { id: "routes/team", meta: pageMeta("Team") }], { + organizationSlug: "acme", + }); + + expect(title).toBe("Team | Acme | Trigger.dev"); + }); + it("carries the root's non-title tags through", () => { const routes = [rootRoute, orgRoute, { id: "routes/runs", meta: pageMeta("Runs") }]; const descriptors = pageMeta("Runs")({ diff --git a/apps/webapp/app/utils/pageTitle.ts b/apps/webapp/app/utils/pageTitle.ts index 2943e9bcc5..21f4c8ebf4 100644 --- a/apps/webapp/app/utils/pageTitle.ts +++ b/apps/webapp/app/utils/pageTitle.ts @@ -64,7 +64,8 @@ export function composePageTitle(segments: string[], matches: Matches): string { } export function scopeFromMatches(matches: Matches): string | undefined { - const data = matches.find((match) => match.id === ORGANIZATION_MATCH_ID)?.data as + const match = matches.find((m) => m.id === ORGANIZATION_MATCH_ID); + const data = match?.data as | { organization?: { title?: string | null }; project?: { name?: string | null }; @@ -74,7 +75,9 @@ export function scopeFromMatches(matches: Matches): string | undefined { if (!data) return undefined; - const project = data.project?.name; + // The org loader resolves a "best" project even on org-scoped URLs that name none, so + // the project scope is only honest when the URL actually carries one. + const project = match?.params?.projectParam ? data.project?.name : undefined; if (project) { const environment = data.environment ? environmentLabel(data.environment) : undefined; return environment ? `${project} (${environment})` : project; From 7acd1c41d936dbfa5d6187228c7381a7135ed1d3 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 6 Aug 2026 01:03:13 +0000 Subject: [PATCH 07/10] chore(webapp): format the page title test --- apps/webapp/app/utils/pageTitle.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/utils/pageTitle.test.ts b/apps/webapp/app/utils/pageTitle.test.ts index b7b1b790a6..a8e00f2fed 100644 --- a/apps/webapp/app/utils/pageTitle.test.ts +++ b/apps/webapp/app/utils/pageTitle.test.ts @@ -127,9 +127,12 @@ describe("pageMeta", () => { it("ignores the project the org loader resolved when the URL names none", () => { // The org loader always resolves a "best" project, so an org page would otherwise // advertise a project the user is not looking at. - const title = renderTitle([rootRoute, orgRoute, { id: "routes/team", meta: pageMeta("Team") }], { - organizationSlug: "acme", - }); + const title = renderTitle( + [rootRoute, orgRoute, { id: "routes/team", meta: pageMeta("Team") }], + { + organizationSlug: "acme", + } + ); expect(title).toBe("Team | Acme | Trigger.dev"); }); From e24e857c54020855927d114fc21be1a2ad69f1dc Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 6 Aug 2026 07:04:07 +0000 Subject: [PATCH 08/10] fix(webapp): drop the project scope from tab titles --- .server-changes/page-tab-titles.md | 2 +- apps/webapp/app/utils/pageTitle.test.ts | 31 ++++---------- apps/webapp/app/utils/pageTitle.ts | 56 +++---------------------- 3 files changed, 15 insertions(+), 74 deletions(-) diff --git a/.server-changes/page-tab-titles.md b/.server-changes/page-tab-titles.md index ad8c8af0f4..78cb5b53bc 100644 --- a/.server-changes/page-tab-titles.md +++ b/.server-changes/page-tab-titles.md @@ -3,4 +3,4 @@ area: webapp type: improvement --- -Browser tabs now show the page you're on, plus the project and environment, so several open dashboard tabs are easy to tell apart. +Browser tabs now name the page you're on, and the run, task or queue when you're looking at one, instead of all reading "Trigger.dev". diff --git a/apps/webapp/app/utils/pageTitle.test.ts b/apps/webapp/app/utils/pageTitle.test.ts index a8e00f2fed..9dca6a9b12 100644 --- a/apps/webapp/app/utils/pageTitle.test.ts +++ b/apps/webapp/app/utils/pageTitle.test.ts @@ -47,8 +47,6 @@ const orgRoute: Route = { id: ORGANIZATION_MATCH_ID, data: { organization: { title: "Acme" }, - project: { name: "my-project" }, - environment: { type: "PRODUCTION" }, }, }; @@ -56,7 +54,7 @@ describe("pageMeta", () => { it("adds the project and environment scope to an environment page", () => { const title = renderTitle([rootRoute, orgRoute, { id: "routes/runs", meta: pageMeta("Runs") }]); - expect(title).toBe("Runs | my-project (Prod) | Trigger.dev"); + expect(title).toBe("Runs | Trigger.dev"); }); it("uses the deepest declared title", () => { @@ -67,7 +65,7 @@ describe("pageMeta", () => { { id: "routes/runs.$runParam", meta: pageMeta(["run_abc", "Runs"]) }, ]); - expect(title).toBe("run_abc | Runs | my-project (Prod) | Trigger.dev"); + expect(title).toBe("run_abc | Runs | Trigger.dev"); }); it("falls back to the nearest declared title, then the app title", () => { @@ -78,7 +76,7 @@ describe("pageMeta", () => { { id: "routes/runs", meta: pageMeta("Runs") }, { id: "routes/runs._index" }, ]) - ).toBe("Runs | my-project (Prod) | Trigger.dev"); + ).toBe("Runs | Trigger.dev"); expect(renderTitle([rootRoute, orgRoute, { id: "routes/runs._index" }])).toBe("Trigger.dev"); }); @@ -95,17 +93,17 @@ describe("pageMeta", () => { orgRoute, { id: "routes/task", data: { task: { slug: "my-task" } }, meta }, ]) - ).toBe("my-task | Tasks | my-project (Prod) | Trigger.dev"); + ).toBe("my-task | Tasks | Trigger.dev"); expect( renderTitle([rootRoute, orgRoute, { id: "routes/task", meta }], { ...ENV_PARAMS, taskParam: "other", }) - ).toBe("other | Tasks | my-project (Prod) | Trigger.dev"); + ).toBe("other | Tasks | Trigger.dev"); }); - it("keeps the org name for org-scoped pages and the app env tag", () => { + it("keeps the app env tag", () => { const stagingRoot: Route = { id: "root", data: { appEnv: "staging" }, @@ -121,20 +119,7 @@ describe("pageMeta", () => { { organizationSlug: "acme" } ); - expect(title).toBe("Team | Acme | Trigger.dev (staging)"); - }); - - it("ignores the project the org loader resolved when the URL names none", () => { - // The org loader always resolves a "best" project, so an org page would otherwise - // advertise a project the user is not looking at. - const title = renderTitle( - [rootRoute, orgRoute, { id: "routes/team", meta: pageMeta("Team") }], - { - organizationSlug: "acme", - } - ); - - expect(title).toBe("Team | Acme | Trigger.dev"); + expect(title).toBe("Team | Trigger.dev (staging)"); }); it("carries the root's non-title tags through", () => { @@ -150,6 +135,6 @@ describe("pageMeta", () => { } as any) as MetaDescriptor[]; expect(descriptors).toContainEqual({ name: "viewport", content: "width=1024" }); - expect(renderTitle(routes)).toBe("Runs | my-project (Prod) | Trigger.dev"); + expect(renderTitle(routes)).toBe("Runs | Trigger.dev"); }); }); diff --git a/apps/webapp/app/utils/pageTitle.ts b/apps/webapp/app/utils/pageTitle.ts index 21f4c8ebf4..215716b753 100644 --- a/apps/webapp/app/utils/pageTitle.ts +++ b/apps/webapp/app/utils/pageTitle.ts @@ -4,13 +4,12 @@ import { appEnvTitleTag } from "~/utils"; /** * Tab titles. Every page exports `export const meta = pageMeta(...)` and only says what the page - * is; the shared scope suffix is added here. + * is; the app title is added here. * * Shape (leading words carry the information, so a narrow tab still reads): - * env-scoped page: `Runs | my-project (Prod) | Trigger.dev` - * env-scoped entity: `run_abc | Runs | my-project (Prod) | Trigger.dev` - * org-scoped page: `Team | Acme | Trigger.dev` - * everything else: `Login to Trigger.dev` etc. (no scope to add) + * page: `Runs | Trigger.dev` + * entity: `run_abc | Runs | Trigger.dev` + * else: `Login to Trigger.dev` etc. * * Remix v2 picks the meta of the deepest route that exports one; a route without a meta export * inherits its nearest ancestor's. So a layout's `pageMeta` is the fallback for children that @@ -20,9 +19,6 @@ import { appEnvTitleTag } from "~/utils"; const APP_NAME = "Trigger.dev"; -/** The org route holds the project/environment the URL resolved to. */ -const ORGANIZATION_MATCH_ID = "routes/_app.orgs.$organizationSlug"; - /** One or more title segments, most specific first: `["run_abc", "Runs"]`. */ export type TitleSegments = string | string[]; @@ -56,53 +52,13 @@ export function pageMeta(page: PageInput): MetaFunct }; } -/** Builds the full title from the page segments plus the scope found in `matches`. */ +/** Builds the full title from the page segments plus the app title. */ export function composePageTitle(segments: string[], matches: Matches): string { - return [...segments, scopeFromMatches(matches), appTitle(appEnvFromMatches(matches))] + return [...segments, appTitle(appEnvFromMatches(matches))] .filter((segment): segment is string => Boolean(segment)) .join(" | "); } -export function scopeFromMatches(matches: Matches): string | undefined { - const match = matches.find((m) => m.id === ORGANIZATION_MATCH_ID); - const data = match?.data as - | { - organization?: { title?: string | null }; - project?: { name?: string | null }; - environment?: { type?: string | null; branchName?: string | null } | null; - } - | undefined; - - if (!data) return undefined; - - // The org loader resolves a "best" project even on org-scoped URLs that name none, so - // the project scope is only honest when the URL actually carries one. - const project = match?.params?.projectParam ? data.project?.name : undefined; - if (project) { - const environment = data.environment ? environmentLabel(data.environment) : undefined; - return environment ? `${project} (${environment})` : project; - } - - return data.organization?.title ?? undefined; -} - -function environmentLabel(environment: { type?: string | null; branchName?: string | null }) { - if (environment.branchName) return environment.branchName; - - switch (environment.type) { - case "PRODUCTION": - return "Prod"; - case "STAGING": - return "Staging"; - case "DEVELOPMENT": - return "Dev"; - case "PREVIEW": - return "Preview"; - default: - return undefined; - } -} - function appEnvFromMatches(matches: Matches): string | undefined { const rootData = matches[0]?.data as { appEnv?: string } | undefined; return rootData?.appEnv; From a537f510701a8375bd9eba159a87db12ece26130 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 6 Aug 2026 07:05:19 +0000 Subject: [PATCH 09/10] fix(webapp): keep the organization in the title on its own pages --- apps/webapp/app/utils/pageTitle.test.ts | 11 +++++++++-- apps/webapp/app/utils/pageTitle.ts | 25 ++++++++++++++++++++----- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/utils/pageTitle.test.ts b/apps/webapp/app/utils/pageTitle.test.ts index 9dca6a9b12..716862e8aa 100644 --- a/apps/webapp/app/utils/pageTitle.test.ts +++ b/apps/webapp/app/utils/pageTitle.test.ts @@ -103,7 +103,7 @@ describe("pageMeta", () => { ).toBe("other | Tasks | Trigger.dev"); }); - it("keeps the app env tag", () => { + it("names the organization on its own pages, with the app env tag", () => { const stagingRoot: Route = { id: "root", data: { appEnv: "staging" }, @@ -119,7 +119,7 @@ describe("pageMeta", () => { { organizationSlug: "acme" } ); - expect(title).toBe("Team | Trigger.dev (staging)"); + expect(title).toBe("Team | Acme | Trigger.dev (staging)"); }); it("carries the root's non-title tags through", () => { @@ -137,4 +137,11 @@ describe("pageMeta", () => { expect(descriptors).toContainEqual({ name: "viewport", content: "width=1024" }); expect(renderTitle(routes)).toBe("Runs | Trigger.dev"); }); + + it("does not name the organization inside a project", () => { + // The dashboard switches projects in every tab at once, so the scope adds nothing there. + expect(renderTitle([rootRoute, orgRoute, { id: "routes/runs", meta: pageMeta("Runs") }])).toBe( + "Runs | Trigger.dev" + ); + }); }); diff --git a/apps/webapp/app/utils/pageTitle.ts b/apps/webapp/app/utils/pageTitle.ts index 215716b753..3942cec782 100644 --- a/apps/webapp/app/utils/pageTitle.ts +++ b/apps/webapp/app/utils/pageTitle.ts @@ -7,9 +7,10 @@ import { appEnvTitleTag } from "~/utils"; * is; the app title is added here. * * Shape (leading words carry the information, so a narrow tab still reads): - * page: `Runs | Trigger.dev` - * entity: `run_abc | Runs | Trigger.dev` - * else: `Login to Trigger.dev` etc. + * env page: `Runs | Trigger.dev` + * env entity: `run_abc | Runs | Trigger.dev` + * org page: `Team | Acme | Trigger.dev` + * else: `Login to Trigger.dev` etc. * * Remix v2 picks the meta of the deepest route that exports one; a route without a meta export * inherits its nearest ancestor's. So a layout's `pageMeta` is the fallback for children that @@ -19,6 +20,9 @@ import { appEnvTitleTag } from "~/utils"; const APP_NAME = "Trigger.dev"; +/** The org route carries the organization the URL resolved to. */ +const ORGANIZATION_MATCH_ID = "routes/_app.orgs.$organizationSlug"; + /** One or more title segments, most specific first: `["run_abc", "Runs"]`. */ export type TitleSegments = string | string[]; @@ -52,13 +56,24 @@ export function pageMeta(page: PageInput): MetaFunct }; } -/** Builds the full title from the page segments plus the app title. */ +/** Builds the full title from the page segments, the org scope and the app title. */ export function composePageTitle(segments: string[], matches: Matches): string { - return [...segments, appTitle(appEnvFromMatches(matches))] + return [...segments, scopeFromMatches(matches), appTitle(appEnvFromMatches(matches))] .filter((segment): segment is string => Boolean(segment)) .join(" | "); } +/** + * The organization, and only on its own pages: inside a project the tab is already about one + * project, and the dashboard switches projects in every tab at once, so naming it adds nothing. + */ +export function scopeFromMatches(matches: Matches): string | undefined { + const match = matches.find((m) => m.id === ORGANIZATION_MATCH_ID); + if (!match || match.params?.projectParam) return undefined; + const data = match.data as { organization?: { title?: string | null } } | undefined; + return data?.organization?.title ?? undefined; +} + function appEnvFromMatches(matches: Matches): string | undefined { const rootData = matches[0]?.data as { appEnv?: string } | undefined; return rootData?.appEnv; From 5f2feaea136b01b1788efd07ade4e782de54adfe Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 6 Aug 2026 07:17:30 +0000 Subject: [PATCH 10/10] docs(webapp): name the title tests after what they assert --- apps/webapp/app/utils/pageTitle.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/utils/pageTitle.test.ts b/apps/webapp/app/utils/pageTitle.test.ts index 716862e8aa..526fbd0074 100644 --- a/apps/webapp/app/utils/pageTitle.test.ts +++ b/apps/webapp/app/utils/pageTitle.test.ts @@ -12,7 +12,7 @@ type Route = { id: string; data?: unknown; meta?: MetaFunction }; * match's array is what renders. It also only passes the matches visited *so far*, which is why * titles are declared per route rather than centrally. */ -/** The URL params an environment-scoped page carries; the org scope is only used when they exist. */ +/** The URL params a page inside a project carries; the org scope is dropped when they are present. */ const ENV_PARAMS = { organizationSlug: "acme", projectParam: "my-project", envParam: "prod" }; function renderTitle( @@ -51,7 +51,7 @@ const orgRoute: Route = { }; describe("pageMeta", () => { - it("adds the project and environment scope to an environment page", () => { + it("titles a page inside a project without any scope", () => { const title = renderTitle([rootRoute, orgRoute, { id: "routes/runs", meta: pageMeta("Runs") }]); expect(title).toBe("Runs | Trigger.dev");