fix(app): preserve host selection across settings navigation (#7893)

Own the selected host for the Settings visit so screen replacements and Back do not reset an explicit picker choice. Apply named-host entry intents once while preserving workspace defaults for new visits.
This commit is contained in:
Mohamed Boudra
2026-10-26 11:30:54 +07:00
committed by GitHub
parent 6521739947
commit 631ed0408d
7 changed files with 227 additions and 17 deletions
@@ -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);
@@ -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 ({
+2
View File
@@ -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();
@@ -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;
@@ -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");
});
});
@@ -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<SettingsSelectionState>(() => ({ selectedServerId: null }));
const enteredRoutes = new Map<string, string>();
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 };
}
+3 -17
View File
@@ -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<string | null>(
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) {