From e9431517a00a3961ba05ca3bfdab2ba94f2194b8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 30 Jun 2026 09:40:24 +0200 Subject: [PATCH] Show host in search results and filter sidebar by multiple hosts (#1825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(app): show host in command center and multi-select host filter Command-center agent results now show which host the workspace lives on, shown only when more than one host is connected (matching the sidebar). The sidebar display-preferences filter becomes multi-select — pick any number of hosts instead of one-or-all — and its host rows now carry a status dot on the left like the other host pickers. Persisted filter state migrates from the old single host to a host list. * test(app): extract host-filter and command-center e2e DSL helpers Move the mechanical menu/search interactions out of the new spec bodies into focused helpers so the tests read as user intent. * fix(app): drop the icon from the host filter "All hosts" item * fix(app): make multi-host e2e seeding order-independent The offline-host seed relied on Playwright running the test's init script after the fixture's registry reset — an ordering Playwright does not guarantee. Write the full registry and set the fixture's disable-once flag, then reload, so the multi-host seed survives regardless of script order. Also tag the pre-v2 hostFilter migration reader with COMPAT so the cleanup sweep can find it. --- packages/app/e2e/command-center-host.spec.ts | 49 ++++++++++++ packages/app/e2e/helpers/command-center.ts | 9 +++ packages/app/e2e/helpers/hosts.ts | 79 +++++++++++++++++++ .../app/e2e/sidebar-host-filter-multi.spec.ts | 56 +++++++++++++ .../app/src/components/command-center.tsx | 16 +++- .../sidebar-workspace-list.test.tsx | 2 +- .../sidebar-display-preferences-menu.tsx | 73 +++++++++-------- ...space-shortcut-targets-subscriber.test.tsx | 4 +- .../src/hooks/use-sidebar-workspaces-list.ts | 30 ++++--- .../app/src/stores/sidebar-view-store.test.ts | 64 +++++++++++---- packages/app/src/stores/sidebar-view-store.ts | 54 +++++++++---- 11 files changed, 351 insertions(+), 85 deletions(-) create mode 100644 packages/app/e2e/command-center-host.spec.ts create mode 100644 packages/app/e2e/helpers/command-center.ts create mode 100644 packages/app/e2e/helpers/hosts.ts create mode 100644 packages/app/e2e/sidebar-host-filter-multi.spec.ts diff --git a/packages/app/e2e/command-center-host.spec.ts b/packages/app/e2e/command-center-host.spec.ts new file mode 100644 index 000000000..e0d78d2d6 --- /dev/null +++ b/packages/app/e2e/command-center-host.spec.ts @@ -0,0 +1,49 @@ +import { randomUUID } from "node:crypto"; +import { expect } from "@playwright/test"; +import { test } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { createIdleAgent } from "./helpers/archive-tab"; +import { openCommandCenter } from "./helpers/command-center"; +import { addOfflineHostAndReload } from "./helpers/hosts"; +import { seedWorkspace } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; + +const PRIMARY_HOST_LABEL = "Primary Host"; +const SECONDARY_HOST_ID = "host-command-center-secondary"; + +test.describe("Command center host labels", () => { + test.describe.configure({ timeout: 180_000 }); + + test("agent results show the host they live on when more than one host exists", async ({ + page, + }) => { + const seeded = await seedWorkspace({ repoPrefix: "command-center-host-" }); + const title = `cc-host-${randomUUID().slice(0, 8)}`; + + try { + const agent = await createIdleAgent(seeded.client, { + cwd: seeded.repoPath, + workspaceId: seeded.workspaceId, + title, + }); + + // A second (offline) host makes the view multi-host, which is when the host label earns its space. + await gotoAppShell(page); + await addOfflineHostAndReload(page, { + serverId: SECONDARY_HOST_ID, + label: "Secondary Host", + primaryLabel: PRIMARY_HOST_LABEL, + }); + + const panel = await openCommandCenter(page); + + // The shared daemon may carry agents from other specs, so target this agent by its id. + const row = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`); + await expect(row).toBeVisible({ timeout: 30_000 }); + await expect(row).toContainText(title); + await expect(row).toContainText(PRIMARY_HOST_LABEL); + } finally { + await seeded.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/helpers/command-center.ts b/packages/app/e2e/helpers/command-center.ts new file mode 100644 index 000000000..4a3a58b56 --- /dev/null +++ b/packages/app/e2e/helpers/command-center.ts @@ -0,0 +1,9 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +// Opens the command center / global search palette from the sidebar and returns its panel. +export async function openCommandCenter(page: Page): Promise { + await page.getByTestId("sidebar-command-center-search").click(); + const panel = page.getByTestId("command-center-panel"); + await expect(panel).toBeVisible({ timeout: 30_000 }); + return panel; +} diff --git a/packages/app/e2e/helpers/hosts.ts b/packages/app/e2e/helpers/hosts.ts new file mode 100644 index 000000000..117981d0a --- /dev/null +++ b/packages/app/e2e/helpers/hosts.ts @@ -0,0 +1,79 @@ +import { expect, type Page } from "@playwright/test"; +import { buildSeededHost } from "./daemon-registry"; + +const REGISTRY_KEY = "@paseo:daemon-registry"; +const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce"; +const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once"; + +// The multi-host UI (the command-center host label, the sidebar host filter) only renders once +// more than one host exists. The e2e harness runs a single real daemon, so we add an extra registry +// entry pointing at an unreachable endpoint: it stays offline, which is enough to make the UI treat +// the view as multi-host without standing up a second daemon. +// +// Must run AFTER the first navigation: the auto-seed fixture writes the registry + nonce on load, +// and reseeds on every navigation. We write the full registry here and set the fixture's +// disable-once flag, then reload — so the fixture skips its reset and the registry survives. This +// avoids depending on the (unspecified) ordering of multiple Playwright init scripts. Optionally +// relabels the seeded primary host so assertions can target a distinctive name. +export async function addOfflineHostAndReload( + page: Page, + input: { serverId: string; label: string; primaryLabel?: string }, +): Promise { + const offlineHost = buildSeededHost({ + serverId: input.serverId, + label: input.label, + endpoint: "127.0.0.1:59999", + nowIso: new Date().toISOString(), + }); + + await page.evaluate( + ({ host, keys, primaryLabel }) => { + const nonce = localStorage.getItem(keys.nonce); + if (!nonce) { + throw new Error("Expected the e2e seed nonce before overriding the host registry."); + } + const raw = localStorage.getItem(keys.registry); + const registry: Array<{ serverId: string; label?: string }> = raw ? JSON.parse(raw) : []; + if (primaryLabel && registry[0]) { + registry[0].label = primaryLabel; + } + if (!registry.some((entry) => entry.serverId === host.serverId)) { + registry.push(host); + } + localStorage.setItem(keys.registry, JSON.stringify(registry)); + localStorage.setItem(keys.disableSeedOnce, nonce); + }, + { + host: offlineHost, + keys: { + registry: REGISTRY_KEY, + nonce: SEED_NONCE_KEY, + disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY, + }, + primaryLabel: input.primaryLabel, + }, + ); + + await page.reload(); +} + +export async function openSidebarDisplayPreferences(page: Page): Promise { + await page.getByTestId("sidebar-display-preferences-menu").click(); + await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({ + timeout: 10_000, + }); +} + +// A host's filter row carries a status dot on the left next to its label. +export async function expectHostFilterRow(page: Page, serverId: string): Promise { + await expect(page.getByTestId(`sidebar-host-filter-${serverId}`)).toBeVisible(); + await expect(page.getByTestId(`sidebar-host-filter-status-${serverId}`)).toBeVisible(); +} + +export async function toggleHostFilter(page: Page, serverId: string): Promise { + await page.getByTestId(`sidebar-host-filter-${serverId}`).click(); +} + +export async function selectAllHostsFilter(page: Page): Promise { + await page.getByTestId("sidebar-host-filter-all").click(); +} diff --git a/packages/app/e2e/sidebar-host-filter-multi.spec.ts b/packages/app/e2e/sidebar-host-filter-multi.spec.ts new file mode 100644 index 000000000..ee065cb0d --- /dev/null +++ b/packages/app/e2e/sidebar-host-filter-multi.spec.ts @@ -0,0 +1,56 @@ +import { expect } from "@playwright/test"; +import { test } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { + addOfflineHostAndReload, + expectHostFilterRow, + openSidebarDisplayPreferences, + selectAllHostsFilter, + toggleHostFilter, +} from "./helpers/hosts"; +import { seedWorkspace } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; + +const SECONDARY_HOST_ID = "host-filter-secondary"; + +test.describe("Sidebar host filter (multi-select)", () => { + test.describe.configure({ timeout: 120_000 }); + + test("pins the sidebar to multiple selected hosts at once", async ({ page }) => { + const seeded = await seedWorkspace({ repoPrefix: "host-filter-" }); + const serverId = getServerId(); + const workspaceRow = page.getByTestId( + `sidebar-workspace-row-${serverId}:${seeded.workspaceId}`, + ); + + try { + // A second (offline) host is enough to surface the host filter without a second daemon. + await gotoAppShell(page); + await addOfflineHostAndReload(page, { serverId: SECONDARY_HOST_ID, label: "Secondary Host" }); + await expect(workspaceRow).toBeVisible({ timeout: 30_000 }); + + await openSidebarDisplayPreferences(page); + await expectHostFilterRow(page, serverId); + await expectHostFilterRow(page, SECONDARY_HOST_ID); + + // Pin the primary host — its workspace stays visible. + await toggleHostFilter(page, serverId); + await expect(workspaceRow).toBeVisible(); + + // Add the secondary host without clearing the primary. Under single-select this would replace + // the primary and hide the workspace; multi-select keeps both pinned, so it stays visible. + await toggleHostFilter(page, SECONDARY_HOST_ID); + await expect(workspaceRow).toBeVisible(); + + // Drop the primary host — only the (empty) secondary host remains pinned, so the workspace hides. + await toggleHostFilter(page, serverId); + await expect(workspaceRow).toHaveCount(0, { timeout: 10_000 }); + + // Back to all hosts — the workspace returns. + await selectAllHostsFilter(page); + await expect(workspaceRow).toBeVisible({ timeout: 10_000 }); + } finally { + await seeded.cleanup(); + } + }); +}); diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx index 8de3ed43f..1288bafa0 100644 --- a/packages/app/src/components/command-center.tsx +++ b/packages/app/src/components/command-center.tsx @@ -13,6 +13,7 @@ import { Home, Plus, Settings } from "lucide-react-native"; import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { useCommandCenter } from "@/hooks/use-command-center"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; +import { useHosts } from "@/runtime/host-runtime"; import { formatTimeAgo } from "@/utils/time"; import { shortenPath } from "@/utils/shorten-path"; import { AgentStatusDot } from "@/components/agent-status-dot"; @@ -199,9 +200,10 @@ function CommandCenterAgentRow({ interface CommandCenterAgentRowContentProps { agent: AggregatedAgent; + showHost: boolean; } -function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentProps) { +function CommandCenterAgentRowContent({ agent, showHost }: CommandCenterAgentRowContentProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); const titleStyle = useMemo( @@ -213,7 +215,7 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro [theme.colors.foregroundMuted], ); return ( - + {agent.title || t("shell.commandCenter.newAgent")} - + + {showHost ? `${agent.serverLabel} · ` : ""} {shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)} @@ -246,6 +249,7 @@ interface AgentItemsSectionProps { onSelect: (item: ReturnType["items"][number]) => void; sectionDividerStyle: React.ComponentProps["style"]; sectionLabelStyle: React.ComponentProps["style"]; + showHost: boolean; } function AgentItemsSection({ @@ -257,6 +261,7 @@ function AgentItemsSection({ onSelect, sectionDividerStyle, sectionLabelStyle, + showHost, }: AgentItemsSectionProps) { const { t } = useTranslation(); @@ -277,7 +282,7 @@ function AgentItemsSection({ onLayout={onRowLayout(rowIndex)} onSelect={onSelect} > - + ); })} @@ -302,6 +307,8 @@ export function CommandCenter() { const isCompact = useIsCompactFormFactor(); const showBottomSheet = isCompact && isNative; + // Host names only earn their space once results can span more than one host. + const showHost = useHosts().length > 1; const rowRefs = useRef>(new Map()); const rowLayouts = useRef>(new Map()); @@ -477,6 +484,7 @@ export function CommandCenter() { onSelect={handleSelectItem} sectionDividerStyle={sectionDividerStyle} sectionLabelStyle={sectionLabelStyle} + showHost={showHost} /> ) : null} diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index 72700a62d..0314e54dd 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -256,7 +256,7 @@ function WorkspaceSelectionProbe({ function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement { counts.frame += 1; - const { projects } = useSidebarWorkspacesList({ hostFilter: SERVER_ID }); + const { projects } = useSidebarWorkspacesList({ hostFilters: [SERVER_ID] }); return ( <> diff --git a/packages/app/src/components/sidebar/sidebar-display-preferences-menu.tsx b/packages/app/src/components/sidebar/sidebar-display-preferences-menu.tsx index ac412943f..bac8319cc 100644 --- a/packages/app/src/components/sidebar/sidebar-display-preferences-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-display-preferences-menu.tsx @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Settings2 } from "lucide-react-native"; @@ -10,11 +10,11 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { HostStatusDot } from "@/components/host-status-dot"; import { isWeb as platformIsWeb } from "@/constants/platform"; import { useAppSettings, type WorkspaceTitleSource } from "@/hooks/use-settings"; -import { useHosts, useHostRuntimeSnapshot } from "@/runtime/host-runtime"; +import { useHosts } from "@/runtime/host-runtime"; import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store"; -import { formatConnectionStatus } from "@/utils/daemons"; const ThemedSettings2 = withUnistyles(Settings2); const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -36,9 +36,10 @@ interface DisplayPreferenceOption { export function SidebarDisplayPreferencesMenu() { const groupMode = useSidebarViewStore((state) => state.groupMode); - const hostFilter = useSidebarViewStore((state) => state.hostFilter); + const hostFilters = useSidebarViewStore((state) => state.hostFilters); const setGroupMode = useSidebarViewStore((state) => state.setGroupMode); - const setHostFilter = useSidebarViewStore((state) => state.setHostFilter); + const toggleHostFilter = useSidebarViewStore((state) => state.toggleHostFilter); + const clearHostFilters = useSidebarViewStore((state) => state.clearHostFilters); const hosts = useHosts(); const { settings: { workspaceTitleSource }, @@ -52,13 +53,6 @@ export function SidebarDisplayPreferencesMenu() { [setGroupMode], ); - const handleSelectHost = useCallback( - (serverId: string | null) => { - setHostFilter(serverId); - }, - [setHostFilter], - ); - const handleWorkspaceTitleSourceSelect = useCallback( (source: WorkspaceTitleSource) => { void updateSettings({ workspaceTitleSource: source }); @@ -75,6 +69,7 @@ export function SidebarDisplayPreferencesMenu() { ); const showHostFilter = hosts.length > 1; + const allHostsSelected = hostFilters.length === 0; return ( @@ -105,20 +100,21 @@ export function SidebarDisplayPreferencesMenu() { Filter - + + All hosts + {hosts.map((host) => ( ))} @@ -167,25 +163,32 @@ function DisplayPreferenceMenuItem({ function HostFilterItem({ label, serverId, - value, - hostFilter, - onSelect, + selected, + onToggle, }: { label: string; - serverId?: string; - value: string | null; - hostFilter: string | null; - onSelect: (serverId: string | null) => void; + serverId: string; + selected: boolean; + onToggle: (serverId: string) => void; }) { - const isSelected = hostFilter === value; - const handleSelect = useCallback(() => onSelect(value), [value, onSelect]); - const status = useHostRuntimeSnapshot(serverId ?? ""); - const subtitle = serverId - ? formatConnectionStatus(status?.connectionStatus ?? "idle") - : undefined; + const handleSelect = useCallback(() => onToggle(serverId), [serverId, onToggle]); + const leading = useMemo( + () => ( + + + + ), + [serverId], + ); return ( - + {label} ); diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx index 5126d3356..3d251a86e 100644 --- a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx +++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx @@ -86,7 +86,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { }); useSidebarViewStore.setState({ groupMode: "project", - hostFilter: null, + hostFilters: [], }); act(() => { @@ -216,7 +216,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { ); useSessionStore.getState().setHasHydratedWorkspaces("host-a", true); useSessionStore.getState().setHasHydratedWorkspaces("host-b", true); - useSidebarViewStore.getState().setHostFilter("host-b"); + useSidebarViewStore.getState().toggleHostFilter("host-b"); }); await act(async () => { diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index d02f8a945..b8248c2d1 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -89,7 +89,7 @@ export interface SidebarWorkspacesListResult { } export function useSidebarWorkspacesList(options?: { - hostFilter?: string | null; + hostFilters?: readonly string[]; enabled?: boolean; }): SidebarWorkspacesListResult { const runtime = getHostRuntimeStore(); @@ -97,27 +97,31 @@ export function useSidebarWorkspacesList(options?: { const hostRegistryLoaded = useHostRegistryLoaded(); const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]); - const storeHostFilter = useSidebarViewStore((state) => state.hostFilter); - const hostFilter = options?.hostFilter ?? storeHostFilter; - const reconcileHostFilter = useSidebarViewStore((state) => state.reconcileHostFilter); - const hasHostFilterMatch = hostFilter ? allServerIds.includes(hostFilter) : false; - const effectiveHostFilter = - hostFilter && (!hostRegistryLoaded || hasHostFilterMatch) ? hostFilter : null; + const storeHostFilters = useSidebarViewStore((state) => state.hostFilters); + const hostFilters = options?.hostFilters ?? storeHostFilters; + const reconcileHostFilters = useSidebarViewStore((state) => state.reconcileHostFilters); const isActive = options?.enabled !== false; const serverIds = useMemo(() => { - if (effectiveHostFilter) { - return allServerIds.filter((id) => id === effectiveHostFilter); + if (hostFilters.length === 0) { + return allServerIds; } - return allServerIds; - }, [allServerIds, effectiveHostFilter]); + const selected = new Set(hostFilters); + const matched = allServerIds.filter((id) => selected.has(id)); + // Registry has settled but none of the pinned hosts still exist — fall back to every + // host rather than leaving the sidebar empty. + if (hostRegistryLoaded && matched.length === 0) { + return allServerIds; + } + return matched; + }, [allServerIds, hostFilters, hostRegistryLoaded]); useEffect(() => { if (!hostRegistryLoaded) { return; } - reconcileHostFilter(allServerIds); - }, [allServerIds, hostRegistryLoaded, reconcileHostFilter]); + reconcileHostFilters(allServerIds); + }, [allServerIds, hostRegistryLoaded, reconcileHostFilters]); const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER); diff --git a/packages/app/src/stores/sidebar-view-store.test.ts b/packages/app/src/stores/sidebar-view-store.test.ts index 33c04c086..ca02d74dc 100644 --- a/packages/app/src/stores/sidebar-view-store.test.ts +++ b/packages/app/src/stores/sidebar-view-store.test.ts @@ -39,24 +39,44 @@ describe("sidebar view store", () => { beforeEach(() => { useSidebarViewStore.setState({ groupMode: "project", - hostFilter: null, + hostFilters: [], }); }); - it("keeps a host filter that still points at an available host", () => { - useSidebarViewStore.getState().setHostFilter("host-a"); + it("toggles multiple hosts into and out of the filter", () => { + const store = useSidebarViewStore.getState(); + store.toggleHostFilter("host-a"); + store.toggleHostFilter("host-b"); - useSidebarViewStore.getState().reconcileHostFilter(["host-a", "host-b"]); + expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-a", "host-b"]); - expect(useSidebarViewStore.getState().hostFilter).toBe("host-a"); + store.toggleHostFilter("host-a"); + + expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-b"]); + + store.clearHostFilters(); + + expect(useSidebarViewStore.getState().hostFilters).toEqual([]); }); - it("clears a host filter after that host is removed", () => { - useSidebarViewStore.getState().setHostFilter("removed-host"); + it("keeps host filters that still point at available hosts", () => { + const store = useSidebarViewStore.getState(); + store.toggleHostFilter("host-a"); + store.toggleHostFilter("host-b"); - useSidebarViewStore.getState().reconcileHostFilter(["host-a"]); + store.reconcileHostFilters(["host-a", "host-b", "host-c"]); - expect(useSidebarViewStore.getState().hostFilter).toBeNull(); + expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-a", "host-b"]); + }); + + it("drops a host filter after that host is removed", () => { + const store = useSidebarViewStore.getState(); + store.toggleHostFilter("host-a"); + store.toggleHostFilter("removed-host"); + + store.reconcileHostFilters(["host-a"]); + + expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-a"]); }); it("migrates legacy per-host group modes to the new global mode", () => { @@ -69,11 +89,11 @@ describe("sidebar view store", () => { }), ).toEqual({ groupMode: "status", - hostFilter: null, + hostFilters: [], }); }); - it("keeps current persisted sidebar view state during version migration", () => { + it("migrates a pre-v2 single host filter to the multi-host list", () => { expect( migrateSidebarViewState({ groupMode: "status", @@ -81,7 +101,19 @@ describe("sidebar view store", () => { }), ).toEqual({ groupMode: "status", - hostFilter: "host-a", + hostFilters: ["host-a"], + }); + }); + + it("keeps current persisted sidebar view state during version migration", () => { + expect( + migrateSidebarViewState({ + groupMode: "status", + hostFilters: ["host-a", "host-b"], + }), + ).toEqual({ + groupMode: "status", + hostFilters: ["host-a", "host-b"], }); }); @@ -108,8 +140,8 @@ describe("sidebar view store", () => { it("uses the new storage key without reading the legacy key when current state exists", async () => { const storage = createMemoryStorage({ "sidebar-view": JSON.stringify({ - state: { groupMode: "project", hostFilter: "host-a" }, - version: 1, + state: { groupMode: "project", hostFilters: ["host-a"] }, + version: 2, }), "sidebar-group-mode": JSON.stringify({ state: { groupModeByServerId: { "host-b": "status" } }, @@ -121,8 +153,8 @@ describe("sidebar view store", () => { expect(value).toBe( JSON.stringify({ - state: { groupMode: "project", hostFilter: "host-a" }, - version: 1, + state: { groupMode: "project", hostFilters: ["host-a"] }, + version: 2, }), ); expect(storage.reads).toEqual(["sidebar-view"]); diff --git a/packages/app/src/stores/sidebar-view-store.ts b/packages/app/src/stores/sidebar-view-store.ts index b1a052b88..52d2198d9 100644 --- a/packages/app/src/stores/sidebar-view-store.ts +++ b/packages/app/src/stores/sidebar-view-store.ts @@ -6,19 +6,21 @@ export type SidebarGroupMode = "project" | "status"; const SIDEBAR_VIEW_STORAGE_KEY = "sidebar-view"; const LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY = "sidebar-group-mode"; -const SIDEBAR_VIEW_STORE_VERSION = 1; +const SIDEBAR_VIEW_STORE_VERSION = 2; interface SidebarViewStoreState { groupMode: SidebarGroupMode; - hostFilter: string | null; + // Empty means "all hosts". A non-empty list pins the sidebar to those hosts. + hostFilters: string[]; setGroupMode: (mode: SidebarGroupMode) => void; - setHostFilter: (serverId: string | null) => void; - reconcileHostFilter: (serverIds: readonly string[]) => void; + toggleHostFilter: (serverId: string) => void; + clearHostFilters: () => void; + reconcileHostFilters: (serverIds: readonly string[]) => void; } interface SidebarViewPersistedState { groupMode: SidebarGroupMode; - hostFilter: string | null; + hostFilters: string[]; } function isSidebarGroupMode(value: unknown): value is SidebarGroupMode { @@ -40,19 +42,32 @@ function readLegacyGroupMode(persistedState: Record): SidebarGr return modes.includes("status") ? "status" : "project"; } +// Reads the host filter from any persisted shape: the current `hostFilters` array, or the +// pre-v2 single `hostFilter` string (null/absent meant "all hosts"). +function readHostFilters(persistedState: Record): string[] { + const hostFilters = persistedState.hostFilters; + if (Array.isArray(hostFilters)) { + return hostFilters.filter((value): value is string => typeof value === "string"); + } + // COMPAT(sidebarHostFilters): added in v0.1.102, remove after 2026-12-30 once pre-v2 persisted + // sidebar state (a single `hostFilter` string) has aged out. + const legacyHostFilter = persistedState.hostFilter; + return typeof legacyHostFilter === "string" ? [legacyHostFilter] : []; +} + export function migrateSidebarViewState(persistedState: unknown): SidebarViewPersistedState { if (!isRecord(persistedState)) { - return { groupMode: "project", hostFilter: null }; + return { groupMode: "project", hostFilters: [] }; } const legacyGroupMode = readLegacyGroupMode(persistedState); if (legacyGroupMode) { - return { groupMode: legacyGroupMode, hostFilter: null }; + return { groupMode: legacyGroupMode, hostFilters: [] }; } return { groupMode: isSidebarGroupMode(persistedState.groupMode) ? persistedState.groupMode : "project", - hostFilter: typeof persistedState.hostFilter === "string" ? persistedState.hostFilter : null, + hostFilters: readHostFilters(persistedState), }; } @@ -76,15 +91,26 @@ export const useSidebarViewStore = create()( persist( (set) => ({ groupMode: "project", - hostFilter: null, + hostFilters: [], setGroupMode: (mode) => set({ groupMode: mode }), - setHostFilter: (serverId) => set({ hostFilter: serverId }), - reconcileHostFilter: (serverIds) => + toggleHostFilter: (serverId) => + set((state) => ({ + hostFilters: state.hostFilters.includes(serverId) + ? state.hostFilters.filter((id) => id !== serverId) + : [...state.hostFilters, serverId], + })), + clearHostFilters: () => set({ hostFilters: [] }), + reconcileHostFilters: (serverIds) => set((state) => { - if (!state.hostFilter || serverIds.includes(state.hostFilter)) { + if (state.hostFilters.length === 0) { return state; } - return { hostFilter: null }; + const allowed = new Set(serverIds); + const next = state.hostFilters.filter((id) => allowed.has(id)); + if (next.length === state.hostFilters.length) { + return state; + } + return { hostFilters: next }; }), }), { @@ -93,7 +119,7 @@ export const useSidebarViewStore = create()( storage: createJSONStorage(createSidebarViewStorage), partialize: (state) => ({ groupMode: state.groupMode, - hostFilter: state.hostFilter, + hostFilters: state.hostFilters, }), migrate: migrateSidebarViewState, },