From e40e837e2fc371abfed7bb15d718f21569832fa2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 10:39:37 +0000 Subject: [PATCH 1/8] feat(webapp): stay on the same page when switching project or organization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project and organization switchers carried you to the Tasks page of wherever you landed. They now carry the page you were on: the switcher link names it, and the project index loader appends it to the environment it already resolves, so the environment is still picked server-side. Pages named after a resource truncate to their list page, from one list shared with the environment switcher — which previously only truncated runs, deploys and schedules, and so carried ids from the other 16 into the new environment. --- .../keep-page-when-switching-project.md | 6 + .../app/components/navigation/SideMenu.tsx | 10 +- .../app/hooks/useEnvironmentSwitcher.ts | 100 ++--- .../route.tsx | 5 +- .../route.tsx | 5 +- apps/webapp/app/utils/pageSwitching.test.ts | 342 ++++++++++++++++++ apps/webapp/app/utils/pageSwitching.ts | 99 +++++ 7 files changed, 494 insertions(+), 73 deletions(-) create mode 100644 .server-changes/keep-page-when-switching-project.md create mode 100644 apps/webapp/app/utils/pageSwitching.test.ts create mode 100644 apps/webapp/app/utils/pageSwitching.ts diff --git a/.server-changes/keep-page-when-switching-project.md b/.server-changes/keep-page-when-switching-project.md new file mode 100644 index 0000000000..b4cb0b7754 --- /dev/null +++ b/.server-changes/keep-page-when-switching-project.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Switching project or organization in the sidebar now keeps you on the same page instead of sending you back to Tasks. Pages for a specific run, deploy or other single item open the matching list instead. diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index ebedc77fc6..292921f273 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -72,6 +72,7 @@ import { VercelLogo } from "~/components/integrations/VercelLogo"; import { Avatar } from "~/components/primitives/Avatar"; import { UserProfilePhoto } from "~/components/UserProfilePhoto"; import { type MatchedEnvironment } from "~/hooks/useEnvironment"; +import { usePageSwitcher } from "~/hooks/useEnvironmentSwitcher"; import { useFeatureFlags } from "~/hooks/useFeatureFlags"; import { useFeatures } from "~/hooks/useFeatures"; import { type MatchedOrganization } from "~/hooks/useOrganizations"; @@ -99,7 +100,6 @@ import { logoutPath, newOrganizationPath, newProjectPath, - organizationPath, organizationRolesPath, organizationSettingsPath, organizationSlackIntegrationPath, @@ -122,7 +122,6 @@ import { v3LogsPath, v3ModelsPath, v3ProjectAlertsPath, - v3ProjectPath, v3ProjectSettingsGeneralPath, v3ProjectSettingsIntegrationsPath, v3PromptsPath, @@ -2007,6 +2006,7 @@ function ProjectSelector({ }) { const [isMenuOpen, setIsMenuOpen] = useState(false); const navigation = useNavigation(); + const { urlForProject } = usePageSwitcher(); useEffect(() => { setIsMenuOpen(false); @@ -2083,7 +2083,7 @@ function ProjectSelector({ return ( {p.name} @@ -2183,6 +2183,8 @@ function SwitchOrganizations({ organizations: MatchedOrganization[]; organization: MatchedOrganization; }) { + const { urlForOrganization } = usePageSwitcher(); + return (
@@ -2198,7 +2200,7 @@ function SwitchOrganizations({ {organizations.map((org) => ( } leadingIconClassName="text-text-dimmed" diff --git a/apps/webapp/app/hooks/useEnvironmentSwitcher.ts b/apps/webapp/app/hooks/useEnvironmentSwitcher.ts index 5c9aa2059b..efbe83c7e1 100644 --- a/apps/webapp/app/hooks/useEnvironmentSwitcher.ts +++ b/apps/webapp/app/hooks/useEnvironmentSwitcher.ts @@ -1,5 +1,18 @@ -import { type Path, useMatches } from "@remix-run/react"; +import { useMatches } from "@remix-run/react"; import { type RuntimeEnvironment } from "@trigger.dev/database"; +import { + ENVIRONMENT_MATCH_ID, + pageBelowEnvironment, + pathForEnvironmentSwitch, + portablePage, + portablePageSearch, +} from "~/utils/pageSwitching"; +import { + organizationPath, + type OrgForPath, + type ProjectForPath, + v3ProjectPath, +} from "~/utils/pathBuilder"; import { useOptimisticLocation } from "./useOptimisticLocation"; /** @@ -7,13 +20,13 @@ import { useOptimisticLocation } from "./useOptimisticLocation"; * @returns */ export function useEnvironmentSwitcher() { - const matches = useMatches(); const location = useOptimisticLocation(); + const environmentPathname = useEnvironmentPathname(); const urlForEnvironment = (newEnvironment: Pick) => { - return routeForEnvironmentSwitch({ + return pathForEnvironmentSwitch({ location, - matchId: matches[matches.length - 1].id, + environmentPathname, environmentSlug: newEnvironment.slug, }); }; @@ -23,71 +36,24 @@ export function useEnvironmentSwitcher() { }; } -/** Function that takes in a UIMatch id, the current URL, the new environment slug, and returns a new URL */ -export function routeForEnvironmentSwitch({ - location, - matchId, - environmentSlug, -}: { - location: Path; - matchId: string; - environmentSlug: string; -}) { - switch (matchId) { - // Run page - case "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam": { - const newLocation: Path = { - pathname: replaceEnvInPath(location.pathname, environmentSlug).replace( - /\/runs\/.*/, - "/runs" - ), - search: "", - hash: "", - }; - return fullPath(newLocation); - } - case "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam": { - const newLocation: Path = { - pathname: replaceEnvInPath(location.pathname, environmentSlug).replace( - /\/deployments\/.*/, - "/deployments" - ), - search: "", - hash: "", - }; - return fullPath(newLocation); - } - case "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam": - case "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.edit.$scheduleParam": { - const newLocation: Path = { - pathname: replaceEnvInPath(location.pathname, environmentSlug).replace( - /\/schedules\/.*/, - "/schedules" - ), - search: "", - hash: "", - }; - return fullPath(newLocation); - } - default: { - const newLocation: Path = { - pathname: replaceEnvInPath(location.pathname, environmentSlug), - search: location.search, - hash: location.hash, - }; - return fullPath(newLocation); - } - } -} - /** - * Replace the /env// in the path so it's /env/ + * It gives the URLs for the current page in another project or organization. Which environment + * that page opens in is left to the server, which picks the same one it would without a page. */ -function replaceEnvInPath(path: string, environmentSlug: string) { - //allow anything except / - return path.replace(/env\/([^/]+)/, `env/${environmentSlug}`); +export function usePageSwitcher() { + const location = useOptimisticLocation(); + const environmentPathname = useEnvironmentPathname(); + const page = portablePage(pageBelowEnvironment(location.pathname, environmentPathname)); + const search = portablePageSearch(page); + + return { + urlForProject: (organization: OrgForPath, project: ProjectForPath) => + `${v3ProjectPath(organization, project)}${search}`, + urlForOrganization: (organization: OrgForPath) => `${organizationPath(organization)}${search}`, + }; } -function fullPath(location: Path) { - return `${location.pathname}${location.search}${location.hash}`; +function useEnvironmentPathname() { + const matches = useMatches(); + return matches.find((match) => match.id === ENVIRONMENT_MATCH_ID)?.pathname; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug._index/route.tsx index c8b725e835..5b2b1fd3be 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug._index/route.tsx @@ -3,6 +3,7 @@ import { prisma } from "~/db.server"; import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server"; import { logger } from "~/services/logger.server"; import { requireUser } from "~/services/session.server"; +import { portablePageSearch, requestedPortablePage } from "~/utils/pageSwitching"; import { newOrganizationPath, newProjectPath, @@ -49,5 +50,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { throw redirect(newProjectPath({ slug: organizationSlug })); } - return redirect(v3ProjectPath({ slug: organizationSlug }, bestProject)); + const projectPath = v3ProjectPath({ slug: organizationSlug }, bestProject); + + return redirect(`${projectPath}${portablePageSearch(requestedPortablePage(request))}`); }; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam._index/route.tsx index ea2b579912..d3cbde7850 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam._index/route.tsx @@ -2,6 +2,7 @@ import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { prisma } from "~/db.server"; import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server"; import { requireUser } from "~/services/session.server"; +import { pagePath, requestedPortablePage } from "~/utils/pageSwitching"; import { ProjectParamSchema, v3EnvironmentPath } from "~/utils/pathBuilder"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { @@ -40,5 +41,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const selector = new SelectBestEnvironmentPresenter(); const environment = await selector.selectBestEnvironment(project.id, user, project.environments); - return redirect(v3EnvironmentPath({ slug: organizationSlug }, project, environment)); + const environmentPath = v3EnvironmentPath({ slug: organizationSlug }, project, environment); + + return redirect(pagePath(environmentPath, requestedPortablePage(request))); }; diff --git a/apps/webapp/app/utils/pageSwitching.test.ts b/apps/webapp/app/utils/pageSwitching.test.ts new file mode 100644 index 0000000000..ad88d45aa2 --- /dev/null +++ b/apps/webapp/app/utils/pageSwitching.test.ts @@ -0,0 +1,342 @@ +import { flatRoutes } from "@remix-run/dev/dist/config/flat-routes.js"; +import type { RouteManifest } from "@remix-run/dev/dist/config/routes.js"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + ENVIRONMENT_MATCH_ID, + ENVIRONMENT_SPECIFIC_PAGES, + pageBelowEnvironment, + pagePath, + pathForEnvironmentSwitch, + portablePage, + portablePageSearch, + PORTABLE_PAGES, + requestedPortablePage, +} from "./pageSwitching"; + +const APP_DIR = join(__dirname, ".."); +const PROBE = "probe_01ABC"; + +const compiledRoutes: RouteManifest = flatRoutes(APP_DIR, ["**/.*"]); + +function compiledUrl(id: string): string { + let route = compiledRoutes[id]; + if (!route) throw new Error(`no compiled route with id ${id}`); + + const segments: string[] = []; + while (route) { + if (route.path) segments.unshift(route.path); + route = route.parentId ? compiledRoutes[route.parentId] : undefined; + } + return `/${segments.join("/")}`; +} + +const ENVIRONMENT_URL = compiledUrl(ENVIRONMENT_MATCH_ID); + +function rendersAPage(file: string): boolean { + return /^export default/m.test(readFileSync(join(APP_DIR, file), "utf8")); +} + +const belowEnvironment = Object.values(compiledRoutes) + .filter((route) => compiledUrl(route.id).startsWith(ENVIRONMENT_URL)) + .map((route) => ({ + suffix: compiledUrl(route.id).slice(ENVIRONMENT_URL.length).replace(/^\//, ""), + rendersAPage: rendersAPage(route.file), + })); + +const environmentRoutes = [...new Set(belowEnvironment.map((route) => route.suffix))]; + +// Streams and Slack callbacks sit below an environment without being pages a user lands on. +const environmentPages = [ + ...new Set(belowEnvironment.filter((route) => route.rendersAPage).map((route) => route.suffix)), +]; + +const idFreePages = environmentPages.filter((page) => !page.includes(":") && page !== ""); +const idPages = environmentPages.filter((page) => page.includes(":")); + +function matchesARoute(page: string): boolean { + const wanted = page === "" ? [] : page.split("/"); + + return environmentRoutes.some((route) => { + const segments = route === "" ? [] : route.split("/"); + return ( + segments.length === wanted.length && + segments.every((segment, i) => segment.startsWith(":") || segment === wanted[i]) + ); + }); +} + +const environmentLocation = { + pathname: "/orgs/acme/projects/api/env/dev", + search: "", + hash: "", +}; + +function locationOn(page: string, search = "", hash = "") { + return { pathname: pagePath(environmentLocation.pathname, page), search, hash }; +} + +describe("the environment routes the portable page list is drawn from", () => { + it("read enough of them for the assertions below to mean anything", () => { + expect(compiledUrl(ENVIRONMENT_MATCH_ID)).toBe( + "/orgs/:organizationSlug/projects/:projectParam/env/:envParam" + ); + expect(idFreePages.length).toBeGreaterThan(20); + expect(idPages.length).toBeGreaterThan(15); + expect(environmentPages).toContain(""); + }); + + it("asks nothing of the routes that render no page", () => { + expect(environmentRoutes).toContain("tasks/stream"); + expect(environmentPages).not.toContain("tasks/stream"); + expect(environmentPages).not.toContain("runs/:runParam/stream"); + }); +}); + +describe("portable pages", () => { + it("cover every environment page that names no resource", () => { + const missing = idFreePages + .filter((page) => !PORTABLE_PAGES.has(page)) + .filter((page) => !ENVIRONMENT_SPECIFIC_PAGES.includes(page)) + .sort(); + + expect(missing).toEqual([]); + }); + + it("all point at a real page", () => { + const phantom = [...PORTABLE_PAGES].filter((page) => !matchesARoute(page)).sort(); + + expect(phantom).toEqual([]); + }); + + it("are all plain relative paths, which is what makes a redirect safe to build from one", () => { + for (const page of PORTABLE_PAGES) { + expect(page).toMatch(/^[a-z0-9-]+(\/[a-z0-9-]+)*$/); + } + }); + + it("each resolve to themselves, so switching twice lands in the same place", () => { + for (const page of PORTABLE_PAGES) { + expect(portablePage(page)).toBe(page); + } + expect(portablePage("")).toBe(""); + }); + + it("include the pages named in the request", () => { + expect(portablePage("apikeys")).toBe("apikeys"); + expect(portablePage("settings/general")).toBe("settings/general"); + expect(portablePage("waitpoints/tokens")).toBe("waitpoints/tokens"); + }); +}); + +describe("pages named after a resource", () => { + it("truncate to a list page, id and all, for every one of them", () => { + const leaked = idPages + .map((page) => page.replace(/:[^/]+/g, PROBE)) + .filter((page) => { + const resolved = portablePage(page); + return resolved.includes(PROBE) || !(resolved === "" || PORTABLE_PAGES.has(resolved)); + }) + .sort(); + + expect(leaked).toEqual([]); + }); + + it("truncate to the list they were reached from", () => { + expect(portablePage("runs/run_123")).toBe("runs"); + expect(portablePage("batches/batch_123")).toBe("batches"); + expect(portablePage("queues/my-queue")).toBe("queues"); + expect(portablePage("schedules/sched_123")).toBe("schedules"); + expect(portablePage("schedules/edit/sched_123")).toBe("schedules"); + expect(portablePage("deployments/deploy_123")).toBe("deployments"); + expect(portablePage("sessions/session_123")).toBe("sessions"); + expect(portablePage("errors/fingerprint_123")).toBe("errors"); + expect(portablePage("bulk-actions/bulk_123")).toBe("bulk-actions"); + expect(portablePage("waitpoints/tokens/waitpoint_123")).toBe("waitpoints/tokens"); + expect(portablePage("dashboards/custom/dashboard_123")).toBe("dashboards"); + expect(portablePage("models/gpt-5")).toBe("models"); + expect(portablePage("prompts/my-prompt")).toBe("prompts"); + expect(portablePage("agents/my-agent")).toBe("agents"); + expect(portablePage("playground/my-agent")).toBe("playground"); + expect(portablePage("test/tasks/my-task")).toBe("test"); + expect(portablePage("runs/run_123/stream")).toBe("runs"); + }); + + it("send a task page back to the task list, which is the environment root", () => { + expect(portablePage("tasks/standard/my-task")).toBe(""); + expect(portablePage("tasks/scheduled/my-task")).toBe(""); + }); + + it("keep the built-in metric dashboards but not the one gated per organization", () => { + expect(portablePage("dashboards/overview")).toBe("dashboards/overview"); + expect(portablePage("dashboards/llm")).toBe("dashboards/llm"); + expect(portablePage("dashboards/queues")).toBe("dashboards"); + }); + + it("send the branch lists to the environment root, since the environment type may change", () => { + expect(portablePage("branches")).toBe(""); + expect(portablePage("dev-branches")).toBe(""); + }); +}); + +describe("a page suffix that is not a plain relative page", () => { + it("falls back to the environment root rather than being sanitised into one", () => { + expect(portablePage("/apikeys")).toBe(""); + expect(portablePage("//evil.example.com")).toBe(""); + expect(portablePage("//evil.example.com/apikeys")).toBe(""); + expect(portablePage("https://evil.example.com")).toBe(""); + expect(portablePage("http://evil.example.com/apikeys")).toBe(""); + expect(portablePage("//")).toBe(""); + expect(portablePage("../../login")).toBe(""); + expect(portablePage("..")).toBe(""); + expect(portablePage(".")).toBe(""); + expect(portablePage("%2e%2e/%2e%2e/login")).toBe(""); + expect(portablePage("..%2f..%2flogin")).toBe(""); + expect(portablePage("\\\\evil.example.com")).toBe(""); + expect(portablePage("javascript:alert(1)")).toBe(""); + expect(portablePage("apikeys?next=//evil.example.com")).toBe(""); + expect(portablePage("apikeys#/../..")).toBe(""); + expect(portablePage("nonsense")).toBe(""); + expect(portablePage("")).toBe(""); + }); + + it("only ever answers with a page it knows, whatever it is handed", () => { + const attempts = [ + "/apikeys", + "//evil.example.com", + "https://evil.example.com/apikeys", + "../../login", + "apikeys/../../login", + "runs/../../../etc/passwd", + "%2e%2e/apikeys", + "settings/general/../../..", + ]; + + for (const attempt of attempts) { + const resolved = portablePage(attempt); + expect(resolved === "" || PORTABLE_PAGES.has(resolved)).toBe(true); + } + }); + + it("does not let a trailing traversal segment change which page is chosen", () => { + expect(portablePage("apikeys/../../login")).toBe("apikeys"); + expect(portablePage("settings/general/../../..")).toBe("settings/general"); + }); +}); + +describe("pageBelowEnvironment", () => { + it("takes the environment prefix off the current path", () => { + expect( + pageBelowEnvironment("/orgs/acme/projects/api/env/dev/apikeys", environmentLocation.pathname) + ).toBe("apikeys"); + expect( + pageBelowEnvironment("/orgs/acme/projects/api/env/dev", environmentLocation.pathname) + ).toBe(""); + expect( + pageBelowEnvironment("/orgs/acme/projects/api/env/dev/", environmentLocation.pathname) + ).toBe(""); + expect( + pageBelowEnvironment( + "/orgs/acme/projects/api/env/dev/runs/run_1", + environmentLocation.pathname + ) + ).toBe("runs/run_1"); + }); + + it("gives nothing when there is no environment path to take off", () => { + expect(pageBelowEnvironment("/orgs/acme/projects/api/env/dev/apikeys", undefined)).toBe(""); + }); + + it("gives nothing for a path outside the environment", () => { + expect(pageBelowEnvironment("/account/tokens", environmentLocation.pathname)).toBe(""); + expect(pageBelowEnvironment("/orgs/acme/settings/team", environmentLocation.pathname)).toBe(""); + }); +}); + +describe("pathForEnvironmentSwitch", () => { + it("keeps a portable page, and its filters with it", () => { + expect( + pathForEnvironmentSwitch({ + location: locationOn("apikeys"), + environmentPathname: environmentLocation.pathname, + environmentSlug: "prod", + }) + ).toBe("/orgs/acme/projects/api/env/prod/apikeys"); + + expect( + pathForEnvironmentSwitch({ + location: locationOn("runs", "?statuses=COMPLETED", "#top"), + environmentPathname: environmentLocation.pathname, + environmentSlug: "prod", + }) + ).toBe("/orgs/acme/projects/api/env/prod/runs?statuses=COMPLETED#top"); + }); + + it("lands on the environment root when there is no page to keep", () => { + expect( + pathForEnvironmentSwitch({ + location: environmentLocation, + environmentPathname: environmentLocation.pathname, + environmentSlug: "prod", + }) + ).toBe("/orgs/acme/projects/api/env/prod"); + }); + + it("drops the filters along with the id when a page truncates", () => { + expect( + pathForEnvironmentSwitch({ + location: locationOn("runs/run_123", "?span=span_1"), + environmentPathname: environmentLocation.pathname, + environmentSlug: "prod", + }) + ).toBe("/orgs/acme/projects/api/env/prod/runs"); + + expect( + pathForEnvironmentSwitch({ + location: locationOn("queues/my-queue", "?page=2"), + environmentPathname: environmentLocation.pathname, + environmentSlug: "prod", + }) + ).toBe("/orgs/acme/projects/api/env/prod/queues"); + }); + + it("only swaps the environment slug when it cannot tell where the environment path ends", () => { + expect( + pathForEnvironmentSwitch({ + location: locationOn("apikeys", "?foo=bar"), + environmentPathname: undefined, + environmentSlug: "prod", + }) + ).toBe("/orgs/acme/projects/api/env/prod/apikeys?foo=bar"); + }); +}); + +describe("carrying a page across a project or organization switch", () => { + it("puts the page in the link, and leaves it out when there is none", () => { + expect(portablePageSearch("apikeys")).toBe("?page=apikeys"); + expect(portablePageSearch("waitpoints/tokens")).toBe("?page=waitpoints/tokens"); + expect(portablePageSearch("")).toBe(""); + }); + + it("reads the page back off the request, validating it again", () => { + const read = (search: string) => + requestedPortablePage(new Request(`http://localhost/orgs/acme${search}`)); + + expect(read("?page=apikeys")).toBe("apikeys"); + expect(read("?page=waitpoints/tokens")).toBe("waitpoints/tokens"); + expect(read("?page=runs/run_123")).toBe("runs"); + expect(read("?page=%2f%2fevil.example.com")).toBe(""); + expect(read("?page=https://evil.example.com")).toBe(""); + expect(read("?page=nonsense")).toBe(""); + expect(read("?page=")).toBe(""); + expect(read("")).toBe(""); + }); + + it("appends the page to the environment the server picked", () => { + expect(pagePath("/orgs/acme/projects/web/env/stg", "apikeys")).toBe( + "/orgs/acme/projects/web/env/stg/apikeys" + ); + expect(pagePath("/orgs/acme/projects/web/env/stg", "")).toBe("/orgs/acme/projects/web/env/stg"); + }); +}); diff --git a/apps/webapp/app/utils/pageSwitching.ts b/apps/webapp/app/utils/pageSwitching.ts new file mode 100644 index 0000000000..7275535cbc --- /dev/null +++ b/apps/webapp/app/utils/pageSwitching.ts @@ -0,0 +1,99 @@ +import { type Path } from "@remix-run/react"; +import { ENV_PAGE_TARGETS } from "./deeplinkPages"; + +export const PORTABLE_PAGE_PARAM = "page"; + +export const ENVIRONMENT_MATCH_ID = + "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam"; + +/** Pages below a section landing that name no resource, plus the built-in metric dashboards. */ +const NESTED_PORTABLE_PAGES = [ + "alerts/new", + "dashboards/llm", + "dashboards/overview", + "environment-variables/new", + "models/compare", + "schedules/new", + "settings/general", + "settings/integrations", + "tasks/dashboard", +]; + +/** These branch lists only load for their own environment type, which the switch may change. */ +export const ENVIRONMENT_SPECIFIC_PAGES = ["branches", "dev-branches"]; + +export const PORTABLE_PAGES: ReadonlySet = new Set( + [...[...ENV_PAGE_TARGETS.values()].map((target) => target.landing), ...NESTED_PORTABLE_PAGES] + .filter((page) => page !== "") + .filter((page) => !ENVIRONMENT_SPECIFIC_PAGES.includes(page)) +); + +/** + * The nearest page above `suffix` that every project has, as a path relative to the environment. + * A page named after a resource truncates to its list page, and anything else — an unknown page, + * or a suffix that is not a plain relative path — falls back to the environment root. + */ +export function portablePage(suffix: string): string { + const segments = suffix.split("/"); + + for (let depth = segments.length; depth > 0; depth--) { + const candidate = segments.slice(0, depth).join("/"); + if (PORTABLE_PAGES.has(candidate)) return candidate; + } + + return ""; +} + +export function requestedPortablePage(request: Request): string { + const requested = new URL(request.url).searchParams.get(PORTABLE_PAGE_PARAM); + return portablePage(requested ?? ""); +} + +export function portablePageSearch(page: string): string { + return page === "" ? "" : `?${PORTABLE_PAGE_PARAM}=${page}`; +} + +export function pagePath(environmentPath: string, page: string): string { + return page === "" ? environmentPath : `${environmentPath}/${page}`; +} + +export function pageBelowEnvironment( + pathname: string, + environmentPathname: string | undefined +): string { + if (environmentPathname === undefined || !pathname.startsWith(environmentPathname)) return ""; + + return pathname.slice(environmentPathname.length).replace(/^\/+/, ""); +} + +/** The current page in another environment of the same project, keeping filters where they apply. */ +export function pathForEnvironmentSwitch({ + location, + environmentPathname, + environmentSlug, +}: { + location: Path; + environmentPathname: string | undefined; + environmentSlug: string; +}): string { + if (environmentPathname === undefined) { + return fullPath({ + ...location, + pathname: replaceEnvInPath(location.pathname, environmentSlug), + }); + } + + const page = pageBelowEnvironment(location.pathname, environmentPathname); + const portable = portablePage(page); + const pathname = pagePath(replaceEnvInPath(environmentPathname, environmentSlug), portable); + + return portable === page ? fullPath({ ...location, pathname }) : pathname; +} + +function replaceEnvInPath(path: string, environmentSlug: string) { + return path.replace(/env\/([^/]+)/, `env/${environmentSlug}`); +} + +function fullPath(location: Path) { + return `${location.pathname}${location.search}${location.hash}`; +} From 5746dd8e54dfb03283eec286049ce94054cb6460 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 11:06:02 +0000 Subject: [PATCH 2/8] fix(webapp): keep the branch lists on an environment switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portability is two properties, not one: the branch lists render under any environment of their project, so an environment switch keeps them — as it did before the page-carrying switchers existed — while a project or organization switch still falls back to Tasks, since the project it opens may have no preview branches. A test locks the environment half: every page below an environment that names no resource has to survive an environment switch, which is exactly what swapping the slug in the path used to give. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nj3iCRvSP9sP7y7hxXVJbx --- .../app/hooks/useEnvironmentSwitcher.ts | 4 +- apps/webapp/app/utils/pageSwitching.test.ts | 215 ++++++++++++------ apps/webapp/app/utils/pageSwitching.ts | 43 ++-- 3 files changed, 175 insertions(+), 87 deletions(-) diff --git a/apps/webapp/app/hooks/useEnvironmentSwitcher.ts b/apps/webapp/app/hooks/useEnvironmentSwitcher.ts index efbe83c7e1..f7a32a4825 100644 --- a/apps/webapp/app/hooks/useEnvironmentSwitcher.ts +++ b/apps/webapp/app/hooks/useEnvironmentSwitcher.ts @@ -4,8 +4,8 @@ import { ENVIRONMENT_MATCH_ID, pageBelowEnvironment, pathForEnvironmentSwitch, - portablePage, portablePageSearch, + projectPortablePage, } from "~/utils/pageSwitching"; import { organizationPath, @@ -43,7 +43,7 @@ export function useEnvironmentSwitcher() { export function usePageSwitcher() { const location = useOptimisticLocation(); const environmentPathname = useEnvironmentPathname(); - const page = portablePage(pageBelowEnvironment(location.pathname, environmentPathname)); + const page = projectPortablePage(pageBelowEnvironment(location.pathname, environmentPathname)); const search = portablePageSearch(page); return { diff --git a/apps/webapp/app/utils/pageSwitching.test.ts b/apps/webapp/app/utils/pageSwitching.test.ts index ad88d45aa2..428bf515ca 100644 --- a/apps/webapp/app/utils/pageSwitching.test.ts +++ b/apps/webapp/app/utils/pageSwitching.test.ts @@ -5,13 +5,15 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { ENVIRONMENT_MATCH_ID, - ENVIRONMENT_SPECIFIC_PAGES, + ENVIRONMENT_PORTABLE_PAGES, + environmentPortablePage, pageBelowEnvironment, pagePath, pathForEnvironmentSwitch, - portablePage, portablePageSearch, - PORTABLE_PAGES, + PROJECT_PORTABLE_PAGES, + PROJECT_SPECIFIC_PAGES, + projectPortablePage, requestedPortablePage, } from "./pageSwitching"; @@ -96,109 +98,155 @@ describe("the environment routes the portable page list is drawn from", () => { describe("portable pages", () => { it("cover every environment page that names no resource", () => { - const missing = idFreePages - .filter((page) => !PORTABLE_PAGES.has(page)) - .filter((page) => !ENVIRONMENT_SPECIFIC_PAGES.includes(page)) - .sort(); + const missing = idFreePages.filter((page) => !ENVIRONMENT_PORTABLE_PAGES.has(page)).sort(); expect(missing).toEqual([]); }); it("all point at a real page", () => { - const phantom = [...PORTABLE_PAGES].filter((page) => !matchesARoute(page)).sort(); + const phantom = [...ENVIRONMENT_PORTABLE_PAGES].filter((page) => !matchesARoute(page)).sort(); expect(phantom).toEqual([]); }); it("are all plain relative paths, which is what makes a redirect safe to build from one", () => { - for (const page of PORTABLE_PAGES) { + for (const page of ENVIRONMENT_PORTABLE_PAGES) { expect(page).toMatch(/^[a-z0-9-]+(\/[a-z0-9-]+)*$/); } }); it("each resolve to themselves, so switching twice lands in the same place", () => { - for (const page of PORTABLE_PAGES) { - expect(portablePage(page)).toBe(page); + for (const page of ENVIRONMENT_PORTABLE_PAGES) { + expect(environmentPortablePage(page)).toBe(page); + } + for (const page of PROJECT_PORTABLE_PAGES) { + expect(projectPortablePage(page)).toBe(page); } - expect(portablePage("")).toBe(""); + expect(environmentPortablePage("")).toBe(""); + expect(projectPortablePage("")).toBe(""); }); it("include the pages named in the request", () => { - expect(portablePage("apikeys")).toBe("apikeys"); - expect(portablePage("settings/general")).toBe("settings/general"); - expect(portablePage("waitpoints/tokens")).toBe("waitpoints/tokens"); + expect(projectPortablePage("apikeys")).toBe("apikeys"); + expect(projectPortablePage("settings/general")).toBe("settings/general"); + expect(projectPortablePage("waitpoints/tokens")).toBe("waitpoints/tokens"); }); }); -describe("pages named after a resource", () => { - it("truncate to a list page, id and all, for every one of them", () => { - const leaked = idPages - .map((page) => page.replace(/:[^/]+/g, PROBE)) - .filter((page) => { - const resolved = portablePage(page); - return resolved.includes(PROBE) || !(resolved === "" || PORTABLE_PAGES.has(resolved)); - }) +describe("pages a project or organization switch cannot carry", () => { + it("are the branch lists, which not every project has", () => { + expect([...PROJECT_SPECIFIC_PAGES].sort()).toEqual(["branches", "dev-branches"]); + + for (const page of PROJECT_SPECIFIC_PAGES) { + expect(ENVIRONMENT_PORTABLE_PAGES.has(page)).toBe(true); + expect(PROJECT_PORTABLE_PAGES.has(page)).toBe(false); + expect(projectPortablePage(page)).toBe(""); + expect(environmentPortablePage(page)).toBe(page); + } + }); + + it("are otherwise the same list, so nothing else is quietly dropped", () => { + const dropped = [...ENVIRONMENT_PORTABLE_PAGES] + .filter((page) => !PROJECT_PORTABLE_PAGES.has(page)) .sort(); - expect(leaked).toEqual([]); + expect(dropped).toEqual([...PROJECT_SPECIFIC_PAGES].sort()); + }); + + it("stay put when only the environment changes", () => { + expect( + pathForEnvironmentSwitch({ + location: locationOn("branches", "?search=feat"), + environmentPathname: environmentLocation.pathname, + environmentSlug: "preview", + }) + ).toBe("/orgs/acme/projects/api/env/preview/branches?search=feat"); + + expect( + pathForEnvironmentSwitch({ + location: locationOn("dev-branches"), + environmentPathname: environmentLocation.pathname, + environmentSlug: "prod", + }) + ).toBe("/orgs/acme/projects/api/env/prod/dev-branches"); + }); + + it("fall back to the tasks page when the project changes", () => { + expect(portablePageSearch(projectPortablePage("branches"))).toBe(""); + expect(portablePageSearch(projectPortablePage("dev-branches"))).toBe(""); + expect(requestedPortablePage(new Request("http://localhost/orgs/acme?page=branches"))).toBe(""); + expect(requestedPortablePage(new Request("http://localhost/orgs/acme?page=dev-branches"))).toBe( + "" + ); + }); +}); + +describe("pages named after a resource", () => { + it("truncate to a list page, id and all, for every one of them", () => { + const leaks = (resolve: (page: string) => string, pages: ReadonlySet) => + idPages + .map((page) => page.replace(/:[^/]+/g, PROBE)) + .filter((page) => { + const resolved = resolve(page); + return resolved.includes(PROBE) || !(resolved === "" || pages.has(resolved)); + }) + .sort(); + + expect(leaks(environmentPortablePage, ENVIRONMENT_PORTABLE_PAGES)).toEqual([]); + expect(leaks(projectPortablePage, PROJECT_PORTABLE_PAGES)).toEqual([]); }); it("truncate to the list they were reached from", () => { - expect(portablePage("runs/run_123")).toBe("runs"); - expect(portablePage("batches/batch_123")).toBe("batches"); - expect(portablePage("queues/my-queue")).toBe("queues"); - expect(portablePage("schedules/sched_123")).toBe("schedules"); - expect(portablePage("schedules/edit/sched_123")).toBe("schedules"); - expect(portablePage("deployments/deploy_123")).toBe("deployments"); - expect(portablePage("sessions/session_123")).toBe("sessions"); - expect(portablePage("errors/fingerprint_123")).toBe("errors"); - expect(portablePage("bulk-actions/bulk_123")).toBe("bulk-actions"); - expect(portablePage("waitpoints/tokens/waitpoint_123")).toBe("waitpoints/tokens"); - expect(portablePage("dashboards/custom/dashboard_123")).toBe("dashboards"); - expect(portablePage("models/gpt-5")).toBe("models"); - expect(portablePage("prompts/my-prompt")).toBe("prompts"); - expect(portablePage("agents/my-agent")).toBe("agents"); - expect(portablePage("playground/my-agent")).toBe("playground"); - expect(portablePage("test/tasks/my-task")).toBe("test"); - expect(portablePage("runs/run_123/stream")).toBe("runs"); + expect(projectPortablePage("runs/run_123")).toBe("runs"); + expect(projectPortablePage("batches/batch_123")).toBe("batches"); + expect(projectPortablePage("queues/my-queue")).toBe("queues"); + expect(projectPortablePage("schedules/sched_123")).toBe("schedules"); + expect(projectPortablePage("schedules/edit/sched_123")).toBe("schedules"); + expect(projectPortablePage("deployments/deploy_123")).toBe("deployments"); + expect(projectPortablePage("sessions/session_123")).toBe("sessions"); + expect(projectPortablePage("errors/fingerprint_123")).toBe("errors"); + expect(projectPortablePage("bulk-actions/bulk_123")).toBe("bulk-actions"); + expect(projectPortablePage("waitpoints/tokens/waitpoint_123")).toBe("waitpoints/tokens"); + expect(projectPortablePage("dashboards/custom/dashboard_123")).toBe("dashboards"); + expect(projectPortablePage("models/gpt-5")).toBe("models"); + expect(projectPortablePage("prompts/my-prompt")).toBe("prompts"); + expect(projectPortablePage("agents/my-agent")).toBe("agents"); + expect(projectPortablePage("playground/my-agent")).toBe("playground"); + expect(projectPortablePage("test/tasks/my-task")).toBe("test"); + expect(projectPortablePage("runs/run_123/stream")).toBe("runs"); }); it("send a task page back to the task list, which is the environment root", () => { - expect(portablePage("tasks/standard/my-task")).toBe(""); - expect(portablePage("tasks/scheduled/my-task")).toBe(""); + expect(projectPortablePage("tasks/standard/my-task")).toBe(""); + expect(projectPortablePage("tasks/scheduled/my-task")).toBe(""); }); it("keep the built-in metric dashboards but not the one gated per organization", () => { - expect(portablePage("dashboards/overview")).toBe("dashboards/overview"); - expect(portablePage("dashboards/llm")).toBe("dashboards/llm"); - expect(portablePage("dashboards/queues")).toBe("dashboards"); - }); - - it("send the branch lists to the environment root, since the environment type may change", () => { - expect(portablePage("branches")).toBe(""); - expect(portablePage("dev-branches")).toBe(""); + expect(projectPortablePage("dashboards/overview")).toBe("dashboards/overview"); + expect(projectPortablePage("dashboards/llm")).toBe("dashboards/llm"); + expect(projectPortablePage("dashboards/queues")).toBe("dashboards"); }); }); describe("a page suffix that is not a plain relative page", () => { it("falls back to the environment root rather than being sanitised into one", () => { - expect(portablePage("/apikeys")).toBe(""); - expect(portablePage("//evil.example.com")).toBe(""); - expect(portablePage("//evil.example.com/apikeys")).toBe(""); - expect(portablePage("https://evil.example.com")).toBe(""); - expect(portablePage("http://evil.example.com/apikeys")).toBe(""); - expect(portablePage("//")).toBe(""); - expect(portablePage("../../login")).toBe(""); - expect(portablePage("..")).toBe(""); - expect(portablePage(".")).toBe(""); - expect(portablePage("%2e%2e/%2e%2e/login")).toBe(""); - expect(portablePage("..%2f..%2flogin")).toBe(""); - expect(portablePage("\\\\evil.example.com")).toBe(""); - expect(portablePage("javascript:alert(1)")).toBe(""); - expect(portablePage("apikeys?next=//evil.example.com")).toBe(""); - expect(portablePage("apikeys#/../..")).toBe(""); - expect(portablePage("nonsense")).toBe(""); - expect(portablePage("")).toBe(""); + expect(projectPortablePage("/apikeys")).toBe(""); + expect(projectPortablePage("//evil.example.com")).toBe(""); + expect(projectPortablePage("//evil.example.com/apikeys")).toBe(""); + expect(projectPortablePage("https://evil.example.com")).toBe(""); + expect(projectPortablePage("http://evil.example.com/apikeys")).toBe(""); + expect(projectPortablePage("//")).toBe(""); + expect(projectPortablePage("../../login")).toBe(""); + expect(projectPortablePage("..")).toBe(""); + expect(projectPortablePage(".")).toBe(""); + expect(projectPortablePage("%2e%2e/%2e%2e/login")).toBe(""); + expect(projectPortablePage("..%2f..%2flogin")).toBe(""); + expect(projectPortablePage("\\\\evil.example.com")).toBe(""); + expect(projectPortablePage("javascript:alert(1)")).toBe(""); + expect(projectPortablePage("apikeys?next=//evil.example.com")).toBe(""); + expect(projectPortablePage("apikeys#/../..")).toBe(""); + expect(projectPortablePage("nonsense")).toBe(""); + expect(projectPortablePage("")).toBe(""); }); it("only ever answers with a page it knows, whatever it is handed", () => { @@ -211,17 +259,25 @@ describe("a page suffix that is not a plain relative page", () => { "runs/../../../etc/passwd", "%2e%2e/apikeys", "settings/general/../../..", + "/branches", + "..%2fbranches", ]; for (const attempt of attempts) { - const resolved = portablePage(attempt); - expect(resolved === "" || PORTABLE_PAGES.has(resolved)).toBe(true); + expect( + projectPortablePage(attempt) === "" || + PROJECT_PORTABLE_PAGES.has(projectPortablePage(attempt)) + ).toBe(true); + expect( + environmentPortablePage(attempt) === "" || + ENVIRONMENT_PORTABLE_PAGES.has(environmentPortablePage(attempt)) + ).toBe(true); } }); it("does not let a trailing traversal segment change which page is chosen", () => { - expect(portablePage("apikeys/../../login")).toBe("apikeys"); - expect(portablePage("settings/general/../../..")).toBe("settings/general"); + expect(projectPortablePage("apikeys/../../login")).toBe("apikeys"); + expect(projectPortablePage("settings/general/../../..")).toBe("settings/general"); }); }); @@ -255,6 +311,21 @@ describe("pageBelowEnvironment", () => { }); describe("pathForEnvironmentSwitch", () => { + it("keeps every page that only swapping the environment slug used to keep", () => { + const lost = idFreePages + .filter( + (page) => + pathForEnvironmentSwitch({ + location: locationOn(page), + environmentPathname: environmentLocation.pathname, + environmentSlug: "prod", + }) !== `/orgs/acme/projects/api/env/prod/${page}` + ) + .sort(); + + expect(lost).toEqual([]); + }); + it("keeps a portable page, and its filters with it", () => { expect( pathForEnvironmentSwitch({ diff --git a/apps/webapp/app/utils/pageSwitching.ts b/apps/webapp/app/utils/pageSwitching.ts index 7275535cbc..791f9fe5d6 100644 --- a/apps/webapp/app/utils/pageSwitching.ts +++ b/apps/webapp/app/utils/pageSwitching.ts @@ -19,34 +19,51 @@ const NESTED_PORTABLE_PAGES = [ "tasks/dashboard", ]; -/** These branch lists only load for their own environment type, which the switch may change. */ -export const ENVIRONMENT_SPECIFIC_PAGES = ["branches", "dev-branches"]; +/** The branch lists render under any environment of their project, but not in every project. */ +export const PROJECT_SPECIFIC_PAGES = ["branches", "dev-branches"]; + +/** Every page below an environment that names no resource, so any environment can render it. */ +export const ENVIRONMENT_PORTABLE_PAGES: ReadonlySet = new Set( + [ + ...[...ENV_PAGE_TARGETS.values()].map((target) => target.landing), + ...NESTED_PORTABLE_PAGES, + ].filter((page) => page !== "") +); -export const PORTABLE_PAGES: ReadonlySet = new Set( - [...[...ENV_PAGE_TARGETS.values()].map((target) => target.landing), ...NESTED_PORTABLE_PAGES] - .filter((page) => page !== "") - .filter((page) => !ENVIRONMENT_SPECIFIC_PAGES.includes(page)) +/** The ones every project has, which is what a project or organization switch can carry. */ +export const PROJECT_PORTABLE_PAGES: ReadonlySet = new Set( + [...ENVIRONMENT_PORTABLE_PAGES].filter((page) => !PROJECT_SPECIFIC_PAGES.includes(page)) ); /** - * The nearest page above `suffix` that every project has, as a path relative to the environment. - * A page named after a resource truncates to its list page, and anything else — an unknown page, - * or a suffix that is not a plain relative path — falls back to the environment root. + * The nearest page above `suffix` in `pages`, as a path relative to the environment. A page named + * after a resource truncates to its list page, and anything else — an unknown page, or a suffix + * that is not a plain relative path — falls back to the environment root. */ -export function portablePage(suffix: string): string { +function nearestPage(suffix: string, pages: ReadonlySet): string { const segments = suffix.split("/"); for (let depth = segments.length; depth > 0; depth--) { const candidate = segments.slice(0, depth).join("/"); - if (PORTABLE_PAGES.has(candidate)) return candidate; + if (pages.has(candidate)) return candidate; } return ""; } +/** The page to keep when only the environment changes. */ +export function environmentPortablePage(suffix: string): string { + return nearestPage(suffix, ENVIRONMENT_PORTABLE_PAGES); +} + +/** The page to keep when the project or organization changes. */ +export function projectPortablePage(suffix: string): string { + return nearestPage(suffix, PROJECT_PORTABLE_PAGES); +} + export function requestedPortablePage(request: Request): string { const requested = new URL(request.url).searchParams.get(PORTABLE_PAGE_PARAM); - return portablePage(requested ?? ""); + return projectPortablePage(requested ?? ""); } export function portablePageSearch(page: string): string { @@ -84,7 +101,7 @@ export function pathForEnvironmentSwitch({ } const page = pageBelowEnvironment(location.pathname, environmentPathname); - const portable = portablePage(page); + const portable = environmentPortablePage(page); const pathname = pagePath(replaceEnvInPath(environmentPathname, environmentSlug), portable); return portable === page ? fullPath({ ...location, pathname }) : pathname; From 5a292da720efd936d899937cdfe76c8d75dbda54 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 11:21:38 +0000 Subject: [PATCH 3/8] fix(webapp): drop the organization-gated pages on an organization switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logs and Query are gated by an organization feature flag, and their loaders redirect home when it is off, so carrying either across an organization switch sent the user through `/` instead of straight into the organization they picked. They now travel with an environment or project switch — both stay inside the organization whose flag let the user open the page — and an organization switch falls back to Tasks, the same shape the branch lists already use. A test scans the routes below an environment for the ones that redirect home and asserts they are exactly the pages the organization switch drops, so a third gated page cannot be added without updating the list. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nj3iCRvSP9sP7y7hxXVJbx --- .../app/hooks/useEnvironmentSwitcher.ts | 11 ++- .../route.tsx | 6 +- .../route.tsx | 4 +- apps/webapp/app/utils/pageSwitching.test.ts | 75 +++++++++++++++++-- apps/webapp/app/utils/pageSwitching.ts | 30 ++++++-- 5 files changed, 106 insertions(+), 20 deletions(-) diff --git a/apps/webapp/app/hooks/useEnvironmentSwitcher.ts b/apps/webapp/app/hooks/useEnvironmentSwitcher.ts index f7a32a4825..ab268f907f 100644 --- a/apps/webapp/app/hooks/useEnvironmentSwitcher.ts +++ b/apps/webapp/app/hooks/useEnvironmentSwitcher.ts @@ -2,6 +2,7 @@ import { useMatches } from "@remix-run/react"; import { type RuntimeEnvironment } from "@trigger.dev/database"; import { ENVIRONMENT_MATCH_ID, + organizationPortablePage, pageBelowEnvironment, pathForEnvironmentSwitch, portablePageSearch, @@ -43,13 +44,15 @@ export function useEnvironmentSwitcher() { export function usePageSwitcher() { const location = useOptimisticLocation(); const environmentPathname = useEnvironmentPathname(); - const page = projectPortablePage(pageBelowEnvironment(location.pathname, environmentPathname)); - const search = portablePageSearch(page); + const page = pageBelowEnvironment(location.pathname, environmentPathname); + const projectSearch = portablePageSearch(projectPortablePage(page)); + const organizationSearch = portablePageSearch(organizationPortablePage(page)); return { urlForProject: (organization: OrgForPath, project: ProjectForPath) => - `${v3ProjectPath(organization, project)}${search}`, - urlForOrganization: (organization: OrgForPath) => `${organizationPath(organization)}${search}`, + `${v3ProjectPath(organization, project)}${projectSearch}`, + urlForOrganization: (organization: OrgForPath) => + `${organizationPath(organization)}${organizationSearch}`, }; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug._index/route.tsx index 5b2b1fd3be..8b99a613c9 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug._index/route.tsx @@ -3,7 +3,7 @@ import { prisma } from "~/db.server"; import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server"; import { logger } from "~/services/logger.server"; import { requireUser } from "~/services/session.server"; -import { portablePageSearch, requestedPortablePage } from "~/utils/pageSwitching"; +import { portablePageSearch, requestedOrganizationPortablePage } from "~/utils/pageSwitching"; import { newOrganizationPath, newProjectPath, @@ -52,5 +52,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const projectPath = v3ProjectPath({ slug: organizationSlug }, bestProject); - return redirect(`${projectPath}${portablePageSearch(requestedPortablePage(request))}`); + return redirect( + `${projectPath}${portablePageSearch(requestedOrganizationPortablePage(request))}` + ); }; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam._index/route.tsx index d3cbde7850..91854efbc2 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam._index/route.tsx @@ -2,7 +2,7 @@ import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { prisma } from "~/db.server"; import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server"; import { requireUser } from "~/services/session.server"; -import { pagePath, requestedPortablePage } from "~/utils/pageSwitching"; +import { pagePath, requestedProjectPortablePage } from "~/utils/pageSwitching"; import { ProjectParamSchema, v3EnvironmentPath } from "~/utils/pathBuilder"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { @@ -43,5 +43,5 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const environmentPath = v3EnvironmentPath({ slug: organizationSlug }, project, environment); - return redirect(pagePath(environmentPath, requestedPortablePage(request))); + return redirect(pagePath(environmentPath, requestedProjectPortablePage(request))); }; diff --git a/apps/webapp/app/utils/pageSwitching.test.ts b/apps/webapp/app/utils/pageSwitching.test.ts index 428bf515ca..f2b3e135c1 100644 --- a/apps/webapp/app/utils/pageSwitching.test.ts +++ b/apps/webapp/app/utils/pageSwitching.test.ts @@ -7,6 +7,9 @@ import { ENVIRONMENT_MATCH_ID, ENVIRONMENT_PORTABLE_PAGES, environmentPortablePage, + ORGANIZATION_PORTABLE_PAGES, + ORGANIZATION_SPECIFIC_PAGES, + organizationPortablePage, pageBelowEnvironment, pagePath, pathForEnvironmentSwitch, @@ -14,7 +17,8 @@ import { PROJECT_PORTABLE_PAGES, PROJECT_SPECIFIC_PAGES, projectPortablePage, - requestedPortablePage, + requestedOrganizationPortablePage, + requestedProjectPortablePage, } from "./pageSwitching"; const APP_DIR = join(__dirname, ".."); @@ -40,11 +44,16 @@ function rendersAPage(file: string): boolean { return /^export default/m.test(readFileSync(join(APP_DIR, file), "utf8")); } +function sendsYouHome(file: string): boolean { + return /redirect\("\/"\)/.test(readFileSync(join(APP_DIR, file), "utf8")); +} + const belowEnvironment = Object.values(compiledRoutes) .filter((route) => compiledUrl(route.id).startsWith(ENVIRONMENT_URL)) .map((route) => ({ suffix: compiledUrl(route.id).slice(ENVIRONMENT_URL.length).replace(/^\//, ""), rendersAPage: rendersAPage(route.file), + sendsYouHome: sendsYouHome(route.file), })); const environmentRoutes = [...new Set(belowEnvironment.map((route) => route.suffix))]; @@ -122,8 +131,12 @@ describe("portable pages", () => { for (const page of PROJECT_PORTABLE_PAGES) { expect(projectPortablePage(page)).toBe(page); } + for (const page of ORGANIZATION_PORTABLE_PAGES) { + expect(organizationPortablePage(page)).toBe(page); + } expect(environmentPortablePage("")).toBe(""); expect(projectPortablePage("")).toBe(""); + expect(organizationPortablePage("")).toBe(""); }); it("include the pages named in the request", () => { @@ -133,7 +146,7 @@ describe("portable pages", () => { }); }); -describe("pages a project or organization switch cannot carry", () => { +describe("pages a project switch cannot carry", () => { it("are the branch lists, which not every project has", () => { expect([...PROJECT_SPECIFIC_PAGES].sort()).toEqual(["branches", "dev-branches"]); @@ -174,10 +187,53 @@ describe("pages a project or organization switch cannot carry", () => { it("fall back to the tasks page when the project changes", () => { expect(portablePageSearch(projectPortablePage("branches"))).toBe(""); expect(portablePageSearch(projectPortablePage("dev-branches"))).toBe(""); - expect(requestedPortablePage(new Request("http://localhost/orgs/acme?page=branches"))).toBe(""); - expect(requestedPortablePage(new Request("http://localhost/orgs/acme?page=dev-branches"))).toBe( - "" - ); + expect( + requestedProjectPortablePage(new Request("http://localhost/orgs/acme?page=branches")) + ).toBe(""); + expect( + requestedProjectPortablePage(new Request("http://localhost/orgs/acme?page=dev-branches")) + ).toBe(""); + }); +}); + +describe("pages an organization switch cannot carry", () => { + it("are the ones whose loaders send you home when the organization is not allowed in", () => { + const sendHome = [ + ...new Set( + belowEnvironment.filter((route) => route.sendsYouHome).map((route) => route.suffix) + ), + ].sort(); + + expect(sendHome).toEqual([...ORGANIZATION_SPECIFIC_PAGES].sort()); + }); + + it("still travel with an environment or project switch, which stay in the same organization", () => { + for (const page of ORGANIZATION_SPECIFIC_PAGES) { + expect(ENVIRONMENT_PORTABLE_PAGES.has(page)).toBe(true); + expect(PROJECT_PORTABLE_PAGES.has(page)).toBe(true); + expect(ORGANIZATION_PORTABLE_PAGES.has(page)).toBe(false); + expect(environmentPortablePage(page)).toBe(page); + expect(projectPortablePage(page)).toBe(page); + expect(organizationPortablePage(page)).toBe(""); + } + }); + + it("are otherwise the same list, so nothing else is quietly dropped", () => { + const dropped = [...PROJECT_PORTABLE_PAGES] + .filter((page) => !ORGANIZATION_PORTABLE_PAGES.has(page)) + .sort(); + + expect(dropped).toEqual([...ORGANIZATION_SPECIFIC_PAGES].sort()); + }); + + it("fall back to the tasks page when the organization changes", () => { + const read = (search: string) => + requestedOrganizationPortablePage(new Request(`http://localhost/orgs/acme${search}`)); + + expect(portablePageSearch(organizationPortablePage("logs"))).toBe(""); + expect(read("?page=logs")).toBe(""); + expect(read("?page=query")).toBe(""); + expect(read("?page=apikeys")).toBe("apikeys"); }); }); @@ -194,6 +250,7 @@ describe("pages named after a resource", () => { expect(leaks(environmentPortablePage, ENVIRONMENT_PORTABLE_PAGES)).toEqual([]); expect(leaks(projectPortablePage, PROJECT_PORTABLE_PAGES)).toEqual([]); + expect(leaks(organizationPortablePage, ORGANIZATION_PORTABLE_PAGES)).toEqual([]); }); it("truncate to the list they were reached from", () => { @@ -272,6 +329,10 @@ describe("a page suffix that is not a plain relative page", () => { environmentPortablePage(attempt) === "" || ENVIRONMENT_PORTABLE_PAGES.has(environmentPortablePage(attempt)) ).toBe(true); + expect( + organizationPortablePage(attempt) === "" || + ORGANIZATION_PORTABLE_PAGES.has(organizationPortablePage(attempt)) + ).toBe(true); } }); @@ -392,7 +453,7 @@ describe("carrying a page across a project or organization switch", () => { it("reads the page back off the request, validating it again", () => { const read = (search: string) => - requestedPortablePage(new Request(`http://localhost/orgs/acme${search}`)); + requestedProjectPortablePage(new Request(`http://localhost/orgs/acme${search}`)); expect(read("?page=apikeys")).toBe("apikeys"); expect(read("?page=waitpoints/tokens")).toBe("waitpoints/tokens"); diff --git a/apps/webapp/app/utils/pageSwitching.ts b/apps/webapp/app/utils/pageSwitching.ts index 791f9fe5d6..960a4aed2d 100644 --- a/apps/webapp/app/utils/pageSwitching.ts +++ b/apps/webapp/app/utils/pageSwitching.ts @@ -22,6 +22,9 @@ const NESTED_PORTABLE_PAGES = [ /** The branch lists render under any environment of their project, but not in every project. */ export const PROJECT_SPECIFIC_PAGES = ["branches", "dev-branches"]; +/** Gated by an organization feature flag, so their loaders send you home from an organization without it. */ +export const ORGANIZATION_SPECIFIC_PAGES = ["logs", "query"]; + /** Every page below an environment that names no resource, so any environment can render it. */ export const ENVIRONMENT_PORTABLE_PAGES: ReadonlySet = new Set( [ @@ -30,11 +33,16 @@ export const ENVIRONMENT_PORTABLE_PAGES: ReadonlySet = new Set( ].filter((page) => page !== "") ); -/** The ones every project has, which is what a project or organization switch can carry. */ +/** The ones every project has, which is what a project switch can carry. */ export const PROJECT_PORTABLE_PAGES: ReadonlySet = new Set( [...ENVIRONMENT_PORTABLE_PAGES].filter((page) => !PROJECT_SPECIFIC_PAGES.includes(page)) ); +/** The ones every organization has, which is what an organization switch can carry. */ +export const ORGANIZATION_PORTABLE_PAGES: ReadonlySet = new Set( + [...PROJECT_PORTABLE_PAGES].filter((page) => !ORGANIZATION_SPECIFIC_PAGES.includes(page)) +); + /** * The nearest page above `suffix` in `pages`, as a path relative to the environment. A page named * after a resource truncates to its list page, and anything else — an unknown page, or a suffix @@ -56,14 +64,26 @@ export function environmentPortablePage(suffix: string): string { return nearestPage(suffix, ENVIRONMENT_PORTABLE_PAGES); } -/** The page to keep when the project or organization changes. */ +/** The page to keep when the project changes. */ export function projectPortablePage(suffix: string): string { return nearestPage(suffix, PROJECT_PORTABLE_PAGES); } -export function requestedPortablePage(request: Request): string { - const requested = new URL(request.url).searchParams.get(PORTABLE_PAGE_PARAM); - return projectPortablePage(requested ?? ""); +/** The page to keep when the organization changes. */ +export function organizationPortablePage(suffix: string): string { + return nearestPage(suffix, ORGANIZATION_PORTABLE_PAGES); +} + +function requestedPage(request: Request): string { + return new URL(request.url).searchParams.get(PORTABLE_PAGE_PARAM) ?? ""; +} + +export function requestedProjectPortablePage(request: Request): string { + return projectPortablePage(requestedPage(request)); +} + +export function requestedOrganizationPortablePage(request: Request): string { + return organizationPortablePage(requestedPage(request)); } export function portablePageSearch(page: string): string { From d386ac496bd69a561033fdeb0e8bac619feb749d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 11:36:21 +0000 Subject: [PATCH 4/8] fix(webapp): keep the queue metrics dashboard on an environment switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The built-in queues dashboard is gated per organization, like Logs and Query, so it belongs in ORGANIZATION_SPECIFIC_PAGES rather than being left out of the portable pages entirely: an environment or project switch stays inside the organization whose flag let you open it. The manifest assertion that derives the gated pages from the route sources now reads both ways a loader turns you away — a redirect home and a 404 on the same shape of organization gate — so a future gated page still cannot slip in unnoticed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nj3iCRvSP9sP7y7hxXVJbx --- apps/webapp/app/utils/pageSwitching.test.ts | 87 +++++++++++++++++---- apps/webapp/app/utils/pageSwitching.ts | 5 +- 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/apps/webapp/app/utils/pageSwitching.test.ts b/apps/webapp/app/utils/pageSwitching.test.ts index f2b3e135c1..482776d3ce 100644 --- a/apps/webapp/app/utils/pageSwitching.test.ts +++ b/apps/webapp/app/utils/pageSwitching.test.ts @@ -44,17 +44,44 @@ function rendersAPage(file: string): boolean { return /^export default/m.test(readFileSync(join(APP_DIR, file), "utf8")); } -function sendsYouHome(file: string): boolean { - return /redirect\("\/"\)/.test(readFileSync(join(APP_DIR, file), "utf8")); +const ORGANIZATION_GATE = String.raw`(?:can|has)[A-Z]\w*\([^)]*\borganizationSlug\b[^)]*\)`; + +const GATE_REJECTS = new RegExp( + String.raw`if \(\s*(?:(\w+) === "([^"]+)" &&\s*)?!\(await ${ORGANIZATION_GATE}\)\s*\)\s*\{\s*throw` +); + +const GATE_REJECTS_VIA_FLAG = new RegExp( + String.raw`const canAccess = await ${ORGANIZATION_GATE};\s*if \(!canAccess\) \{\s*throw` +); + +/** + * The page a loader turns you away from when an organization-scoped check says no, whether it + * sends you home or 404s. A gate that only covers one value of a route param names that page; an + * unconditional gate on a route that takes a resource id names nothing, since a page with an id in + * it is never portable anyway. + */ +function organizationGatedPage(suffix: string, file: string): string | undefined { + const source = readFileSync(join(APP_DIR, file), "utf8"); + const guarded = GATE_REJECTS.exec(source); + + if (guarded === null) return GATE_REJECTS_VIA_FLAG.test(source) ? suffix : undefined; + + const [, param, key] = guarded; + if (param === undefined) return suffix.includes(":") ? undefined : suffix; + + return suffix.includes(`:${param}`) ? suffix.replace(`:${param}`, key) : undefined; } const belowEnvironment = Object.values(compiledRoutes) .filter((route) => compiledUrl(route.id).startsWith(ENVIRONMENT_URL)) - .map((route) => ({ - suffix: compiledUrl(route.id).slice(ENVIRONMENT_URL.length).replace(/^\//, ""), - rendersAPage: rendersAPage(route.file), - sendsYouHome: sendsYouHome(route.file), - })); + .map((route) => { + const suffix = compiledUrl(route.id).slice(ENVIRONMENT_URL.length).replace(/^\//, ""); + return { + suffix, + rendersAPage: rendersAPage(route.file), + organizationGatedPage: organizationGatedPage(suffix, route.file), + }; + }); const environmentRoutes = [...new Set(belowEnvironment.map((route) => route.suffix))]; @@ -197,14 +224,26 @@ describe("pages a project switch cannot carry", () => { }); describe("pages an organization switch cannot carry", () => { - it("are the ones whose loaders send you home when the organization is not allowed in", () => { - const sendHome = [ + it("are the ones whose loaders turn you away when the organization is not allowed in", () => { + const gated = [ ...new Set( - belowEnvironment.filter((route) => route.sendsYouHome).map((route) => route.suffix) + belowEnvironment + .map((route) => route.organizationGatedPage) + .filter((page): page is string => page !== undefined) ), ].sort(); - expect(sendHome).toEqual([...ORGANIZATION_SPECIFIC_PAGES].sort()); + expect(gated).toEqual([...ORGANIZATION_SPECIFIC_PAGES].sort()); + }); + + it("are read from both ways a loader turns you away, so neither stops being noticed", () => { + const gatedPage = (suffix: string) => + belowEnvironment.find((route) => route.suffix === suffix)?.organizationGatedPage; + + expect(gatedPage("logs")).toBe("logs"); + expect(gatedPage("dashboards/:dashboardKey")).toBe("dashboards/queues"); + expect(gatedPage("queues/:queueParam")).toBeUndefined(); + expect(gatedPage("apikeys")).toBeUndefined(); }); it("still travel with an environment or project switch, which stay in the same organization", () => { @@ -214,10 +253,27 @@ describe("pages an organization switch cannot carry", () => { expect(ORGANIZATION_PORTABLE_PAGES.has(page)).toBe(false); expect(environmentPortablePage(page)).toBe(page); expect(projectPortablePage(page)).toBe(page); - expect(organizationPortablePage(page)).toBe(""); } }); + it("stay put when only the environment changes", () => { + expect( + pathForEnvironmentSwitch({ + location: locationOn("dashboards/queues", "?period=1d"), + environmentPathname: environmentLocation.pathname, + environmentSlug: "preview", + }) + ).toBe("/orgs/acme/projects/api/env/preview/dashboards/queues?period=1d"); + + expect( + pathForEnvironmentSwitch({ + location: locationOn("logs"), + environmentPathname: environmentLocation.pathname, + environmentSlug: "prod", + }) + ).toBe("/orgs/acme/projects/api/env/prod/logs"); + }); + it("are otherwise the same list, so nothing else is quietly dropped", () => { const dropped = [...PROJECT_PORTABLE_PAGES] .filter((page) => !ORGANIZATION_PORTABLE_PAGES.has(page)) @@ -226,13 +282,14 @@ describe("pages an organization switch cannot carry", () => { expect(dropped).toEqual([...ORGANIZATION_SPECIFIC_PAGES].sort()); }); - it("fall back to the tasks page when the organization changes", () => { + it("fall back to the nearest page the organization switched into can open", () => { const read = (search: string) => requestedOrganizationPortablePage(new Request(`http://localhost/orgs/acme${search}`)); expect(portablePageSearch(organizationPortablePage("logs"))).toBe(""); expect(read("?page=logs")).toBe(""); expect(read("?page=query")).toBe(""); + expect(read("?page=dashboards/queues")).toBe("dashboards"); expect(read("?page=apikeys")).toBe("apikeys"); }); }); @@ -278,10 +335,10 @@ describe("pages named after a resource", () => { expect(projectPortablePage("tasks/scheduled/my-task")).toBe(""); }); - it("keep the built-in metric dashboards but not the one gated per organization", () => { + it("keep the built-in metric dashboards, which are pages rather than saved dashboards", () => { expect(projectPortablePage("dashboards/overview")).toBe("dashboards/overview"); expect(projectPortablePage("dashboards/llm")).toBe("dashboards/llm"); - expect(projectPortablePage("dashboards/queues")).toBe("dashboards"); + expect(projectPortablePage("dashboards/queues")).toBe("dashboards/queues"); }); }); diff --git a/apps/webapp/app/utils/pageSwitching.ts b/apps/webapp/app/utils/pageSwitching.ts index 960a4aed2d..97d055a2f1 100644 --- a/apps/webapp/app/utils/pageSwitching.ts +++ b/apps/webapp/app/utils/pageSwitching.ts @@ -11,6 +11,7 @@ const NESTED_PORTABLE_PAGES = [ "alerts/new", "dashboards/llm", "dashboards/overview", + "dashboards/queues", "environment-variables/new", "models/compare", "schedules/new", @@ -22,8 +23,8 @@ const NESTED_PORTABLE_PAGES = [ /** The branch lists render under any environment of their project, but not in every project. */ export const PROJECT_SPECIFIC_PAGES = ["branches", "dev-branches"]; -/** Gated by an organization feature flag, so their loaders send you home from an organization without it. */ -export const ORGANIZATION_SPECIFIC_PAGES = ["logs", "query"]; +/** Gated by an organization feature flag, so their loaders turn you away in an organization without it. */ +export const ORGANIZATION_SPECIFIC_PAGES = ["logs", "query", "dashboards/queues"]; /** Every page below an environment that names no resource, so any environment can render it. */ export const ENVIRONMENT_PORTABLE_PAGES: ReadonlySet = new Set( From 2d2c417191dc0e1a686d6e7098f9c6406375bce5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 12:46:12 +0000 Subject: [PATCH 5/8] test(webapp): check the portable page list against the built-in dashboards The built-in metric dashboards all share one route, so the route manifest cannot notice a new one. Compare the declared dashboards/* portable pages against builtInDashboardList() instead, so adding a fourth dashboard without listing it fails the test rather than silently degrading to the dashboards index on a switch. Co-Authored-By: Claude --- apps/webapp/app/utils/pageSwitching.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/webapp/app/utils/pageSwitching.test.ts b/apps/webapp/app/utils/pageSwitching.test.ts index 482776d3ce..e148562daf 100644 --- a/apps/webapp/app/utils/pageSwitching.test.ts +++ b/apps/webapp/app/utils/pageSwitching.test.ts @@ -3,6 +3,7 @@ import type { RouteManifest } from "@remix-run/dev/dist/config/routes.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { builtInDashboardList } from "../presenters/v3/BuiltInDashboards.server"; import { ENVIRONMENT_MATCH_ID, ENVIRONMENT_PORTABLE_PAGES, @@ -145,6 +146,16 @@ describe("portable pages", () => { expect(phantom).toEqual([]); }); + it("name every built-in metric dashboard, which the routes cannot tell us since they share one", () => { + expect( + [...ENVIRONMENT_PORTABLE_PAGES].filter((page) => page.startsWith("dashboards/")).sort() + ).toEqual( + builtInDashboardList() + .map((dashboard) => `dashboards/${dashboard.key}`) + .sort() + ); + }); + it("are all plain relative paths, which is what makes a redirect safe to build from one", () => { for (const page of ENVIRONMENT_PORTABLE_PAGES) { expect(page).toMatch(/^[a-z0-9-]+(\/[a-z0-9-]+)*$/); From e0719bb18a3ad5bfec0582dda16b8ea650ae582a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 12:56:33 +0000 Subject: [PATCH 6/8] fix(webapp): match the environment path on a segment boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page below the environment was sliced off the front of the pathname on a bare startsWith, so an environment slug that strictly prefixes another — branch slugs are `-`, making `preview-feat` a prefix of `preview-feat-2` — yielded a garbled suffix that resolved to no page. The switcher hooks can hit this mid-navigation, where the pathname comes from the pending location but the environment path still comes from the current match. Co-Authored-By: Claude --- apps/webapp/app/utils/pageSwitching.test.ts | 8 ++++++++ apps/webapp/app/utils/pageSwitching.ts | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/utils/pageSwitching.test.ts b/apps/webapp/app/utils/pageSwitching.test.ts index e148562daf..e83fc35df4 100644 --- a/apps/webapp/app/utils/pageSwitching.test.ts +++ b/apps/webapp/app/utils/pageSwitching.test.ts @@ -437,6 +437,14 @@ describe("pageBelowEnvironment", () => { expect(pageBelowEnvironment("/account/tokens", environmentLocation.pathname)).toBe(""); expect(pageBelowEnvironment("/orgs/acme/settings/team", environmentLocation.pathname)).toBe(""); }); + + it("gives nothing when the environment path only prefixes the one in the page path", () => { + const branch = "/orgs/acme/projects/api/env/preview-feat"; + + expect(pageBelowEnvironment(`${branch}-2/runs`, branch)).toBe(""); + expect(pageBelowEnvironment(`${branch}-2`, branch)).toBe(""); + expect(pageBelowEnvironment(`${branch}/runs`, branch)).toBe("runs"); + }); }); describe("pathForEnvironmentSwitch", () => { diff --git a/apps/webapp/app/utils/pageSwitching.ts b/apps/webapp/app/utils/pageSwitching.ts index 97d055a2f1..78277c64bf 100644 --- a/apps/webapp/app/utils/pageSwitching.ts +++ b/apps/webapp/app/utils/pageSwitching.ts @@ -101,7 +101,10 @@ export function pageBelowEnvironment( ): string { if (environmentPathname === undefined || !pathname.startsWith(environmentPathname)) return ""; - return pathname.slice(environmentPathname.length).replace(/^\/+/, ""); + const below = pathname.slice(environmentPathname.length); + if (below !== "" && !below.startsWith("/")) return ""; + + return below.replace(/^\/+/, ""); } /** The current page in another environment of the same project, keeping filters where they apply. */ From 00174eb4fab4944eacf3ed11185493d437f90dfb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 13:11:30 +0000 Subject: [PATCH 7/8] fix(webapp): keep a task, agent or prompt page on an environment switch Truncating every resource page to its list is right for a resource an environment issued an id for, but the last segment of a task, agent, prompt, playground or model page is a name that comes from the user's code or the model catalog, so it names the same thing in every environment of the project. An environment switch now keeps that name, as swapping the slug used to; a project or organization switch still truncates, since the name need not exist over there. The leak assertion is split rather than relaxed: pages addressed by an id must still resolve with the id gone, for all three switches, and the slug-addressed ones must come back whole from an environment switch. Co-Authored-By: Claude --- apps/webapp/app/utils/pageSwitching.test.ts | 84 +++++++++++++++++++-- apps/webapp/app/utils/pageSwitching.ts | 35 ++++++++- 2 files changed, 112 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/utils/pageSwitching.test.ts b/apps/webapp/app/utils/pageSwitching.test.ts index e83fc35df4..ef85c1c5f7 100644 --- a/apps/webapp/app/utils/pageSwitching.test.ts +++ b/apps/webapp/app/utils/pageSwitching.test.ts @@ -20,6 +20,7 @@ import { projectPortablePage, requestedOrganizationPortablePage, requestedProjectPortablePage, + SLUG_ADDRESSED_PAGES, } from "./pageSwitching"; const APP_DIR = join(__dirname, ".."); @@ -94,6 +95,15 @@ const environmentPages = [ const idFreePages = environmentPages.filter((page) => !page.includes(":") && page !== ""); const idPages = environmentPages.filter((page) => page.includes(":")); +const probes = idPages.map((page) => page.replace(/:[^/]+/g, PROBE)); + +function listAbove(page: string): string { + return page.slice(0, page.lastIndexOf("/")); +} + +const slugAddressedProbes = probes.filter((page) => SLUG_ADDRESSED_PAGES.includes(listAbove(page))); +const idAddressedProbes = probes.filter((page) => !SLUG_ADDRESSED_PAGES.includes(listAbove(page))); + function matchesARoute(page: string): boolean { const wanted = page === "" ? [] : page.split("/"); @@ -307,18 +317,19 @@ describe("pages an organization switch cannot carry", () => { describe("pages named after a resource", () => { it("truncate to a list page, id and all, for every one of them", () => { - const leaks = (resolve: (page: string) => string, pages: ReadonlySet) => - idPages - .map((page) => page.replace(/:[^/]+/g, PROBE)) + const leaks = (resolve: (page: string) => string, pages: ReadonlySet, from: string[]) => + from .filter((page) => { const resolved = resolve(page); return resolved.includes(PROBE) || !(resolved === "" || pages.has(resolved)); }) .sort(); - expect(leaks(environmentPortablePage, ENVIRONMENT_PORTABLE_PAGES)).toEqual([]); - expect(leaks(projectPortablePage, PROJECT_PORTABLE_PAGES)).toEqual([]); - expect(leaks(organizationPortablePage, ORGANIZATION_PORTABLE_PAGES)).toEqual([]); + expect(leaks(environmentPortablePage, ENVIRONMENT_PORTABLE_PAGES, idAddressedProbes)).toEqual( + [] + ); + expect(leaks(projectPortablePage, PROJECT_PORTABLE_PAGES, probes)).toEqual([]); + expect(leaks(organizationPortablePage, ORGANIZATION_PORTABLE_PAGES, probes)).toEqual([]); }); it("truncate to the list they were reached from", () => { @@ -353,6 +364,64 @@ describe("pages named after a resource", () => { }); }); +describe("pages named after something the environment did not issue", () => { + it("are the ones a route below them takes a code or catalog name for", () => { + expect([...new Set(slugAddressedProbes.map(listAbove))].sort()).toEqual( + [...SLUG_ADDRESSED_PAGES].sort() + ); + + for (const page of slugAddressedProbes) { + expect(environmentPortablePage(page)).toBe(page); + } + }); + + it("keep their name when only the environment changes, since it names the same thing there", () => { + const switched = (page: string, search = "") => + pathForEnvironmentSwitch({ + location: locationOn(page, search), + environmentPathname: environmentLocation.pathname, + environmentSlug: "prod", + }); + + expect(switched("tasks/standard/my-task", "?period=1d")).toBe( + "/orgs/acme/projects/api/env/prod/tasks/standard/my-task?period=1d" + ); + expect(switched("tasks/scheduled/my-task")).toBe( + "/orgs/acme/projects/api/env/prod/tasks/scheduled/my-task" + ); + expect(switched("test/tasks/my-task")).toBe( + "/orgs/acme/projects/api/env/prod/test/tasks/my-task" + ); + expect(switched("agents/my-agent")).toBe("/orgs/acme/projects/api/env/prod/agents/my-agent"); + expect(switched("playground/my-agent")).toBe( + "/orgs/acme/projects/api/env/prod/playground/my-agent" + ); + expect(switched("prompts/my-prompt")).toBe( + "/orgs/acme/projects/api/env/prod/prompts/my-prompt" + ); + expect(switched("models/gpt-5")).toBe("/orgs/acme/projects/api/env/prod/models/gpt-5"); + }); + + it("fall back to their list page when the project or organization changes, which may not have the name", () => { + expect(projectPortablePage("tasks/standard/my-task")).toBe(""); + expect(projectPortablePage("test/tasks/my-task")).toBe("test"); + expect(projectPortablePage("agents/my-agent")).toBe("agents"); + expect(organizationPortablePage("prompts/my-prompt")).toBe("prompts"); + expect(organizationPortablePage("models/gpt-5")).toBe("models"); + }); + + it("keep nothing but a single plain name in that last segment", () => { + expect(environmentPortablePage("tasks/standard/..%2f..%2flogin")).toBe(""); + expect(environmentPortablePage("tasks/standard/../../login")).toBe(""); + expect(environmentPortablePage("agents/%2e%2e")).toBe("agents"); + expect(environmentPortablePage("agents/..")).toBe("agents"); + expect(environmentPortablePage("agents/%zz")).toBe("agents"); + expect(environmentPortablePage("agents/")).toBe("agents"); + expect(environmentPortablePage("models/my%2Fmodel")).toBe("models"); + expect(environmentPortablePage("prompts/my-prompt/extra")).toBe("prompts"); + }); +}); + describe("a page suffix that is not a plain relative page", () => { it("falls back to the environment root rather than being sanitised into one", () => { expect(projectPortablePage("/apikeys")).toBe(""); @@ -386,6 +455,9 @@ describe("a page suffix that is not a plain relative page", () => { "settings/general/../../..", "/branches", "..%2fbranches", + "tasks/standard/../../login", + "agents/..%2f..%2flogin", + "models/%2f%2fevil.example.com", ]; for (const attempt of attempts) { diff --git a/apps/webapp/app/utils/pageSwitching.ts b/apps/webapp/app/utils/pageSwitching.ts index 78277c64bf..017ac3d6c9 100644 --- a/apps/webapp/app/utils/pageSwitching.ts +++ b/apps/webapp/app/utils/pageSwitching.ts @@ -26,6 +26,21 @@ export const PROJECT_SPECIFIC_PAGES = ["branches", "dev-branches"]; /** Gated by an organization feature flag, so their loaders turn you away in an organization without it. */ export const ORGANIZATION_SPECIFIC_PAGES = ["logs", "query", "dashboards/queues"]; +/** + * Pages whose last segment is a name the user's code or the model catalog decides, rather than an + * id one environment issued, so the same address names the same thing in every environment of the + * project. Another project need not have that name, so only an environment switch carries it. + */ +export const SLUG_ADDRESSED_PAGES = [ + "agents", + "models", + "playground", + "prompts", + "tasks/scheduled", + "tasks/standard", + "test/tasks", +]; + /** Every page below an environment that names no resource, so any environment can render it. */ export const ENVIRONMENT_PORTABLE_PAGES: ReadonlySet = new Set( [ @@ -60,9 +75,27 @@ function nearestPage(suffix: string, pages: ReadonlySet): string { return ""; } +/** + * `suffix` itself when it is a slug-addressed page, as long as the slug is a single plain segment — + * a traversal or an encoded path in its place falls through to the list page above it. + */ +function slugAddressedPage(suffix: string): string | undefined { + const boundary = suffix.lastIndexOf("/"); + if (boundary < 1 || !SLUG_ADDRESSED_PAGES.includes(suffix.slice(0, boundary))) return undefined; + + let slug: string; + try { + slug = decodeURIComponent(suffix.slice(boundary + 1)); + } catch { + return undefined; + } + + return slug !== "" && !/^\.+$/.test(slug) && !/[/\\]/.test(slug) ? suffix : undefined; +} + /** The page to keep when only the environment changes. */ export function environmentPortablePage(suffix: string): string { - return nearestPage(suffix, ENVIRONMENT_PORTABLE_PAGES); + return slugAddressedPage(suffix) ?? nearestPage(suffix, ENVIRONMENT_PORTABLE_PAGES); } /** The page to keep when the project changes. */ From ed4461d7060f8edf20a0a872f989c653a74a2c33 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 13:32:25 +0000 Subject: [PATCH 8/8] fix(webapp): carry the pages a switch can still open A custom dashboard is looked up by its friendly id scoped to the organization rather than the environment, so every environment of the project opens the same dashboard at the same address. Truncating `dashboards/custom/` to the dashboard list on an environment switch lost the user's place for no reason; it now travels like the slug-addressed pages, under the same guard on the trailing segment, while a project or organization switch still truncates it. Integrations is gated on the caller's role rather than on an organization feature flag, and a role differs between organizations, so carrying that page across an organization switch could land on the permission panel where the switch used to land on Tasks. It joins the pages an organization switch drops, and the manifest-derived check now reads a role gate on a loader as well as a feature flag, so a future one cannot slip in unnoticed. Co-Authored-By: Claude --- apps/webapp/app/utils/pageSwitching.test.ts | 112 ++++++++++++++++++-- apps/webapp/app/utils/pageSwitching.ts | 35 ++++-- 2 files changed, 132 insertions(+), 15 deletions(-) diff --git a/apps/webapp/app/utils/pageSwitching.test.ts b/apps/webapp/app/utils/pageSwitching.test.ts index ef85c1c5f7..5b8f8e6988 100644 --- a/apps/webapp/app/utils/pageSwitching.test.ts +++ b/apps/webapp/app/utils/pageSwitching.test.ts @@ -8,6 +8,7 @@ import { ENVIRONMENT_MATCH_ID, ENVIRONMENT_PORTABLE_PAGES, environmentPortablePage, + ORGANIZATION_ADDRESSED_PAGES, ORGANIZATION_PORTABLE_PAGES, ORGANIZATION_SPECIFIC_PAGES, organizationPortablePage, @@ -56,17 +57,38 @@ const GATE_REJECTS_VIA_FLAG = new RegExp( String.raw`const canAccess = await ${ORGANIZATION_GATE};\s*if \(!canAccess\) \{\s*throw` ); +// A role the caller holds in one organization but not the next: an `authorization` block on the +// loader, or the denial thrown directly for a check the block cannot express. +const GATE_REJECTS_ON_ROLE = /throwPermissionDenied\(|authorization: \{/; + +/** The loader's half of a route module, so a gate on its action is not read as a gate on landing. */ +function loaderSource(source: string): string { + const start = source.search(/^export (?:const|async function) loader\b/m); + if (start < 0) return ""; + + const loaderOnwards = source.slice(start); + const next = loaderOnwards.slice(1).search(/^export (?:const|async function|default)/m); + + return next < 0 ? loaderOnwards : loaderOnwards.slice(0, next + 1); +} + /** - * The page a loader turns you away from when an organization-scoped check says no, whether it - * sends you home or 404s. A gate that only covers one value of a route param names that page; an - * unconditional gate on a route that takes a resource id names nothing, since a page with an id in - * it is never portable anyway. + * The page a loader turns you away from when a check the organization answers says no, whether it + * sends you home, 404s or renders the permission panel. A gate that only covers one value of a + * route param names that page; a gate on a route that takes a resource id names nothing, since a + * page with an id in it is never portable anyway. */ function organizationGatedPage(suffix: string, file: string): string | undefined { const source = readFileSync(join(APP_DIR, file), "utf8"); const guarded = GATE_REJECTS.exec(source); - if (guarded === null) return GATE_REJECTS_VIA_FLAG.test(source) ? suffix : undefined; + if (guarded === null) { + if (GATE_REJECTS_VIA_FLAG.test(source)) return suffix; + + return GATE_REJECTS_ON_ROLE.test(loaderSource(source)) && !suffix.includes(":") + ? suffix + : undefined; + } const [, param, key] = guarded; if (param === undefined) return suffix.includes(":") ? undefined : suffix; @@ -80,11 +102,19 @@ const belowEnvironment = Object.values(compiledRoutes) const suffix = compiledUrl(route.id).slice(ENVIRONMENT_URL.length).replace(/^\//, ""); return { suffix, + file: route.file, rendersAPage: rendersAPage(route.file), organizationGatedPage: organizationGatedPage(suffix, route.file), }; }); +function sourceOf(suffix: string): string { + const route = belowEnvironment.find((route) => route.suffix === suffix); + if (!route) throw new Error(`no route below the environment at ${suffix}`); + + return readFileSync(join(APP_DIR, route.file), "utf8"); +} + const environmentRoutes = [...new Set(belowEnvironment.map((route) => route.suffix))]; // Streams and Slack callbacks sit below an environment without being pages a user lands on. @@ -102,7 +132,11 @@ function listAbove(page: string): string { } const slugAddressedProbes = probes.filter((page) => SLUG_ADDRESSED_PAGES.includes(listAbove(page))); -const idAddressedProbes = probes.filter((page) => !SLUG_ADDRESSED_PAGES.includes(listAbove(page))); +const organizationAddressedProbes = probes.filter((page) => + ORGANIZATION_ADDRESSED_PAGES.includes(listAbove(page)) +); +const environmentKeptProbes = [...slugAddressedProbes, ...organizationAddressedProbes]; +const idAddressedProbes = probes.filter((page) => !environmentKeptProbes.includes(page)); function matchesARoute(page: string): boolean { const wanted = page === "" ? [] : page.split("/"); @@ -257,16 +291,30 @@ describe("pages an organization switch cannot carry", () => { expect(gated).toEqual([...ORGANIZATION_SPECIFIC_PAGES].sort()); }); - it("are read from both ways a loader turns you away, so neither stops being noticed", () => { + it("are read from every way a loader turns you away, so none stops being noticed", () => { const gatedPage = (suffix: string) => belowEnvironment.find((route) => route.suffix === suffix)?.organizationGatedPage; expect(gatedPage("logs")).toBe("logs"); expect(gatedPage("dashboards/:dashboardKey")).toBe("dashboards/queues"); + expect(gatedPage("settings/integrations")).toBe("settings/integrations"); + expect(gatedPage("bulk-actions/:bulkActionParam")).toBeUndefined(); expect(gatedPage("queues/:queueParam")).toBeUndefined(); expect(gatedPage("apikeys")).toBeUndefined(); }); + it("read a role gate off the loader, not off an action the page never runs on landing", () => { + const gatedAction = [ + "export const loader = dashboardLoader({ params: Schema }, async () => {});", + "", + 'export const action = dashboardAction({ authorization: { action: "write" } }, async () => {});', + ].join("\n"); + + expect(GATE_REJECTS_ON_ROLE.test(gatedAction)).toBe(true); + expect(GATE_REJECTS_ON_ROLE.test(loaderSource(gatedAction))).toBe(false); + expect(GATE_REJECTS_ON_ROLE.test(loaderSource(sourceOf("settings/integrations")))).toBe(true); + }); + it("still travel with an environment or project switch, which stay in the same organization", () => { for (const page of ORGANIZATION_SPECIFIC_PAGES) { expect(ENVIRONMENT_PORTABLE_PAGES.has(page)).toBe(true); @@ -277,7 +325,16 @@ describe("pages an organization switch cannot carry", () => { } }); - it("stay put when only the environment changes", () => { + it("stay put when only the environment or project changes", () => { + expect(projectPortablePage("settings/integrations")).toBe("settings/integrations"); + expect( + pathForEnvironmentSwitch({ + location: locationOn("settings/integrations"), + environmentPathname: environmentLocation.pathname, + environmentSlug: "prod", + }) + ).toBe("/orgs/acme/projects/api/env/prod/settings/integrations"); + expect( pathForEnvironmentSwitch({ location: locationOn("dashboards/queues", "?period=1d"), @@ -311,6 +368,7 @@ describe("pages an organization switch cannot carry", () => { expect(read("?page=logs")).toBe(""); expect(read("?page=query")).toBe(""); expect(read("?page=dashboards/queues")).toBe("dashboards"); + expect(read("?page=settings/integrations")).toBe("settings"); expect(read("?page=apikeys")).toBe("apikeys"); }); }); @@ -422,6 +480,43 @@ describe("pages named after something the environment did not issue", () => { }); }); +describe("pages named after an id the organization issued", () => { + it("are the ones a route below them takes an id its organization, not its environment, holds", () => { + expect(organizationAddressedProbes.length).toBeGreaterThan(0); + expect([...new Set(organizationAddressedProbes.map(listAbove))].sort()).toEqual( + [...ORGANIZATION_ADDRESSED_PAGES].sort() + ); + + for (const page of organizationAddressedProbes) { + expect(environmentPortablePage(page)).toBe(page); + } + }); + + it("stay open when only the environment changes, since the same id opens them there", () => { + expect( + pathForEnvironmentSwitch({ + location: locationOn("dashboards/custom/dashboard_123", "?period=1d"), + environmentPathname: environmentLocation.pathname, + environmentSlug: "prod", + }) + ).toBe("/orgs/acme/projects/api/env/prod/dashboards/custom/dashboard_123?period=1d"); + }); + + it("fall back to the dashboard list when the project or organization changes", () => { + expect(projectPortablePage("dashboards/custom/dashboard_123")).toBe("dashboards"); + expect(organizationPortablePage("dashboards/custom/dashboard_123")).toBe("dashboards"); + }); + + it("keep nothing but a single plain id in that last segment", () => { + expect(environmentPortablePage("dashboards/custom/..%2f..%2flogin")).toBe("dashboards"); + expect(environmentPortablePage("dashboards/custom/../../login")).toBe("dashboards"); + expect(environmentPortablePage("dashboards/custom/%2e%2e")).toBe("dashboards"); + expect(environmentPortablePage("dashboards/custom/..")).toBe("dashboards"); + expect(environmentPortablePage("dashboards/custom/")).toBe("dashboards"); + expect(environmentPortablePage("dashboards/custom/dashboard_123/extra")).toBe("dashboards"); + }); +}); + describe("a page suffix that is not a plain relative page", () => { it("falls back to the environment root rather than being sanitised into one", () => { expect(projectPortablePage("/apikeys")).toBe(""); @@ -458,6 +553,7 @@ describe("a page suffix that is not a plain relative page", () => { "tasks/standard/../../login", "agents/..%2f..%2flogin", "models/%2f%2fevil.example.com", + "dashboards/custom/..%2f..%2flogin", ]; for (const attempt of attempts) { diff --git a/apps/webapp/app/utils/pageSwitching.ts b/apps/webapp/app/utils/pageSwitching.ts index 017ac3d6c9..6122e71400 100644 --- a/apps/webapp/app/utils/pageSwitching.ts +++ b/apps/webapp/app/utils/pageSwitching.ts @@ -23,8 +23,16 @@ const NESTED_PORTABLE_PAGES = [ /** The branch lists render under any environment of their project, but not in every project. */ export const PROJECT_SPECIFIC_PAGES = ["branches", "dev-branches"]; -/** Gated by an organization feature flag, so their loaders turn you away in an organization without it. */ -export const ORGANIZATION_SPECIFIC_PAGES = ["logs", "query", "dashboards/queues"]; +/** + * Gated on the organization — by a feature flag, or by the role the caller holds there — so their + * loaders turn you away in an organization that answers differently. + */ +export const ORGANIZATION_SPECIFIC_PAGES = [ + "logs", + "query", + "dashboards/queues", + "settings/integrations", +]; /** * Pages whose last segment is a name the user's code or the model catalog decides, rather than an @@ -41,6 +49,13 @@ export const SLUG_ADDRESSED_PAGES = [ "test/tasks", ]; +/** + * Pages whose last segment is an id the organization issued rather than one environment, so the + * same address names the same resource in every environment of the project. Another organization + * never issued that id, so only an environment switch carries it. + */ +export const ORGANIZATION_ADDRESSED_PAGES = ["dashboards/custom"]; + /** Every page below an environment that names no resource, so any environment can render it. */ export const ENVIRONMENT_PORTABLE_PAGES: ReadonlySet = new Set( [ @@ -76,12 +91,18 @@ function nearestPage(suffix: string, pages: ReadonlySet): string { } /** - * `suffix` itself when it is a slug-addressed page, as long as the slug is a single plain segment — - * a traversal or an encoded path in its place falls through to the list page above it. + * `suffix` itself when its last segment names the same thing in every environment, as long as that + * segment is a single plain one — a traversal or an encoded path in its place falls through to the + * list page above it. */ -function slugAddressedPage(suffix: string): string | undefined { +function environmentNeutralPage(suffix: string): string | undefined { const boundary = suffix.lastIndexOf("/"); - if (boundary < 1 || !SLUG_ADDRESSED_PAGES.includes(suffix.slice(0, boundary))) return undefined; + if (boundary < 1) return undefined; + + const list = suffix.slice(0, boundary); + if (!SLUG_ADDRESSED_PAGES.includes(list) && !ORGANIZATION_ADDRESSED_PAGES.includes(list)) { + return undefined; + } let slug: string; try { @@ -95,7 +116,7 @@ function slugAddressedPage(suffix: string): string | undefined { /** The page to keep when only the environment changes. */ export function environmentPortablePage(suffix: string): string { - return slugAddressedPage(suffix) ?? nearestPage(suffix, ENVIRONMENT_PORTABLE_PAGES); + return environmentNeutralPage(suffix) ?? nearestPage(suffix, ENVIRONMENT_PORTABLE_PAGES); } /** The page to keep when the project changes. */