fix(app): wrap daemon settings section in React Query with loading state

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.
This commit is contained in:
Mohamed Boudra
2026-04-09 18:56:02 +07:00
parent 0f0bfb551c
commit ce77947654
2 changed files with 195 additions and 149 deletions
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useState } from "react";
import { ActivityIndicator, Alert, Text, View } from "react-native"; import { ActivityIndicator, Alert, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
import { import {
@@ -21,15 +20,12 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { isVersionMismatch } from "@/desktop/updates/desktop-updates"; import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import { import {
getCliDaemonStatus, getCliDaemonStatus,
getDesktopDaemonLogs,
getDesktopDaemonStatus,
restartDesktopDaemon, restartDesktopDaemon,
shouldUseDesktopDaemon, shouldUseDesktopDaemon,
startDesktopDaemon, startDesktopDaemon,
stopDesktopDaemon, stopDesktopDaemon,
type DesktopDaemonLogs,
type DesktopDaemonStatus,
} from "@/desktop/daemon/desktop-daemon"; } from "@/desktop/daemon/desktop-daemon";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
export interface LocalDaemonSectionProps { export interface LocalDaemonSectionProps {
appVersion: string | null; appVersion: string | null;
@@ -40,44 +36,18 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon(); const showSection = shouldUseDesktopDaemon();
const { settings, updateSettings } = useAppSettings(); const { settings, updateSettings } = useAppSettings();
const [daemonStatus, setDaemonStatus] = useState<DesktopDaemonStatus | null>(null); const { data, isLoading, error: statusError, setStatus, refetch } = useDaemonStatus();
const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
const [statusError, setStatusError] = useState<string | null>(null);
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false); const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false); const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
const [statusMessage, setStatusMessage] = useState<string | null>(null); const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false); const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null); const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false); const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false); const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
const loadDaemonData = useCallback(() => { const daemonStatus = data?.status ?? null;
if (!showSection) { const daemonLogs = data?.logs ?? null;
return Promise.resolve(); const daemonVersion = daemonStatus?.version ?? null;
}
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 daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion); const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
const daemonStatusStateText = const daemonStatusStateText =
@@ -116,12 +86,12 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
daemonStatus?.status === "running" ? restartDesktopDaemon : startDesktopDaemon; daemonStatus?.status === "running" ? restartDesktopDaemon : startDesktopDaemon;
void action() void action()
.then((status) => { .then((newStatus) => {
setDaemonStatus(status); setStatus(newStatus);
setStatusMessage( setStatusMessage(
daemonStatus?.status === "running" ? "Daemon restarted." : "Daemon started.", daemonStatus?.status === "running" ? "Daemon restarted." : "Daemon started.",
); );
return loadDaemonData(); refetch();
}) })
.catch((error) => { .catch((error) => {
console.error("[Settings] Failed to change desktop daemon state", 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); console.error("[Settings] Failed to open desktop daemon action confirmation", error);
Alert.alert("Error", "Unable to open the daemon confirmation dialog."); 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(() => { const handleToggleDaemonManagement = useCallback(() => {
if (isUpdatingDaemonManagement) { if (isUpdatingDaemonManagement) {
@@ -182,9 +152,14 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
: Promise.resolve(daemonStatus ?? null); : Promise.resolve(daemonStatus ?? null);
void stopPromise void stopPromise
.then(() => updateSettings({ manageBuiltInDaemon: false })) .then((newStatus) => {
.then(() => loadDaemonData()) if (newStatus) {
setStatus(newStatus);
}
return updateSettings({ manageBuiltInDaemon: false });
})
.then(() => { .then(() => {
refetch();
setStatusMessage("Built-in daemon paused and stopped."); setStatusMessage("Built-in daemon paused and stopped.");
}) })
.catch((error) => { .catch((error) => {
@@ -202,7 +177,8 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
}, [ }, [
daemonStatus, daemonStatus,
isUpdatingDaemonManagement, isUpdatingDaemonManagement,
loadDaemonData, refetch,
setStatus,
settings.manageBuiltInDaemon, settings.manageBuiltInDaemon,
updateSettings, updateSettings,
]); ]);
@@ -277,11 +253,19 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
Advanced settings Advanced settings
</Button> </Button>
</View> </View>
{isLoading ? (
<View style={[settingsStyles.card, styles.loadingCard]}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
) : (
<>
<View style={settingsStyles.card}> <View style={settingsStyles.card}>
<View style={settingsStyles.row}> <View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}> <View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Status</Text> <Text style={settingsStyles.rowTitle}>Status</Text>
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here.</Text> <Text style={settingsStyles.rowHint}>
Only the built-in desktop daemon is shown here.
</Text>
</View> </View>
<View style={styles.statusValueGroup}> <View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text> <Text style={styles.valueText}>{daemonStatusStateText}</Text>
@@ -346,7 +330,9 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
<View style={[settingsStyles.row, settingsStyles.rowBorder]}> <View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}> <View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Log file</Text> <Text style={settingsStyles.rowTitle}>Log file</Text>
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text> <Text style={settingsStyles.rowHint}>
{daemonLogs?.logPath ?? "Log path unavailable."}
</Text>
</View> </View>
<View style={styles.actionGroup}> <View style={styles.actionGroup}>
{daemonLogs?.logPath ? ( {daemonLogs?.logPath ? (
@@ -397,6 +383,8 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</Text> </Text>
</View> </View>
) : null} ) : null}
</>
)}
<AdaptiveModalSheet <AdaptiveModalSheet
visible={isLogsModalOpen} visible={isLogsModalOpen}
@@ -447,6 +435,11 @@ const styles = StyleSheet.create((theme) => ({
flexWrap: "wrap", flexWrap: "wrap",
justifyContent: "flex-end", justifyContent: "flex-end",
}, },
loadingCard: {
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[6],
},
statusValueGroup: { statusValueGroup: {
alignItems: "flex-end", alignItems: "flex-end",
gap: 2, gap: 2,
@@ -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<DaemonStatusData>({
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<DaemonStatusData>(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,
};
}