From 03f179c65b7dbf376e0d782ddc1f3cbdf3bdc5b7 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 10 Oct 2026 14:07:30 -0400 Subject: [PATCH 1/4] fix(web): providers settings no longer imply a project scope Signed-off-by: Yordis Prieto --- .../settings/SettingsScopeSentence.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/web/src/components/settings/SettingsScopeSentence.tsx b/apps/web/src/components/settings/SettingsScopeSentence.tsx index 04312fd03142..121ed5d2bb72 100644 --- a/apps/web/src/components/settings/SettingsScopeSentence.tsx +++ b/apps/web/src/components/settings/SettingsScopeSentence.tsx @@ -36,6 +36,11 @@ export const SETTINGS_DEVICE_ONLY_PATHS: ReadonlySet = new Set([ "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/settings/connections", ]); +/** Pages saved per environment that ignore the project, so only the environment is a target. */ +export const SETTINGS_ENVIRONMENT_ONLY_PATHS: ReadonlySet = new Set([ + "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/settings/providers", +]); + interface SettingsScopeMenuProps { readonly value: SettingsScopeSearch; readonly groups: readonly SidebarProjectSnapshot[]; @@ -62,6 +67,17 @@ export function SettingsScopeSentence() { environments, onChange: scope.selectScope, }; + if (SETTINGS_ENVIRONMENT_ONLY_PATHS.has(pathname)) { + return ( +

+ Applying settings on + scope.selectScope(next.machine ? { machine: next.machine } : {})} + /> +

+ ); + } return (

{/* Each connective stays with its picker so a wrap never strands "on". */} From b95487896317a5087d56a2dd21be87ba5ec662f0 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 10 Oct 2026 14:15:16 -0400 Subject: [PATCH 2/4] fix(web): providers ignore a retained project so it cannot block the page Signed-off-by: Yordis Prieto --- .../components/settings/SettingsScopeSentence.tsx | 5 +---- apps/web/src/routes/settings.providers.tsx | 3 +-- apps/web/src/routes/settings.tsx | 14 +++++++++++--- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/settings/SettingsScopeSentence.tsx b/apps/web/src/components/settings/SettingsScopeSentence.tsx index 121ed5d2bb72..99aed8c0291d 100644 --- a/apps/web/src/components/settings/SettingsScopeSentence.tsx +++ b/apps/web/src/components/settings/SettingsScopeSentence.tsx @@ -71,10 +71,7 @@ export function SettingsScopeSentence() { return (

Applying settings on - scope.selectScope(next.machine ? { machine: next.machine } : {})} - /> +

); } diff --git a/apps/web/src/routes/settings.providers.tsx b/apps/web/src/routes/settings.providers.tsx index 33e02505b992..97af1b136779 100644 --- a/apps/web/src/routes/settings.providers.tsx +++ b/apps/web/src/routes/settings.providers.tsx @@ -6,8 +6,7 @@ import { useSettingsScope } from "../components/settings/SettingsScopeContext"; /** * Providers are machine state, so the page shows one environment at a time: - * the chosen one from the settings scope selector. - * A project crumb narrows candidates to where that project is registered. + * the chosen one from the settings scope selector. The project is ignored. */ function SettingsProvidersRoute() { const target = Route.useSearch(); diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index a1e02c12d929..23aa0a429e73 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -1,5 +1,5 @@ import { Outlet, createFileRoute, redirect, useLocation } from "@tanstack/react-router"; -import { useState, type ReactNode } from "react"; +import { useMemo, useState, type ReactNode } from "react"; import { RotateCcwIcon } from "lucide-react"; import { Button } from "../components/ui/button"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; @@ -16,7 +16,10 @@ import { } from "../components/settings/SettingsScopeContext"; import { useEnvironments } from "../state/environments"; import { SettingsScopeNotice } from "../components/settings/SettingsScopeNotice"; -import { SETTINGS_DEVICE_ONLY_PATHS } from "../components/settings/SettingsScopeSentence"; +import { + SETTINGS_DEVICE_ONLY_PATHS, + SETTINGS_ENVIRONMENT_ONLY_PATHS, +} from "../components/settings/SettingsScopeSentence"; import { SettingsPageContainer } from "../components/settings/settingsLayout"; import { retainSettingsScope, @@ -151,9 +154,14 @@ function SettingsRouteLayout() { const rawSearch = Route.useSearch(); const navigate = Route.useNavigate(); const pathname = useLocation({ select: (location) => location.pathname }); + const environmentOnly = SETTINGS_ENVIRONMENT_ONLY_PATHS.has(pathname); + const search = useMemo( + () => (environmentOnly ? (rawSearch.machine ? { machine: rawSearch.machine } : {}) : rawSearch), + [environmentOnly, rawSearch], + ); return ( { // Send every axis so the retain middleware sees an explicit target From 2ee16ce60b002a04138d120973dd79e9f6220aa6 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 10 Oct 2026 14:21:31 -0400 Subject: [PATCH 3/4] fix(web): switching machines on providers keeps the project for other settings pages Signed-off-by: Yordis Prieto --- apps/web/src/routes/settings.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 23aa0a429e73..8a28d259f757 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -166,12 +166,14 @@ function SettingsRouteLayout() { onChange={(next) => { // Send every axis so the retain middleware sees an explicit target // even when the choice is "all", which is the absence of a key. + // Environment-only pages never see the project, so carry it over for + // the next page; a checkout belongs to the previous environment. void navigate({ to: pathname, search: () => ({ - project: next.project, + project: environmentOnly ? rawSearch.project : next.project, machine: next.machine, - checkout: next.checkout, + checkout: environmentOnly ? undefined : next.checkout, }), hash: "", resetScroll: false, From 2a3a171d70e69102525d5ae1953b41ac14462fa7 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 10 Oct 2026 14:27:13 -0400 Subject: [PATCH 4/4] fix(web): providers opened from a checkout link target that checkout's machine Signed-off-by: Yordis Prieto --- .../components/settings/settingsScope.test.ts | 33 +++++++++++++++++++ .../components/settings/settingsScopeAxis.ts | 18 ++++++---- apps/web/src/routes/settings.providers.tsx | 3 +- apps/web/src/routes/settings.tsx | 10 ++---- 4 files changed, 49 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/settings/settingsScope.test.ts b/apps/web/src/components/settings/settingsScope.test.ts index 0199f6eb6cb9..4b09b36042c8 100644 --- a/apps/web/src/components/settings/settingsScope.test.ts +++ b/apps/web/src/components/settings/settingsScope.test.ts @@ -6,6 +6,7 @@ import type { SidebarProjectSnapshot, } from "../../sidebarProjectGrouping"; import { resolveSettingsScope, validateSettingsScopeSearch } from "./settingsScope"; +import { selectSingleEnvironmentScope } from "./settingsScopeAxis"; const laptopId = EnvironmentId.make("laptop"); const serverId = EnvironmentId.make("server"); @@ -203,3 +204,35 @@ describe("settings scope resolution", () => { }); }); }); + +describe("single-environment scope", () => { + const connected = environments.map((environment) => ({ + ...environment, + connection: { phase: "connected" as const }, + })); + const select = (search: Parameters[0]) => + selectSingleEnvironmentScope( + search, + resolveSettingsScope(search, groups, environments), + connected, + laptopId, + ); + + it("targets the checkout's environment for a link without a machine", () => { + expect(select({ project: "t3code", checkout: third.physicalProjectKey })).toEqual({ + machine: serverId, + }); + }); + + it("narrows to where the project is registered", () => { + expect(select({ project: "other" })).toEqual({ machine: serverId }); + }); + + it("falls back to the primary environment when the project is gone", () => { + expect(select({ project: "missing" })).toEqual({ machine: laptopId }); + }); + + it("keeps an explicit machine and drops the project", () => { + expect(select({ project: "other", machine: laptopId })).toEqual({ machine: laptopId }); + }); +}); diff --git a/apps/web/src/components/settings/settingsScopeAxis.ts b/apps/web/src/components/settings/settingsScopeAxis.ts index 0c0915c54c88..86e1682ef901 100644 --- a/apps/web/src/components/settings/settingsScopeAxis.ts +++ b/apps/web/src/components/settings/settingsScopeAxis.ts @@ -54,7 +54,11 @@ export function selectProjectAxis(search: SettingsScopeSearch, value: string): S return next; } -/** Provider configuration always belongs to one environment, including project scopes. */ +/** + * Provider configuration always belongs to one environment. A project or + * checkout only picks which one when no machine is chosen and never reaches + * the result, so a stale project cannot leave the page without a target. + */ export function selectSingleEnvironmentScope( search: SettingsScopeSearch, scope: ResolvedSettingsScope, @@ -64,14 +68,14 @@ export function selectSingleEnvironmentScope( }[], primaryEnvironmentId: string | null, ): SettingsScopeSearch { - if (search.machine || scope.kind === "unavailable") return search; - const selectedIds = new Set(scope.environmentIds); - const candidates = environments.filter((environment) => - selectedIds.has(environment.environmentId), - ); + if (search.machine) return { machine: search.machine }; + const selectedIds = scope.kind === "unavailable" ? null : new Set(scope.environmentIds); + const candidates = selectedIds + ? environments.filter((environment) => selectedIds.has(environment.environmentId)) + : environments; const selected = candidates.find((environment) => environment.environmentId === primaryEnvironmentId) ?? candidates.find((environment) => environment.connection.phase === "connected") ?? candidates[0]; - return selected ? { ...search, machine: selected.environmentId } : search; + return selected ? { machine: selected.environmentId } : {}; } diff --git a/apps/web/src/routes/settings.providers.tsx b/apps/web/src/routes/settings.providers.tsx index 97af1b136779..33e02505b992 100644 --- a/apps/web/src/routes/settings.providers.tsx +++ b/apps/web/src/routes/settings.providers.tsx @@ -6,7 +6,8 @@ import { useSettingsScope } from "../components/settings/SettingsScopeContext"; /** * Providers are machine state, so the page shows one environment at a time: - * the chosen one from the settings scope selector. The project is ignored. + * the chosen one from the settings scope selector. + * A project crumb narrows candidates to where that project is registered. */ function SettingsProvidersRoute() { const target = Route.useSearch(); diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 8a28d259f757..242cb94489b3 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -1,5 +1,5 @@ import { Outlet, createFileRoute, redirect, useLocation } from "@tanstack/react-router"; -import { useMemo, useState, type ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { RotateCcwIcon } from "lucide-react"; import { Button } from "../components/ui/button"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; @@ -155,14 +155,10 @@ function SettingsRouteLayout() { const navigate = Route.useNavigate(); const pathname = useLocation({ select: (location) => location.pathname }); const environmentOnly = SETTINGS_ENVIRONMENT_ONLY_PATHS.has(pathname); - const search = useMemo( - () => (environmentOnly ? (rawSearch.machine ? { machine: rawSearch.machine } : {}) : rawSearch), - [environmentOnly, rawSearch], - ); return ( { // Send every axis so the retain middleware sees an explicit target // even when the choice is "all", which is the absence of a key.