From ba9f6e7f31c8dc45aae07a6333b775ed18dbea27 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Tue, 7 Jul 2026 23:57:47 +0000 Subject: [PATCH 01/10] feat(webapp): migrate from the classic Remix compiler to Vite Workspace packages resolve to TS source via the @triggerdotdev/source condition; server.ts keeps the Express/cluster/ws wiring with vite middleware in dev and the ESM server bundle in prod. Fixes surfaced by the stricter ESM pipeline: destructured route exports, server-only imports in routes/components, CJS interop (redlock, cuid, regression), browser node-global shims, font asset handling, and a websockets TDZ. --- .changeset/vite-ignore-optional-imports.md | 5 + .../app/components/primitives/Avatar.tsx | 3 +- .../presenters/v3/UsagePresenter.server.ts | 5 +- apps/webapp/app/root.tsx | 8 +- .../routes/api.v1.errors.$errorId.ignore.ts | 5 +- .../routes/api.v1.errors.$errorId.resolve.ts | 5 +- .../api.v1.errors.$errorId.unresolve.ts | 5 +- .../api.v1.idempotencyKeys.$key.reset.ts | 4 +- .../routes/api.v1.orgs.$orgParam.projects.ts | 3 +- ...queues.$queueParam.concurrency.override.ts | 4 +- ...v1.queues.$queueParam.concurrency.reset.ts | 4 +- .../routes/api.v1.queues.$queueParam.pause.ts | 4 +- .../app/routes/api.v1.runs.$runId.metadata.ts | 2 +- ...api.v1.sessions.$sessionId.snapshot-url.ts | 4 +- .../app/routes/auth.github.callback.tsx | 4 +- apps/webapp/app/routes/auth.github.ts | 13 +- .../app/routes/auth.google.callback.tsx | 4 +- apps/webapp/app/routes/auth.google.ts | 13 +- .../app/services/redirectCookies.server.ts | 19 +++ apps/webapp/app/tailwind.css | 3 - apps/webapp/app/v3/querySchemas.ts | 6 +- apps/webapp/package.json | 12 +- apps/webapp/remix.config.js | 48 ------ apps/webapp/remix.env.d.ts | 1 + apps/webapp/server.ts | 67 ++++++--- apps/webapp/vite.config.ts | 66 ++++++++ apps/webapp/vite/node-globals-shim.js | 10 ++ docker/Dockerfile | 6 + docker/docker-compose.yml | 8 +- internal-packages/rbac/src/index.ts | 3 +- .../run-engine/src/engine/locking.ts | 24 +-- internal-packages/sso/src/index.ts | 3 +- .../trigger-sdk/src/v3/aiAutoTelemetry.ts | 6 +- pnpm-lock.yaml | 142 +++++++++--------- 34 files changed, 310 insertions(+), 209 deletions(-) create mode 100644 .changeset/vite-ignore-optional-imports.md create mode 100644 apps/webapp/app/services/redirectCookies.server.ts delete mode 100644 apps/webapp/remix.config.js create mode 100644 apps/webapp/vite.config.ts create mode 100644 apps/webapp/vite/node-globals-shim.js diff --git a/.changeset/vite-ignore-optional-imports.md b/.changeset/vite-ignore-optional-imports.md new file mode 100644 index 00000000000..1720d87044e --- /dev/null +++ b/.changeset/vite-ignore-optional-imports.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Annotate the optional `@ai-sdk/otel` dynamic import with `@vite-ignore` so Vite-based bundlers don't warn about an unanalyzable import. diff --git a/apps/webapp/app/components/primitives/Avatar.tsx b/apps/webapp/app/components/primitives/Avatar.tsx index 0d000a9a372..9fc6832fcc4 100644 --- a/apps/webapp/app/components/primitives/Avatar.tsx +++ b/apps/webapp/app/components/primitives/Avatar.tsx @@ -10,7 +10,6 @@ import { } from "@heroicons/react/20/solid"; import type { Prisma } from "@trigger.dev/database"; import { z } from "zod"; -import { logger } from "~/services/logger.server"; import { cn } from "~/utils/cn"; export const AvatarType = z.enum(["icon", "letters", "image"]); @@ -45,7 +44,7 @@ export function parseAvatar(json: Prisma.JsonValue, defaultAvatar: Avatar): Avat const parsed = AvatarData.safeParse(json); if (!parsed.success) { - logger.error("Invalid org avatar", { json, error: parsed.error }); + console.error("Invalid org avatar", { json, error: parsed.error }); return defaultAvatar; } diff --git a/apps/webapp/app/presenters/v3/UsagePresenter.server.ts b/apps/webapp/app/presenters/v3/UsagePresenter.server.ts index fe5ab1ec3ce..a3b96cf6ac3 100644 --- a/apps/webapp/app/presenters/v3/UsagePresenter.server.ts +++ b/apps/webapp/app/presenters/v3/UsagePresenter.server.ts @@ -1,5 +1,8 @@ import type { DataPoint } from "regression"; -import { linear } from "regression"; +// Default-import: regression is CJS and its named exports aren't statically +// analyzable under ESM interop. +import regression from "regression"; +const { linear } = regression; import type { PrismaClientOrTransaction } from "~/db.server"; import { env } from "~/env.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index c84da200ad4..66df9ddff53 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -1,11 +1,14 @@ import type { LinksFunction, LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; import type { ShouldRevalidateFunction } from "@remix-run/react"; -import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react"; +import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react"; import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; import { ExternalScripts } from "remix-utils/external-scripts"; import type { ToastMessage } from "~/models/message.server"; import { commitSession, getSession } from "~/models/message.server"; -import tailwindStylesheetUrl from "~/tailwind.css"; +// Fonts imported here so Vite rebases the urls and emits the woff2 assets +import "non.geist"; +import "non.geist/mono"; +import tailwindStylesheetUrl from "~/tailwind.css?url"; import { RouteErrorDisplay } from "./components/ErrorDisplay"; import { AppContainer, MainCenteredContainer } from "./components/layout/AppLayout"; import { ShortcutsProvider } from "./components/primitives/ShortcutsProvider"; @@ -137,7 +140,6 @@ export default function App() { - diff --git a/apps/webapp/app/routes/api.v1.errors.$errorId.ignore.ts b/apps/webapp/app/routes/api.v1.errors.$errorId.ignore.ts index 4b0ad2ab671..9aba43042c9 100644 --- a/apps/webapp/app/routes/api.v1.errors.$errorId.ignore.ts +++ b/apps/webapp/app/routes/api.v1.errors.$errorId.ignore.ts @@ -9,7 +9,7 @@ const ParamsSchema = z.object({ errorId: z.string(), }); -export const { action, loader } = createActionApiRoute( +const route = createActionApiRoute( { params: ParamsSchema, body: IgnoreErrorRequestBody, @@ -56,3 +56,6 @@ export const { action, loader } = createActionApiRoute( return json(updated); } ); + +export const action = route.action; +export const loader = route.loader; diff --git a/apps/webapp/app/routes/api.v1.errors.$errorId.resolve.ts b/apps/webapp/app/routes/api.v1.errors.$errorId.resolve.ts index e0818c89f49..68d4a094ab8 100644 --- a/apps/webapp/app/routes/api.v1.errors.$errorId.resolve.ts +++ b/apps/webapp/app/routes/api.v1.errors.$errorId.resolve.ts @@ -9,7 +9,7 @@ const ParamsSchema = z.object({ errorId: z.string(), }); -export const { action, loader } = createActionApiRoute( +const route = createActionApiRoute( { params: ParamsSchema, body: ResolveErrorRequestBody, @@ -48,3 +48,6 @@ export const { action, loader } = createActionApiRoute( return json(updated); } ); + +export const action = route.action; +export const loader = route.loader; diff --git a/apps/webapp/app/routes/api.v1.errors.$errorId.unresolve.ts b/apps/webapp/app/routes/api.v1.errors.$errorId.unresolve.ts index 9362b7c4c4b..ba16118c816 100644 --- a/apps/webapp/app/routes/api.v1.errors.$errorId.unresolve.ts +++ b/apps/webapp/app/routes/api.v1.errors.$errorId.unresolve.ts @@ -8,7 +8,7 @@ const ParamsSchema = z.object({ errorId: z.string(), }); -export const { action, loader } = createActionApiRoute( +const route = createActionApiRoute( { params: ParamsSchema, method: "POST", @@ -40,3 +40,6 @@ export const { action, loader } = createActionApiRoute( return json(updated); } ); + +export const action = route.action; +export const loader = route.loader; diff --git a/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts b/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts index feec6dd6554..d0304035f7b 100644 --- a/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts +++ b/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts @@ -13,7 +13,7 @@ const BodySchema = z.object({ taskIdentifier: z.string().min(1, "Task identifier is required"), }); -export const { action } = createActionApiRoute( +const route = createActionApiRoute( { params: ParamsSchema, body: BodySchema, @@ -50,3 +50,5 @@ export const { action } = createActionApiRoute( } } ); + +export const action = route.action; diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts index 33b68ad244a..6b91205e464 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts @@ -7,7 +7,8 @@ import { prisma } from "~/db.server"; import { createProject } from "~/models/project.server"; import { logger } from "~/services/logger.server"; import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; -import { isCuid } from "cuid"; +import cuid from "cuid"; +const { isCuid } = cuid; const ParamsSchema = z.object({ orgParam: z.string(), diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts index 3223a2a6062..1d3aa69b152 100644 --- a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts @@ -10,7 +10,7 @@ const BodySchema = z.object({ concurrencyLimit: z.number().int().min(0).max(100000), }); -export const { action } = createActionApiRoute( +const route = createActionApiRoute( { body: BodySchema, params: z.object({ @@ -73,3 +73,5 @@ export const { action } = createActionApiRoute( ); } ); + +export const action = route.action; diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts index dbeea591ade..78c2729808e 100644 --- a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts @@ -9,7 +9,7 @@ const BodySchema = z.object({ type: RetrieveQueueType.default("id"), }); -export const { action } = createActionApiRoute( +const route = createActionApiRoute( { body: BodySchema, params: z.object({ @@ -73,3 +73,5 @@ export const { action } = createActionApiRoute( ); } ); + +export const action = route.action; diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts index 452bd81746b..98ecd290c4b 100644 --- a/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts @@ -9,7 +9,7 @@ const BodySchema = z.object({ action: z.enum(["pause", "resume"]), }); -export const { action } = createActionApiRoute( +const route = createActionApiRoute( { body: BodySchema, params: z.object({ @@ -44,3 +44,5 @@ export const { action } = createActionApiRoute( return json(q); } ); + +export const action = route.action; diff --git a/apps/webapp/app/routes/api.v1.runs.$runId.metadata.ts b/apps/webapp/app/routes/api.v1.runs.$runId.metadata.ts index 58cf572f44d..41af5f388fd 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runId.metadata.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runId.metadata.ts @@ -79,7 +79,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { // wraps it in auth + body parsing + error-handler middleware), but the // fan-out helper carries the load-bearing logic — including the ops- // visibility branch this change adds. -export async function routeOperationsToRun( +async function routeOperationsToRun( targetRunId: string | undefined, operations: RunMetadataChangeOperation[] | undefined, env: AuthenticatedEnvironment diff --git a/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts b/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts index ba70f08daae..bfe609b7a85 100644 --- a/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts +++ b/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts @@ -40,7 +40,7 @@ function sessionResource( return anyResource([...ids].map((id) => ({ type: "sessions" as const, id }))); } -export const { action } = createActionApiRoute( +const route = createActionApiRoute( { ...routeConfig, method: "PUT", @@ -94,3 +94,5 @@ export const loader = createLoaderApiRoute( return json({ presignedUrl: signed.url }); } ); + +export const action = route.action; diff --git a/apps/webapp/app/routes/auth.github.callback.tsx b/apps/webapp/app/routes/auth.github.callback.tsx index 32c23ab665b..28d5deb706c 100644 --- a/apps/webapp/app/routes/auth.github.callback.tsx +++ b/apps/webapp/app/routes/auth.github.callback.tsx @@ -9,12 +9,12 @@ import { commitAuthenticatedSession } from "~/services/sessionDuration.server"; import { trackAndClearReferralSource } from "~/services/referralSource.server"; import { appendRedirectTo, ssoRedirectFromAuthError } from "~/services/ssoAutoDiscovery.server"; import type { AuthUser } from "~/services/authUser"; -import { redirectCookie } from "./auth.github"; +import { githubRedirectCookie } from "~/services/redirectCookies.server"; import { sanitizeRedirectPath } from "~/utils"; export let loader: LoaderFunction = async ({ request }) => { const cookie = request.headers.get("Cookie"); - const redirectValue = await redirectCookie.parse(cookie); + const redirectValue = await githubRedirectCookie.parse(cookie); const redirectTo = sanitizeRedirectPath(redirectValue); // The SSO auto-discovery gate runs inside the strategy's verify diff --git a/apps/webapp/app/routes/auth.github.ts b/apps/webapp/app/routes/auth.github.ts index 8c464e0e598..04bd5fe1284 100644 --- a/apps/webapp/app/routes/auth.github.ts +++ b/apps/webapp/app/routes/auth.github.ts @@ -1,6 +1,6 @@ -import { type ActionFunction, type LoaderFunction, redirect, createCookie } from "@remix-run/node"; +import { type ActionFunction, type LoaderFunction, redirect } from "@remix-run/node"; import { authenticator } from "~/services/auth.server"; -import { env } from "~/env.server"; +import { githubRedirectCookie } from "~/services/redirectCookies.server"; import { sanitizeRedirectPath } from "~/utils"; export let loader: LoaderFunction = () => redirect("/login"); @@ -23,15 +23,8 @@ export let action: ActionFunction = async ({ request }) => { if (error instanceof Response) { // we need to append a Set-Cookie header with a cookie storing the // returnTo value (store the sanitized path) - error.headers.append("Set-Cookie", await redirectCookie.serialize(safeRedirect)); + error.headers.append("Set-Cookie", await githubRedirectCookie.serialize(safeRedirect)); } throw error; } }; - -export const redirectCookie = createCookie("redirect-to", { - maxAge: 60 * 60, // 1 hour - httpOnly: true, - sameSite: "lax", - secure: env.NODE_ENV === "production", -}); diff --git a/apps/webapp/app/routes/auth.google.callback.tsx b/apps/webapp/app/routes/auth.google.callback.tsx index 593b9d40fb8..e32dd43384f 100644 --- a/apps/webapp/app/routes/auth.google.callback.tsx +++ b/apps/webapp/app/routes/auth.google.callback.tsx @@ -9,12 +9,12 @@ import { commitAuthenticatedSession } from "~/services/sessionDuration.server"; import { trackAndClearReferralSource } from "~/services/referralSource.server"; import { appendRedirectTo, ssoRedirectFromAuthError } from "~/services/ssoAutoDiscovery.server"; import type { AuthUser } from "~/services/authUser"; -import { redirectCookie } from "./auth.google"; +import { googleRedirectCookie } from "~/services/redirectCookies.server"; import { sanitizeRedirectPath } from "~/utils"; export let loader: LoaderFunction = async ({ request }) => { const cookie = request.headers.get("Cookie"); - const redirectValue = await redirectCookie.parse(cookie); + const redirectValue = await googleRedirectCookie.parse(cookie); const redirectTo = sanitizeRedirectPath(redirectValue); // The SSO auto-discovery gate runs inside the strategy's verify diff --git a/apps/webapp/app/routes/auth.google.ts b/apps/webapp/app/routes/auth.google.ts index 95fb4ff7b58..09ab022bd9f 100644 --- a/apps/webapp/app/routes/auth.google.ts +++ b/apps/webapp/app/routes/auth.google.ts @@ -1,6 +1,6 @@ -import { type ActionFunction, type LoaderFunction, redirect, createCookie } from "@remix-run/node"; +import { type ActionFunction, type LoaderFunction, redirect } from "@remix-run/node"; import { authenticator } from "~/services/auth.server"; -import { env } from "~/env.server"; +import { googleRedirectCookie } from "~/services/redirectCookies.server"; import { sanitizeRedirectPath } from "~/utils"; export let loader: LoaderFunction = () => redirect("/login"); @@ -23,15 +23,8 @@ export let action: ActionFunction = async ({ request }) => { if (error instanceof Response) { // we need to append a Set-Cookie header with a cookie storing the // returnTo value (store the sanitized path) - error.headers.append("Set-Cookie", await redirectCookie.serialize(safeRedirect)); + error.headers.append("Set-Cookie", await googleRedirectCookie.serialize(safeRedirect)); } throw error; } }; - -export const redirectCookie = createCookie("google-redirect-to", { - maxAge: 60 * 60, // 1 hour - httpOnly: true, - sameSite: "lax", - secure: env.NODE_ENV === "production", -}); diff --git a/apps/webapp/app/services/redirectCookies.server.ts b/apps/webapp/app/services/redirectCookies.server.ts new file mode 100644 index 00000000000..58cecbc3398 --- /dev/null +++ b/apps/webapp/app/services/redirectCookies.server.ts @@ -0,0 +1,19 @@ +import { createCookie } from "@remix-run/node"; +import { env } from "~/env.server"; + +// Post-auth redirect cookies. Kept in a .server module: Vite can't strip +// non-standard route exports that pull in server-only code. + +export const githubRedirectCookie = createCookie("redirect-to", { + maxAge: 60 * 60, // 1 hour + httpOnly: true, + sameSite: "lax", + secure: env.NODE_ENV === "production", +}); + +export const googleRedirectCookie = createCookie("google-redirect-to", { + maxAge: 60 * 60, // 1 hour + httpOnly: true, + sameSite: "lax", + secure: env.NODE_ENV === "production", +}); diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css index f7b05430c89..5a6429fce80 100644 --- a/apps/webapp/app/tailwind.css +++ b/apps/webapp/app/tailwind.css @@ -1,6 +1,3 @@ -@import url("non.geist"); -@import url("non.geist/mono"); - @import "react-grid-layout/css/styles.css" layer(base); @import "react-resizable/css/styles.css" layer(base); diff --git a/apps/webapp/app/v3/querySchemas.ts b/apps/webapp/app/v3/querySchemas.ts index 4784ad75629..a860d411a37 100644 --- a/apps/webapp/app/v3/querySchemas.ts +++ b/apps/webapp/app/v3/querySchemas.ts @@ -2,7 +2,6 @@ import { column, type BucketThreshold, type TableSchema } from "@internal/tsql"; import { z } from "zod"; import { autoFormatSQL } from "~/components/code/TSQLEditor"; import { runFriendlyStatus, runStatusTitleFromStatus } from "~/components/runs/v3/TaskRunStatus"; -import { logger } from "~/services/logger.server"; export const QueryScopeSchema = z.enum(["organization", "project", "environment"]); export type QueryScope = z.infer; @@ -443,10 +442,7 @@ export const runsSchema: TableSchema = { ...column("Array(String)", { description: "Any bulk actions that operated on this run.", example: '["bulk_12345678", "bulk_34567890"]', - whereTransform: (value: string) => { - logger.log(`WHERE TRANSFORM: ${value}`); - return value.replace(/^bulk_/, ""); - }, + whereTransform: (value: string) => value.replace(/^bulk_/, ""), }), }, }, diff --git a/apps/webapp/package.json b/apps/webapp/package.json index ea7352cb3c1..c98376d754f 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -5,10 +5,10 @@ "sideEffects": false, "scripts": { "build": "run-s build:** && pnpm run upload:sourcemaps", - "build:remix": "remix build --sourcemap", + "build:remix": "remix vite:build", "build:server": "esbuild --platform=node --format=cjs ./server.ts --outdir=build --sourcemap", "build:sentry": "esbuild --platform=node --format=cjs --outbase=. ./sentry.server.ts ./app/utils/sentryTraceContext.server.ts --outdir=build --sourcemap", - "dev": "cross-env PORT=3030 remix dev -c \"node ./build/server.js\"", + "dev": "cross-env NODE_ENV=development PORT=3030 tsx ./server.ts", "dev:worker": "cross-env NODE_PATH=../../node_modules/.pnpm/node_modules node ./build/server.js", "format": "oxfmt .", "lint": "oxlint -c ../../.oxlintrc.json", @@ -141,6 +141,7 @@ "@whatwg-node/fetch": "^0.9.14", "@window-splitter/react": "1.1.3", "ai": "^6.0.116", + "assert": "^2.1.0", "assert-never": "^1.2.1", "aws4fetch": "^1.0.18", "class-variance-authority": "^0.5.2", @@ -184,6 +185,7 @@ "p-map": "^6.0.0", "p-retry": "^4.6.1", "parse-duration": "^2.1.0", + "pg": "8.15.6", "posthog-js": "^1.93.3", "posthog-node": "5.35.6", "prism-react-renderer": "^2.3.1", @@ -227,10 +229,11 @@ "superjson": "^2.2.1", "tailwind-merge": "^3.6.0", "tailwind-scrollbar-hide": "^4.0.0", - "tw-animate-css": "^1.4.0", "tiny-invariant": "^1.2.0", + "tw-animate-css": "^1.4.0", "ulid": "^2.3.0", "ulidx": "^2.2.1", + "util": "^0.12.5", "uuid": "^14.0.0", "ws": "^8.11.0", "zod": "3.25.76", @@ -291,7 +294,8 @@ "tailwindcss": "^4.3.1", "tsconfig-paths": "^3.14.1", "tsx": "^4.20.6", - "vite-tsconfig-paths": "^4.0.5" + "vite-tsconfig-paths": "^5.1.4", + "vite": "^6.4.2" }, "engines": { "node": ">=18.19.0 || >=20.6.0" diff --git a/apps/webapp/remix.config.js b/apps/webapp/remix.config.js deleted file mode 100644 index f6bad31aa77..00000000000 --- a/apps/webapp/remix.config.js +++ /dev/null @@ -1,48 +0,0 @@ -/** @type {import('@remix-run/dev').AppConfig} */ -module.exports = { - dev: { - port: 8002, - }, - // Tailwind v4 runs through PostCSS (@tailwindcss/postcss), not the built-in Remix integration - tailwind: false, - postcss: true, - cacheDirectory: "./node_modules/.cache/remix", - ignoredRouteFiles: ["**/.*"], - serverModuleFormat: "cjs", - serverDependenciesToBundle: [ - /^remix-utils.*/, - /^@internal\//, // Bundle all internal packages - /^@trigger\.dev\//, // Bundle all trigger packages - "marked", - "agentcrumbs", - "axios", - "p-limit", - "p-map", - "yocto-queue", - "@unkey/cache", - "@unkey/cache/stores", - "emails", - "highlight.run", - "random-words", - "superjson", - "copy-anything", - "is-what", - "prismjs/components/prism-json", - "prismjs/components/prism-typescript", - "redlock", - "parse-duration", - "uncrypto", - "std-env", - "uuid", - ], - browserNodeBuiltinsPolyfill: { - modules: { - path: true, - os: true, - crypto: true, - http2: true, - assert: true, - util: true, - }, - }, -}; diff --git a/apps/webapp/remix.env.d.ts b/apps/webapp/remix.env.d.ts index 72e2affe311..3a7ebcc0835 100644 --- a/apps/webapp/remix.env.d.ts +++ b/apps/webapp/remix.env.d.ts @@ -1,2 +1,3 @@ /// +/// /// diff --git a/apps/webapp/server.ts b/apps/webapp/server.ts index c3c1a45f633..2b96d24ead3 100644 --- a/apps/webapp/server.ts +++ b/apps/webapp/server.ts @@ -1,13 +1,13 @@ import "./sentry.server"; import { createRequestHandler } from "@remix-run/express"; -import { broadcastDevReady, logDevReady } from "@remix-run/server-runtime"; import compression from "compression"; import type { Server as EngineServer } from "engine.io"; import express, { type RequestHandler } from "express"; import morgan from "morgan"; import { nanoid } from "nanoid"; import path from "path"; +import { pathToFileURL } from "node:url"; import type { Server as IoServer } from "socket.io"; import type { WebSocketServer } from "ws"; import type { RateLimitMiddleware } from "~/services/apiRateLimit.server"; @@ -74,6 +74,12 @@ function installPrimarySignalHandlers() { }); } +// Bundled to CJS (esbuild rewrites import() to require); vite and the Remix +// server bundle are ESM, so load them via a real dynamic import. +const dynamicImport = new Function("specifier", "return import(specifier)") as ( + specifier: string +) => Promise; + if (ENABLE_CLUSTER && cluster.isPrimary) { process.title = `node webapp-server primary`; console.log(`[cluster] Primary ${process.pid} is starting with ${WORKERS} workers`); @@ -92,6 +98,13 @@ if (ENABLE_CLUSTER && cluster.isPrimary) { installPrimarySignalHandlers(); } else { + startServer().catch((error) => { + console.error("Failed to start server:", error); + process.exit(1); + }); +} + +async function startServer() { const app = express(); if (process.env.DISABLE_COMPRESSION !== "1") { @@ -101,16 +114,25 @@ if (ENABLE_CLUSTER && cluster.isPrimary) { // http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header app.disable("x-powered-by"); - // Remix fingerprints its assets so we can cache forever. - app.use("/build", express.static("public/build", { immutable: true, maxAge: "1y" })); - // Stale dev builds can request an old hashed manifest; don't fall through to Remix. - app.use("/build", (_req, res) => { - res.status(404).end(); - }); + const MODE = process.env.NODE_ENV; - // Everything else (like favicon.ico) is cached for an hour. You may want to be - // more aggressive with this caching. - app.use(express.static("public", { maxAge: "1h" })); + // In development, Vite serves assets (and handles HMR) via middleware. + const viteDevServer = + MODE === "production" + ? undefined + : await dynamicImport("vite").then((vite) => + vite.createServer({ server: { middlewareMode: true } }) + ); + + if (viteDevServer) { + app.use(viteDevServer.middlewares); + } else { + // Vite fingerprints its assets so we can cache forever. + app.use("/assets", express.static("build/client/assets", { immutable: true, maxAge: "1y" })); + // Everything else (like favicon.ico) is cached for an hour. You may want to be + // more aggressive with this caching. + app.use(express.static("build/client", { maxAge: "1h" })); + } // On high-volume machine-ingest services (e.g. otel) the per-request access // log dominates log volume. HTTP_ACCESS_LOG_DISABLED suppresses successful @@ -127,9 +149,17 @@ if (ENABLE_CLUSTER && cluster.isPrimary) { ? `node webapp-worker-${cluster.isWorker ? cluster.worker?.id : "solo"}` : "node webapp-server"; - const MODE = process.env.NODE_ENV; - const BUILD_DIR = path.join(process.cwd(), "build"); - const build = require(BUILD_DIR); + const loadBuild = () => { + if (viteDevServer) { + return viteDevServer.ssrLoadModule("virtual:remix/server-build"); + } + return dynamicImport( + pathToFileURL(path.join(process.cwd(), "build", "server", "index.mjs")).href + ); + }; + + // Boots the entry.server singletons (socket.io, wss, rate limiters). + const build = await loadBuild(); const port = process.env.REMIX_APP_PORT || process.env.PORT || 3000; @@ -196,7 +226,7 @@ if (ENABLE_CLUSTER && cluster.isPrimary) { "*", // @ts-ignore createRequestHandler({ - build, + build: viteDevServer ? loadBuild : build, mode: MODE, }) ); @@ -209,7 +239,7 @@ if (ENABLE_CLUSTER && cluster.isPrimary) { "/healthcheck", // @ts-ignore createRequestHandler({ - build, + build: viteDevServer ? loadBuild : build, mode: MODE, }) ); @@ -221,12 +251,6 @@ if (ENABLE_CLUSTER && cluster.isPrimary) { ENABLE_CLUSTER && cluster.isWorker ? ` [worker ${cluster.worker?.id}/${process.pid}]` : "" }` ); - - if (MODE === "development") { - broadcastDevReady(build) - .then(() => logDevReady(build)) - .catch(console.error); - } }); server.keepAliveTimeout = HTTP_KEEPALIVE_TIMEOUT_MS; @@ -293,7 +317,6 @@ if (ENABLE_CLUSTER && cluster.isPrimary) { }); }); } else { - require(BUILD_DIR); console.log(`✅ app ready (skipping http server)`); } } diff --git a/apps/webapp/vite.config.ts b/apps/webapp/vite.config.ts new file mode 100644 index 00000000000..b008ae04ed0 --- /dev/null +++ b/apps/webapp/vite.config.ts @@ -0,0 +1,66 @@ +import { vitePlugin as remix } from "@remix-run/dev"; +import { defaultClientConditions, defaultServerConditions, defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [ + remix({ + ignoredRouteFiles: ["**/.*"], + // .mjs so the CJS server.ts wrapper can dynamic-import it + serverBuildFile: "index.mjs", + }), + tsconfigPaths(), + ], + resolve: { + // Resolve workspace packages to TS source (same condition the CLI uses) + conditions: ["@triggerdotdev/source", ...defaultClientConditions], + // Browser polyfills for node builtins used by client deps (antlr4ts) + alias: [ + { find: /^assert$/, replacement: "assert/" }, + { find: /^util$/, replacement: "util/" }, + ], + }, + optimizeDeps: { + // Crawl all routes up front - mid-session re-optimization duplicates React + entries: ["./app/entry.client.tsx", "./app/root.tsx", "./app/routes/**/*.{ts,tsx}"], + esbuildOptions: { + // node globals for prebundled CJS deps (client-only by construction) + define: { global: "globalThis" }, + inject: ["./vite/node-globals-shim.js"], + }, + }, + server: { + warmup: { + clientFiles: ["./app/entry.client.tsx", "./app/root.tsx", "./app/components/**/*.tsx"], + ssrFiles: ["./app/entry.server.tsx", "./app/root.tsx"], + }, + }, + build: { + sourcemap: true, + rollupOptions: { + // Prisma wrappers and pg have CJS/native pieces Rollup can't inline + external: [/^@trigger\.dev\/database$/, /^@internal\/run-ops-database$/, /^pg$/], + }, + }, + ssr: { + resolve: { + conditions: ["@triggerdotdev/source", ...defaultServerConditions], + externalConditions: ["@triggerdotdev/source", "node"], + }, + // CJS Prisma clients and native pg must load through node + external: ["@trigger.dev/database", "@internal/run-ops-database", "pg"], + // CJS deps whose named exports node's ESM interop can't detect + noExternal: [ + /^@radix-ui\//, + "react-use", + "cron-parser", + "@fingerprintjs/fingerprintjs-pro-react", + "@kapaai/react-sdk", + "@fingerprintjs/fingerprintjs-pro", + "@fingerprintjs/fingerprintjs-pro-spa", + ], + optimizeDeps: { + include: ["cron-parser"], + }, + }, +}); diff --git a/apps/webapp/vite/node-globals-shim.js b/apps/webapp/vite/node-globals-shim.js new file mode 100644 index 00000000000..68d6826c862 --- /dev/null +++ b/apps/webapp/vite/node-globals-shim.js @@ -0,0 +1,10 @@ +// Minimal `process` stand-in injected into prebundled browser deps +// (see vite.config.ts optimizeDeps). Client-only. +export const process = { + env: {}, + browser: true, + version: "", + platform: "browser", + cwd: () => "/", + nextTick: (fn, ...args) => setTimeout(() => fn(...args), 0), +}; diff --git a/docker/Dockerfile b/docker/Dockerfile index 5b140b037f3..2e94f121498 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -91,6 +91,12 @@ COPY --from=dev-deps --chown=node:node /triggerdotdev/internal-packages/database # Run-ops Prisma client (query engine + client). Only constructed when the split is enabled, # but the image must carry it so a split-on deployment doesn't hit a missing query engine. COPY --from=dev-deps --chown=node:node /triggerdotdev/internal-packages/run-ops-database/generated ./internal-packages/run-ops-database/generated +# These workspace packages stay external to the Vite SSR bundle, so their +# built entry points must ship in the image (core is the sdk's runtime dep). +COPY --from=builder --chown=node:node /triggerdotdev/internal-packages/database/dist ./internal-packages/database/dist +COPY --from=builder --chown=node:node /triggerdotdev/internal-packages/run-ops-database/dist ./internal-packages/run-ops-database/dist +COPY --from=builder --chown=node:node /triggerdotdev/packages/trigger-sdk/dist ./packages/trigger-sdk/dist +COPY --from=builder --chown=node:node /triggerdotdev/packages/core/dist ./packages/core/dist COPY --from=builder --chown=node:node /triggerdotdev/apps/webapp/build/server.js ./apps/webapp/build/server.js COPY --from=builder --chown=node:node /triggerdotdev/apps/webapp/build ./apps/webapp/build COPY --from=builder --chown=node:node /triggerdotdev/apps/webapp/public ./apps/webapp/public diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 46574c2677c..bc682658d21 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -231,10 +231,10 @@ services: "--query", "SELECT 1", ] - interval: "3s" - timeout: "5s" - retries: "5" - start_period: "10s" + interval: 3s + timeout: 5s + retries: 5 + start_period: 10s clickhouse_migrator: build: diff --git a/internal-packages/rbac/src/index.ts b/internal-packages/rbac/src/index.ts index 258dc12f3b1..dcf046f9ab3 100644 --- a/internal-packages/rbac/src/index.ts +++ b/internal-packages/rbac/src/index.ts @@ -85,7 +85,8 @@ class LazyController implements RoleBaseAccessController { } const moduleName = "@triggerdotdev/plugins/rbac"; try { - const module = await import(moduleName); + // Optional plugin, resolved at runtime only + const module = await import(/* @vite-ignore */ moduleName); const plugin: RoleBasedAccessControlPlugin = module.default; console.log("RBAC: using plugin implementation"); return plugin.create({ userActorSecret: options?.userActorSecret }); diff --git a/internal-packages/run-engine/src/engine/locking.ts b/internal-packages/run-engine/src/engine/locking.ts index 0c86da80cc1..900b964bee6 100644 --- a/internal-packages/run-engine/src/engine/locking.ts +++ b/internal-packages/run-engine/src/engine/locking.ts @@ -1,8 +1,12 @@ -// import { default: Redlock } from "redlock"; -const { default: Redlock } = require("redlock"); import { AsyncLocalStorage } from "async_hooks"; import type { Redis } from "@internal/redis"; -import type * as redlock from "redlock"; +import * as redlockModule from "redlock"; + +// redlock is CJS with `exports.default`; probe the interop shapes instead of +// a bare require(), which breaks in ESM module runners. +const Redlock = ((redlockModule as any).default?.default ?? + (redlockModule as any).default ?? + redlockModule) as typeof redlockModule.default; import { tryCatch } from "@trigger.dev/core"; import type { Logger } from "@trigger.dev/core/logger"; import type { Tracer, Meter, ObservableResult, Attributes, Histogram } from "@internal/tracing"; @@ -34,12 +38,12 @@ export class LockAcquisitionTimeoutError extends Error { interface LockContext { resources: string; - signal: redlock.RedlockAbortSignal; + signal: redlockModule.RedlockAbortSignal; lockType: string; } interface ManualLockContext { - lock: redlock.Lock; + lock: redlockModule.Lock; timeout: NodeJS.Timeout | null | undefined; extension: Promise | undefined; } @@ -60,7 +64,7 @@ export interface LockRetryConfig { } export class RunLocker { - private redlock: InstanceType; + private redlock: InstanceType; private asyncLocalStorage: AsyncLocalStorage; private logger: Logger; private tracer: Tracer; @@ -216,7 +220,7 @@ export class RunLocker { let totalWaitTime = 0; // Retry the lock acquisition with exponential backoff - let lock: redlock.Lock | undefined; + let lock: redlockModule.Lock | undefined; let lastError: Error | undefined; for (let attempt = 0; attempt <= maxAttempts; attempt++) { @@ -346,7 +350,7 @@ export class RunLocker { // Create an AbortController for our signal const controller = new AbortController(); - const signal = controller.signal as redlock.RedlockAbortSignal; + const signal = controller.signal as redlockModule.RedlockAbortSignal; const manualContext: ManualLockContext = { lock, @@ -425,7 +429,7 @@ export class RunLocker { #setupAutoExtension( context: ManualLockContext, duration: number, - signal: redlock.RedlockAbortSignal, + signal: redlockModule.RedlockAbortSignal, controller: AbortController ): void { if (this.automaticExtensionThreshold > duration - 100) { @@ -460,7 +464,7 @@ export class RunLocker { async #extendLock( context: ManualLockContext, duration: number, - signal: redlock.RedlockAbortSignal, + signal: redlockModule.RedlockAbortSignal, controller: AbortController, scheduleNext: () => void ): Promise { diff --git a/internal-packages/sso/src/index.ts b/internal-packages/sso/src/index.ts index 0ec6df3adbd..422035b141f 100644 --- a/internal-packages/sso/src/index.ts +++ b/internal-packages/sso/src/index.ts @@ -50,7 +50,8 @@ export class LazyController implements SsoController { } const moduleName = "@triggerdotdev/plugins/sso"; const importer = - options?.importer ?? ((m: string) => import(m) as Promise<{ default: SsoPlugin }>); + options?.importer ?? + ((m: string) => import(/* @vite-ignore */ m) as Promise<{ default: SsoPlugin }>); try { const module = await importer(moduleName); const plugin: SsoPlugin = module.default; diff --git a/packages/trigger-sdk/src/v3/aiAutoTelemetry.ts b/packages/trigger-sdk/src/v3/aiAutoTelemetry.ts index 7533dc9bf05..04bf4b9525a 100644 --- a/packages/trigger-sdk/src/v3/aiAutoTelemetry.ts +++ b/packages/trigger-sdk/src/v3/aiAutoTelemetry.ts @@ -42,10 +42,10 @@ async function register(): Promise { if (typeof aiMod.registerTelemetry !== "function") { return; // v5 / v6 — `ai` core emits spans itself, nothing to wire. } - // Computed specifier keeps the optional peer out of static bundler - // resolution; resolves at runtime only when the customer installed it. + // Computed specifier + @vite-ignore keep the optional peer out of static + // bundler resolution; resolves at runtime only when installed. const otelSpecifier = ["@ai-sdk", "otel"].join("/"); - const otelMod: any = await import(otelSpecifier).catch(() => null); + const otelMod: any = await import(/* @vite-ignore */ otelSpecifier).catch(() => null); if (typeof otelMod?.OpenTelemetry !== "function") { return; // optional peer not installed } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72d5a7afc2c..8994da70c9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -548,6 +548,9 @@ importers: ai: specifier: 6.0.116 version: 6.0.116(zod@3.25.76) + assert: + specifier: ^2.1.0 + version: 2.1.0 assert-never: specifier: ^1.2.1 version: 1.2.1 @@ -677,6 +680,9 @@ importers: parse-duration: specifier: ^2.1.0 version: 2.1.4 + pg: + specifier: 8.15.6 + version: 8.15.6 posthog-js: specifier: ^1.93.3 version: 1.93.3 @@ -818,6 +824,9 @@ importers: ulidx: specifier: ^2.2.1 version: 2.2.1 + util: + specifier: ^0.12.5 + version: 0.12.5 uuid: specifier: ^14.0.0 version: 14.0.0 @@ -993,9 +1002,12 @@ importers: tsx: specifier: ^4.20.6 version: 4.20.6 + vite: + specifier: ^6.4.2 + version: 6.4.2(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) vite-tsconfig-paths: - specifier: ^4.0.5 - version: 4.0.5(typescript@5.5.4) + specifier: ^5.1.4 + version: 5.1.4(typescript@5.5.4)(vite@6.4.2(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)) docs: {} @@ -9171,6 +9183,9 @@ packages: assert-never@1.2.1: resolution: {integrity: sha512-TaTivMB6pYI1kXwrFlEhLeGfOqoDNdTxjCdwRfFFkEA30Eu+k48W34nlok2EYWJfFFzqaEmichdNM7th6M5HNw==} + assert@2.1.0: + resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -9466,10 +9481,6 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - call-bind@1.0.7: - resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} - engines: {node: '>= 0.4'} - call-bind@1.0.8: resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} engines: {node: '>= 0.4'} @@ -10268,10 +10279,6 @@ packages: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} - define-properties@1.1.4: - resolution: {integrity: sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==} - engines: {node: '>= 0.4'} - define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} @@ -11845,6 +11852,10 @@ packages: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} + is-nan@1.3.2: + resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==} + engines: {node: '>= 0.4'} + is-negative-zero@2.0.2: resolution: {integrity: sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==} engines: {node: '>= 0.4'} @@ -13227,6 +13238,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -13583,9 +13598,6 @@ packages: pg-cloudflare@1.2.7: resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} - pg-connection-string@2.8.5: - resolution: {integrity: sha512-Ni8FuZ8yAF+sWZzojvtLE2b03cqjO5jNULcHFfM9ZZ0/JXrgom5pBREbtnAw7oxsxJqHw9Nz/XWORUEL3/IFow==} - pg-connection-string@2.9.1: resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==} @@ -13602,11 +13614,6 @@ packages: peerDependencies: pg: '>=8.0' - pg-pool@3.9.6: - resolution: {integrity: sha512-rFen0G7adh1YmgvrmE5IPIqbb+IgEzENUm+tzm6MLLDSlPRoZVhzU1WdML9PV2W5GOdRA9qBKURlbt1OsXOsPw==} - peerDependencies: - pg: '>=8.0' - pg-protocol@1.10.3: resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} @@ -13624,15 +13631,6 @@ packages: resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==} engines: {node: '>=10'} - pg@8.11.5: - resolution: {integrity: sha512-jqgNHSKL5cbDjFlHyYsCXmQDrfIX/3RsNwYqpd4N0Kt8niLuNoRNH+aazv6cOd43gPh9Y4DjQCtb+X0MH0Hvnw==} - engines: {node: '>= 8.0.0'} - peerDependencies: - pg-native: '>=3.0.1' - peerDependenciesMeta: - pg-native: - optional: true - pg@8.15.6: resolution: {integrity: sha512-yvao7YI3GdmmrslNVsZgx9PfntfWrnXwtR+K/DjI0I/sTKif4Z623um+sjVZ1hk5670B+ODjvHDAckKdjmPTsg==} engines: {node: '>= 8.0.0'} @@ -15985,6 +15983,14 @@ packages: vite-tsconfig-paths@4.0.5: resolution: {integrity: sha512-/L/eHwySFYjwxoYt1WRJniuK/jPv+WGwgRGBYx3leciR5wBeqntQpUE6Js6+TJemChc+ter7fDBKieyEWDx4yQ==} + vite-tsconfig-paths@5.1.4: + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: ^6.4.2 + peerDependenciesMeta: + vite: + optional: true + vite@4.4.9: resolution: {integrity: sha512-2mbUn2LlUmNASWwSCNSJ/EG2HuSRTnVNaydp6vMCm5VIqJsjMfbIWtbH2kDuwUVW5mMUKKZvGPX/rqeqVvv1XA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -19055,7 +19061,7 @@ snapshots: '@electric-sql/client@0.4.0': optionalDependencies: - '@rollup/rollup-darwin-arm64': 4.53.2 + '@rollup/rollup-darwin-arm64': 4.60.1 '@electric-sql/client@1.0.14': dependencies: @@ -25602,6 +25608,14 @@ snapshots: assert-never@1.2.1: {} + assert@2.1.0: + dependencies: + call-bind: 1.0.8 + is-nan: 1.3.2 + object-is: 1.1.6 + object.assign: 4.1.5 + util: 0.12.5 + assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.2: @@ -25970,14 +25984,6 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 - call-bind@1.0.7: - dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 - call-bind@1.0.8: dependencies: call-bind-apply-helpers: 1.0.2 @@ -26780,11 +26786,6 @@ snapshots: define-lazy-prop@3.0.0: {} - define-properties@1.1.4: - dependencies: - has-property-descriptors: 1.0.2 - object-keys: 1.1.1 - define-properties@1.2.1: dependencies: define-data-property: 1.1.4 @@ -28296,7 +28297,7 @@ snapshots: cosmiconfig: 8.3.6(typescript@5.5.4) graphile-config: 0.0.1-beta.8 json5: 2.2.3 - pg: 8.11.5 + pg: 8.15.6 tslib: 2.6.2 yargs: 17.7.2 transitivePeerDependencies: @@ -28420,7 +28421,7 @@ snapshots: hast-util-to-jsx-runtime@2.3.6: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/hast': 3.0.4 '@types/unist': 3.0.3 comma-separated-tokens: 2.0.3 @@ -28756,6 +28757,11 @@ snapshots: is-interactive@1.0.0: {} + is-nan@1.3.2: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + is-negative-zero@2.0.2: {} is-negative-zero@2.0.3: {} @@ -30438,6 +30444,11 @@ snapshots: object-inspect@1.13.4: {} + object-is@1.1.6: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + object-keys@1.1.1: {} object.assign@4.1.5: @@ -30821,19 +30832,13 @@ snapshots: pg-cloudflare@1.2.7: optional: true - pg-connection-string@2.8.5: {} - pg-connection-string@2.9.1: {} pg-int8@1.0.1: {} pg-numeric@1.0.2: {} - pg-pool@3.10.1(pg@8.11.5): - dependencies: - pg: 8.11.5 - - pg-pool@3.9.6(pg@8.15.6): + pg-pool@3.10.1(pg@8.15.6): dependencies: pg: 8.15.6 @@ -30861,26 +30866,16 @@ snapshots: postgres-interval: 3.0.0 postgres-range: 1.1.4 - pg@8.11.5: + pg@8.15.6: dependencies: pg-connection-string: 2.9.1 - pg-pool: 3.10.1(pg@8.11.5) + pg-pool: 3.10.1(pg@8.15.6) pg-protocol: 1.10.3 pg-types: 2.2.0 pgpass: 1.0.5 optionalDependencies: pg-cloudflare: 1.2.7 - pg@8.15.6: - dependencies: - pg-connection-string: 2.8.5 - pg-pool: 3.9.6(pg@8.15.6) - pg-protocol: 1.9.5 - pg-types: 2.2.0 - pgpass: 1.0.5 - optionalDependencies: - pg-cloudflare: 1.2.7 - pgpass@1.0.5: dependencies: split2: 4.2.0 @@ -32632,8 +32627,8 @@ snapshots: string.prototype.padend@3.1.4: dependencies: - call-bind: 1.0.7 - define-properties: 1.1.4 + call-bind: 1.0.8 + define-properties: 1.2.1 es-abstract: 1.21.1 string.prototype.trim@1.2.9: @@ -33690,10 +33685,21 @@ snapshots: - supports-color - typescript + vite-tsconfig-paths@5.1.4(typescript@5.5.4)(vite@6.4.2(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0)): + dependencies: + debug: 4.4.3(supports-color@10.0.0) + globrex: 0.1.2 + tsconfck: 3.1.3(typescript@5.5.4) + optionalDependencies: + vite: 6.4.2(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.20.6)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + - typescript + vite@4.4.9(@types/node@22.20.0)(lightningcss@1.32.0)(terser@5.46.1): dependencies: esbuild: 0.18.20 - postcss: 8.5.10 + postcss: 8.5.15 rollup: 3.29.1 optionalDependencies: '@types/node': 22.20.0 @@ -33706,7 +33712,7 @@ snapshots: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.10 + postcss: 8.5.15 rollup: 4.60.1 tinyglobby: 0.2.16 optionalDependencies: @@ -33723,7 +33729,7 @@ snapshots: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.10 + postcss: 8.5.15 rollup: 4.60.1 tinyglobby: 0.2.16 optionalDependencies: @@ -33740,7 +33746,7 @@ snapshots: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.10 + postcss: 8.5.15 rollup: 4.60.1 tinyglobby: 0.2.16 optionalDependencies: From 9ae20e5ac488974464380e8bfdcbeb9b8cbbe8ab Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 8 Jul 2026 00:30:30 +0000 Subject: [PATCH 02/10] fix(webapp): move routeOperationsToRun into a server module Restores the unit-testable export (removed to satisfy the Vite route export rules) by extracting the helper out of the route file. --- .../app/routes/api.v1.runs.$runId.metadata.ts | 115 +---------------- .../mollifier/routeOperationsToRun.server.ts | 116 ++++++++++++++++++ .../metadataRouteOperationsLogging.test.ts | 2 +- 3 files changed, 119 insertions(+), 114 deletions(-) create mode 100644 apps/webapp/app/v3/mollifier/routeOperationsToRun.server.ts diff --git a/apps/webapp/app/routes/api.v1.runs.$runId.metadata.ts b/apps/webapp/app/routes/api.v1.runs.$runId.metadata.ts index 41af5f388fd..6ad19065045 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runId.metadata.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runId.metadata.ts @@ -1,21 +1,18 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core/utils"; -import type { RunMetadataChangeOperation } from "@trigger.dev/core/v3/schemas"; import { UpdateMetadataRequestBody } from "@trigger.dev/core/v3"; import { z } from "zod"; import { $replica } from "~/db.server"; -// Aliased to avoid shadowing the local `env: AuthenticatedEnvironment` -// parameter the route handler and `routeOperationsToRun` use. +// Aliased to avoid shadowing the local `env` parameter in the handler. import { env as appEnv } from "~/env.server"; -import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { authenticateApiRequest } from "~/services/apiAuth.server"; -import { logger } from "~/services/logger.server"; import { updateMetadataService } from "~/services/metadata/updateMetadataInstance.server"; import { publishChangeRecord } from "~/services/realtime/runChangeNotifierInstance.server"; import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { ServiceValidationError } from "~/v3/services/common.server"; import { applyMetadataMutationToBufferedRun } from "~/v3/mollifier/applyMetadataMutation.server"; +import { routeOperationsToRun } from "~/v3/mollifier/routeOperationsToRun.server"; import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server"; import { runStore } from "~/v3/runStore.server"; @@ -67,114 +64,6 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return json({ error: "Run not found" }, { status: 404 }); } -// Route parent/root operations to the existing PG service by directly -// invoking it against the parent/root runId. The service ingests via -// its batching worker, which targets PG by id. If the parent/root is -// itself buffered we recurse through our buffered-mutation helper. -// `_ingestion_only` flag: a synthetic body that has the operations -// promoted to top-level `operations` so the service applies them to -// `targetRunId` directly. -// Exported so the silent-failure logging behaviour can be unit-tested. -// The route handler itself isn't an attractive test target (createActionApiRoute -// wraps it in auth + body parsing + error-handler middleware), but the -// fan-out helper carries the load-bearing logic — including the ops- -// visibility branch this change adds. -async function routeOperationsToRun( - targetRunId: string | undefined, - operations: RunMetadataChangeOperation[] | undefined, - env: AuthenticatedEnvironment -): Promise { - if (!targetRunId || !operations || operations.length === 0) return; - - // Try PG first via the existing service (this is how parent/root - // operations have always landed; preserve that). Accepts the full - // AuthenticatedEnvironment so we don't have to recover the unsafe - // `as unknown` cast that the previous narrowed `{ id, organizationId }` - // signature forced on us. - // - // Two non-success outcomes from `call`: - // * throws — PG threw (e.g. "Cannot update metadata for a completed - // run", or a transient PG outage). - // * resolves with undefined — PG row didn't exist (the target may be - // buffered, not yet materialised). - // Either way we want to try the buffer fallback below; treating the - // undefined-return as success would make the fallback unreachable. - const [error, result] = await tryCatch( - updateMetadataService.call(targetRunId, { operations }, env) - ); - if (!error && result !== undefined) { - // The parent/root run changed too — wake its live feeds (only when something was - // actually written here; buffered writes publish from the flusher). - if (result.updatedAtMs !== undefined) { - publishChangeRecord({ - runId: result.runId, - envId: env.id, - tags: result.runTags, - batchId: result.batchId, - updatedAtMs: result.updatedAtMs, - }); - } - return; - } - - if (error) { - // PG threw — auxiliary op, stay best-effort and don't surface this - // to the caller (the caller's primary mutation already landed). But - // warn so a genuine PG outage on these ops isn't invisible. - logger.warn("metadata route: parent/root PG op failed", { - targetRunId, - error: error instanceof Error ? error.message : String(error), - }); - } - - // Buffer fallback only makes sense for friendlyId-keyed entries. The - // PG-side parent/root IDs are internal cuids; the buffer keys entries - // by friendlyId, so passing the internal id would silently no-op. - // Skip explicitly — a buffered child's parent is always materialised - // in PG already (a buffered run hasn't executed, so it can't have - // triggered the child), so the buffered-parent branch isn't actually - // reachable. Treating the no-op as intentional rather than incidental. - if (!targetRunId.startsWith("run_")) return; - - // Best-effort buffer fallback. Wrap so a transient Redis throw on - // this auxiliary op can't 500 the request after the primary mutation - // already succeeded. - const [bufferError, bufferOutcome] = await tryCatch( - applyMetadataMutationToBufferedRun({ - runId: targetRunId, - environmentId: env.id, - organizationId: env.organizationId, - maximumSize: appEnv.TASK_RUN_METADATA_MAXIMUM_SIZE, - maxRetries: appEnv.TRIGGER_MOLLIFIER_METADATA_MAX_RETRIES, - backoffBaseMs: appEnv.TRIGGER_MOLLIFIER_METADATA_BACKOFF_BASE_MS, - backoffStepMs: appEnv.TRIGGER_MOLLIFIER_METADATA_BACKOFF_STEP_MS, - body: { operations }, - }) - ); - if (bufferError) { - logger.warn("metadata route: buffer fallback for parent/root op failed", { - targetRunId, - error: bufferError instanceof Error ? bufferError.message : String(bufferError), - }); - return; - } - // `applyMetadataMutationToBufferedRun` reports non-throw failures via - // its returned outcome kind: `not_found`, `busy`, `version_exhausted`, - // `metadata_too_large`. Without inspecting `.kind`, the parent/root - // operation can silently disappear — no PG row landed it (handled - // above) and the buffer rejected it for one of these reasons but the - // helper returned cleanly. Surface a warn log per non-success branch - // so ops can trace why a parent/root op went missing. The customer's - // primary mutation has already succeeded by this point; this remains - // best-effort, so we still don't bubble these to the response. - if (bufferOutcome && bufferOutcome.kind !== "applied") { - logger.warn("metadata route: parent/root buffer op did not apply", { - targetRunId, - kind: bufferOutcome.kind, - }); - } -} - const { action } = createActionApiRoute( { params: ParamsSchema, diff --git a/apps/webapp/app/v3/mollifier/routeOperationsToRun.server.ts b/apps/webapp/app/v3/mollifier/routeOperationsToRun.server.ts new file mode 100644 index 00000000000..387759ff9e8 --- /dev/null +++ b/apps/webapp/app/v3/mollifier/routeOperationsToRun.server.ts @@ -0,0 +1,116 @@ +import { tryCatch } from "@trigger.dev/core/utils"; +import type { RunMetadataChangeOperation } from "@trigger.dev/core/v3/schemas"; +import { env as appEnv } from "~/env.server"; +import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; +import { updateMetadataService } from "~/services/metadata/updateMetadataInstance.server"; +import { publishChangeRecord } from "~/services/realtime/runChangeNotifierInstance.server"; +import { applyMetadataMutationToBufferedRun } from "./applyMetadataMutation.server"; + +// Route parent/root operations to the existing PG service by directly +// invoking it against the parent/root runId. The service ingests via +// its batching worker, which targets PG by id. If the parent/root is +// itself buffered we recurse through our buffered-mutation helper. +// `_ingestion_only` flag: a synthetic body that has the operations +// promoted to top-level `operations` so the service applies them to +// `targetRunId` directly. +// Exported so the silent-failure logging behaviour can be unit-tested. +// The route handler itself isn't an attractive test target (createActionApiRoute +// wraps it in auth + body parsing + error-handler middleware), but the +// fan-out helper carries the load-bearing logic — including the ops- +// visibility branch this change adds. +export async function routeOperationsToRun( + targetRunId: string | undefined, + operations: RunMetadataChangeOperation[] | undefined, + env: AuthenticatedEnvironment +): Promise { + if (!targetRunId || !operations || operations.length === 0) return; + + // Try PG first via the existing service (this is how parent/root + // operations have always landed; preserve that). Accepts the full + // AuthenticatedEnvironment so we don't have to recover the unsafe + // `as unknown` cast that the previous narrowed `{ id, organizationId }` + // signature forced on us. + // + // Two non-success outcomes from `call`: + // * throws — PG threw (e.g. "Cannot update metadata for a completed + // run", or a transient PG outage). + // * resolves with undefined — PG row didn't exist (the target may be + // buffered, not yet materialised). + // Either way we want to try the buffer fallback below; treating the + // undefined-return as success would make the fallback unreachable. + const [error, result] = await tryCatch( + updateMetadataService.call(targetRunId, { operations }, env) + ); + if (!error && result !== undefined) { + // The parent/root run changed too — wake its live feeds (only when something was + // actually written here; buffered writes publish from the flusher). + if (result.updatedAtMs !== undefined) { + publishChangeRecord({ + runId: result.runId, + envId: env.id, + tags: result.runTags, + batchId: result.batchId, + updatedAtMs: result.updatedAtMs, + }); + } + return; + } + + if (error) { + // PG threw — auxiliary op, stay best-effort and don't surface this + // to the caller (the caller's primary mutation already landed). But + // warn so a genuine PG outage on these ops isn't invisible. + logger.warn("metadata route: parent/root PG op failed", { + targetRunId, + error: error instanceof Error ? error.message : String(error), + }); + } + + // Buffer fallback only makes sense for friendlyId-keyed entries. The + // PG-side parent/root IDs are internal cuids; the buffer keys entries + // by friendlyId, so passing the internal id would silently no-op. + // Skip explicitly — a buffered child's parent is always materialised + // in PG already (a buffered run hasn't executed, so it can't have + // triggered the child), so the buffered-parent branch isn't actually + // reachable. Treating the no-op as intentional rather than incidental. + if (!targetRunId.startsWith("run_")) return; + + // Best-effort buffer fallback. Wrap so a transient Redis throw on + // this auxiliary op can't 500 the request after the primary mutation + // already succeeded. + const [bufferError, bufferOutcome] = await tryCatch( + applyMetadataMutationToBufferedRun({ + runId: targetRunId, + environmentId: env.id, + organizationId: env.organizationId, + maximumSize: appEnv.TASK_RUN_METADATA_MAXIMUM_SIZE, + maxRetries: appEnv.TRIGGER_MOLLIFIER_METADATA_MAX_RETRIES, + backoffBaseMs: appEnv.TRIGGER_MOLLIFIER_METADATA_BACKOFF_BASE_MS, + backoffStepMs: appEnv.TRIGGER_MOLLIFIER_METADATA_BACKOFF_STEP_MS, + body: { operations }, + }) + ); + if (bufferError) { + logger.warn("metadata route: buffer fallback for parent/root op failed", { + targetRunId, + error: bufferError instanceof Error ? bufferError.message : String(bufferError), + }); + return; + } + // `applyMetadataMutationToBufferedRun` reports non-throw failures via + // its returned outcome kind: `not_found`, `busy`, `version_exhausted`, + // `metadata_too_large`. Without inspecting `.kind`, the parent/root + // operation can silently disappear — no PG row landed it (handled + // above) and the buffer rejected it for one of these reasons but the + // helper returned cleanly. Surface a warn log per non-success branch + // so ops can trace why a parent/root op went missing. The customer's + // primary mutation has already succeeded by this point; this remains + // best-effort, so we still don't bubble these to the response. + if (bufferOutcome && bufferOutcome.kind !== "applied") { + logger.warn("metadata route: parent/root buffer op did not apply", { + targetRunId, + kind: bufferOutcome.kind, + }); + } +} diff --git a/apps/webapp/test/metadataRouteOperationsLogging.test.ts b/apps/webapp/test/metadataRouteOperationsLogging.test.ts index b7e5f860198..588c1547ed4 100644 --- a/apps/webapp/test/metadataRouteOperationsLogging.test.ts +++ b/apps/webapp/test/metadataRouteOperationsLogging.test.ts @@ -52,7 +52,7 @@ vi.mock("~/services/logger.server", () => ({ }, })); -import { routeOperationsToRun } from "~/routes/api.v1.runs.$runId.metadata"; +import { routeOperationsToRun } from "~/v3/mollifier/routeOperationsToRun.server"; import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; const env = { From 42244adf82c49b56e2f4189f9aa680532c4735bd Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 8 Jul 2026 13:28:20 +0000 Subject: [PATCH 03/10] fix(webapp): guard prom metrics registration against vite dev HMR re-evaluation --- .../app/utils/reloadingRegistry.server.ts | 49 ++++++++++--------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/apps/webapp/app/utils/reloadingRegistry.server.ts b/apps/webapp/app/utils/reloadingRegistry.server.ts index 81eb6723457..3bbb9b4e8b1 100644 --- a/apps/webapp/app/utils/reloadingRegistry.server.ts +++ b/apps/webapp/app/utils/reloadingRegistry.server.ts @@ -3,29 +3,34 @@ import { Counter, Gauge } from "prom-client"; import { metricsRegister } from "~/metrics.server"; import { logger } from "~/services/logger.server"; import { signalsEmitter } from "~/services/signals.server"; +import { singleton } from "~/utils/singleton"; -const loadFailures = new Counter({ - name: "reloading_registry_load_failures_total", - help: "Failed loads of a reloading registry", - labelNames: ["name"], - registers: [metricsRegister], -}); - -const lastSuccessfulLoadAt = new Gauge({ - name: "reloading_registry_last_successful_load_timestamp_seconds", - help: "Unix time of the last successful registry load (staleness signal)", - labelNames: ["name"], - registers: [metricsRegister], -}); - -// 0 until the first successful load, then 1. Starts at 0 (not absent) so a -// never-loaded registry is an alertable series, distinct from "feature off". -const registryLoaded = new Gauge({ - name: "reloading_registry_loaded", - help: "1 once the registry has loaded at least once, else 0 (0 = serving cold fallback)", - labelNames: ["name"], - registers: [metricsRegister], -}); +// singleton: module-scope registrations double-register under Vite dev HMR +const { loadFailures, lastSuccessfulLoadAt, registryLoaded } = singleton( + "reloadingRegistryMetrics", + () => ({ + loadFailures: new Counter({ + name: "reloading_registry_load_failures_total", + help: "Failed loads of a reloading registry", + labelNames: ["name"], + registers: [metricsRegister], + }), + lastSuccessfulLoadAt: new Gauge({ + name: "reloading_registry_last_successful_load_timestamp_seconds", + help: "Unix time of the last successful registry load (staleness signal)", + labelNames: ["name"], + registers: [metricsRegister], + }), + // 0 until the first successful load, then 1. Starts at 0 (not absent) so a + // never-loaded registry is an alertable series, distinct from "feature off". + registryLoaded: new Gauge({ + name: "reloading_registry_loaded", + help: "1 once the registry has loaded at least once, else 0 (0 = serving cold fallback)", + labelNames: ["name"], + registers: [metricsRegister], + }), + }) +); export type ReloadingRegistry = { isReady: Promise; From 653cb439b741258f4c7824e5d2aae436c477ce2a Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 8 Jul 2026 13:40:30 +0000 Subject: [PATCH 04/10] fix(webapp): export the builder loader from converted API routes createActionApiRoute's loader handles CORS OPTIONS preflight; the destructured exports never surfaced it (pre-existing), do it now. --- apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts | 2 ++ .../routes/api.v1.queues.$queueParam.concurrency.override.ts | 2 ++ .../app/routes/api.v1.queues.$queueParam.concurrency.reset.ts | 2 ++ apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts | 2 ++ 4 files changed, 8 insertions(+) diff --git a/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts b/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts index d0304035f7b..2943c21cca6 100644 --- a/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts +++ b/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts @@ -52,3 +52,5 @@ const route = createActionApiRoute( ); export const action = route.action; +// The builder's loader handles CORS OPTIONS preflight +export const loader = route.loader; diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts index 1d3aa69b152..e5e4a926ee6 100644 --- a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts @@ -75,3 +75,5 @@ const route = createActionApiRoute( ); export const action = route.action; +// The builder's loader handles CORS OPTIONS preflight +export const loader = route.loader; diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts index 78c2729808e..a7aca141932 100644 --- a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts @@ -75,3 +75,5 @@ const route = createActionApiRoute( ); export const action = route.action; +// The builder's loader handles CORS OPTIONS preflight +export const loader = route.loader; diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts index 98ecd290c4b..00f94853f43 100644 --- a/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts @@ -46,3 +46,5 @@ const route = createActionApiRoute( ); export const action = route.action; +// The builder's loader handles CORS OPTIONS preflight +export const loader = route.loader; From 9aadad25e3c449ccba083ee644f1eb0f4e6685c5 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Sun, 12 Jul 2026 04:58:22 +0000 Subject: [PATCH 05/10] fix(webapp): close vite dev server on shutdown --- apps/webapp/server.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/webapp/server.ts b/apps/webapp/server.ts index 2b96d24ead3..93c0cab917b 100644 --- a/apps/webapp/server.ts +++ b/apps/webapp/server.ts @@ -272,6 +272,8 @@ async function startServer() { console.log("Express server closed gracefully."); } }); + // Dev-only: release Vite's file watchers and HMR websocket + viteDevServer?.close(); } process.on("SIGTERM", closeServer); From 140073d4c1afbe226596e716e16331aeace9bd8e Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 9 Jul 2026 15:16:43 +0000 Subject: [PATCH 06/10] feat(webapp): add light theme behind feature flag - semantic token overrides for light, trigger.light editor/code palettes - Interface theme dropdown on /account, gated by hasThemeSwitcher flag - unify light surfaces: buttons, inputs, radios, checkboxes, tabs, clipboard fields - fix checkbox :read-only override and useThemeColor hydration mismatch - resolve Firefox panel animation check from request UA --- .server-changes/light-theme.md | 6 + apps/webapp/app/components/AskAI.tsx | 2 +- .../app/components/admin/debugTooltip.tsx | 2 +- .../billing/AnimatedOrgBannerBar.tsx | 4 +- .../billing/BillingLimitRecoveryPanel.tsx | 2 +- .../app/components/billing/UsageBar.tsx | 2 +- .../app/components/code/ChartConfigPanel.tsx | 4 +- .../components/primitives/AppliedFilter.tsx | 4 +- .../app/components/primitives/Avatar.tsx | 2 +- .../app/components/primitives/Buttons.tsx | 22 ++- .../app/components/primitives/Checkbox.tsx | 14 +- .../app/components/primitives/ClientTabs.tsx | 4 +- .../components/primitives/ClipboardField.tsx | 8 +- .../app/components/primitives/Input.tsx | 8 +- .../app/components/primitives/RadioButton.tsx | 14 +- .../app/components/primitives/Resizable.tsx | 32 +++- .../primitives/SegmentedControl.tsx | 5 +- .../app/components/primitives/Select.tsx | 2 +- .../app/components/primitives/Switch.tsx | 7 +- .../app/components/runs/v3/RunFilters.tsx | 4 +- apps/webapp/app/components/runs/v3/RunTag.tsx | 25 ++- .../app/components/runs/v3/SpanEvents.tsx | 2 +- .../runs/v3/agent/AgentMessageView.tsx | 4 +- .../components/runs/v3/ai/AIChatMessages.tsx | 2 +- .../components/runs/v3/ai/AIModelSummary.tsx | 2 +- .../components/runs/v3/ai/SpanMetricRow.tsx | 2 +- apps/webapp/app/hooks/useThemeColor.ts | 17 +- apps/webapp/app/root.tsx | 21 ++- .../route.tsx | 41 +++-- .../route.tsx | 4 +- .../route.tsx | 4 +- .../route.tsx | 2 +- .../app/routes/account._index/route.tsx | 95 +++++++++- .../route.tsx | 2 +- ...cts.$projectParam.env.$envParam.vercel.tsx | 2 +- ...ces.orgs.$organizationSlug.select-plan.tsx | 2 +- .../services/dashboardPreferences.server.ts | 27 +++ apps/webapp/app/tailwind.css | 166 ++++++++++++++++++ apps/webapp/app/v3/featureFlags.ts | 3 + 39 files changed, 468 insertions(+), 103 deletions(-) create mode 100644 .server-changes/light-theme.md diff --git a/.server-changes/light-theme.md b/.server-changes/light-theme.md new file mode 100644 index 00000000000..ceaae7914f8 --- /dev/null +++ b/.server-changes/light-theme.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Adds an opt-in light theme behind a feature flag. When enabled, an Interface theme setting appears on the account page (dark stays the default). Code editors and syntax highlighting use the trigger.light palette. diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx index d62ffa5b33d..e7008e114e8 100644 --- a/apps/webapp/app/components/AskAI.tsx +++ b/apps/webapp/app/components/AskAI.tsx @@ -389,7 +389,7 @@ function ChatMessages({
Error generating answer: - + {error} If the problem persists after retrying, please contact support. diff --git a/apps/webapp/app/components/admin/debugTooltip.tsx b/apps/webapp/app/components/admin/debugTooltip.tsx index b4ccb74f88d..0b0954ab361 100644 --- a/apps/webapp/app/components/admin/debugTooltip.tsx +++ b/apps/webapp/app/components/admin/debugTooltip.tsx @@ -40,7 +40,7 @@ function Content({ children }: { children: React.ReactNode }) { const user = useUser(); return ( -
+
User ID diff --git a/apps/webapp/app/components/billing/AnimatedOrgBannerBar.tsx b/apps/webapp/app/components/billing/AnimatedOrgBannerBar.tsx index b0f11b7eba3..58dcc27b3bc 100644 --- a/apps/webapp/app/components/billing/AnimatedOrgBannerBar.tsx +++ b/apps/webapp/app/components/billing/AnimatedOrgBannerBar.tsx @@ -44,7 +44,9 @@ export function AnimatedOrgBannerBar({ /> {children} diff --git a/apps/webapp/app/components/billing/BillingLimitRecoveryPanel.tsx b/apps/webapp/app/components/billing/BillingLimitRecoveryPanel.tsx index 53b3c1695de..1dfcdd73707 100644 --- a/apps/webapp/app/components/billing/BillingLimitRecoveryPanel.tsx +++ b/apps/webapp/app/components/billing/BillingLimitRecoveryPanel.tsx @@ -113,7 +113,7 @@ export function BillingLimitRecoveryPanel({
Action required - + {isGrace ? ( <> Your organization has reached its billing limit. Processing is paused and new runs diff --git a/apps/webapp/app/components/billing/UsageBar.tsx b/apps/webapp/app/components/billing/UsageBar.tsx index 49346492287..fcb6377757c 100644 --- a/apps/webapp/app/components/billing/UsageBar.tsx +++ b/apps/webapp/app/components/billing/UsageBar.tsx @@ -67,7 +67,7 @@ export function UsageBar({ current, billingLimit, tierLimit, isPaying }: UsageBa animate={{ width: tierRunLimitPercentage + "%" }} transition={{ duration: 1.5, type: "spring" }} style={{ width: `${tierRunLimitPercentage}%` }} - className="absolute h-3 rounded-l-sm bg-green-900/50" + className="absolute h-3 rounded-l-sm bg-green-900/20" > @@ -587,7 +587,7 @@ function SeriesColorPicker({ onColorChange(c); setOpen(false); }} - className="group/swatch flex h-6 w-6 items-center justify-center rounded-full border border-white/30" + className="group/swatch flex h-6 w-6 items-center justify-center rounded-full border border-text-bright/30" style={{ backgroundColor: c }} title={c} > diff --git a/apps/webapp/app/components/primitives/AppliedFilter.tsx b/apps/webapp/app/components/primitives/AppliedFilter.tsx index c8fbc4d3ac3..6390a5826d6 100644 --- a/apps/webapp/app/components/primitives/AppliedFilter.tsx +++ b/apps/webapp/app/components/primitives/AppliedFilter.tsx @@ -4,11 +4,11 @@ import { cn } from "~/utils/cn"; const variants = { "secondary/small": { - box: "h-6 bg-secondary rounded pl-1.5 gap-1.5 text-xs divide-x divide-black/15 group-hover:bg-surface-control group-hover:border-border-brighter text-text-bright border border-border-bright", + box: "h-6 bg-secondary rounded pl-1.5 gap-1.5 text-xs divide-x divide-black/15 shadow-xs group-hover:bg-background-raised text-text-bright border border-border-bright/50", clear: "size-6 text-text-bright hover:text-text-bright transition-colors", }, "tertiary/small": { - box: "h-6 bg-tertiary rounded pl-1.5 gap-1.5 text-xs divide-x divide-black/15 group-hover:bg-surface-control", + box: "h-6 bg-tertiary rounded pl-1.5 gap-1.5 text-xs divide-x divide-black/15 group-hover:bg-background-raised", clear: "size-6 text-text-dimmed hover:text-text-bright transition-colors", }, "minimal/medium": { diff --git a/apps/webapp/app/components/primitives/Avatar.tsx b/apps/webapp/app/components/primitives/Avatar.tsx index 9fc6832fcc4..e4de8a8b478 100644 --- a/apps/webapp/app/components/primitives/Avatar.tsx +++ b/apps/webapp/app/components/primitives/Avatar.tsx @@ -138,7 +138,7 @@ function AvatarLetters({ return ( {/* This is the square container */} diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 323f3743826..67d511e3948 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -49,18 +49,17 @@ type Size = keyof typeof sizes; const theme = { primary: { - textColor: - "text-text-bright group-hover/button:text-white transition group-disabled/button:text-text-dimmed", + textColor: "text-white transition group-disabled/button:text-white/60", button: "bg-indigo-600 border border-indigo-500 group-hover/button:bg-indigo-500 group-hover/button:border-indigo-400 group-disabled/button:opacity-50 group-disabled/button:bg-indigo-600 group-disabled/button:border-indigo-500 group-disabled/button:pointer-events-none", shortcut: - "border-text-bright/40 text-text-bright group-hover/button:border-text-bright/60 group-hover/button:text-text-bright", - icon: "text-text-bright", + "border-white/40 text-white group-hover/button:border-white/60 group-hover/button:text-white", + icon: "text-white", }, secondary: { textColor: "text-text-bright transition group-disabled/button:text-text-dimmed/80", button: - "bg-secondary group-hover/button:bg-surface-control group-hover/button:border-border-brighter border border-border-bright group-disabled/button:bg-secondary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none", + "bg-secondary border border-border-bright/50 shadow-xs group-hover/button:bg-background-raised group-disabled/button:bg-secondary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none", shortcut: "border-text-dimmed/40 text-text-dimmed group-hover/button:text-text-bright group-hover/button:border-text-dimmed", icon: "text-text-bright", @@ -82,17 +81,16 @@ const theme = { icon: "text-text-dimmed", }, danger: { - textColor: - "text-text-bright group-hover/button:text-white transition group-disabled/button:text-text-bright/80", + textColor: "text-white transition group-disabled/button:text-white/80", button: "bg-error group-hover/button:bg-rose-500 disabled:opacity-50 group-disabled/button:bg-error group-disabled/button:pointer-events-none", - shortcut: "border-text-bright text-text-bright group-hover/button:border-text-bright/60", - icon: "text-text-bright", + shortcut: "border-white text-white group-hover/button:border-white/60", + icon: "text-white", }, docs: { - textColor: "text-blue-200/70 transition group-disabled/button:text-text-dimmed/80", + textColor: "text-callout-docs-text/70 transition group-disabled/button:text-text-dimmed/80", button: - "bg-background-raised border border-border-bright/50 shadow-sm group-hover/button:bg-secondary group-disabled/button:bg-tertiary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none", + "bg-secondary border border-border-bright/50 shadow-xs group-hover/button:bg-background-raised group-disabled/button:bg-tertiary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none", shortcut: "border-text-dimmed/40 text-text-dimmed group-hover/button:text-text-bright group-hover/button:border-text-dimmed", icon: "text-blue-500", @@ -167,7 +165,7 @@ const variant = { }; const allVariants = { - $all: "font-normal text-center font-sans justify-center items-center shrink-0 transition duration-150 rounded-[3px] select-none group-focus/button:outline-hidden group-disabled/button:opacity-75 group-disabled/button:pointer-events-none focus-custom", + $all: "cursor-pointer font-normal text-center font-sans justify-center items-center shrink-0 transition duration-150 rounded-[3px] select-none group-focus/button:outline-hidden group-disabled/button:opacity-75 group-disabled/button:pointer-events-none focus-custom", variant: variant, }; diff --git a/apps/webapp/app/components/primitives/Checkbox.tsx b/apps/webapp/app/components/primitives/Checkbox.tsx index 75850b304d9..da63e2ed993 100644 --- a/apps/webapp/app/components/primitives/Checkbox.tsx +++ b/apps/webapp/app/components/primitives/Checkbox.tsx @@ -139,7 +139,11 @@ export const CheckboxWithLabel = React.forwardRef( : props.readOnly ? "cursor-default" : "cursor-pointer", - "read-only:border-border-bright disabled:border-border-bright disabled:opacity-50 rounded-sm border border-border-bright bg-transparent transition checked:bg-indigo-500! read-only:bg-background-raised! group-hover:bg-background-deep checked:group-hover:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-hidden focus-visible:ring-indigo-500 disabled:bg-background-raised!" + // NB: don't use the `read-only:` variant here — checkboxes always + // match :read-only, so it would override the checked style. + "rounded-sm border border-border-bright bg-transparent transition checked:bg-indigo-500! group-hover:bg-background-deep checked:group-hover:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-hidden focus-visible:ring-indigo-500", + props.disabled && "opacity-50", + (props.disabled || props.readOnly) && + "bg-background-raised! checked:bg-background-raised! checked:group-hover:bg-background-raised! group-hover:bg-background-raised!", + className )} {...props} ref={ref} diff --git a/apps/webapp/app/components/primitives/ClientTabs.tsx b/apps/webapp/app/components/primitives/ClientTabs.tsx index 533a09c84e6..c564c425164 100644 --- a/apps/webapp/app/components/primitives/ClientTabs.tsx +++ b/apps/webapp/app/components/primitives/ClientTabs.tsx @@ -122,10 +122,10 @@ const ClientTabsTrigger = React.forwardRef< ) : ( -
+
) ) : null} diff --git a/apps/webapp/app/components/primitives/ClipboardField.tsx b/apps/webapp/app/components/primitives/ClipboardField.tsx index 0304e5bca6b..07d82f5efe3 100644 --- a/apps/webapp/app/components/primitives/ClipboardField.tsx +++ b/apps/webapp/app/components/primitives/ClipboardField.tsx @@ -5,7 +5,7 @@ import { CopyButton } from "./CopyButton"; const variants = { "primary/small": { container: - "flex items-center text-text-dimmed font-mono rounded border bg-background-hover text-xs transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", + "flex items-center text-text-dimmed font-mono rounded border border-border-bright/50 shadow-xs bg-input-bg text-xs transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", input: "bg-transparent border-0 text-xs px-2 w-auto rounded-l h-6 leading-6 focus:ring-transparent", buttonVariant: "primary" as const, @@ -14,7 +14,7 @@ const variants = { }, "secondary/small": { container: - "flex items-center text-text-dimmed font-mono rounded border bg-background-hover text-xs transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", + "flex items-center text-text-dimmed font-mono rounded border border-border-bright/50 shadow-xs bg-input-bg text-xs transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", input: "bg-transparent border-0 text-xs px-2 w-auto rounded-l h-6 leading-6 focus:ring-transparent", buttonVariant: "tertiary" as const, @@ -33,7 +33,7 @@ const variants = { }, "primary/medium": { container: - "flex items-center text-text-dimmed font-mono rounded border bg-background-hover text-sm transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", + "flex items-center text-text-dimmed font-mono rounded border border-border-bright/50 shadow-xs bg-input-bg text-sm transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", input: "bg-transparent border-0 text-sm px-3 w-auto rounded-l h-8 leading-6 focus:ring-transparent", buttonVariant: "primary" as const, @@ -42,7 +42,7 @@ const variants = { }, "secondary/medium": { container: - "flex items-center text-text-dimmed font-mono rounded bg-background-hover text-sm transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", + "flex items-center text-text-dimmed font-mono rounded border border-border-bright/50 shadow-xs bg-input-bg text-sm transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", input: "bg-transparent border-0 text-sm px-3 w-auto rounded-l h-8 leading-6 focus:ring-transparent", buttonVariant: "tertiary" as const, diff --git a/apps/webapp/app/components/primitives/Input.tsx b/apps/webapp/app/components/primitives/Input.tsx index 4f20c0c11d6..5c3235a66c9 100644 --- a/apps/webapp/app/components/primitives/Input.tsx +++ b/apps/webapp/app/components/primitives/Input.tsx @@ -12,21 +12,21 @@ const inputBase = const variants = { large: { container: - "px-1 w-full h-10 rounded-[3px] border border-background-bright bg-background-hover hover:border-border-bright hover:bg-secondary", + "px-1 w-full h-10 rounded-[3px] border border-border-bright/50 shadow-xs bg-input-bg hover:bg-background-raised", input: "px-2 text-sm", iconSize: "size-4 ml-1", accessory: "pr-1", }, medium: { container: - "px-1 h-8 w-full rounded border border-background-bright bg-background-hover hover:border-border-bright hover:bg-secondary", + "px-1 h-8 w-full rounded border border-border-bright/50 shadow-xs bg-input-bg hover:bg-background-raised", input: "px-1.5 rounded text-sm", iconSize: "size-4 ml-0.5", accessory: "pr-1", }, small: { container: - "px-1 h-6 w-full rounded border border-background-bright bg-background-hover hover:border-border-bright hover:bg-secondary", + "px-1 h-6 w-full rounded border border-border-bright/50 shadow-xs bg-input-bg hover:bg-background-raised", input: "px-1 rounded text-xs", iconSize: "size-3 ml-0.5", accessory: "pr-0.5", @@ -39,7 +39,7 @@ const variants = { }, "secondary-small": { container: - "px-1 h-6 w-full rounded border border-border-bright hover:border-border-brighter bg-grid-dimmed hover:bg-secondary", + "px-1 h-6 w-full rounded border border-border-bright/50 shadow-xs bg-input-bg hover:bg-background-raised", input: "px-1 rounded text-xs", iconSize: "size-3 ml-0.5", accessory: "pr-0.5", diff --git a/apps/webapp/app/components/primitives/RadioButton.tsx b/apps/webapp/app/components/primitives/RadioButton.tsx index c52389f788f..bf7c736d728 100644 --- a/apps/webapp/app/components/primitives/RadioButton.tsx +++ b/apps/webapp/app/components/primitives/RadioButton.tsx @@ -22,7 +22,7 @@ const variants = { }, "button/small": { button: - "flex items-center w-fit h-8 pl-2 pr-3 rounded-md border hover:data-[state=checked]:border-border-bright border-border-bright hover:border-border-bright transition data-disabled:opacity-70 data-disabled:hover:bg-transparent hover:data-[state=checked]:bg-white/4 data-[state=checked]:bg-white/4", + "flex items-center w-fit h-8 pl-2 pr-3 rounded-md border border-border-bright/50 shadow-xs bg-secondary transition hover:bg-background-raised data-disabled:opacity-70 data-disabled:hover:bg-secondary hover:data-[state=checked]:bg-text-bright/4 data-[state=checked]:bg-text-bright/4", label: "text-sm text-text-bright select-none", description: "text-text-dimmed", inputPosition: "mt-0", @@ -30,7 +30,7 @@ const variants = { }, button: { button: - "w-fit py-2 pl-3 pr-4 rounded border border-border-bright hover:bg-background-dimmed hover:border-border-brightest transition data-[state=checked]:bg-background-dimmed data-disabled:opacity-70", + "w-fit py-2 pl-3 pr-4 rounded border border-border-bright/50 shadow-xs bg-secondary hover:bg-background-raised transition data-[state=checked]:bg-background-dimmed data-disabled:opacity-70", label: "text-text-bright select-none", description: "text-text-dimmed", inputPosition: "mt-1", @@ -38,7 +38,7 @@ const variants = { }, description: { button: - "w-full p-2.5 hover:data-[state=checked]:bg-white/4 data-[state=checked]:bg-white/4 transition data-disabled:opacity-70 hover:border-border-bright border-border-bright hover:data-[state=checked]:border-border-bright border rounded-md", + "w-full p-2.5 rounded-md border border-border-bright/50 shadow-xs bg-secondary transition hover:bg-background-raised data-disabled:opacity-70 hover:data-[state=checked]:bg-text-bright/4 data-[state=checked]:bg-text-bright/4", label: "text-text-bright font-semibold -mt-0.5 text-left text-sm", description: "text-text-dimmed mt-0 text-left", inputPosition: "mt-0", @@ -46,7 +46,7 @@ const variants = { }, icon: { button: - "w-full p-2.5 pb-4 hover:bg-background-dimmed transition data-disabled:opacity-70 data-[state=checked]:bg-background-dimmed border-border-bright border rounded-sm", + "w-full p-2.5 pb-4 rounded-sm border border-border-bright/50 shadow-xs bg-secondary hover:bg-background-raised transition data-disabled:opacity-70 data-[state=checked]:bg-background-dimmed", label: "text-text-bright font-semibold -mt-1 text-left", description: "text-text-dimmed mt-0 text-left", inputPosition: "mt-0", @@ -81,9 +81,7 @@ export function RadioButtonCircle({ outerCircleClassName )} > - +
)}
@@ -136,7 +134,7 @@ export const RadioGroupItem = React.forwardRef< )} > - +
diff --git a/apps/webapp/app/components/primitives/Resizable.tsx b/apps/webapp/app/components/primitives/Resizable.tsx index 59550332c26..0bd4f8e86d9 100644 --- a/apps/webapp/app/components/primitives/Resizable.tsx +++ b/apps/webapp/app/components/primitives/Resizable.tsx @@ -2,6 +2,8 @@ import React, { useRef } from "react"; import { PanelGroup, Panel, PanelResizer } from "@window-splitter/react"; +import { useTypedMatchesData } from "~/hooks/useTypedMatchData"; +import type { loader as rootLoader } from "~/root"; import { cn } from "~/utils/cn"; const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps) => ( @@ -14,7 +16,25 @@ const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps ); -const ResizablePanel = Panel; +// react-window-splitter drives the collapse animation through @react-spring/rafz, +// which has timing/interaction issues with Firefox that produce visual glitches +// (alternating frames, panels stuck at min, panelHasSpace invariant violations), +// so the animation is dropped on Firefox. The browser check must agree between +// SSR and hydration (a client-only `navigator` check made the panel tree differ +// and shifted useIds), so it comes from the root loader's user-agent sniff. +const ResizablePanel = React.forwardRef< + React.ElementRef, + React.ComponentProps +>(function ResizablePanel({ collapseAnimation, ...props }, ref) { + const rootData = useTypedMatchesData({ id: "root" }); + return ( + + ); +}); const ResizableHandle = ({ withHandle = true, @@ -69,14 +89,8 @@ const ResizableHandle = ({ ); -// react-window-splitter drives the collapse animation through @react-spring/rafz, -// which has timing/interaction issues with Firefox that produce visual glitches -// (alternating frames, panels stuck at min, panelHasSpace invariant violations). -// Disable the animation on Firefox; it works correctly in Chromium and Safari. -const RESIZABLE_PANEL_ANIMATION = - typeof navigator !== "undefined" && /firefox/i.test(navigator.userAgent) - ? undefined - : ({ easing: "ease-in-out", duration: 300 } as const); +// Firefox filtering happens inside ResizablePanel (see above). +const RESIZABLE_PANEL_ANIMATION = { easing: "ease-in-out", duration: 300 } as const; const COLLAPSIBLE_HANDLE_CLASSNAME = "transition-opacity duration-200"; diff --git a/apps/webapp/app/components/primitives/SegmentedControl.tsx b/apps/webapp/app/components/primitives/SegmentedControl.tsx index 2f749215c29..a3c365b930b 100644 --- a/apps/webapp/app/components/primitives/SegmentedControl.tsx +++ b/apps/webapp/app/components/primitives/SegmentedControl.tsx @@ -24,10 +24,11 @@ const theme = { selected: "absolute inset-0 rounded-[2px] outline-solid outline-3 outline-primary", }, secondary: { - base: "bg-background-raised/50", + base: "bg-transparent dark:bg-background-raised/50", active: "text-text-bright", inactive: "text-text-dimmed transition hover:text-text-bright", - selected: "absolute inset-0 rounded bg-background-raised border border-border-bright", + selected: + "absolute inset-0 rounded bg-white border border-[#e2e4e9] dark:bg-background-raised dark:border-border-bright", }, }; diff --git a/apps/webapp/app/components/primitives/Select.tsx b/apps/webapp/app/components/primitives/Select.tsx index 66b291b7524..54b160edfa2 100644 --- a/apps/webapp/app/components/primitives/Select.tsx +++ b/apps/webapp/app/components/primitives/Select.tsx @@ -30,7 +30,7 @@ const style = { }, secondary: { button: - "bg-secondary focus-custom border border-border-bright hover:text-text-bright hover:border-border-brighter text-text-bright hover:bg-surface-control", + "bg-secondary focus-custom border border-border-bright/50 shadow-xs hover:text-text-bright text-text-bright hover:bg-background-raised", }, }; diff --git a/apps/webapp/app/components/primitives/Switch.tsx b/apps/webapp/app/components/primitives/Switch.tsx index 96943e9bebb..27ed530492c 100644 --- a/apps/webapp/app/components/primitives/Switch.tsx +++ b/apps/webapp/app/components/primitives/Switch.tsx @@ -36,7 +36,7 @@ const variations = { "secondary/small": { container: cn( small.container, - "border border-border-bright hover:border-border-brighter bg-secondary hover:bg-surface-control" + "border border-border-bright/50 shadow-xs bg-secondary hover:bg-background-raised" ), root: cn( small.root, @@ -96,10 +96,7 @@ export const Switch = React.forwardRef
); diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index f5980d9e597..8d812a413fd 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -673,7 +673,7 @@ function PermanentStatusFilter() { className="pl-1" /> ) : ( -
+
@@ -852,7 +852,7 @@ function PermanentTasksFilter({ possibleTasks }: Pick ) : ( -
+
{filterIcon("tasks")} Tasks
diff --git a/apps/webapp/app/components/runs/v3/RunTag.tsx b/apps/webapp/app/components/runs/v3/RunTag.tsx index 3d6c6751ce3..1defae01674 100644 --- a/apps/webapp/app/components/runs/v3/RunTag.tsx +++ b/apps/webapp/app/components/runs/v3/RunTag.tsx @@ -1,5 +1,4 @@ import { useCallback, useMemo, useState } from "react"; -import tagLeftPath from "./tag-left.svg"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { Link } from "@remix-run/react"; import { cn } from "~/utils/cn"; @@ -7,6 +6,26 @@ import { ClipboardCheckIcon, ClipboardIcon, XIcon } from "lucide-react"; type Tag = string | { key: string; value: string }; +function TagNotch() { + return ( + + ); +} + export function RunTag({ tag, to, @@ -26,7 +45,7 @@ export function RunTag({ if (typeof tagResult === "string") { return ( <> - + {tag} @@ -35,7 +54,7 @@ export function RunTag({ } else { return ( <> - + {tagResult.key} diff --git a/apps/webapp/app/components/runs/v3/SpanEvents.tsx b/apps/webapp/app/components/runs/v3/SpanEvents.tsx index c79a147b86f..069246c89b7 100644 --- a/apps/webapp/app/components/runs/v3/SpanEvents.tsx +++ b/apps/webapp/app/components/runs/v3/SpanEvents.tsx @@ -85,7 +85,7 @@ export function SpanEventError({ /> {enhancedException.message && ( -
+          
             {enhancedException.message}
           
diff --git a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx index a82d8b9d0c6..3941283be40 100644 --- a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx +++ b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx @@ -136,7 +136,9 @@ export function renderPart(part: UIMessage["parts"][number], i: number) { return (
-
{p.text ?? ""}
+
+ {p.text ?? ""} +
); diff --git a/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx b/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx index a5115897f97..cc02a280e0d 100644 --- a/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx +++ b/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx @@ -483,7 +483,7 @@ function SubAgentContent({ parts }: { parts: any[] }) { if (partType === "reasoning" && part.text) { return (
-
+
{part.text}
diff --git a/apps/webapp/app/components/runs/v3/ai/AIModelSummary.tsx b/apps/webapp/app/components/runs/v3/ai/AIModelSummary.tsx index 11ef7aa8a52..775efd7c308 100644 --- a/apps/webapp/app/components/runs/v3/ai/AIModelSummary.tsx +++ b/apps/webapp/app/components/runs/v3/ai/AIModelSummary.tsx @@ -128,7 +128,7 @@ function MetricRow({ bold?: boolean; }) { return ( -
+
{label} +
{label} {value}
diff --git a/apps/webapp/app/hooks/useThemeColor.ts b/apps/webapp/app/hooks/useThemeColor.ts index d78fd39fa79..61e1473e140 100644 --- a/apps/webapp/app/hooks/useThemeColor.ts +++ b/apps/webapp/app/hooks/useThemeColor.ts @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; /** * Normalize any CSS color (hex, oklch, hsl, ...) to rgb()/rgba() by rendering @@ -17,16 +17,17 @@ function toRgb(color: string): string { } /** - * Resolve a theme CSS variable to a concrete, animatable color once on mount. + * Resolve a theme CSS variable to a concrete, animatable color on mount. * framer-motion can't interpolate `var()` strings or oklch values, so animated - * colors must be resolved and normalized first. The fallback is used during - * SSR and should match the default dark theme (see tailwind.css). + * colors must be resolved and normalized first. Resolution happens in an + * effect so server and hydration renders both use the fallback — resolving + * during render caused hydration style mismatches. */ export function useThemeColor(variable: `--${string}`, fallback: string): string { - const [color] = useState(() => { - if (typeof document === "undefined") return fallback; + const [color, setColor] = useState(fallback); + useEffect(() => { const value = getComputedStyle(document.documentElement).getPropertyValue(variable).trim(); - return value ? toRgb(value) : fallback; - }); + if (value) setColor(toRgb(value)); + }, [variable]); return color; } diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index 66df9ddff53..0270e7be4ee 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -18,6 +18,7 @@ import { env } from "./env.server"; import { featuresForRequest } from "./features.server"; import { usePostHog } from "./hooks/usePostHog"; import { getUser } from "./services/session.server"; +import { flag } from "~/v3/featureFlags.server"; import { getTimezonePreference } from "./services/preferences/uiPreferences.server"; import { appEnvTitleTag } from "./utils"; @@ -63,6 +64,11 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }; const user = await getUser(request); + // Theme switching is feature-flagged; while off, everyone stays on dark + // even if a preference was saved earlier. + const showThemeSwitcher = user + ? await flag({ key: "hasThemeSwitcher", defaultValue: false }) + : false; const headers = new Headers(); headers.append("Set-Cookie", await commitSession(session)); @@ -80,6 +86,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { triggerCliTag: env.TRIGGER_CLI_TAG, kapa, timezone, + showThemeSwitcher, + // Consumed by ResizablePanel: the browser check must match between SSR + // and hydration, so it is derived from the request user-agent. + isFirefox: /firefox/i.test(request.headers.get("user-agent") ?? ""), }, { headers } ); @@ -121,12 +131,19 @@ export function ErrorBoundary() { } export default function App() { - const { posthogProjectKey, posthogUiHost, kapa: _kapa } = useTypedLoaderData(); + const { + posthogProjectKey, + posthogUiHost, + kapa: _kapa, + user, + showThemeSwitcher, + } = useTypedLoaderData(); usePostHog(posthogProjectKey, posthogUiHost); + const theme = (showThemeSwitcher ? user?.dashboardPreferences.theme : "dark") ?? "dark"; return ( <> - + 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 e27b11b7545..37d7c58bc5e 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 @@ -11,7 +11,6 @@ import { Feedback } from "~/components/Feedback"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { EnvironmentSelector } from "~/components/navigation/EnvironmentSelector"; import { AnimatedNumber } from "~/components/primitives/AnimatedNumber"; -import { Badge } from "~/components/primitives/Badge"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Header2 } from "~/components/primitives/Headers"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; @@ -445,21 +444,24 @@ function RateLimitTypeBadge({ switch (rateLimitType) { case "tokenBucket": return ( - - Token bucket - + ); case "fixedWindow": return ( - - Fixed window - + ); case "slidingWindow": return ( - - Sliding window - + ); default: return null; @@ -858,19 +860,32 @@ function getUsageColorClass( } } +function RateLimitTypePill({ className, label }: { className: string; label: string }) { + return ( + + {label} + + ); +} + function SourceBadge({ source }: { source: "default" | "plan" | "override" }) { const variants: Record = { default: { label: "Default", - className: "bg-indigo-500/20 text-indigo-400", + className: "bg-indigo-500/20 text-indigo-600 dark:text-indigo-400", }, plan: { label: "Plan", - className: "bg-purple-500/20 text-purple-400", + className: "bg-purple-500/20 text-purple-600 dark:text-purple-400", }, override: { label: "Override", - className: "bg-amber-500/20 text-amber-400", + className: "bg-amber-500/20 text-amber-700 dark:text-amber-400", }, }; 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 34d9a613d9d..aafb685f7d2 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 @@ -1482,7 +1482,7 @@ function TimelineView({ {(ms) => ( setFaviconError(false)} /> ) : ( - + )} { return json && typeof json === "object" ? (json as Record) : {}; 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 e58afdb65df..1e92cb08eeb 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 @@ -668,7 +668,7 @@ function DomainList({ domains }: { domains: ReadonlyArray }) {
{d.domain} {d.state === "failed" && d.verificationFailedReason && ( - + Reason: {d.verificationFailedReason} )} diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index b4b92c8a133..45e14a007b8 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -1,7 +1,14 @@ import { getFormProps, getInputProps, useForm } from "@conform-to/react"; import { conformZodMessage, parseWithZod } from "@conform-to/zod"; -import { Form, type MetaFunction, useActionData } from "@remix-run/react"; -import { type ActionFunction, json } from "@remix-run/server-runtime"; +import { MoonIcon, SunIcon } from "@heroicons/react/20/solid"; +import { + Form, + type MetaFunction, + useActionData, + useFetcher, + useLoaderData, +} from "@remix-run/react"; +import { type ActionFunction, json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; import { AvatarCircleIcon } from "~/assets/icons/AvatarCircleIcon"; import { EnvelopeIcon } from "~/assets/icons/EnvelopeIcon"; @@ -13,6 +20,7 @@ import { } from "~/components/layout/AppLayout"; import { Button } from "~/components/primitives/Buttons"; import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; +import { Select, SelectItem } from "~/components/primitives/Select"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; @@ -26,7 +34,9 @@ import { prisma } from "~/db.server"; import { useUser } from "~/hooks/useUser"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { updateUser } from "~/models/user.server"; -import { requireUserId } from "~/services/session.server"; +import { updateThemePreference } from "~/services/dashboardPreferences.server"; +import { flag } from "~/v3/featureFlags.server"; +import { requireUser, requireUserId } from "~/services/session.server"; import { accountPath } from "~/utils/pathBuilder"; export const meta: MetaFunction = () => { @@ -75,11 +85,28 @@ function createSchema( }); } +export async function loader({ request }: LoaderFunctionArgs) { + await requireUserId(request); + const showThemeSwitcher = await flag({ key: "hasThemeSwitcher", defaultValue: false }); + return json({ showThemeSwitcher }); +} + export const action: ActionFunction = async ({ request }) => { const userId = await requireUserId(request); const formData = await request.formData(); + if (formData.get("action") === "update-theme") { + const showThemeSwitcher = await flag({ key: "hasThemeSwitcher", defaultValue: false }); + if (!showThemeSwitcher) { + return json({ error: "Not available" }, { status: 404 }); + } + const user = await requireUser(request); + const theme = formData.get("theme") === "light" ? "light" : "dark"; + await updateThemePreference({ user, theme }); + return json({ success: true }); + } + const formSchema = createSchema({ isEmailUnique: async (email) => { const existingUser = await prisma.user.findFirst({ @@ -126,7 +153,14 @@ export const action: ActionFunction = async ({ request }) => { export default function Page() { const user = useUser(); + const { showThemeSwitcher } = useLoaderData(); const lastSubmission = useActionData(); + const themeFetcher = useFetcher(); + const pendingTheme = themeFetcher.formData?.get("theme"); + const theme = + typeof pendingTheme === "string" + ? (pendingTheme as "dark" | "light") + : (user.dashboardPreferences.theme ?? "dark"); const [form, { name, email, marketingEmails }] = useForm({ id: "account", @@ -195,6 +229,61 @@ export default function Page() { /> + {showThemeSwitcher && ( + <> +
+ Appearance +
+
+ + + value={theme} + setValue={(value) => + themeFetcher.submit( + { action: "update-theme", theme: value }, + { method: "post" } + ) + } + variant="secondary/small" + dropdownIcon + items={["dark", "light"]} + text={(value) => ( + + {value === "dark" ? ( + + + + ) : ( + + )} + {value === "dark" ? "Dark" : "Light"} + + )} + className="w-32" + > + {(items) => + items.map((item) => ( + + + + ) : ( + + ) + } + > + {item === "dark" ? "Dark" : "Light"} + + )) + } + +
+ + )} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index 4c8f77a582a..6db0b6ff566 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -1226,7 +1226,7 @@ function RunError({ error }: { error: TaskRunError }) { {name} {enhancedError.message && ( -
+              
                 {enhancedError.message}
               
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index 7a5f61a48dc..b05d22439da 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -1125,7 +1125,7 @@ function VercelSettingsPanel({

Failed to load Vercel settings

-

+

There was an error loading the Vercel integration settings. Please refresh the page to try again.

diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx index 7414960f47a..67e68e1360a 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx @@ -759,7 +759,7 @@ export function TierEnterprise() { +
Contact us
} diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts index 7af007dc381..fafd1438427 100644 --- a/apps/webapp/app/services/dashboardPreferences.server.ts +++ b/apps/webapp/app/services/dashboardPreferences.server.ts @@ -25,6 +25,7 @@ export type { SideMenuSectionId }; const DashboardPreferences = z.object({ version: z.literal("1"), + theme: z.enum(["dark", "light"]).optional(), currentProjectId: z.string().optional(), projects: z.record( z.string(), @@ -101,6 +102,32 @@ export async function updateCurrentProjectEnvironmentId({ }); } +export async function updateThemePreference({ + user, + theme, +}: { + user: UserFromSession; + theme: "dark" | "light"; +}) { + if (user.isImpersonating) { + return; + } + + if (user.dashboardPreferences.theme === theme) { + return; + } + + const updatedPreferences: DashboardPreferences = { + ...user.dashboardPreferences, + theme, + }; + + return prisma.user.update({ + where: { id: user.id }, + data: { dashboardPreferences: updatedPreferences }, + }); +} + export async function clearCurrentProject({ user }: { user: UserFromSession }) { if (user.isImpersonating) { return; diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css index 5a6429fce80..8bf9c3e1305 100644 --- a/apps/webapp/app/tailwind.css +++ b/apps/webapp/app/tailwind.css @@ -158,6 +158,7 @@ --color-surface-control: var(--color-charcoal-600); --color-surface-control-hover: var(--color-charcoal-550); --color-surface-control-active: var(--color-charcoal-500); + --color-input-bg: var(--color-charcoal-750); /* Borders, from subtlest to most visible */ --color-grid-dimmed: var(--color-charcoal-750); @@ -599,3 +600,168 @@ @apply leading-relaxed; } } + +/* + Light theme. Overrides the themable variables only; raw palettes stay put. + Code/editor values come from the trigger.light VS Code theme. +*/ +[data-theme="light"] { + --color-secondary: #ffffff; + --color-input-bg: #ffffff; + + /* shadcn vars consumed by charts/streamdown (tooltip cursor fill etc.) */ + --background: #ffffff; + --foreground: #1a1b1f; + --muted: #eceef1; + --muted-foreground: #5f6570; + --border: #e2e4e9; + --sidebar: #f6f7f8; + --primary-foreground: #ffffff; + + /* Text */ + --color-primary: var(--color-apple-600); + --color-tertiary: #eef0f3; + --color-text-link: var(--color-lavender-600); + --color-text-faint: var(--color-charcoal-400); + --color-text-dimmed: var(--color-charcoal-500); + --color-text-bright: var(--color-charcoal-800); + + /* Surfaces */ + --color-background-deep: #f1f2f4; + --color-background-dimmed: #fbfbfc; + --color-background-bright: #ffffff; + --color-background-hover: #f2f3f5; + --color-background-raised: #e9eaee; + --color-surface-control: #dcdee3; + --color-surface-control-hover: #cfd2d9; + --color-surface-control-active: #b8bcc6; + + /* Borders */ + --color-grid-dimmed: #eceef1; + --color-grid-bright: #e2e4e9; + --color-border-bright: #d2d5db; + --color-border-brighter: #b9bdc7; + --color-border-brightest: #9ba1ad; + + /* Status - darker steps for contrast on white */ + --color-success: var(--color-mint-600); + --color-warning: var(--color-amber-600); + --color-dev: var(--color-pink-600); + --color-prod: var(--color-mint-600); + --color-staging: var(--color-orange-600); + --color-preview: var(--color-yellow-700); + + /* Neutral run statuses: soft light grays for sparkbars and charts */ + --color-run-pending: #ccd0d6; + --color-run-delayed: #d3d6db; + --color-run-waiting-to-resume: #c5c9d0; + --color-run-canceled: #d8dbdf; + --color-run-expired: #dee0e4; + + /* Icons that fail contrast on white */ + --color-schedules: var(--color-yellow-600); + --color-previewBranches: var(--color-yellow-600); + --color-customDashboards: var(--color-charcoal-500); + + /* Callouts - deep tints instead of the dark theme's pastels */ + --color-callout-warning-text: var(--color-yellow-800); + --color-callout-error-text: var(--color-rose-700); + --color-callout-success-text: var(--color-green-800); + --color-callout-docs-text: var(--color-blue-800); + --color-callout-pending-bg: var(--color-blue-100); + --color-callout-pending-text: var(--color-blue-800); + --color-callout-pricing-bg: var(--color-indigo-100); + --color-callout-pricing-text: var(--color-indigo-800); + + /* Code syntax - trigger.light */ + --color-code-background: #ffffff; + --color-code-foreground: #333333; + --color-code-line-number: #a8a8ad; + --color-code-plain: #2e2e4b; + --color-code-muted: #333333; + --color-code-comment: #767a81; + --color-code-keyword: #b114d3; + --color-code-storage: #b114d3; + --color-code-type: #b114d3; + --color-code-function: #6532f5; + --color-code-variable: #404040; + --color-code-constant: #1e1e1e; + --color-code-language: #b114d3; + --color-code-object-key: #222222; + --color-code-string: #262626; + --color-code-template-punctuation: #0879e2; + --color-code-number: #262626; + --color-code-builtin: #3080e0; + --color-code-attribute: #222222; + --color-code-escape: #222222; + --color-code-regexp: #dc3545; + --color-code-regexp-constant: #5f6570; + --color-code-invalid: #dc3545; + --color-code-deleted: #dc3545; + --color-code-jsx-text: #2e2e4b; + + /* CodeMirror - trigger.light */ + --color-editor-background: #ffffff; + --color-editor-foreground: #2e2e4b; + --color-editor-keyword: #b114d3; + --color-editor-name: #197c3c; + --color-editor-function: #6532f5; + --color-editor-constant: #3080e0; + --color-editor-type: #2980b9; + --color-editor-operator: #333333; + --color-editor-comment: #767a81; + --color-editor-heading: #2c3e50; + --color-editor-string: #262626; + --color-editor-invalid: #dc3545; + --color-editor-panel-background: #f4f4f6; + --color-editor-highlight-background: #f8f8fa; + --color-editor-tooltip-background: #ffffff; + --color-editor-selection: #d9dce3; + --color-editor-cursor: #2e2e4b; + --color-editor-search-match: #0879e226; + --color-editor-search-match-outline: #0879e2; + --color-editor-search-match-selected: #0879e240; + --color-editor-selection-match: #e8e8ed; + --color-editor-matching-bracket: rgba(240, 241, 244, 0.9); + --color-editor-matching-bracket-outline: rgba(160, 166, 180, 0.5); + --color-editor-fold-placeholder: #555555; + --color-editor-scrollbar-track-active: #e8e9ec; + --color-editor-scrollbar-thumb: #c9ccd4; + --color-editor-scrollbar-thumb-active: #aeb3be; +} + +/* Streamdown's muted surface has no semantic token (charcoal-775); theme it here */ +[data-theme="light"] .streamdown-container { + --muted: #eceef1; +} + +/* The timeline label shadow is a dark-theme legibility aid; drop it on light */ +[data-theme="light"] .text-shadow-custom { + text-shadow: none; +} + +/* Neutral timeline points: invert to a light dot with a gray ring */ +[data-theme="light"] .timeline-point.bg-surface-control-active { + border-color: var(--color-surface-control-active); + background-color: var(--color-background-bright); +} + +/* Run timeline bars: no fade gradient on light */ +[data-theme="light"] .timeline-span { + background-image: none; +} +/* On saturated bars the duration label keeps the dark-theme treatment, + but only when the bar is wide enough to contain the label — on narrow + bars the sticky label overflows onto the page background, where the + default dark-on-light text is correct. */ +[data-theme="light"] .timeline-span.bg-success, +[data-theme="light"] .timeline-span.bg-error { + container-type: inline-size; +} +@container (min-width: 3.5rem) { + [data-theme="light"] .timeline-span.bg-success .text-shadow-custom, + [data-theme="light"] .timeline-span.bg-error .text-shadow-custom { + color: #ffffff; + text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5); + } +} diff --git a/apps/webapp/app/v3/featureFlags.ts b/apps/webapp/app/v3/featureFlags.ts index 637830aef06..9a6270fa6d3 100644 --- a/apps/webapp/app/v3/featureFlags.ts +++ b/apps/webapp/app/v3/featureFlags.ts @@ -10,6 +10,7 @@ export const FEATURE_FLAG = { hasComputeAccess: "hasComputeAccess", hasPrivateConnections: "hasPrivateConnections", hasSso: "hasSso", + hasThemeSwitcher: "hasThemeSwitcher", mollifierEnabled: "mollifierEnabled", workerQueueScheduledSplitEnabled: "workerQueueScheduledSplitEnabled", realtimeBackend: "realtimeBackend", @@ -33,6 +34,8 @@ export const FeatureFlagCatalog = { [FEATURE_FLAG.hasComputeAccess]: z.coerce.boolean(), [FEATURE_FLAG.hasPrivateConnections]: z.coerce.boolean(), [FEATURE_FLAG.hasSso]: z.coerce.boolean(), + // Gates the Interface theme setting in /account. Off by default. + [FEATURE_FLAG.hasThemeSwitcher]: z.coerce.boolean(), [FEATURE_FLAG.mollifierEnabled]: z.coerce.boolean(), [FEATURE_FLAG.workerQueueScheduledSplitEnabled]: z.coerce.boolean(), // Which backend serves the realtime run feed. Controllable From 942c2b1731a2212640bd18ce017ac1bfdc5772c0 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Sun, 12 Jul 2026 04:55:47 +0000 Subject: [PATCH 07/10] fix(webapp): address light theme review feedback - re-resolve theme colors when data-theme changes - narrow jsonb_set write for theme preference - opaque amber for reasoning text in light mode --- .../runs/v3/agent/AgentMessageView.tsx | 2 +- .../components/runs/v3/ai/AIChatMessages.tsx | 2 +- apps/webapp/app/hooks/useThemeColor.ts | 19 ++++++++++++++---- .../services/dashboardPreferences.server.ts | 20 ++++++++++--------- 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx index 3941283be40..e696f202559 100644 --- a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx +++ b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx @@ -136,7 +136,7 @@ export function renderPart(part: UIMessage["parts"][number], i: number) { return (
-
+
{p.text ?? ""}
diff --git a/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx b/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx index cc02a280e0d..ae46cbe867c 100644 --- a/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx +++ b/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx @@ -483,7 +483,7 @@ function SubAgentContent({ parts }: { parts: any[] }) { if (partType === "reasoning" && part.text) { return (
-
+
{part.text}
diff --git a/apps/webapp/app/hooks/useThemeColor.ts b/apps/webapp/app/hooks/useThemeColor.ts index 61e1473e140..f088d16b525 100644 --- a/apps/webapp/app/hooks/useThemeColor.ts +++ b/apps/webapp/app/hooks/useThemeColor.ts @@ -17,17 +17,28 @@ function toRgb(color: string): string { } /** - * Resolve a theme CSS variable to a concrete, animatable color on mount. + * Resolve a theme CSS variable to a concrete, animatable color. * framer-motion can't interpolate `var()` strings or oklch values, so animated * colors must be resolved and normalized first. Resolution happens in an * effect so server and hydration renders both use the fallback — resolving - * during render caused hydration style mismatches. + * during render caused hydration style mismatches. Long-lived components + * (e.g. the side menu) outlive theme switches, so re-resolve whenever + * `data-theme` flips on . */ export function useThemeColor(variable: `--${string}`, fallback: string): string { const [color, setColor] = useState(fallback); useEffect(() => { - const value = getComputedStyle(document.documentElement).getPropertyValue(variable).trim(); - if (value) setColor(toRgb(value)); + const resolve = () => { + const value = getComputedStyle(document.documentElement).getPropertyValue(variable).trim(); + if (value) setColor(toRgb(value)); + }; + resolve(); + const observer = new MutationObserver(resolve); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + return () => observer.disconnect(); }, [variable]); return color; } diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts index fafd1438427..7bec9ee7e6d 100644 --- a/apps/webapp/app/services/dashboardPreferences.server.ts +++ b/apps/webapp/app/services/dashboardPreferences.server.ts @@ -117,15 +117,17 @@ export async function updateThemePreference({ return; } - const updatedPreferences: DashboardPreferences = { - ...user.dashboardPreferences, - theme, - }; - - return prisma.user.update({ - where: { id: user.id }, - data: { dashboardPreferences: updatedPreferences }, - }); + // Narrow jsonb_set write: a full-blob update from the session snapshot can + // race with other preference writes and drop unrelated fields. + return prisma.$executeRaw` + UPDATE "User" + SET "dashboardPreferences" = jsonb_set( + COALESCE("dashboardPreferences", '{}'::jsonb), + '{theme}', + to_jsonb(${theme}::text) + ) + WHERE id = ${user.id} + `; } export async function clearCurrentProject({ user }: { user: UserFromSession }) { From 030b9a5c444e05826ef7804844a3a07474bdbec6 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 13 Jul 2026 09:50:31 +0000 Subject: [PATCH 08/10] fix(webapp): address light theme review round 2 - keep dark-theme switch/radio thumb colors, white only in light - pick avatar letter color by background luminance - seed required preference fields when jsonb column is null --- apps/webapp/app/components/primitives/Avatar.tsx | 16 ++++++++++++---- .../app/components/primitives/RadioButton.tsx | 9 +++++++-- apps/webapp/app/components/primitives/Switch.tsx | 5 ++++- .../app/services/dashboardPreferences.server.ts | 5 ++++- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/components/primitives/Avatar.tsx b/apps/webapp/app/components/primitives/Avatar.tsx index e4de8a8b478..c514a7f1790 100644 --- a/apps/webapp/app/components/primitives/Avatar.tsx +++ b/apps/webapp/app/components/primitives/Avatar.tsx @@ -117,6 +117,16 @@ function styleFromSize(size: number) { }; } +// Bright tiles (Yellow, Orange) need dark letters for contrast; the rest read +// best with white. +function letterColorForBackground(hex: string): string { + const match = /^#?([0-9a-f]{6})$/i.exec(hex); + if (!match) return "#fff"; + const n = parseInt(match[1], 16); + const luminance = 0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255); + return luminance > 140 ? "#272A2E" : "#fff"; +} + function AvatarLetters({ avatar, size, @@ -132,15 +142,13 @@ function AvatarLetters({ const style = { backgroundColor: avatar.hex, + color: letterColorForBackground(avatar.hex), }; const scaleFactor = includePadding ? 0.8 : 1; return ( - + {/* This is the square container */} - +
)}
@@ -134,7 +139,7 @@ export const RadioGroupItem = React.forwardRef< )} > - +
diff --git a/apps/webapp/app/components/primitives/Switch.tsx b/apps/webapp/app/components/primitives/Switch.tsx index 27ed530492c..e07187176a8 100644 --- a/apps/webapp/app/components/primitives/Switch.tsx +++ b/apps/webapp/app/components/primitives/Switch.tsx @@ -96,7 +96,10 @@ export const Switch = React.forwardRef
); diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts index 7bec9ee7e6d..46bb393224b 100644 --- a/apps/webapp/app/services/dashboardPreferences.server.ts +++ b/apps/webapp/app/services/dashboardPreferences.server.ts @@ -122,7 +122,10 @@ export async function updateThemePreference({ return prisma.$executeRaw` UPDATE "User" SET "dashboardPreferences" = jsonb_set( - COALESCE("dashboardPreferences", '{}'::jsonb), + COALESCE( + "dashboardPreferences", + '{"version":"1","projects":{}}'::jsonb + ), '{theme}', to_jsonb(${theme}::text) ) From 68a919cb4581de5a81ed82eb25bd682654ba90e1 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Tue, 14 Jul 2026 13:03:07 +0000 Subject: [PATCH 09/10] fix(webapp): light theme polish for query, apikeys, alerts, regions, billing - white query editor and apikeys accordion surfaces - white Cancel and Contact us buttons - drop the doubled hover ring on the regions suggest button - route the section-header menu through the shared ellipsis primitive --- .../components/navigation/SideMenuHeader.tsx | 14 ++++++++------ .../app/components/primitives/Popover.tsx | 19 ++++++++++++++++--- .../app/components/query/QueryEditor.tsx | 2 +- .../route.tsx | 2 +- .../route.tsx | 2 +- .../route.tsx | 1 + ...ces.orgs.$organizationSlug.select-plan.tsx | 2 +- apps/webapp/app/tailwind.css | 7 +++++++ 8 files changed, 36 insertions(+), 13 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenuHeader.tsx b/apps/webapp/app/components/navigation/SideMenuHeader.tsx index 80143e9e0d9..75f6224cd15 100644 --- a/apps/webapp/app/components/navigation/SideMenuHeader.tsx +++ b/apps/webapp/app/components/navigation/SideMenuHeader.tsx @@ -1,8 +1,7 @@ import { useNavigation } from "@remix-run/react"; import { useEffect, useState } from "react"; import { motion } from "framer-motion"; -import { Popover, PopoverContent, PopoverCustomTrigger } from "../primitives/Popover"; -import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid"; +import { Popover, PopoverContent, PopoverEllipseTrigger } from "../primitives/Popover"; export function SideMenuHeader({ title, @@ -30,7 +29,7 @@ export function SideMenuHeader({ return ( {children !== undefined ? ( setHeaderMenuOpen(open)} open={isHeaderMenuOpen}> - - - + ) { const styles = popoverVerticalEllipseVariants[variant]; + const Icon = orientation === "horizontal" ? EllipsisHorizontalIcon : EllipsisVerticalIcon; return ( - + ); } +// Back-compat alias: the trigger now supports both orientations. +const PopoverVerticalEllipseTrigger = PopoverEllipseTrigger; + export { Popover, PopoverArrowTrigger, @@ -314,6 +326,7 @@ export { PopoverCustomTrigger, PopoverMenuItem, PopoverSectionHeader, + PopoverEllipseTrigger, PopoverSideMenuTrigger, PopoverTrigger, PopoverVerticalEllipseTrigger, diff --git a/apps/webapp/app/components/query/QueryEditor.tsx b/apps/webapp/app/components/query/QueryEditor.tsx index 37747a77dae..d66de588067 100644 --- a/apps/webapp/app/components/query/QueryEditor.tsx +++ b/apps/webapp/app/components/query/QueryEditor.tsx @@ -241,7 +241,7 @@ const QueryEditorForm = forwardRef< ); return ( -
+
Cancel 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 839b66f70b3..029efaa4d54 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 @@ -190,7 +190,7 @@ export default function Page() { )} - + How to set these environment variables
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 783b60cb1ca..136cc06658c 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 @@ -282,6 +282,7 @@ export default function Page() { Suggest a new region +
Contact us
} diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css index 8bf9c3e1305..6ef03402328 100644 --- a/apps/webapp/app/tailwind.css +++ b/apps/webapp/app/tailwind.css @@ -765,3 +765,10 @@ text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5); } } + +/* The table row-hover menu wraps the always-visible "Suggest a region" button + in a ring container; on light that ring doubles up with the button's own + border, so drop it here. */ +[data-theme="light"] .suggest-region-cell > div > div { + box-shadow: none; +} From 000ccd16bdef3a47e70c27c572dad23e4476d4d0 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Sun, 12 Jul 2026 04:55:48 +0000 Subject: [PATCH 10/10] chore(webapp): default theme switcher on for preview (drop before merge) --- apps/webapp/app/root.tsx | 2 +- apps/webapp/app/routes/account._index/route.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index 0270e7be4ee..e9d5c7cbafa 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -67,7 +67,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { // Theme switching is feature-flagged; while off, everyone stays on dark // even if a preference was saved earlier. const showThemeSwitcher = user - ? await flag({ key: "hasThemeSwitcher", defaultValue: false }) + ? await flag({ key: "hasThemeSwitcher", defaultValue: true }) : false; const headers = new Headers(); diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index 45e14a007b8..03bb6ec9147 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -87,7 +87,7 @@ function createSchema( export async function loader({ request }: LoaderFunctionArgs) { await requireUserId(request); - const showThemeSwitcher = await flag({ key: "hasThemeSwitcher", defaultValue: false }); + const showThemeSwitcher = await flag({ key: "hasThemeSwitcher", defaultValue: true }); return json({ showThemeSwitcher }); } @@ -97,7 +97,7 @@ export const action: ActionFunction = async ({ request }) => { const formData = await request.formData(); if (formData.get("action") === "update-theme") { - const showThemeSwitcher = await flag({ key: "hasThemeSwitcher", defaultValue: false }); + const showThemeSwitcher = await flag({ key: "hasThemeSwitcher", defaultValue: true }); if (!showThemeSwitcher) { return json({ error: "Not available" }, { status: 404 }); }