diff --git a/packages/app/e2e/browser/settings-host-selection.spec.ts b/packages/app/e2e/browser/settings-host-selection.spec.ts index aa92e5611f..a3e998e940 100644 --- a/packages/app/e2e/browser/settings-host-selection.spec.ts +++ b/packages/app/e2e/browser/settings-host-selection.spec.ts @@ -6,7 +6,10 @@ import { addDirectHostFromSettings, clickSettingsBackToWorkspace, openSettingsHostSection, + openSettingsSection, + selectSettingsHost, } from "../support/helpers/settings"; +import { getServerId } from "../support/helpers/server-id"; import { seedWorkspace } from "../support/helpers/seed-client"; import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui"; @@ -44,6 +47,14 @@ test.describe("Settings host selection", () => { await openSettings(page); await openSettingsHostSection(page, remoteDaemon.serverId, "connections"); + + await selectSettingsHost(page, getServerId()); + await openSettingsSection(page, "appearance"); + await openSettingsHostSection(page, getServerId(), "connections"); + + await clickSettingsBackToWorkspace(page); + await openSettings(page); + await openSettingsHostSection(page, remoteDaemon.serverId, "connections"); } finally { await remoteWorkspace.cleanup().catch(() => undefined); await remoteDaemon.close().catch(() => undefined); diff --git a/packages/app/e2e/browser/settings-navigation.spec.ts b/packages/app/e2e/browser/settings-navigation.spec.ts index 165bb168cd..214a7881c1 100644 --- a/packages/app/e2e/browser/settings-navigation.spec.ts +++ b/packages/app/e2e/browser/settings-navigation.spec.ts @@ -147,6 +147,28 @@ test.describe("Settings sidebar navigation", () => { await expect(page).not.toHaveURL(/\/settings(\/|$)/); }); + test("host choice survives app sections and host screen replacements", async ({ page }) => { + const primaryServerId = getServerId(); + const secondaryServerId = "srv_e2e_settings_secondary"; + const endpoint = `127.0.0.1:${getE2EDaemonPort()}`; + await seedSavedSettingsHosts(page, [ + { serverId: primaryServerId, label: "First horse", endpoint }, + { serverId: secondaryServerId, label: "Stable horse", endpoint }, + ]); + await gotoAppShell(page); + await openSettings(page); + await selectSettingsHost(page, secondaryServerId); + await openSettingsHostSection(page, secondaryServerId, "connections"); + await openSettingsSection(page, "appearance"); + await expectSettingsHostPickerLabel(page, "Stable horse"); + await openSettingsSection(page, "general"); + await expectSettingsHostPickerLabel(page, "Stable horse"); + await openSettingsHostSection(page, secondaryServerId, "connections"); + await selectSettingsHost(page, primaryServerId); + await openSettingsSection(page, "about"); + await expectSettingsHostPickerLabel(page, "First horse"); + }); + test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => { await gotoAppShell(page); await verifyLegacyHostSettingsRedirect(page); @@ -329,6 +351,15 @@ test.describe("Settings — compact master-detail", () => { await expectSettingsHostPickerLabel(page, secondaryHostLabel); await openSettingsHostSection(page, secondaryServerId, "connections"); + await goBackInSettings(page); + await expectSettingsHostPickerLabel(page, secondaryHostLabel); + + await openSettingsSection(page, "appearance"); + await goBackInSettings(page); + await expectSettingsHostPickerLabel(page, secondaryHostLabel); + await openSettingsHostSection(page, secondaryServerId, "connections"); + await goBackInSettings(page); + await expectSettingsHostPickerLabel(page, secondaryHostLabel); }); test("removing the last active host returns to welcome after settings closes", async ({ diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 99022863f5..f6db461c09 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -72,6 +72,7 @@ import { type StartupBlocker, } from "@/navigation/host-runtime-bootstrap"; import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation"; +import { useSettingsRouteEntry } from "@/navigation/settings-selection"; import { ThemedStack } from "@/navigation/themed-stack"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { AgentNavigationListener } from "@/desktop/agent-navigation"; @@ -759,6 +760,7 @@ function OpenProjectListener() { } function AppWithSidebar({ children }: { children: ReactNode }) { + useSettingsRouteEntry(); const pathname = usePathname(); const hosts = useHosts(); const storeReady = useStoreReady(); diff --git a/packages/app/src/navigation/settings-selection/index.ts b/packages/app/src/navigation/settings-selection/index.ts new file mode 100644 index 0000000000..dd4d57edd9 --- /dev/null +++ b/packages/app/src/navigation/settings-selection/index.ts @@ -0,0 +1,32 @@ +import { useLayoutEffect } from "react"; +import { usePathname, useRootNavigationState } from "expo-router"; +import { useStore } from "zustand"; +import { + useIsLastWorkspaceSelectionHydrated, + useLastWorkspaceSelection, +} from "@/stores/navigation-active-workspace-store"; +import { createSettingsSelection } from "./model"; + +const selection = createSettingsSelection(); + +// Mount once above the sibling Settings routes so replacements and Back share +// one visit, and leaving Settings gives the next workspace entry a fresh default. +export function useSettingsRouteEntry(): void { + const pathname = usePathname(); + const navigationState = useRootNavigationState(); + const routeKey = navigationState?.routes[navigationState.index]?.key; + const workspace = useLastWorkspaceSelection(); + const isWorkspaceHydrated = useIsLastWorkspaceSelectionHydrated(); + const workspaceServerId = workspace?.serverId ?? null; + useLayoutEffect(() => { + if (isWorkspaceHydrated && routeKey) { + selection.enterRoute({ key: routeKey, pathname }, workspaceServerId); + } + }, [pathname, routeKey, workspaceServerId, isWorkspaceHydrated]); +} + +export function useSettingsHostSelection(): string | null { + return useStore(selection.store, (state) => state.selectedServerId); +} + +export const selectSettingsHost = selection.selectHost; diff --git a/packages/app/src/navigation/settings-selection/model.test.ts b/packages/app/src/navigation/settings-selection/model.test.ts new file mode 100644 index 0000000000..1ffbff8485 --- /dev/null +++ b/packages/app/src/navigation/settings-selection/model.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { createSettingsSelection } from "./model"; + +describe("Settings host selection", () => { + it("keeps picker choices across sections, replacement and Back", () => { + const settings = createSettingsSelection(); + settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host"); + expect(settings.store.getState().selectedServerId).toBe("workspace-host"); + settings.selectHost("chosen-host"); + settings.enterRoute( + { + key: "/settings/hosts/chosen-host/connections", + pathname: "/settings/hosts/chosen-host/connections", + }, + "workspace-host", + ); + settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host"); + settings.enterRoute( + { key: "/settings/appearance", pathname: "/settings/appearance" }, + "workspace-host", + ); + settings.enterRoute( + { key: "/settings/general", pathname: "/settings/general" }, + "workspace-host", + ); + expect(settings.store.getState().selectedServerId).toBe("chosen-host"); + }); + + it("applies explicit host links once, including project and plugin screens", () => { + const settings = createSettingsSelection(); + settings.enterRoute( + { + key: "/settings/hosts/linked-host/projects/project", + pathname: "/settings/hosts/linked-host/projects/project", + }, + "workspace-host", + ); + expect(settings.store.getState().selectedServerId).toBe("linked-host"); + settings.selectHost("chosen-host"); + settings.enterRoute( + { + key: "/settings/hosts/linked-host/projects/project", + pathname: "/settings/hosts/linked-host/projects/project", + }, + "workspace-host", + ); + expect(settings.store.getState().selectedServerId).toBe("chosen-host"); + settings.enterRoute( + { + key: "/settings/hosts/plugin-host/plugins/plugin/screen", + pathname: "/settings/hosts/plugin-host/plugins/plugin/screen", + }, + "workspace-host", + ); + expect(settings.store.getState().selectedServerId).toBe("plugin-host"); + }); + + it("seeds each new visit from the workspace, including a retained settings route", () => { + const settings = createSettingsSelection(); + settings.enterRoute( + { key: "/settings/general", pathname: "/settings/general" }, + "first-workspace-host", + ); + settings.selectHost("chosen-host"); + settings.enterRoute( + { + key: "/h/second-workspace-host/workspace/workspace", + pathname: "/h/second-workspace-host/workspace/workspace", + }, + "second-workspace-host", + ); + settings.enterRoute( + { key: "/settings/general", pathname: "/settings/general" }, + "second-workspace-host", + ); + expect(settings.store.getState().selectedServerId).toBe("second-workspace-host"); + }); + + it("does not replay a retained host route after a newer picker choice", () => { + const settings = createSettingsSelection(); + const oldRoute = { key: "old-host-screen", pathname: "/settings/hosts/first/host" }; + settings.enterRoute(oldRoute, "workspace-host"); + settings.selectHost("second"); + settings.enterRoute( + { key: "new-host-screen", pathname: "/settings/hosts/second/host" }, + "workspace-host", + ); + settings.enterRoute(oldRoute, "workspace-host"); + settings.enterRoute({ key: "app-screen", pathname: "/settings/appearance" }, "workspace-host"); + expect(settings.store.getState().selectedServerId).toBe("second"); + settings.enterRoute({ ...oldRoute, key: "explicit-new-entry" }, "workspace-host"); + expect(settings.store.getState().selectedServerId).toBe("first"); + }); + + it("does not reseed a visit when remembered workspace state changes", () => { + const settings = createSettingsSelection(); + settings.enterRoute({ key: "/settings", pathname: "/settings" }, null); + settings.selectHost("chosen-host"); + settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host"); + expect(settings.store.getState().selectedServerId).toBe("chosen-host"); + }); +}); diff --git a/packages/app/src/navigation/settings-selection/model.ts b/packages/app/src/navigation/settings-selection/model.ts new file mode 100644 index 0000000000..bedfb70626 --- /dev/null +++ b/packages/app/src/navigation/settings-selection/model.ts @@ -0,0 +1,46 @@ +import { createStore } from "zustand/vanilla"; + +interface SettingsRouteEntry { + key: string; + pathname: string; +} + +interface SettingsSelectionState { + selectedServerId: string | null; +} + +export function createSettingsSelection() { + const store = createStore(() => ({ selectedServerId: null })); + const enteredRoutes = new Map(); + let inSettings = false; + + function enterRoute( + { key, pathname }: SettingsRouteEntry, + workspaceServerId: string | null, + ): void { + const isSettings = pathname === "/settings" || pathname.startsWith("/settings/"); + if (!isSettings) { + inSettings = false; + enteredRoutes.clear(); + return; + } + // A retained route returning through Back must not replay its old host intent. + if (enteredRoutes.get(key) === pathname) return; + const isNewVisit = !inSettings; + inSettings = true; + enteredRoutes.set(key, pathname); + + const hostMatch = /^\/settings\/hosts\/([^/]+)(?:\/|$)/.exec(pathname); + if (hostMatch) { + store.setState({ selectedServerId: decodeURIComponent(hostMatch[1]) }); + } else if (isNewVisit) { + store.setState({ selectedServerId: workspaceServerId }); + } + } + + function selectHost(serverId: string): void { + store.setState({ selectedServerId: serverId }); + } + + return { store, enterRoute, selectHost }; +} diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index b90f5ccd24..8cfa720245 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -133,7 +133,7 @@ import { type HostSectionSlug, type SettingsSectionSlug, } from "@/utils/host-routes"; -import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import { selectSettingsHost, useSettingsHostSelection } from "@/navigation/settings-selection"; import { returnFromSettings, type SettingsView } from "@/navigation/settings-navigation"; import { isWeb } from "@/constants/platform"; @@ -1098,21 +1098,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti const hosts = useHosts(); const localServerId = useLocalDaemonServerId(); const sortedHosts = useSortedHosts(hosts, localServerId); - const lastWorkspaceSelection = useLastWorkspaceSelection(); - const routedSettingsHostServerId = - view.kind === "host" || view.kind === "project" || view.kind === "plugin" - ? view.serverId - : null; - const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState( - routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null, - ); - useFocusEffect( - useCallback(() => { - setSelectedSettingsHostServerId( - routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null, - ); - }, [lastWorkspaceSelection?.serverId, routedSettingsHostServerId]), - ); + const selectedSettingsHostServerId = useSettingsHostSelection(); // The host the four sections scope to: the host on the active view, otherwise // the picker choice, otherwise the connected local daemon, otherwise the first host. @@ -1232,7 +1218,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti // host detail route, keep that detail section and swap only the host segment. const handleSelectHost = useCallback( (serverId: string) => { - setSelectedSettingsHostServerId(serverId); + selectSettingsHost(serverId); if (view.kind === "project") { const target = buildSettingsHostSectionRoute(serverId, "projects"); if (isCompactLayout) {