diff --git a/app/components/VersionNotificationBanner.tsx b/app/components/VersionNotificationBanner.tsx
new file mode 100644
index 000000000..a54121329
--- /dev/null
+++ b/app/components/VersionNotificationBanner.tsx
@@ -0,0 +1,57 @@
+import { toast } from 'sonner';
+import { Button } from '@ui/Button';
+import { SymbolIcon } from '@radix-ui/react-icons';
+import { captureMessage } from '@sentry/remix';
+import useSWR from 'swr';
+
+export default function useVersionNotificationBanner() {
+ // eslint-disable-next-line local/no-direct-process-env
+ const currentSha = process.env.VERCEL_GIT_COMMIT_SHA;
+ const { data, error } = useSWR<{ sha?: string | null }>('/api/version', {
+ // Refresh every hour.
+ refreshInterval: 1000 * 60 * 60,
+ // Refresh on focus at most every 10 minutes.
+ focusThrottleInterval: 1000 * 60 * 10,
+ shouldRetryOnError: false,
+ fetcher: versionFetcher,
+ });
+
+ if (!error && data?.sha && currentSha && data.sha !== currentSha) {
+ toast.info(
+
+ A new version of Chef is available! Refresh this page to update.
+ }
+ // Make the href the current page so that the page refreshes.
+ onClick={() => window.location.reload()}
+ >
+ Refresh
+
+
,
+ {
+ id: 'chefVersion',
+ duration: Number.POSITIVE_INFINITY,
+ },
+ );
+ }
+}
+
+const versionFetcher = async (url: string) => {
+ const res = await fetch(url, {
+ method: 'POST',
+ });
+
+ if (!res.ok) {
+ try {
+ const { error } = await res.json();
+ captureMessage(error);
+ } catch (_e) {
+ captureMessage('Failed to fetch dashboard version information.');
+ }
+ throw new Error('Failed to fetch dashboard version information.');
+ }
+ return res.json();
+};
diff --git a/app/entry.client.tsx b/app/entry.client.tsx
index bdffc1c6b..af00e976e 100644
--- a/app/entry.client.tsx
+++ b/app/entry.client.tsx
@@ -3,6 +3,7 @@ import { RemixBrowser, useLocation, useMatches } from '@remix-run/react';
import { startTransition, useEffect } from 'react';
import { hydrateRoot } from 'react-dom/client';
+// eslint-disable-next-line local/no-direct-process-env
const environment = process.env.VERCEL_ENV === 'production' ? 'production' : 'development';
Sentry.init({
diff --git a/app/root.tsx b/app/root.tsx
index 822806d3a..a528049e2 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -21,9 +21,11 @@ import posthog from 'posthog-js';
import 'allotment/dist/style.css';
import { ErrorDisplay } from './components/ErrorComponent';
+import useVersionNotificationBanner from './components/VersionNotificationBanner';
export async function loader() {
// These environment variables are available in the client (they aren't secret).
+ // eslint-disable-next-line local/no-direct-process-env
const CONVEX_URL = process.env.VITE_CONVEX_URL || globalThis.process.env.CONVEX_URL!;
const CONVEX_OAUTH_CLIENT_ID = globalThis.process.env.CONVEX_OAUTH_CLIENT_ID!;
return json({
@@ -130,6 +132,8 @@ export function Layout({ children }: { children: React.ReactNode }) {
});
}, []);
+ useVersionNotificationBanner();
+
return (
<>
diff --git a/app/routes/api.enhance-prompt.ts b/app/routes/api.enhance-prompt.ts
index 57a0365c0..e4f2ee017 100644
--- a/app/routes/api.enhance-prompt.ts
+++ b/app/routes/api.enhance-prompt.ts
@@ -127,7 +127,7 @@ export async function action({ request }: ActionFunctionArgs) {
}
const openai = new OpenAI({
- apiKey: process.env.OPENAI_API_KEY,
+ apiKey: globalThis.process.env.OPENAI_API_KEY,
});
const completion = await openai.chat.completions.create({
diff --git a/app/routes/api.version.ts b/app/routes/api.version.ts
new file mode 100644
index 000000000..68aa80128
--- /dev/null
+++ b/app/routes/api.version.ts
@@ -0,0 +1,80 @@
+import { json } from '@vercel/remix';
+import type { ActionFunctionArgs } from '@vercel/remix';
+
+export async function action({ request }: ActionFunctionArgs) {
+ const globalEnv = globalThis.process.env;
+ const projectId = globalEnv.VERCEL_PROJECT_ID;
+ const teamId = globalEnv.VERCEL_TEAM_ID;
+ const productionBranchUrl = globalEnv.VERCEL_PRODUCTION_BRANCH_URL || 'chef.convex.dev';
+
+ if (request.method !== 'POST') {
+ return new Response(JSON.stringify({ error: 'Method not allowed' }), {
+ status: 405,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+
+ if (!globalEnv.VERCEL_TOKEN) {
+ return json({ error: 'Failed to fetch version information' }, { status: 500 });
+ }
+
+ const requestOptions = {
+ headers: {
+ Authorization: `Bearer ${globalThis.process.env.VERCEL_TOKEN}`,
+ },
+ method: 'get',
+ };
+
+ if (globalThis.process.env.VERCEL_ENV !== 'preview') {
+ // If we're not in a preview deployment, fetch the production deployment from Vercel's undocumented
+ // production-deployment API.
+ // This response includes a boolean indicating if the production deployment is stale (i.e. rolled back)
+ // We accept the risk that this API might change because it is not documented, meaning the version
+ // notification feature might silently fail.
+ const prodResponse = await fetch(
+ `https://vercel.com/api/v1/projects/${projectId}/production-deployment?teamId=${teamId}`,
+ requestOptions,
+ );
+ if (!prodResponse.ok) {
+ return json({ error: 'Failed to fetch production version information' }, { status: 500 });
+ }
+
+ const prodData = await prodResponse.json();
+
+ // Since we retrieved data from an undocumented API
+ // let's defensively check that the data we need is present
+ // and return an opaque error if it isn't.
+ if (!prodData || typeof prodData.deploymentIsStale !== 'boolean') {
+ return json({ error: 'Failed to fetch production deployment' }, { status: 500 });
+ }
+
+ // If the production deployment is rolled back,
+ // we should not show a version notification.
+ if (prodData.deploymentIsStale) {
+ return json({ sha: null }, { status: 200 });
+ }
+ }
+
+ // Even though we retrieved the production data, we might be on a preview branch deployment.
+ // So, fetch the data specific to the latest branch deployment.
+ const branchUrl = globalThis.process.env.VERCEL_BRANCH_URL || productionBranchUrl;
+ if (!branchUrl) {
+ throw new Error('VERCEL_BRANCH_URL or VERCEL_PRODUCTION_BRANCH_URL not set');
+ }
+ const branchResponse = await fetch(
+ `https://api.vercel.com/v13/deployments/${branchUrl}?teamId=${teamId}`,
+ requestOptions,
+ );
+ if (!branchResponse.ok) {
+ throw new Error('Failed to fetch branch version information');
+ }
+
+ const branchData = await branchResponse.json();
+
+ return json(
+ {
+ sha: branchData.gitSource.sha,
+ },
+ { status: 200 },
+ );
+}
diff --git a/eslint.config.mjs b/eslint.config.mjs
index ee69eab06..054e966e3 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -6,6 +6,34 @@ import reactPlugin from 'eslint-plugin-react';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import noGlobalFetchRule from './eslint-rules/no-global-fetch.js';
+const noDirectProcessEnv = {
+ meta: {
+ type: 'problem',
+ docs: {
+ description: 'Disallow direct process.env usage',
+ category: 'Best Practices',
+ },
+ fixable: null,
+ schema: [],
+ messages: {
+ noDirectProcessEnv:
+ 'Direct process.env usage is not allowed. Use globalThis.process.env instead because process.env is shimmed in for both the browser and the server.',
+ },
+ },
+ create(context) {
+ return {
+ MemberExpression(node) {
+ if (node.object.name === 'process' && node.property.name === 'env') {
+ context.report({
+ node,
+ messageId: 'noDirectProcessEnv',
+ });
+ }
+ },
+ };
+ },
+};
+
export default [
{
ignores: [
@@ -24,6 +52,11 @@ export default [
plugins: {
react: reactPlugin,
'react-hooks': reactHooksPlugin,
+ local: {
+ rules: {
+ 'no-direct-process-env': noDirectProcessEnv,
+ },
+ },
},
rules: {
...reactPlugin.configs.flat.recommended.rules,
@@ -84,6 +117,8 @@ export default [
selector: 'Literal[value=/bottom-4(?:\\D|$)/i]',
},
],
+ // Don't allow direct process.env usage
+ 'local/no-direct-process-env': 'error',
},
settings: {
react: {
diff --git a/package.json b/package.json
index 30d69c1a8..63762a2cc 100644
--- a/package.json
+++ b/package.json
@@ -33,7 +33,6 @@
"dependencies": {
"@ai-sdk/amazon-bedrock": "^2.2.9",
"@ai-sdk/anthropic": "^1.2.12",
- "@convex-dev/ai-sdk-google": "1.2.17",
"@ai-sdk/google": "^1.2.11",
"@ai-sdk/openai": "^1.3.6",
"@ai-sdk/react": "^1.2.5",
@@ -57,6 +56,7 @@
"@codemirror/search": "^6.5.8",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.35.0",
+ "@convex-dev/ai-sdk-google": "1.2.17",
"@convex-dev/design-system": "0.1.11",
"@convex-dev/eslint-plugin": "0.0.1-alpha.4",
"@convex-dev/migrations": "^0.2.8",
@@ -135,6 +135,7 @@
"remix-utils": "^7.7.0",
"shiki": "^1.24.0",
"sonner": "^2.0.3",
+ "swr": "^2.3.4",
"ua-parser-js": "^1.0.40",
"undici": "^7.7.0",
"unist-util-visit": "^5.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4c432d121..c6f70f8b2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -325,6 +325,9 @@ importers:
sonner:
specifier: ^2.0.3
version: 2.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ swr:
+ specifier: ^2.3.4
+ version: 2.3.4(react@18.3.1)
ua-parser-js:
specifier: ^1.0.40
version: 1.0.40
@@ -8657,6 +8660,11 @@ packages:
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ swr@2.3.4:
+ resolution: {integrity: sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg==}
+ peerDependencies:
+ react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
swrev@4.0.0:
resolution: {integrity: sha512-LqVcOHSB4cPGgitD1riJ1Hh4vdmITOp+BkmfmXRh4hSF/t7EnS4iD+SOTmq7w5pPm/SiPeto4ADbKS6dHUDWFA==}
@@ -9620,7 +9628,7 @@ snapshots:
'@ai-sdk/provider-utils': 2.2.4(zod@3.24.1)
'@ai-sdk/ui-utils': 1.2.5(zod@3.24.1)
react: 18.3.1
- swr: 2.3.2(react@18.3.1)
+ swr: 2.3.4(react@18.3.1)
throttleit: 2.1.0
optionalDependencies:
zod: 3.24.1
@@ -18982,6 +18990,12 @@ snapshots:
react: 18.3.1
use-sync-external-store: 1.4.0(react@18.3.1)
+ swr@2.3.4(react@18.3.1):
+ dependencies:
+ dequal: 2.0.3
+ react: 18.3.1
+ use-sync-external-store: 1.4.0(react@18.3.1)
+
swrev@4.0.0: {}
swrv@1.1.0(vue@3.5.13(typescript@5.8.3)):
diff --git a/template/eslint.config.js b/template/eslint.config.js
deleted file mode 100644
index dc2e21955..000000000
--- a/template/eslint.config.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import js from "@eslint/js";
-import globals from "globals";
-import reactHooks from "eslint-plugin-react-hooks";
-import reactRefresh from "eslint-plugin-react-refresh";
-import tseslint from "typescript-eslint";
-
-export default tseslint.config(
- {
- ignores: [
- "dist",
- "eslint.config.js",
- "convex/_generated",
- "postcss.config.js",
- "tailwind.config.js",
- "vite.config.ts",
- ],
- },
- {
- extends: [
- js.configs.recommended,
- ...tseslint.configs.recommendedTypeChecked,
- ],
- files: ["**/*.{ts,tsx}"],
- languageOptions: {
- ecmaVersion: 2020,
- globals: {
- ...globals.browser,
- ...globals.node,
- },
- parserOptions: {
- project: [
- "./tsconfig.node.json",
- "./tsconfig.app.json",
- "./convex/tsconfig.json",
- ],
- },
- },
- plugins: {
- "react-hooks": reactHooks,
- "react-refresh": reactRefresh,
- },
- rules: {
- ...reactHooks.configs.recommended.rules,
- "react-refresh/only-export-components": [
- "warn",
- { allowConstantExport: true },
- ],
- // All of these overrides ease getting into
- // TypeScript, and can be removed for stricter
- // linting down the line.
-
- // Only warn on unused variables, and ignore variables starting with `_`
- "@typescript-eslint/no-unused-vars": [
- "warn",
- { varsIgnorePattern: "^_", argsIgnorePattern: "^_" },
- ],
-
- // Allow escaping the compiler
- "@typescript-eslint/ban-ts-comment": "error",
-
- // Allow explicit `any`s
- "@typescript-eslint/no-explicit-any": "off",
-
- // START: Allow implicit `any`s
- "@typescript-eslint/no-unsafe-argument": "off",
- "@typescript-eslint/no-unsafe-assignment": "off",
- "@typescript-eslint/no-unsafe-call": "off",
- "@typescript-eslint/no-unsafe-member-access": "off",
- "@typescript-eslint/no-unsafe-return": "off",
- // END: Allow implicit `any`s
-
- // Allow async functions without await
- // for consistency (esp. Convex `handler`s)
- "@typescript-eslint/require-await": "off",
- },
- },
-);
diff --git a/vite.config.ts b/vite.config.ts
index 775c3bc18..9f6babd3b 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -14,6 +14,7 @@ export default defineConfig((config) => {
return {
define: {
'process.env.VERCEL_ENV': JSON.stringify(process.env.VERCEL_ENV),
+ 'process.env.VERCEL_GIT_COMMIT_SHA': JSON.stringify(process.env.VERCEL_GIT_COMMIT_SHA),
},
/*