* 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.
128 lines
4.4 KiB
TypeScript
128 lines
4.4 KiB
TypeScript
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
import { create } from "zustand";
|
|
import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";
|
|
|
|
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 = 2;
|
|
|
|
interface SidebarViewStoreState {
|
|
groupMode: SidebarGroupMode;
|
|
// Empty means "all hosts". A non-empty list pins the sidebar to those hosts.
|
|
hostFilters: string[];
|
|
setGroupMode: (mode: SidebarGroupMode) => void;
|
|
toggleHostFilter: (serverId: string) => void;
|
|
clearHostFilters: () => void;
|
|
reconcileHostFilters: (serverIds: readonly string[]) => void;
|
|
}
|
|
|
|
interface SidebarViewPersistedState {
|
|
groupMode: SidebarGroupMode;
|
|
hostFilters: string[];
|
|
}
|
|
|
|
function isSidebarGroupMode(value: unknown): value is SidebarGroupMode {
|
|
return value === "project" || value === "status";
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function readLegacyGroupMode(persistedState: Record<string, unknown>): SidebarGroupMode | null {
|
|
const groupModeByServerId = persistedState.groupModeByServerId;
|
|
if (!isRecord(groupModeByServerId)) {
|
|
return null;
|
|
}
|
|
|
|
const modes = Object.values(groupModeByServerId).filter(isSidebarGroupMode);
|
|
if (modes.length === 0) return null;
|
|
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", hostFilters: [] };
|
|
}
|
|
|
|
const legacyGroupMode = readLegacyGroupMode(persistedState);
|
|
if (legacyGroupMode) {
|
|
return { groupMode: legacyGroupMode, hostFilters: [] };
|
|
}
|
|
|
|
return {
|
|
groupMode: isSidebarGroupMode(persistedState.groupMode) ? persistedState.groupMode : "project",
|
|
hostFilters: readHostFilters(persistedState),
|
|
};
|
|
}
|
|
|
|
export function createSidebarViewStorage(
|
|
backingStorage: StateStorage = AsyncStorage,
|
|
): StateStorage {
|
|
return {
|
|
getItem: async (name) => {
|
|
const value = await backingStorage.getItem(name);
|
|
if (value !== null || name !== SIDEBAR_VIEW_STORAGE_KEY) {
|
|
return value;
|
|
}
|
|
return backingStorage.getItem(LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY);
|
|
},
|
|
setItem: (name, value) => backingStorage.setItem(name, value),
|
|
removeItem: (name) => backingStorage.removeItem(name),
|
|
};
|
|
}
|
|
|
|
export const useSidebarViewStore = create<SidebarViewStoreState>()(
|
|
persist(
|
|
(set) => ({
|
|
groupMode: "project",
|
|
hostFilters: [],
|
|
setGroupMode: (mode) => set({ groupMode: mode }),
|
|
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.hostFilters.length === 0) {
|
|
return state;
|
|
}
|
|
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 };
|
|
}),
|
|
}),
|
|
{
|
|
name: SIDEBAR_VIEW_STORAGE_KEY,
|
|
version: SIDEBAR_VIEW_STORE_VERSION,
|
|
storage: createJSONStorage(createSidebarViewStorage),
|
|
partialize: (state) => ({
|
|
groupMode: state.groupMode,
|
|
hostFilters: state.hostFilters,
|
|
}),
|
|
migrate: migrateSidebarViewState,
|
|
},
|
|
),
|
|
);
|