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:
Mohamed Boudra
2026-06-30 09:40:24 +02:00
committed by GitHub
parent 4e28b0941b
commit e9431517a0
11 changed files with 351 additions and 85 deletions
+12 -4
View File
@@ -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 (
<View style={styles.rowContent}>
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<AgentStatusDot
@@ -226,7 +228,8 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
<Text style={titleStyle} numberOfLines={1}>
{agent.title || t("shell.commandCenter.newAgent")}
</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)}
</Text>
</View>
@@ -246,6 +249,7 @@ interface AgentItemsSectionProps {
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
sectionLabelStyle: React.ComponentProps<typeof Text>["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}
>
<CommandCenterAgentRowContent agent={agent} />
<CommandCenterAgentRowContent agent={agent} showHost={showHost} />
</CommandCenterAgentRow>
);
})}
@@ -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<Map<number, View>>(new Map());
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
@@ -477,6 +484,7 @@ export function CommandCenter() {
onSelect={handleSelectItem}
sectionDividerStyle={sectionDividerStyle}
sectionLabelStyle={sectionLabelStyle}
showHost={showHost}
/>
) : null}
</>
@@ -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 (
<>
@@ -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<Value extends string> {
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 (
<DropdownMenu>
@@ -105,20 +100,21 @@ export function SidebarDisplayPreferencesMenu() {
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Filter</Text>
</View>
<HostFilterItem
label="All hosts"
value={null}
hostFilter={hostFilter}
onSelect={handleSelectHost}
/>
<DropdownMenuItem
testID="sidebar-host-filter-all"
selected={allHostsSelected}
closeOnSelect={false}
onSelect={clearHostFilters}
>
All hosts
</DropdownMenuItem>
{hosts.map((host) => (
<HostFilterItem
key={host.serverId}
label={host.label?.trim() || host.serverId}
serverId={host.serverId}
value={host.serverId}
hostFilter={hostFilter}
onSelect={handleSelectHost}
selected={hostFilters.includes(host.serverId)}
onToggle={toggleHostFilter}
/>
))}
</>
@@ -167,25 +163,32 @@ function DisplayPreferenceMenuItem<Value extends string>({
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(
() => (
<View testID={`sidebar-host-filter-status-${serverId}`}>
<HostStatusDot serverId={serverId} />
</View>
),
[serverId],
);
return (
<DropdownMenuItem selected={isSelected} description={subtitle} onSelect={handleSelect}>
<DropdownMenuItem
testID={`sidebar-host-filter-${serverId}`}
selected={selected}
closeOnSelect={false}
leading={leading}
onSelect={handleSelect}
>
{label}
</DropdownMenuItem>
);
@@ -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 () => {