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:
@@ -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"]);
|
||||
|
||||
@@ -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<string, unknown>): 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, 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 {
|
||||
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<SidebarViewStoreState>()(
|
||||
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<SidebarViewStoreState>()(
|
||||
storage: createJSONStorage(createSidebarViewStorage),
|
||||
partialize: (state) => ({
|
||||
groupMode: state.groupMode,
|
||||
hostFilter: state.hostFilter,
|
||||
hostFilters: state.hostFilters,
|
||||
}),
|
||||
migrate: migrateSidebarViewState,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user