diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 04cbb3958..aebdeb086 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -56,7 +56,11 @@ import { } from "@/hooks/use-settings"; import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; -import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection"; +import { + orderHostsLocalFirst, + resolveActiveHostServerId, + type HostProfile, +} from "@/types/host-connection"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window"; import { confirmDialog } from "@/utils/confirm-dialog"; @@ -1144,15 +1148,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState( view.kind === "host" ? view.serverId : null, ); - const knownSelectedSettingsHostServerId = useMemo(() => { - if (!selectedSettingsHostServerId) { - return null; - } - return hosts.some((host) => host.serverId === selectedSettingsHostServerId) - ? selectedSettingsHostServerId - : null; - }, [hosts, selectedSettingsHostServerId]); - useEffect(() => { if (view.kind === "host") { setSelectedSettingsHostServerId(view.serverId); @@ -1160,11 +1155,16 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti }, [view]); // The host the four sections scope to: the host on the active view, otherwise - // the picker choice, otherwise the local daemon, otherwise the first host. + // the picker choice, otherwise the connected local daemon, otherwise the first host. const activeHostServerId = useMemo(() => { if (view.kind === "host") return view.serverId; - return knownSelectedSettingsHostServerId ?? localServerId ?? sortedHosts[0]?.serverId ?? null; - }, [view, knownSelectedSettingsHostServerId, localServerId, sortedHosts]); + return resolveActiveHostServerId({ + selectedServerId: selectedSettingsHostServerId, + localServerId, + hosts, + orderedHosts: sortedHosts, + }); + }, [view, selectedSettingsHostServerId, localServerId, hosts, sortedHosts]); const handleSendBehaviorChange = useCallback( (behavior: SendBehavior) => { diff --git a/packages/app/src/types/host-connection.test.ts b/packages/app/src/types/host-connection.test.ts index 2bf1c6fde..f5b107e32 100644 --- a/packages/app/src/types/host-connection.test.ts +++ b/packages/app/src/types/host-connection.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { normalizeStoredHostProfile, orderHostsLocalFirst, + resolveActiveHostServerId, type HostProfile, } from "./host-connection"; @@ -114,3 +115,75 @@ describe("normalizeStoredHostProfile", () => { }); }); }); + +describe("resolveActiveHostServerId", () => { + it("uses the selected host when one is set", () => { + expect( + resolveActiveHostServerId({ + selectedServerId: "srv_selected", + localServerId: "srv_local", + hosts: [makeHost("srv_local"), makeHost("srv_selected")], + orderedHosts: [makeHost("srv_local"), makeHost("srv_selected")], + }), + ).toBe("srv_selected"); + }); + + it("falls back to the local host when it is connected", () => { + expect( + resolveActiveHostServerId({ + selectedServerId: null, + localServerId: "srv_local", + hosts: [makeHost("srv_local"), makeHost("srv_remote")], + orderedHosts: [makeHost("srv_local"), makeHost("srv_remote")], + }), + ).toBe("srv_local"); + }); + + it("skips a stopped local daemon and uses the first connected host", () => { + // Regression: a stopped local daemon's serverId persists but isn't in `hosts`. + // Falling back to it would resolve the section to an unknown id ("host not found"). + expect( + resolveActiveHostServerId({ + selectedServerId: null, + localServerId: "srv_local_stopped", + hosts: [makeHost("srv_remote")], + orderedHosts: [makeHost("srv_remote")], + }), + ).toBe("srv_remote"); + }); + + it("returns null when no hosts are connected", () => { + expect( + resolveActiveHostServerId({ + selectedServerId: null, + localServerId: "srv_local_stopped", + hosts: [], + orderedHosts: [], + }), + ).toBeNull(); + }); + + it("ignores a selected host that is not connected", () => { + // A stale selection (e.g. the host was removed) must not be used unless it is + // currently connected, or the section resolves to an unknown id ("host not found"). + expect( + resolveActiveHostServerId({ + selectedServerId: "srv_stale_selection", + localServerId: null, + hosts: [makeHost("srv_remote")], + orderedHosts: [makeHost("srv_remote")], + }), + ).toBe("srv_remote"); + }); + + it("falls through a disconnected selection to the connected local host", () => { + expect( + resolveActiveHostServerId({ + selectedServerId: "srv_stale_selection", + localServerId: "srv_local", + hosts: [makeHost("srv_local"), makeHost("srv_remote")], + orderedHosts: [makeHost("srv_local"), makeHost("srv_remote")], + }), + ).toBe("srv_local"); + }); +}); diff --git a/packages/app/src/types/host-connection.ts b/packages/app/src/types/host-connection.ts index 3170ce0f6..092b4eb9e 100644 --- a/packages/app/src/types/host-connection.ts +++ b/packages/app/src/types/host-connection.ts @@ -75,6 +75,29 @@ export function orderHostsLocalFirst( return ordered; } +/** + * Resolves which host a settings host section should target: the picker + * selection, else the local daemon, else the first connected host. + * + * Only a serverId that names a currently connected host is used. Both the + * selection and the local daemon can name a host that isn't connected (a stale + * selection, or a local daemon whose id persists in storage while it's stopped); + * using one would resolve the section to an unknown id and render "host not found". + */ +export function resolveActiveHostServerId(params: { + selectedServerId: string | null; + localServerId: string | null; + hosts: readonly { serverId: string }[]; + orderedHosts: readonly { serverId: string }[]; +}): string | null { + const { selectedServerId, localServerId, hosts, orderedHosts } = params; + const connected = (serverId: string | null): string | null => + serverId && hosts.some((host) => host.serverId === serverId) ? serverId : null; + return ( + connected(selectedServerId) ?? connected(localServerId) ?? orderedHosts[0]?.serverId ?? null + ); +} + function hostConnectionEquals(left: HostConnection, right: HostConnection): boolean { if (left.type !== right.type || left.id !== right.id) { return false;