Show host in search results and filter sidebar by multiple hosts (#1825)
* 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.
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Locator> {
|
||||||
|
await page.getByTestId("sidebar-command-center-search").click();
|
||||||
|
const panel = page.getByTestId("command-center-panel");
|
||||||
|
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await page.getByTestId(`sidebar-host-filter-${serverId}`).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectAllHostsFilter(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId("sidebar-host-filter-all").click();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import { Home, Plus, Settings } from "lucide-react-native";
|
|||||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||||
import { useCommandCenter } from "@/hooks/use-command-center";
|
import { useCommandCenter } from "@/hooks/use-command-center";
|
||||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { formatTimeAgo } from "@/utils/time";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||||
@@ -199,9 +200,10 @@ function CommandCenterAgentRow({
|
|||||||
|
|
||||||
interface CommandCenterAgentRowContentProps {
|
interface CommandCenterAgentRowContentProps {
|
||||||
agent: AggregatedAgent;
|
agent: AggregatedAgent;
|
||||||
|
showHost: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentProps) {
|
function CommandCenterAgentRowContent({ agent, showHost }: CommandCenterAgentRowContentProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const titleStyle = useMemo(
|
const titleStyle = useMemo(
|
||||||
@@ -213,7 +215,7 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
|
|||||||
[theme.colors.foregroundMuted],
|
[theme.colors.foregroundMuted],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View style={styles.rowContent}>
|
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
|
||||||
<View style={styles.rowMain}>
|
<View style={styles.rowMain}>
|
||||||
<View style={styles.iconSlot}>
|
<View style={styles.iconSlot}>
|
||||||
<AgentStatusDot
|
<AgentStatusDot
|
||||||
@@ -226,7 +228,8 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
|
|||||||
<Text style={titleStyle} numberOfLines={1}>
|
<Text style={titleStyle} numberOfLines={1}>
|
||||||
{agent.title || t("shell.commandCenter.newAgent")}
|
{agent.title || t("shell.commandCenter.newAgent")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={subtitleStyle} numberOfLines={1}>
|
<Text style={subtitleStyle} numberOfLines={1} testID="command-center-agent-subtitle">
|
||||||
|
{showHost ? `${agent.serverLabel} · ` : ""}
|
||||||
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
|
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -246,6 +249,7 @@ interface AgentItemsSectionProps {
|
|||||||
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
|
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
|
||||||
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
|
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
|
||||||
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
|
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
|
||||||
|
showHost: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentItemsSection({
|
function AgentItemsSection({
|
||||||
@@ -257,6 +261,7 @@ function AgentItemsSection({
|
|||||||
onSelect,
|
onSelect,
|
||||||
sectionDividerStyle,
|
sectionDividerStyle,
|
||||||
sectionLabelStyle,
|
sectionLabelStyle,
|
||||||
|
showHost,
|
||||||
}: AgentItemsSectionProps) {
|
}: AgentItemsSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -277,7 +282,7 @@ function AgentItemsSection({
|
|||||||
onLayout={onRowLayout(rowIndex)}
|
onLayout={onRowLayout(rowIndex)}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
>
|
>
|
||||||
<CommandCenterAgentRowContent agent={agent} />
|
<CommandCenterAgentRowContent agent={agent} showHost={showHost} />
|
||||||
</CommandCenterAgentRow>
|
</CommandCenterAgentRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -302,6 +307,8 @@ export function CommandCenter() {
|
|||||||
|
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const showBottomSheet = isCompact && isNative;
|
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<Map<number, View>>(new Map());
|
const rowRefs = useRef<Map<number, View>>(new Map());
|
||||||
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
|
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
|
||||||
@@ -477,6 +484,7 @@ export function CommandCenter() {
|
|||||||
onSelect={handleSelectItem}
|
onSelect={handleSelectItem}
|
||||||
sectionDividerStyle={sectionDividerStyle}
|
sectionDividerStyle={sectionDividerStyle}
|
||||||
sectionLabelStyle={sectionLabelStyle}
|
sectionLabelStyle={sectionLabelStyle}
|
||||||
|
showHost={showHost}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ function WorkspaceSelectionProbe({
|
|||||||
|
|
||||||
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
|
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
|
||||||
counts.frame += 1;
|
counts.frame += 1;
|
||||||
const { projects } = useSidebarWorkspacesList({ hostFilter: SERVER_ID });
|
const { projects } = useSidebarWorkspacesList({ hostFilters: [SERVER_ID] });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { Settings2 } from "lucide-react-native";
|
import { Settings2 } from "lucide-react-native";
|
||||||
@@ -10,11 +10,11 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { HostStatusDot } from "@/components/host-status-dot";
|
||||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||||
import { useAppSettings, type WorkspaceTitleSource } from "@/hooks/use-settings";
|
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 { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||||
import { formatConnectionStatus } from "@/utils/daemons";
|
|
||||||
|
|
||||||
const ThemedSettings2 = withUnistyles(Settings2);
|
const ThemedSettings2 = withUnistyles(Settings2);
|
||||||
const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
@@ -36,9 +36,10 @@ interface DisplayPreferenceOption<Value extends string> {
|
|||||||
|
|
||||||
export function SidebarDisplayPreferencesMenu() {
|
export function SidebarDisplayPreferencesMenu() {
|
||||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
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 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 hosts = useHosts();
|
||||||
const {
|
const {
|
||||||
settings: { workspaceTitleSource },
|
settings: { workspaceTitleSource },
|
||||||
@@ -52,13 +53,6 @@ export function SidebarDisplayPreferencesMenu() {
|
|||||||
[setGroupMode],
|
[setGroupMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectHost = useCallback(
|
|
||||||
(serverId: string | null) => {
|
|
||||||
setHostFilter(serverId);
|
|
||||||
},
|
|
||||||
[setHostFilter],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleWorkspaceTitleSourceSelect = useCallback(
|
const handleWorkspaceTitleSourceSelect = useCallback(
|
||||||
(source: WorkspaceTitleSource) => {
|
(source: WorkspaceTitleSource) => {
|
||||||
void updateSettings({ workspaceTitleSource: source });
|
void updateSettings({ workspaceTitleSource: source });
|
||||||
@@ -75,6 +69,7 @@ export function SidebarDisplayPreferencesMenu() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const showHostFilter = hosts.length > 1;
|
const showHostFilter = hosts.length > 1;
|
||||||
|
const allHostsSelected = hostFilters.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -105,20 +100,21 @@ export function SidebarDisplayPreferencesMenu() {
|
|||||||
<View style={styles.menuHeader}>
|
<View style={styles.menuHeader}>
|
||||||
<Text style={styles.menuHeaderLabel}>Filter</Text>
|
<Text style={styles.menuHeaderLabel}>Filter</Text>
|
||||||
</View>
|
</View>
|
||||||
<HostFilterItem
|
<DropdownMenuItem
|
||||||
label="All hosts"
|
testID="sidebar-host-filter-all"
|
||||||
value={null}
|
selected={allHostsSelected}
|
||||||
hostFilter={hostFilter}
|
closeOnSelect={false}
|
||||||
onSelect={handleSelectHost}
|
onSelect={clearHostFilters}
|
||||||
/>
|
>
|
||||||
|
All hosts
|
||||||
|
</DropdownMenuItem>
|
||||||
{hosts.map((host) => (
|
{hosts.map((host) => (
|
||||||
<HostFilterItem
|
<HostFilterItem
|
||||||
key={host.serverId}
|
key={host.serverId}
|
||||||
label={host.label?.trim() || host.serverId}
|
label={host.label?.trim() || host.serverId}
|
||||||
serverId={host.serverId}
|
serverId={host.serverId}
|
||||||
value={host.serverId}
|
selected={hostFilters.includes(host.serverId)}
|
||||||
hostFilter={hostFilter}
|
onToggle={toggleHostFilter}
|
||||||
onSelect={handleSelectHost}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -167,25 +163,32 @@ function DisplayPreferenceMenuItem<Value extends string>({
|
|||||||
function HostFilterItem({
|
function HostFilterItem({
|
||||||
label,
|
label,
|
||||||
serverId,
|
serverId,
|
||||||
value,
|
selected,
|
||||||
hostFilter,
|
onToggle,
|
||||||
onSelect,
|
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
serverId?: string;
|
serverId: string;
|
||||||
value: string | null;
|
selected: boolean;
|
||||||
hostFilter: string | null;
|
onToggle: (serverId: string) => void;
|
||||||
onSelect: (serverId: string | null) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const isSelected = hostFilter === value;
|
const handleSelect = useCallback(() => onToggle(serverId), [serverId, onToggle]);
|
||||||
const handleSelect = useCallback(() => onSelect(value), [value, onSelect]);
|
const leading = useMemo(
|
||||||
const status = useHostRuntimeSnapshot(serverId ?? "");
|
() => (
|
||||||
const subtitle = serverId
|
<View testID={`sidebar-host-filter-status-${serverId}`}>
|
||||||
? formatConnectionStatus(status?.connectionStatus ?? "idle")
|
<HostStatusDot serverId={serverId} />
|
||||||
: undefined;
|
</View>
|
||||||
|
),
|
||||||
|
[serverId],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem selected={isSelected} description={subtitle} onSelect={handleSelect}>
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-host-filter-${serverId}`}
|
||||||
|
selected={selected}
|
||||||
|
closeOnSelect={false}
|
||||||
|
leading={leading}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
|||||||
});
|
});
|
||||||
useSidebarViewStore.setState({
|
useSidebarViewStore.setState({
|
||||||
groupMode: "project",
|
groupMode: "project",
|
||||||
hostFilter: null,
|
hostFilters: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -216,7 +216,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
|||||||
);
|
);
|
||||||
useSessionStore.getState().setHasHydratedWorkspaces("host-a", true);
|
useSessionStore.getState().setHasHydratedWorkspaces("host-a", true);
|
||||||
useSessionStore.getState().setHasHydratedWorkspaces("host-b", true);
|
useSessionStore.getState().setHasHydratedWorkspaces("host-b", true);
|
||||||
useSidebarViewStore.getState().setHostFilter("host-b");
|
useSidebarViewStore.getState().toggleHostFilter("host-b");
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export interface SidebarWorkspacesListResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSidebarWorkspacesList(options?: {
|
export function useSidebarWorkspacesList(options?: {
|
||||||
hostFilter?: string | null;
|
hostFilters?: readonly string[];
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}): SidebarWorkspacesListResult {
|
}): SidebarWorkspacesListResult {
|
||||||
const runtime = getHostRuntimeStore();
|
const runtime = getHostRuntimeStore();
|
||||||
@@ -97,27 +97,31 @@ export function useSidebarWorkspacesList(options?: {
|
|||||||
const hostRegistryLoaded = useHostRegistryLoaded();
|
const hostRegistryLoaded = useHostRegistryLoaded();
|
||||||
const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]);
|
const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]);
|
||||||
|
|
||||||
const storeHostFilter = useSidebarViewStore((state) => state.hostFilter);
|
const storeHostFilters = useSidebarViewStore((state) => state.hostFilters);
|
||||||
const hostFilter = options?.hostFilter ?? storeHostFilter;
|
const hostFilters = options?.hostFilters ?? storeHostFilters;
|
||||||
const reconcileHostFilter = useSidebarViewStore((state) => state.reconcileHostFilter);
|
const reconcileHostFilters = useSidebarViewStore((state) => state.reconcileHostFilters);
|
||||||
const hasHostFilterMatch = hostFilter ? allServerIds.includes(hostFilter) : false;
|
|
||||||
const effectiveHostFilter =
|
|
||||||
hostFilter && (!hostRegistryLoaded || hasHostFilterMatch) ? hostFilter : null;
|
|
||||||
const isActive = options?.enabled !== false;
|
const isActive = options?.enabled !== false;
|
||||||
|
|
||||||
const serverIds = useMemo(() => {
|
const serverIds = useMemo(() => {
|
||||||
if (effectiveHostFilter) {
|
if (hostFilters.length === 0) {
|
||||||
return allServerIds.filter((id) => id === effectiveHostFilter);
|
return allServerIds;
|
||||||
}
|
}
|
||||||
return allServerIds;
|
const selected = new Set(hostFilters);
|
||||||
}, [allServerIds, effectiveHostFilter]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!hostRegistryLoaded) {
|
if (!hostRegistryLoaded) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reconcileHostFilter(allServerIds);
|
reconcileHostFilters(allServerIds);
|
||||||
}, [allServerIds, hostRegistryLoaded, reconcileHostFilter]);
|
}, [allServerIds, hostRegistryLoaded, reconcileHostFilters]);
|
||||||
|
|
||||||
const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER);
|
const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER);
|
||||||
|
|
||||||
|
|||||||
@@ -39,24 +39,44 @@ describe("sidebar view store", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useSidebarViewStore.setState({
|
useSidebarViewStore.setState({
|
||||||
groupMode: "project",
|
groupMode: "project",
|
||||||
hostFilter: null,
|
hostFilters: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a host filter that still points at an available host", () => {
|
it("toggles multiple hosts into and out of the filter", () => {
|
||||||
useSidebarViewStore.getState().setHostFilter("host-a");
|
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", () => {
|
it("keeps host filters that still point at available hosts", () => {
|
||||||
useSidebarViewStore.getState().setHostFilter("removed-host");
|
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", () => {
|
it("migrates legacy per-host group modes to the new global mode", () => {
|
||||||
@@ -69,11 +89,11 @@ describe("sidebar view store", () => {
|
|||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
groupMode: "status",
|
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(
|
expect(
|
||||||
migrateSidebarViewState({
|
migrateSidebarViewState({
|
||||||
groupMode: "status",
|
groupMode: "status",
|
||||||
@@ -81,7 +101,19 @@ describe("sidebar view store", () => {
|
|||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
groupMode: "status",
|
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 () => {
|
it("uses the new storage key without reading the legacy key when current state exists", async () => {
|
||||||
const storage = createMemoryStorage({
|
const storage = createMemoryStorage({
|
||||||
"sidebar-view": JSON.stringify({
|
"sidebar-view": JSON.stringify({
|
||||||
state: { groupMode: "project", hostFilter: "host-a" },
|
state: { groupMode: "project", hostFilters: ["host-a"] },
|
||||||
version: 1,
|
version: 2,
|
||||||
}),
|
}),
|
||||||
"sidebar-group-mode": JSON.stringify({
|
"sidebar-group-mode": JSON.stringify({
|
||||||
state: { groupModeByServerId: { "host-b": "status" } },
|
state: { groupModeByServerId: { "host-b": "status" } },
|
||||||
@@ -121,8 +153,8 @@ describe("sidebar view store", () => {
|
|||||||
|
|
||||||
expect(value).toBe(
|
expect(value).toBe(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
state: { groupMode: "project", hostFilter: "host-a" },
|
state: { groupMode: "project", hostFilters: ["host-a"] },
|
||||||
version: 1,
|
version: 2,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(storage.reads).toEqual(["sidebar-view"]);
|
expect(storage.reads).toEqual(["sidebar-view"]);
|
||||||
|
|||||||
@@ -6,19 +6,21 @@ export type SidebarGroupMode = "project" | "status";
|
|||||||
|
|
||||||
const SIDEBAR_VIEW_STORAGE_KEY = "sidebar-view";
|
const SIDEBAR_VIEW_STORAGE_KEY = "sidebar-view";
|
||||||
const LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY = "sidebar-group-mode";
|
const LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY = "sidebar-group-mode";
|
||||||
const SIDEBAR_VIEW_STORE_VERSION = 1;
|
const SIDEBAR_VIEW_STORE_VERSION = 2;
|
||||||
|
|
||||||
interface SidebarViewStoreState {
|
interface SidebarViewStoreState {
|
||||||
groupMode: SidebarGroupMode;
|
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;
|
setGroupMode: (mode: SidebarGroupMode) => void;
|
||||||
setHostFilter: (serverId: string | null) => void;
|
toggleHostFilter: (serverId: string) => void;
|
||||||
reconcileHostFilter: (serverIds: readonly string[]) => void;
|
clearHostFilters: () => void;
|
||||||
|
reconcileHostFilters: (serverIds: readonly string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarViewPersistedState {
|
interface SidebarViewPersistedState {
|
||||||
groupMode: SidebarGroupMode;
|
groupMode: SidebarGroupMode;
|
||||||
hostFilter: string | null;
|
hostFilters: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSidebarGroupMode(value: unknown): value is SidebarGroupMode {
|
function isSidebarGroupMode(value: unknown): value is SidebarGroupMode {
|
||||||
@@ -40,19 +42,32 @@ function readLegacyGroupMode(persistedState: Record<string, unknown>): SidebarGr
|
|||||||
return modes.includes("status") ? "status" : "project";
|
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, unknown>): 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 {
|
export function migrateSidebarViewState(persistedState: unknown): SidebarViewPersistedState {
|
||||||
if (!isRecord(persistedState)) {
|
if (!isRecord(persistedState)) {
|
||||||
return { groupMode: "project", hostFilter: null };
|
return { groupMode: "project", hostFilters: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const legacyGroupMode = readLegacyGroupMode(persistedState);
|
const legacyGroupMode = readLegacyGroupMode(persistedState);
|
||||||
if (legacyGroupMode) {
|
if (legacyGroupMode) {
|
||||||
return { groupMode: legacyGroupMode, hostFilter: null };
|
return { groupMode: legacyGroupMode, hostFilters: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
groupMode: isSidebarGroupMode(persistedState.groupMode) ? persistedState.groupMode : "project",
|
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<SidebarViewStoreState>()(
|
|||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
groupMode: "project",
|
groupMode: "project",
|
||||||
hostFilter: null,
|
hostFilters: [],
|
||||||
setGroupMode: (mode) => set({ groupMode: mode }),
|
setGroupMode: (mode) => set({ groupMode: mode }),
|
||||||
setHostFilter: (serverId) => set({ hostFilter: serverId }),
|
toggleHostFilter: (serverId) =>
|
||||||
reconcileHostFilter: (serverIds) =>
|
set((state) => ({
|
||||||
|
hostFilters: state.hostFilters.includes(serverId)
|
||||||
|
? state.hostFilters.filter((id) => id !== serverId)
|
||||||
|
: [...state.hostFilters, serverId],
|
||||||
|
})),
|
||||||
|
clearHostFilters: () => set({ hostFilters: [] }),
|
||||||
|
reconcileHostFilters: (serverIds) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (!state.hostFilter || serverIds.includes(state.hostFilter)) {
|
if (state.hostFilters.length === 0) {
|
||||||
return state;
|
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<SidebarViewStoreState>()(
|
|||||||
storage: createJSONStorage(createSidebarViewStorage),
|
storage: createJSONStorage(createSidebarViewStorage),
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
groupMode: state.groupMode,
|
groupMode: state.groupMode,
|
||||||
hostFilter: state.hostFilter,
|
hostFilters: state.hostFilters,
|
||||||
}),
|
}),
|
||||||
migrate: migrateSidebarViewState,
|
migrate: migrateSidebarViewState,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user