From d4c1b4dc599a536103ce3a0454c9d2d65879feba Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Thu, 19 Feb 2026 17:19:44 +0100 Subject: [PATCH 1/2] feat(webapp): show Vercel deployment links in UI Add Vercel integration lookup and expose a deployment URL so linked Vercel deployments are discoverable from the app. - Import VercelProjectIntegrationDataSchema and query for organizationProjectIntegration to detect Vercel project data. - Parse integrationData and, when a vercelTeamSlug is present, look up the latest integrationDeployment for the deployment and construct a Vercel URL (vercel.com///). - Attach vercelDeploymentUrl to the DeploymentPresenter output. - Render the Vercel link in deployment detail and deployments list by using the VercelLink component where vercelDeploymentUrl exists. - Remove an unused tooltip import and swap VercelLogo usage to the VercelLink component in the list view. This makes it easier for users to jump directly to the corresponding Vercel deployment from the webapp. --- .../components/integrations/VercelLink.tsx | 22 +++++++++ .../v3/DeploymentPresenter.server.ts | 47 +++++++++++++++++++ .../route.tsx | 11 +++++ .../route.tsx | 25 ++++------ 4 files changed, 89 insertions(+), 16 deletions(-) create mode 100644 apps/webapp/app/components/integrations/VercelLink.tsx diff --git a/apps/webapp/app/components/integrations/VercelLink.tsx b/apps/webapp/app/components/integrations/VercelLink.tsx new file mode 100644 index 00000000000..4a74e599f3d --- /dev/null +++ b/apps/webapp/app/components/integrations/VercelLink.tsx @@ -0,0 +1,22 @@ +import { VercelLogo } from "./VercelLogo"; +import { LinkButton } from "~/components/primitives/Buttons"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; + +export function VercelLink({ vercelDeploymentUrl }: { vercelDeploymentUrl: string }) { + return ( + } + iconSpacing="gap-x-1" + to={vercelDeploymentUrl} + className="pl-1" + > + Vercel + + } + content="View on Vercel" + /> + ); +} diff --git a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts index bc494c118aa..c5414290e4e 100644 --- a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts @@ -12,6 +12,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { type User } from "~/models/user.server"; import { getUsername } from "~/utils/username"; import { processGitMetadata } from "./BranchesPresenter.server"; +import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema"; import { S2 } from "@s2-dev/streamstore"; import { env } from "~/env.server"; import { createRedisClient } from "~/redis.server"; @@ -161,6 +162,51 @@ export class DeploymentPresenter { }); const gitMetadata = processGitMetadata(deployment.git); + + // Look up Vercel integration data to construct a deployment URL + let vercelDeploymentUrl: string | undefined; + const vercelProjectIntegration = + await this.#prismaClient.organizationProjectIntegration.findFirst({ + where: { + projectId: project.id, + deletedAt: null, + organizationIntegration: { + service: "VERCEL", + deletedAt: null, + }, + }, + select: { + integrationData: true, + }, + }); + + if (vercelProjectIntegration) { + const parsed = VercelProjectIntegrationDataSchema.safeParse( + vercelProjectIntegration.integrationData + ); + + if (parsed.success && parsed.data.vercelTeamSlug) { + const integrationDeployment = + await this.#prismaClient.integrationDeployment.findFirst({ + where: { + deploymentId: deployment.id, + integrationName: "vercel", + }, + select: { + integrationDeploymentId: true, + }, + orderBy: { + createdAt: "desc", + }, + }); + + if (integrationDeployment) { + const vercelId = integrationDeployment.integrationDeploymentId.replace(/^dpl_/, ""); + vercelDeploymentUrl = `https://vercel.com/${parsed.data.vercelTeamSlug}/${parsed.data.vercelProjectName}/${vercelId}`; + } + } + } + const externalBuildData = deployment.externalBuildData ? ExternalBuildData.safeParse(deployment.externalBuildData) : undefined; @@ -227,6 +273,7 @@ export class DeploymentPresenter { type: deployment.type, git: gitMetadata, triggeredVia: deployment.triggeredVia, + vercelDeploymentUrl, }, }; } 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 9d32d89fd56..4a7d1df5ce8 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 @@ -15,6 +15,7 @@ import { } from "lucide-react"; import { ExitIcon } from "~/assets/icons/ExitIcon"; import { GitMetadata } from "~/components/GitMetadata"; +import { VercelLink } from "~/components/integrations/VercelLink"; import { RuntimeIcon } from "~/components/RuntimeIcon"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; @@ -516,6 +517,16 @@ export default function Page() { })()} + {deployment.vercelDeploymentUrl && ( + + Linked + +
+ +
+
+
+ )} 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 61e789e138b..c93d2875238 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 @@ -19,7 +19,7 @@ import { useEffect } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { PromoteIcon } from "~/assets/icons/PromoteIcon"; -import { VercelLogo } from "~/components/integrations/VercelLogo"; +import { VercelLink } from "~/components/integrations/VercelLink"; import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels"; import { OctoKitty } from "~/components/GitHubLoginButton"; import { GitMetadata } from "~/components/GitMetadata"; @@ -56,7 +56,6 @@ import { TableHeaderCell, TableRow, } from "~/components/primitives/Table"; -import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { DeploymentStatus, deploymentStatusDescription, @@ -314,20 +313,14 @@ export default function Page() { {hasVercelIntegration && ( {deployment.vercelDeploymentUrl ? ( - e.stopPropagation()} - > - - - } - content="View on Vercel" - /> +
e.stopPropagation()} + > + +
) : ( "–" )} From b1e293181c6ab5ce927415f11a50857832383d0f Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Thu, 19 Feb 2026 18:02:40 +0100 Subject: [PATCH 2/2] feat(vercel): extract buildVercelDeploymentUrl helper Introduce buildVercelDeploymentUrl to centralize construction of Vercel deployment URLs and replace inline URL building in presenters. - Add buildVercelDeploymentUrl to vercelProjectIntegrationSchema.ts. It strips the "dpl_" prefix from integrationDeploymentId and returns the canonical Vercel URL. - Update DeploymentListPresenter and DeploymentPresenter to import and use the new helper instead of duplicating string assembly. This reduces duplicated logic, ensures consistent URL formatting, and makes future URL-related changes easier to maintain. --- .../presenters/v3/DeploymentListPresenter.server.ts | 12 +++++++++--- .../app/presenters/v3/DeploymentPresenter.server.ts | 12 +++++++++--- .../app/v3/vercel/vercelProjectIntegrationSchema.ts | 9 +++++++++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts index 1f5996f9967..9abc0ed0ab9 100644 --- a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts @@ -10,7 +10,10 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { type User } from "~/models/user.server"; import { processGitMetadata } from "./BranchesPresenter.server"; import { BranchTrackingConfigSchema, getTrackedBranchForEnvironment } from "~/v3/github"; -import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema"; +import { + VercelProjectIntegrationDataSchema, + buildVercelDeploymentUrl, +} from "~/v3/vercel/vercelProjectIntegrationSchema"; const pageSize = 20; @@ -232,8 +235,11 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`; let vercelDeploymentUrl: string | null = null; if (hasVercelIntegration && deployment.integrationDeploymentId && vercelTeamSlug && vercelProjectName) { - const vercelId = deployment.integrationDeploymentId.replace(/^dpl_/, ""); - vercelDeploymentUrl = `https://vercel.com/${vercelTeamSlug}/${vercelProjectName}/${vercelId}`; + vercelDeploymentUrl = buildVercelDeploymentUrl( + vercelTeamSlug, + vercelProjectName, + deployment.integrationDeploymentId + ); } return { diff --git a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts index c5414290e4e..ae9ed3577b8 100644 --- a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts @@ -12,7 +12,10 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { type User } from "~/models/user.server"; import { getUsername } from "~/utils/username"; import { processGitMetadata } from "./BranchesPresenter.server"; -import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema"; +import { + VercelProjectIntegrationDataSchema, + buildVercelDeploymentUrl, +} from "~/v3/vercel/vercelProjectIntegrationSchema"; import { S2 } from "@s2-dev/streamstore"; import { env } from "~/env.server"; import { createRedisClient } from "~/redis.server"; @@ -201,8 +204,11 @@ export class DeploymentPresenter { }); if (integrationDeployment) { - const vercelId = integrationDeployment.integrationDeploymentId.replace(/^dpl_/, ""); - vercelDeploymentUrl = `https://vercel.com/${parsed.data.vercelTeamSlug}/${parsed.data.vercelProjectName}/${vercelId}`; + vercelDeploymentUrl = buildVercelDeploymentUrl( + parsed.data.vercelTeamSlug, + parsed.data.vercelProjectName, + integrationDeployment.integrationDeploymentId + ); } } } diff --git a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts index 213e730c643..64ed5d55a3c 100644 --- a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts +++ b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts @@ -202,6 +202,15 @@ export function shouldSyncEnvVarForAnyEnvironment( return false; } +export function buildVercelDeploymentUrl( + vercelTeamSlug: string | undefined, + vercelProjectName: string, + integrationDeploymentId: string +): string { + const vercelId = integrationDeploymentId.replace(/^dpl_/, ""); + return `https://vercel.com/${vercelTeamSlug}/${vercelProjectName}/${vercelId}`; +} + export function isPullEnvVarsEnabledForEnvironment( pullEnvVarsBeforeBuild: EnvSlug[] | null | undefined, environmentType: TriggerEnvironmentType