Files
paseo/packages/app/src/hosts/host-chooser.tsx
T
Mohamed BoudraandGitHub a49c658d1f Merge sidebar workspaces across all connected hosts (#1538)
* Merge workspaces across all hosts in the sidebar

Replace per-host sidebar sections with a single merged project list.
Projects that exist on multiple hosts (matched by projectKey) are
collapsed into one row. Host identity is surfaced via:

- A status-only footer pill showing online/offline host counts
- Hover cards with a mandatory host row on every workspace entry
- Subtitles showing the host name when a project spans >1 host
- A host filter in the grouping dropdown (not a sidebar switcher)

Order and group-mode stores migrated from per-server to global keys
with zustand persist migrations. New-workspace screen now shows a
merged project list with a host selector combobox.

Key files:
- workspace-structure.ts: merge algorithm by projectKey
- sidebar-view-store.ts / sidebar-order-store.ts: global state
- sidebar-workspace-list.tsx: context-based subtitle injection
- new-workspace-screen.tsx: host selector in footer row

* Add host filtering to the merged sidebar

The sidebar now merges workspaces from every connected host, so a single
global "active host" no longer fits. Host selection becomes explicit per
action via a host chooser, and agent history pages across all hosts at
once. Removes the active-host / active-server-id model and the
all-agents-list hook that fed it.

* Fix rebase fallout: translate archive host error, update stale workspace-key test

archiveWorkspacesOptimistically used a raw "Host is not connected" string
that the i18n completeness test forbids; route it through i18n.t with the
existing hostDisconnected key. The selectors memoization test asserted
un-prefixed workspace keys, but merged-sidebar keys are serverId-prefixed.

* Fix sidebar review fallout

* Address sidebar review followups

* Fix sidebar workspace identity migration

* Address sidebar review followups

* Handle partial host history failures

* Fix merged sidebar project removal and E2E drift

* Restore history routes and project removal shape

* Remove sidebar host context plumbing

* Tighten sidebar status and host filter state

* Keep new workspace project selection host-compatible

* Migrate sidebar view storage

* Streamline host selection flows

* Update all-host history on archive

* Fix empty project sidebar action

Code regression: the main merge dropped the empty-project New workspace child row while keeping the empty project persistence contract. Restore the row using the project host target so the sidebar stays aligned with the no-host-selection model.

* Fix Japanese shortcut translation key

Stale-base integration: main added the Japanese locale after this branch added cycleAgentMode to the English shortcut help map. Merging main exposed the missing ja key in CI typecheck and resources.test.

* Fix Playwright speech teardown race
2026-06-26 12:16:49 +08:00

355 lines
9.7 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
type PressableStateCallbackType,
} from "react-native";
import { router } from "expo-router";
import { Server } from "lucide-react-native";
import { create } from "zustand";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
import { isNative } from "@/constants/platform";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useHosts } from "@/runtime/host-runtime";
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
type HostFilter = (host: HostProfile) => boolean;
type HostChoiceHandler = (serverId: string) => void | Promise<void>;
export interface ChooseHostInput {
title?: string;
filter?: HostFilter;
onChooseHost: HostChoiceHandler;
onNoHosts?: () => void;
}
interface HostChoiceRequest {
id: number;
title: string;
serverIds: string[];
onChooseHost: HostChoiceHandler;
}
interface HostChooserState {
request: HostChoiceRequest | null;
open: (request: Omit<HostChoiceRequest, "id">) => void;
close: () => void;
}
let nextRequestId = 1;
const useHostChooserStore = create<HostChooserState>((set) => ({
request: null,
open: (request) => {
set({ request: { ...request, id: nextRequestId++ } });
},
close: () => set({ request: null }),
}));
function matchesHostQuery(host: HostProfile, query: string): boolean {
const normalized = query.trim().toLowerCase();
if (!normalized) return true;
return (
host.label.toLowerCase().includes(normalized) ||
host.serverId.toLowerCase().includes(normalized)
);
}
export function useHostChooser() {
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const open = useHostChooserStore((state) => state.open);
return useCallback(
(input: ChooseHostInput) => {
const availableHosts = orderHostsLocalFirst(hosts, localServerId).filter(
input.filter ?? (() => true),
);
if (availableHosts.length === 0) {
(input.onNoHosts ?? (() => router.push(buildSettingsAddHostRoute(Date.now()))))();
return false;
}
if (availableHosts.length === 1) {
void input.onChooseHost(availableHosts[0].serverId);
return true;
}
open({
title: input.title ?? "Choose host",
serverIds: availableHosts.map((host) => host.serverId),
onChooseHost: input.onChooseHost,
});
return true;
},
[hosts, localServerId, open],
);
}
function HostChooserRow({
host,
active,
onChooseHost,
}: {
host: HostProfile;
active: boolean;
onChooseHost: (serverId: string) => void;
}) {
const { theme } = useUnistyles();
const handlePress = useCallback(() => onChooseHost(host.serverId), [host.serverId, onChooseHost]);
const rowStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.row,
(active || hovered || pressed) && styles.rowActive,
],
[active],
);
return (
<Pressable
accessibilityRole="button"
onPress={handlePress}
style={rowStyle}
testID={`host-chooser-row-${host.serverId}`}
>
<View style={styles.rowIconSlot}>
<HostStatusDotSlot serverId={host.serverId} />
</View>
<View style={styles.rowText}>
<Text style={styles.rowTitle} numberOfLines={1}>
{host.label}
</Text>
<Text style={styles.rowSubtitle} numberOfLines={1}>
{host.serverId}
</Text>
</View>
<Server size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
);
}
export function HostChooserModal() {
const { theme } = useUnistyles();
const hosts = useHosts();
const request = useHostChooserStore((state) => state.request);
const close = useHostChooserStore((state) => state.close);
const inputRef = useRef<TextInput>(null);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const requestHosts = useMemo(() => {
if (!request) return [];
const hostByServerId = new Map(hosts.map((host) => [host.serverId, host] as const));
return request.serverIds.flatMap((serverId) => {
const host = hostByServerId.get(serverId);
return host ? [host] : [];
});
}, [hosts, request]);
const options = useMemo(
() => requestHosts.filter((host) => matchesHostQuery(host, query)),
[query, requestHosts],
);
const activeOptionIndex = options.length === 0 ? 0 : Math.min(activeIndex, options.length - 1);
useEffect(() => {
if (!request) return;
setQuery("");
setActiveIndex(0);
const id = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(id);
}, [request]);
const handleQueryChange = useCallback((value: string) => {
setQuery(value);
setActiveIndex(0);
}, []);
const chooseHost = useCallback(
(serverId: string) => {
const currentRequest = request;
close();
if (!currentRequest) return;
void currentRequest.onChooseHost(serverId);
},
[close, request],
);
useEffect(() => {
if (!request || isNative || typeof window === "undefined") return;
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key !== "ArrowDown" &&
event.key !== "ArrowUp" &&
event.key !== "Enter" &&
event.key !== "Escape"
) {
return;
}
if (event.key === "Escape") {
event.preventDefault();
close();
return;
}
if (event.key === "Enter") {
const host = options[activeOptionIndex];
if (!host) return;
event.preventDefault();
chooseHost(host.serverId);
return;
}
if (options.length === 0) return;
event.preventDefault();
const next = event.key === "ArrowDown" ? activeOptionIndex + 1 : activeOptionIndex - 1;
if (next < 0) {
setActiveIndex(options.length - 1);
return;
}
if (next >= options.length) {
setActiveIndex(0);
return;
}
setActiveIndex(next);
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [activeOptionIndex, chooseHost, close, options, request]);
if (!request) return null;
return (
<Modal visible transparent animationType="fade" onRequestClose={close} testID="host-chooser">
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={close} />
<View style={styles.panel}>
<View style={styles.header}>
<Text style={styles.title}>{request.title}</Text>
<TextInput
ref={inputRef}
value={query}
onChangeText={handleQueryChange}
placeholder="Search hosts..."
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
testID="host-chooser-search"
/>
</View>
<ScrollView
style={styles.results}
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{options.length === 0 ? <Text style={styles.emptyText}>No matching hosts</Text> : null}
{options.map((host, index) => (
<HostChooserRow
key={host.serverId}
host={host}
active={index === activeOptionIndex}
onChooseHost={chooseHost}
/>
))}
</ScrollView>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: {
flex: 1,
justifyContent: "flex-start",
alignItems: "center",
paddingTop: theme.spacing[12],
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
panel: {
width: 640,
maxWidth: "92%",
maxHeight: "80%",
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface0,
overflow: "hidden",
...theme.shadow.lg,
},
header: {
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[4],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
},
input: {
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
padding: 0,
outlineStyle: "none",
} as object,
results: {
maxHeight: 420,
},
resultsContent: {
paddingVertical: theme.spacing[2],
},
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
minHeight: 56,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
rowActive: {
backgroundColor: theme.colors.surface1,
},
rowIconSlot: {
width: 18,
alignItems: "center",
justifyContent: "center",
},
rowText: {
flex: 1,
minWidth: 0,
gap: theme.spacing[1],
},
rowTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
rowSubtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
}));