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
This commit is contained in:
Mohamed Boudra
2026-06-26 12:16:49 +08:00
committed by GitHub
parent 1109e453bc
commit a49c658d1f
103 changed files with 6856 additions and 2857 deletions
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,7 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
import { CommunityLinks } from "@/components/community-links";
import { MenuHeader } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useHostChooser } from "@/hosts/host-chooser";
import { usePanelStore } from "@/stores/panel-store";
import {
useIsCompactFormFactor,
@@ -16,7 +17,7 @@ import {
HEADER_TOP_PADDING_MOBILE,
} from "@/constants/layout";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
import { buildHostAgentDetailRoute, buildSettingsHostSectionRoute } from "@/utils/host-routes";
import { ImportSessionSheet } from "@/components/import-session-sheet";
@@ -24,14 +25,16 @@ import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useOpenProject } from "@/hooks/use-open-project";
import type { Href } from "expo-router";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
export function OpenProjectScreen() {
const { t } = useTranslation();
const router = useRouter();
const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList);
const openProjectPicker = useOpenProjectPicker(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
const client = useHostRuntimeClient(serverId);
const openProject = useOpenProject(serverId);
const openProjectPicker = useOpenProjectPicker();
const chooseHost = useHostChooser();
const localServerId = useLocalDaemonServerId();
const [importServerId, setImportServerId] = useState<string | null>(null);
const importClient = useHostRuntimeClient(importServerId ?? "");
const openImportedProject = useOpenProject(importServerId);
const [isPairDeviceOpen, setIsPairDeviceOpen] = useState(false);
const [isImportSheetOpen, setIsImportSheetOpen] = useState(false);
@@ -50,22 +53,38 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
const handleOpenPairDevice = useCallback(() => setIsPairDeviceOpen(true), []);
const handleClosePairDevice = useCallback(() => setIsPairDeviceOpen(false), []);
const handleOpenImportSession = useCallback(() => setIsImportSheetOpen(true), []);
const handleOpenImportSession = useCallback(() => {
chooseHost({
title: "Import from host",
onChooseHost: (serverId) => {
setImportServerId(serverId);
setIsImportSheetOpen(true);
},
});
}, [chooseHost]);
const handleCloseImportSession = useCallback(() => setIsImportSheetOpen(false), []);
const handleImported = useCallback(
(agent: { id: string; cwd: string }) => {
if (!importServerId) return;
void (async () => {
await openProject(agent.cwd);
router.push(buildHostAgentDetailRoute(serverId, agent.id) as Href);
const result = await openImportedProject(agent.cwd);
if (result.ok) {
router.push(buildHostAgentDetailRoute(importServerId, agent.id) as Href);
}
})();
},
[openProject, router, serverId],
[importServerId, openImportedProject, router],
);
const handleOpenProviders = useCallback(() => {
router.push(buildSettingsHostSectionRoute(serverId, "providers"));
}, [router, serverId]);
chooseHost({
title: "Choose host",
onChooseHost: (serverId) => {
router.push(buildSettingsHostSectionRoute(serverId, "providers"));
},
});
}, [chooseHost, router]);
return (
<View style={styles.container}>
@@ -98,7 +117,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
onPress={handleOpenProviders}
testID="open-project-setup-providers"
/>
{isLocalDaemon ? (
{localServerId ? (
<HomeTile
icon={Smartphone}
title={t("openProject.tiles.pairDevice.title")}
@@ -119,8 +138,8 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
/>
<ImportSessionSheet
visible={isImportSheetOpen}
client={client}
serverId={serverId}
client={importClient}
serverId={importServerId}
onClose={handleCloseImportSession}
onImported={handleImported}
/>
@@ -1,12 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Pressable, Text, TextInput, View } from "react-native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet } from "react-native-unistyles";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Check, ChevronDown, MoreVertical, Pencil, Plus, X } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view";
import { HostPicker as SharedHostPicker, HostStatusDotSlot } from "@/components/hosts/host-picker";
import type {
PaseoConfigRaw,
PaseoConfigRevision,
@@ -216,7 +217,6 @@ function ProjectSettingsBody({
[project.projectKey, selectedHost.repoRoot, selectedHost.serverId],
);
const projectIconDataByKey = useProjectIconDataByProjectKey({
serverId: null,
projects: projectIconTargets,
});
const projectIconDataUri = projectIconDataByKey.get(project.projectKey) ?? null;
@@ -942,7 +942,7 @@ function HostContext({ hosts, selectedHost, onSelectHost }: HostContextProps) {
}
return (
<View testID="host-indicator" style={styles.hostIndicator}>
<HostStatusDot serverId={selectedHost.serverId} />
<HostStatusDotSlot serverId={selectedHost.serverId} />
<Text style={styles.hostName} numberOfLines={1}>
{selectedHost.serverName}
</Text>
@@ -950,18 +950,6 @@ function HostContext({ hosts, selectedHost, onSelectHost }: HostContextProps) {
);
}
function HostStatusDot({ serverId }: { serverId: string }) {
const { theme } = useUnistyles();
const snapshot = useHostRuntimeSnapshot(serverId);
const status = snapshot?.connectionStatus ?? "connecting";
let color: string;
if (status === "online") color = theme.colors.palette.green[400];
else if (status === "connecting") color = theme.colors.palette.amber[500];
else color = theme.colors.palette.red[500];
const dotStyle = useMemo(() => [styles.hostStatusDot, { backgroundColor: color }], [color]);
return <View style={dotStyle} />;
}
interface HostPickerProps {
hosts: ProjectHostEntry[];
selectedHost: ProjectHostEntry;
@@ -970,52 +958,42 @@ interface HostPickerProps {
function HostPicker({ hosts, selectedHost, onSelectHost }: HostPickerProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const triggerRef = useRef<View | null>(null);
const hostOptions = useMemo(
() => hosts.map((host) => ({ serverId: host.serverId, label: host.serverName })),
[hosts],
);
const handleOpen = useCallback(() => setOpen(true), []);
const hostOptionTestID = useCallback((serverId: string) => `host-picker-item-${serverId}`, []);
return (
<DropdownMenu>
<DropdownMenuTrigger
<SharedHostPicker
hosts={hostOptions}
value={selectedHost.serverId}
onSelect={onSelectHost}
open={open}
onOpenChange={setOpen}
anchorRef={triggerRef}
searchable={false}
title={t("settings.project.switchHost")}
desktopPlacement="bottom-start"
desktopMinWidth={240}
hostOptionTestID={hostOptionTestID}
>
<Pressable
ref={triggerRef}
accessibilityLabel={t("settings.project.switchHost")}
testID="host-picker"
style={styles.hostIndicator}
onPress={handleOpen}
>
<HostStatusDot serverId={selectedHost.serverId} />
<HostStatusDotSlot serverId={selectedHost.serverId} />
<Text style={styles.hostName} numberOfLines={1}>
{selectedHost.serverName}
</Text>
<ChevronDown size={ICON_SIZE} color={styles.chevronColor.color} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" minWidth={240}>
{hosts.map((host) => (
<HostPickerItem
key={host.serverId}
host={host}
isSelected={host.serverId === selectedHost.serverId}
onSelectHost={onSelectHost}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
interface HostPickerItemProps {
host: ProjectHostEntry;
isSelected: boolean;
onSelectHost: (serverId: string) => void;
}
function HostPickerItem({ host, isSelected, onSelectHost }: HostPickerItemProps) {
const handleSelect = useCallback(
() => onSelectHost(host.serverId),
[host.serverId, onSelectHost],
);
return (
<DropdownMenuItem
testID={`host-picker-item-${host.serverId}`}
selected={isSelected}
onSelect={handleSelect}
>
{host.serverName}
</DropdownMenuItem>
</Pressable>
</SharedHostPicker>
);
}
@@ -1353,11 +1331,6 @@ const styles = StyleSheet.create((theme) => ({
alignSelf: "flex-start",
minWidth: 0,
},
hostStatusDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
},
hostName: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
@@ -36,7 +36,6 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) {
[projects],
);
const iconDataByProjectKey = useProjectIconDataByProjectKey({
serverId: null,
projects: iconTargets,
});
+160 -27
View File
@@ -1,64 +1,146 @@
import { useMemo, useState, useCallback, useEffect } from "react";
import { View, Text } from "react-native";
import { useMemo, useState, useCallback, useEffect, useRef } from "react";
import { Pressable, type PressableStateCallbackType, View, Text } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronLeft } from "lucide-react-native";
import { ChevronDown, ChevronLeft, Server } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { MenuHeader } from "@/components/headers/menu-header";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { AgentList } from "@/components/agent-list";
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
import {
ALL_HOSTS_OPTION_ID,
getHostPickerLabel,
HostPicker,
} from "@/components/hosts/host-picker";
import { useAgentHistory } from "@/hooks/use-agent-history";
import { buildHostOpenProjectRoute } from "@/utils/host-routes";
import { useHosts } from "@/runtime/host-runtime";
import { type HostProfile } from "@/types/host-connection";
import { buildOpenProjectRoute } from "@/utils/host-routes";
export function SessionsScreen({ serverId }: { serverId: string }) {
export function SessionsScreen() {
const isFocused = useIsFocused();
if (!isFocused) {
return <View style={styles.container} />;
}
return <SessionsScreenContent serverId={serverId} />;
return <SessionsScreenContent />;
}
function SessionsScreenContent({ serverId }: { serverId: string }) {
function SessionsHostFilter({
hosts,
selectedHost,
onSelectHost,
}: {
hosts: HostProfile[];
selectedHost: string;
onSelectHost: (serverId: string) => void;
}) {
const { theme } = useUnistyles();
const [isFilterOpen, setIsFilterOpen] = useState(false);
const filterAnchorRef = useRef<View>(null);
const selectedHostLabel = useMemo(
() => getHostPickerLabel(hosts, selectedHost, { includeAllHost: true }),
[hosts, selectedHost],
);
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
const filterTriggerStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.filterTrigger,
Boolean(hovered) && styles.filterTriggerHovered,
pressed && styles.filterTriggerPressed,
],
[],
);
return (
<HostPicker
hosts={hosts}
value={selectedHost}
onSelect={onSelectHost}
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
includeAllHost
searchable={false}
title="Filter by host"
desktopPlacement="bottom-start"
>
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
onPress={handleFilterOpen}
style={filterTriggerStyle}
testID="sessions-host-filter-trigger"
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedHostLabel}`}
>
{selectedHost === ALL_HOSTS_OPTION_ID ? (
<Server size={14} color={theme.colors.foregroundMuted} />
) : (
<HostStatusDotSlot serverId={selectedHost} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedHostLabel}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
</HostPicker>
);
}
function SessionsScreenContent() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const { agents, hasMore, isInitialLoad, isLoadingMore, isRevalidating, loadMore, refreshAll } =
const hosts = useHosts();
const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID);
const historyServerId = selectedHost === ALL_HOSTS_OPTION_ID ? null : selectedHost;
const { agents, hasMore, isInitialLoad, isLoadingMore, isError, loadMore, refreshAll } =
useAgentHistory({
serverId,
serverId: historyServerId,
});
// Track user-initiated refresh to avoid showing spinner on background revalidation
useEffect(() => {
if (
selectedHost !== ALL_HOSTS_OPTION_ID &&
!hosts.some((host) => host.serverId === selectedHost)
) {
setSelectedHost(ALL_HOSTS_OPTION_ID);
}
}, [hosts, selectedHost]);
const [isManualRefresh, setIsManualRefresh] = useState(false);
const handleRefresh = useCallback(() => {
setIsManualRefresh(true);
refreshAll();
void refreshAll().finally(() => setIsManualRefresh(false));
}, [refreshAll]);
// Reset manual refresh flag when revalidation completes
useEffect(() => {
if (!isRevalidating && isManualRefresh) {
setIsManualRefresh(false);
}
}, [isRevalidating, isManualRefresh]);
const sortedAgents = useMemo(() => {
return [...agents].sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
}, [agents]);
const emptyText =
selectedHost === ALL_HOSTS_OPTION_ID ? t("sessions.empty") : "No sessions for this host";
const showHostFilter = hosts.length > 1;
const showLoadError = isError && sortedAgents.length === 0;
const handleBack = useCallback(() => {
router.navigate(buildHostOpenProjectRoute(serverId));
}, [serverId]);
router.navigate(buildOpenProjectRoute());
}, []);
const listFooterComponent = useMemo(
() =>
hasMore ? (
<View style={styles.footer}>
<Button variant="ghost" onPress={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? t("common.loading") : t("sessions.actions.loadMore")}
{isLoadingMore ? "Loading..." : t("sessions.actions.loadMore")}
</Button>
</View>
) : null,
@@ -68,27 +150,45 @@ function SessionsScreenContent({ serverId }: { serverId: string }) {
return (
<View style={styles.container}>
<MenuHeader title={t("sessions.title")} />
{showHostFilter ? (
<View style={styles.filterContainer}>
<SessionsHostFilter
hosts={hosts}
selectedHost={selectedHost}
onSelectHost={setSelectedHost}
/>
</View>
) : null}
{isInitialLoad ? (
<View style={styles.loadingContainer}>
<LoadingSpinner size="large" color={theme.colors.foregroundMuted} />
</View>
) : null}
{!isInitialLoad && sortedAgents.length === 0 ? (
{!isInitialLoad && showLoadError ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>{t("sessions.empty")}</Text>
<Button variant="ghost" leftIcon={ChevronLeft} onPress={handleBack}>
{t("common.actions.back")}
<Text style={styles.emptyText}>Unable to load sessions</Text>
<Button variant="ghost" onPress={handleRefresh}>
Try again
</Button>
</View>
) : null}
{!isInitialLoad && sortedAgents.length > 0 ? (
{!isInitialLoad && !showLoadError && sortedAgents.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>{emptyText}</Text>
<Button variant="ghost" leftIcon={ChevronLeft} onPress={handleBack}>
Back
</Button>
</View>
) : null}
{!isInitialLoad && !showLoadError && sortedAgents.length > 0 ? (
<AgentList
agents={sortedAgents}
showCheckoutInfo={false}
isRefreshing={isManualRefresh && isRevalidating}
isRefreshing={isManualRefresh}
onRefresh={handleRefresh}
listFooterComponent={listFooterComponent}
showAttentionIndicator={false}
showHostColumn
/>
) : null}
</View>
@@ -100,6 +200,39 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
backgroundColor: theme.colors.surface0,
},
filterContainer: {
paddingHorizontal: {
xs: theme.spacing[3],
md: theme.spacing[6],
},
paddingTop: theme.spacing[4],
},
filterTriggerWrap: {
alignSelf: "flex-start",
},
filterTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
alignSelf: "flex-start",
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
filterTriggerHovered: {
backgroundColor: theme.colors.surface2,
},
filterTriggerPressed: {
backgroundColor: theme.colors.surface3,
},
filterTriggerText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
emptyContainer: {
flex: 1,
justifyContent: "center",
+46 -211
View File
@@ -1,13 +1,5 @@
import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import type { ComponentType, ReactElement, ReactNode } from "react";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ComponentType, ReactNode } from "react";
import {
Alert,
Pressable,
@@ -26,7 +18,6 @@ import type { TFunction } from "i18next";
import { Buffer } from "buffer";
import {
ArrowLeft,
Monitor,
Settings,
Palette,
Server,
@@ -48,6 +39,7 @@ import { AppDiagnosticSheet } from "@/components/app-diagnostic-sheet";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
import { HostPicker as SharedHostPicker, HostStatusDotSlot } from "@/components/hosts/host-picker";
import { ScreenTitle } from "@/components/headers/screen-title";
import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
import { SettingsSection } from "@/screens/settings/settings-section";
@@ -61,14 +53,9 @@ import {
type ServiceUrlBehavior,
type Settings as EffectiveSettings,
} from "@/hooks/use-settings";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHostRuntimeIsConnected,
useHosts,
} from "@/runtime/host-runtime";
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import type { HostProfile } from "@/types/host-connection";
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { confirmDialog } from "@/utils/confirm-dialog";
@@ -82,7 +69,6 @@ import { Button } from "@/components/ui/button";
import { CommunityLinks } from "@/components/community-links";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { IntegrationsSection } from "@/desktop/components/integrations-section";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
@@ -115,7 +101,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import {
buildHostOpenProjectRoute,
buildOpenProjectRoute,
buildProjectsSettingsRoute,
buildSettingsHostSectionRoute,
buildSettingsSectionRoute,
@@ -774,48 +760,11 @@ function DesktopAppUpdateRow() {
// Sidebar
// ---------------------------------------------------------------------------
function useAnyOnlineHostServerId(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId;
},
() => null,
);
}
/**
* Local daemon first, then remaining hosts in their existing order. Lets the
* picker and the active-host resolver agree on a stable "first" host.
* Local daemon first, then remaining hosts in their existing order.
*/
function useSortedHosts(hosts: HostProfile[], localServerId: string | null): HostProfile[] {
return useMemo(() => {
if (!localServerId) {
return hosts;
}
const localIndex = hosts.findIndex((host) => host.serverId === localServerId);
if (localIndex <= 0) {
return hosts;
}
const next = hosts.slice();
const [local] = next.splice(localIndex, 1);
next.unshift(local);
return next;
}, [hosts, localServerId]);
return useMemo(() => orderHostsLocalFirst(hosts, localServerId), [hosts, localServerId]);
}
interface SidebarSectionButtonProps {
@@ -935,78 +884,9 @@ function SidebarProjectsButton({ isSelected, onSelect }: SidebarProjectsButtonPr
);
}
// Sentinel option id for the "Add host" row appended to the picker list.
const ADD_HOST_OPTION_ID = "__add_host__";
interface HostPickerOptionProps {
serverId: string;
label: string;
isLocal: boolean;
selected: boolean;
active: boolean;
onPress: () => void;
}
function HostPickerOption({
serverId,
label,
isLocal,
selected,
active,
onPress,
}: HostPickerOptionProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const leadingSlot = useMemo(
() => <Server size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[theme.iconSize.sm, theme.colors.foregroundMuted],
);
// The local host carries a "Local" marker; the active host is conveyed by the
// row's selected check, so both can coexist on one row.
const trailingSlot = useMemo(
() =>
isLocal ? (
<Text style={sidebarStyles.localMarker} testID="settings-host-local-marker">
{t("settings.hostPicker.local")}
</Text>
) : undefined,
[isLocal, t],
);
return (
<ComboboxItem
label={label}
leadingSlot={leadingSlot}
trailingSlot={trailingSlot}
selected={selected}
active={active}
onPress={onPress}
testID={`settings-host-picker-item-${serverId}`}
/>
);
}
function AddHostOption({ active, onPress }: { active: boolean; onPress: () => void }) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const leadingSlot = useMemo(
() => <Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[theme.iconSize.sm, theme.colors.foregroundMuted],
);
return (
<ComboboxItem
label={t("settings.addHost")}
leadingSlot={leadingSlot}
active={active}
onPress={onPress}
testID="settings-add-host"
/>
);
}
interface HostPickerProps {
activeServerId: string | null;
sortedHosts: HostProfile[];
localServerId: string | null;
onSelectHost: (serverId: string) => void;
onAddHost: () => void;
}
@@ -1017,66 +897,19 @@ interface HostPickerProps {
* <Combobox>. The local host is listed first and tagged "Local"; an "Add host"
* row is always reachable from the list — even with a single host.
*/
function HostPicker({
activeServerId,
sortedHosts,
localServerId,
onSelectHost,
onAddHost,
}: HostPickerProps) {
const { theme } = useUnistyles();
function HostPicker({ activeServerId, sortedHosts, onSelectHost, onAddHost }: HostPickerProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef<View | null>(null);
const activeHost =
sortedHosts.find((host) => host.serverId === activeServerId) ?? sortedHosts[0] ?? null;
const options = useMemo<ComboboxOption[]>(() => {
const hostOptions = sortedHosts.map((host) => ({ id: host.serverId, label: host.label }));
return [...hostOptions, { id: ADD_HOST_OPTION_ID, label: t("settings.addHost") }];
}, [sortedHosts, t]);
const handleSelect = useCallback(
(id: string) => {
if (id === ADD_HOST_OPTION_ID) {
onAddHost();
return;
}
onSelectHost(id);
},
[onAddHost, onSelectHost],
);
const renderOption = useCallback(
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}): ReactElement => {
if (option.id === ADD_HOST_OPTION_ID) {
return <AddHostOption active={active} onPress={onPress} />;
}
return (
<HostPickerOption
serverId={option.id}
label={option.label}
isLocal={localServerId !== null && option.id === localServerId}
selected={selected}
active={active}
onPress={onPress}
/>
);
},
[localServerId],
);
const handleOpen = useCallback(() => setIsOpen(true), []);
const hostLocalMarkerTestID = useCallback(() => "settings-host-local-marker", []);
const hostOptionTestID = useCallback(
(serverId: string) => `settings-host-picker-item-${serverId}`,
[],
);
const triggerStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
sidebarStyles.pickerTrigger,
@@ -1086,7 +919,23 @@ function HostPicker({
);
return (
<>
<SharedHostPicker
hosts={sortedHosts}
value={activeServerId ?? ""}
onSelect={onSelectHost}
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={triggerRef}
includeAddHost
onAddHost={onAddHost}
showLocalMarker
searchable={false}
title={t("settings.hostPicker.switchHost")}
desktopMinWidth={240}
addHostTestID="settings-add-host"
hostLocalMarkerTestID={hostLocalMarkerTestID}
hostOptionTestID={hostOptionTestID}
>
<ComboboxTrigger
ref={triggerRef}
style={triggerStyle}
@@ -1095,24 +944,12 @@ function HostPicker({
accessibilityLabel={t("settings.hostPicker.switchHost")}
testID="settings-host-picker"
>
<Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
{activeHost ? <HostStatusDotSlot serverId={activeHost.serverId} /> : null}
<Text style={sidebarStyles.pickerTriggerLabel} numberOfLines={1}>
{activeHost?.label ?? t("settings.groups.host")}
</Text>
</ComboboxTrigger>
<Combobox
options={options}
value={activeServerId ?? ""}
onSelect={handleSelect}
renderOption={renderOption}
searchable={false}
title={t("settings.hostPicker.switchHost")}
desktopMinWidth={240}
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={triggerRef}
/>
</>
</SharedHostPicker>
);
}
@@ -1189,7 +1026,6 @@ function SettingsSidebar({
<HostPicker
activeServerId={activeHostServerId}
sortedHosts={sortedHosts}
localServerId={localServerId}
onSelectHost={onSelectHost}
onAddHost={onAddHost}
/>
@@ -1254,9 +1090,10 @@ function SettingsSidebar({
export interface SettingsScreenProps {
view: SettingsView;
openAddHostIntent?: string | null;
}
export default function SettingsScreen({ view }: SettingsScreenProps) {
export default function SettingsScreen({ view, openAddHostIntent = null }: SettingsScreenProps) {
const router = useRouter();
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -1267,6 +1104,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
const [isPlaybackTestRunning, setIsPlaybackTestRunning] = useState(false);
const [playbackTestResult, setPlaybackTestResult] = useState<string | null>(null);
const lastOpenedAddHostIntentRef = useRef<string | null>(null);
const isDesktopApp = isElectronRuntime();
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
@@ -1281,8 +1119,6 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const sortedHosts = useSortedHosts(hosts, localServerId);
const hostServerIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const anyOnlineServerId = useAnyOnlineHostServerId(hostServerIds);
const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState<string | null>(
view.kind === "host" ? view.serverId : null,
);
@@ -1381,6 +1217,14 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
setIsAddHostMethodVisible(true);
}, []);
useEffect(() => {
if (!openAddHostIntent || lastOpenedAddHostIntentRef.current === openAddHostIntent) {
return;
}
lastOpenedAddHostIntentRef.current = openAddHostIntent;
handleAddHost();
}, [handleAddHost, openAddHostIntent]);
const handleSelectDirectConnection = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsDirectHostVisible(true);
@@ -1488,12 +1332,8 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
if (navigateToLastWorkspace()) {
return;
}
if (anyOnlineServerId) {
router.replace(buildHostOpenProjectRoute(anyOnlineServerId));
return;
}
router.replace("/");
}, [anyOnlineServerId, router]);
router.replace(buildOpenProjectRoute());
}, [router]);
const detailHeader = ((): {
title: string;
@@ -1849,11 +1689,6 @@ const sidebarStyles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal,
flex: 1,
},
localMarker: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
marginLeft: theme.spacing[1],
},
pickerTrigger: {
flexDirection: "row",
alignItems: "center",