From 83f540f10aaf23e17716947595aa574e90cca21f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 9 Apr 2026 09:45:51 +0700 Subject: [PATCH] fix(app): wrap daemon settings section in React Query with loading state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents the daemon section from flashing "not running" / "PID —" while IPC data loads. The whole card now shows a spinner until the first fetch completes, and mutations (restart/stop/start) optimistically update the query cache with the returned status. --- .../components/desktop-updates-section.tsx | 291 +++++++++--------- .../src/desktop/hooks/use-daemon-status.ts | 53 ++++ 2 files changed, 195 insertions(+), 149 deletions(-) create mode 100644 packages/app/src/desktop/hooks/use-daemon-status.ts diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index ef8be49ab..f196c7b19 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -1,7 +1,6 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { ActivityIndicator, Alert, Text, View } from "react-native"; import * as Clipboard from "expo-clipboard"; -import { useFocusEffect } from "@react-navigation/native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { settingsStyles } from "@/styles/settings"; import { @@ -21,15 +20,12 @@ import { openExternalUrl } from "@/utils/open-external-url"; import { isVersionMismatch } from "@/desktop/updates/desktop-updates"; import { getCliDaemonStatus, - getDesktopDaemonLogs, - getDesktopDaemonStatus, restartDesktopDaemon, shouldUseDesktopDaemon, startDesktopDaemon, stopDesktopDaemon, - type DesktopDaemonLogs, - type DesktopDaemonStatus, } from "@/desktop/daemon/desktop-daemon"; +import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status"; export interface LocalDaemonSectionProps { appVersion: string | null; @@ -40,44 +36,18 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD const { theme } = useUnistyles(); const showSection = shouldUseDesktopDaemon(); const { settings, updateSettings } = useAppSettings(); - const [daemonStatus, setDaemonStatus] = useState(null); - const [daemonVersion, setDaemonVersion] = useState(null); - const [statusError, setStatusError] = useState(null); + const { data, isLoading, error: statusError, setStatus, refetch } = useDaemonStatus(); const [isRestartingDaemon, setIsRestartingDaemon] = useState(false); const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false); const [statusMessage, setStatusMessage] = useState(null); - const [daemonLogs, setDaemonLogs] = useState(null); const [isLogsModalOpen, setIsLogsModalOpen] = useState(false); const [cliStatusOutput, setCliStatusOutput] = useState(null); const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false); const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false); - const loadDaemonData = useCallback(() => { - if (!showSection) { - return Promise.resolve(); - } - return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]) - .then(([status, logs]) => { - setDaemonStatus(status); - setDaemonLogs(logs); - setDaemonVersion(status.version); - setStatusError(null); - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error); - setStatusError(message); - }); - }, [showSection]); - - useFocusEffect( - useCallback(() => { - if (!showSection) { - return undefined; - } - void loadDaemonData(); - return undefined; - }, [loadDaemonData, showSection]), - ); + const daemonStatus = data?.status ?? null; + const daemonLogs = data?.logs ?? null; + const daemonVersion = daemonStatus?.version ?? null; const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion); const daemonStatusStateText = @@ -116,12 +86,12 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD daemonStatus?.status === "running" ? restartDesktopDaemon : startDesktopDaemon; void action() - .then((status) => { - setDaemonStatus(status); + .then((newStatus) => { + setStatus(newStatus); setStatusMessage( daemonStatus?.status === "running" ? "Daemon restarted." : "Daemon started.", ); - return loadDaemonData(); + refetch(); }) .catch((error) => { console.error("[Settings] Failed to change desktop daemon state", error); @@ -136,7 +106,7 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD console.error("[Settings] Failed to open desktop daemon action confirmation", error); Alert.alert("Error", "Unable to open the daemon confirmation dialog."); }); - }, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, loadDaemonData, showSection]); + }, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, refetch, setStatus, showSection]); const handleToggleDaemonManagement = useCallback(() => { if (isUpdatingDaemonManagement) { @@ -182,9 +152,14 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD : Promise.resolve(daemonStatus ?? null); void stopPromise - .then(() => updateSettings({ manageBuiltInDaemon: false })) - .then(() => loadDaemonData()) + .then((newStatus) => { + if (newStatus) { + setStatus(newStatus); + } + return updateSettings({ manageBuiltInDaemon: false }); + }) .then(() => { + refetch(); setStatusMessage("Built-in daemon paused and stopped."); }) .catch((error) => { @@ -202,7 +177,8 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD }, [ daemonStatus, isUpdatingDaemonManagement, - loadDaemonData, + refetch, + setStatus, settings.manageBuiltInDaemon, updateSettings, ]); @@ -277,126 +253,138 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD Advanced settings - - - - Status - Only the built-in desktop daemon is shown here. - - - {daemonStatusStateText} - {daemonStatusDetailText} - + {isLoading ? ( + + - {showLifecycleControls ? ( - <> + ) : ( + <> + + + + Status + + Only the built-in desktop daemon is shown here. + + + + {daemonStatusStateText} + {daemonStatusDetailText} + + + {showLifecycleControls ? ( + <> + + + Daemon management + + {isDaemonManagementPaused + ? "Paused. The built-in daemon stays stopped until you start it again." + : "Enabled. Paseo can manage the built-in daemon from the desktop app."} + + + + + + + {daemonActionLabel} + {daemonActionMessage} + {statusMessage ? {statusMessage} : null} + + + + + ) : null} - Daemon management + Log file - {isDaemonManagementPaused - ? "Paused. The built-in daemon stays stopped until you start it again." - : "Enabled. Paseo can manage the built-in daemon from the desktop app."} + {daemonLogs?.logPath ?? "Log path unavailable."} + + + + {daemonLogs?.logPath ? ( + + ) : null} + + + + + + Full status + + Runs `paseo daemon status` and shows the output. - - - {daemonActionLabel} - {daemonActionMessage} - {statusMessage ? {statusMessage} : null} - - - - - ) : null} - - - Log file - {daemonLogs?.logPath ?? "Log path unavailable."} - - {daemonLogs?.logPath ? ( - - ) : null} - - - - - - Full status - - Runs `paseo daemon status` and shows the output. - - - - - - {daemonVersionMismatch ? ( - - - App and daemon versions don't match. Update both to the same version for the best - experience. - - - ) : null} + {daemonVersionMismatch ? ( + + + App and daemon versions don't match. Update both to the same version for the best + experience. + + + ) : null} + + )} ({ flexWrap: "wrap", justifyContent: "flex-end", }, + loadingCard: { + alignItems: "center", + justifyContent: "center", + paddingVertical: theme.spacing[6], + }, statusValueGroup: { alignItems: "flex-end", gap: 2, diff --git a/packages/app/src/desktop/hooks/use-daemon-status.ts b/packages/app/src/desktop/hooks/use-daemon-status.ts new file mode 100644 index 000000000..5260ae43b --- /dev/null +++ b/packages/app/src/desktop/hooks/use-daemon-status.ts @@ -0,0 +1,53 @@ +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + getDesktopDaemonLogs, + getDesktopDaemonStatus, + shouldUseDesktopDaemon, + type DesktopDaemonLogs, + type DesktopDaemonStatus, +} from "@/desktop/daemon/desktop-daemon"; + +const DAEMON_STATUS_QUERY_KEY = ["desktopDaemonStatus"] as const; + +interface DaemonStatusData { + status: DesktopDaemonStatus; + logs: DesktopDaemonLogs; +} + +export function useDaemonStatus() { + const queryClient = useQueryClient(); + const enabled = shouldUseDesktopDaemon(); + + const query = useQuery({ + queryKey: DAEMON_STATUS_QUERY_KEY, + enabled, + staleTime: 30_000, + refetchOnMount: "always", + queryFn: async () => { + const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]); + return { status, logs }; + }, + }); + + const setStatus = useCallback( + (status: DesktopDaemonStatus) => { + queryClient.setQueryData(DAEMON_STATUS_QUERY_KEY, (prev) => + prev ? { ...prev, status } : undefined, + ); + }, + [queryClient], + ); + + const refetch = useCallback(() => { + void queryClient.invalidateQueries({ queryKey: DAEMON_STATUS_QUERY_KEY }); + }, [queryClient]); + + return { + data: query.data ?? null, + isLoading: query.isLoading, + error: query.error instanceof Error ? query.error.message : null, + setStatus, + refetch, + }; +}