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:
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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user