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,126 +253,138 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
Advanced settings Advanced settings
</Button> </Button>
</View> </View>
<View style={settingsStyles.card}> {isLoading ? (
<View style={settingsStyles.row}> <View style={[settingsStyles.card, styles.loadingCard]}>
<View style={settingsStyles.rowContent}> <ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={settingsStyles.rowTitle}>Status</Text>
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here.</Text>
</View>
<View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
</View>
</View> </View>
{showLifecycleControls ? ( ) : (
<> <>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Status</Text>
<Text style={settingsStyles.rowHint}>
Only the built-in desktop daemon is shown here.
</Text>
</View>
<View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
</View>
</View>
{showLifecycleControls ? (
<>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
<Text style={settingsStyles.rowHint}>
{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."}
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={
isDaemonManagementPaused ? (
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
) : (
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
)
}
onPress={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement}
>
{isUpdatingDaemonManagement
? isDaemonManagementPaused
? "Resuming..."
: "Pausing..."
: isDaemonManagementPaused
? "Resume"
: "Pause"}
</Button>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
</View>
<Button
variant="outline"
size="sm"
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleUpdateLocalDaemon}
disabled={isRestartingDaemon}
>
{isRestartingDaemon
? daemonStatus?.status === "running"
? "Restarting..."
: "Starting..."
: daemonActionLabel}
</Button>
</View>
</>
) : null}
<View style={[settingsStyles.row, settingsStyles.rowBorder]}> <View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}> <View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Daemon management</Text> <Text style={settingsStyles.rowTitle}>Log file</Text>
<Text style={settingsStyles.rowHint}> <Text style={settingsStyles.rowHint}>
{isDaemonManagementPaused {daemonLogs?.logPath ?? "Log path unavailable."}
? "Paused. The built-in daemon stays stopped until you start it again." </Text>
: "Enabled. Paseo can manage the built-in daemon from the desktop app."} </View>
<View style={styles.actionGroup}>
{daemonLogs?.logPath ? (
<Button
variant="outline"
size="sm"
leftIcon={<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleCopyLogPath}
>
Copy path
</Button>
) : null}
<Button
variant="outline"
size="sm"
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleOpenLogs}
disabled={!daemonLogs}
>
Open logs
</Button>
</View>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Full status</Text>
<Text style={settingsStyles.rowHint}>
Runs `paseo daemon status` and shows the output.
</Text> </Text>
</View> </View>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
leftIcon={ leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />}
isDaemonManagementPaused ? ( onPress={() => void handleOpenCliStatus()}
<Play size={theme.iconSize.sm} color={theme.colors.foreground} /> disabled={isLoadingCliStatus}
) : (
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
)
}
onPress={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement}
> >
{isUpdatingDaemonManagement {isLoadingCliStatus ? "Loading..." : "View status"}
? isDaemonManagementPaused
? "Resuming..."
: "Pausing..."
: isDaemonManagementPaused
? "Resume"
: "Pause"}
</Button> </Button>
</View> </View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
</View>
<Button
variant="outline"
size="sm"
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleUpdateLocalDaemon}
disabled={isRestartingDaemon}
>
{isRestartingDaemon
? daemonStatus?.status === "running"
? "Restarting..."
: "Starting..."
: daemonActionLabel}
</Button>
</View>
</>
) : null}
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Log file</Text>
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
</View> </View>
<View style={styles.actionGroup}>
{daemonLogs?.logPath ? (
<Button
variant="outline"
size="sm"
leftIcon={<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleCopyLogPath}
>
Copy path
</Button>
) : null}
<Button
variant="outline"
size="sm"
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleOpenLogs}
disabled={!daemonLogs}
>
Open logs
</Button>
</View>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Full status</Text>
<Text style={settingsStyles.rowHint}>
Runs `paseo daemon status` and shows the output.
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={() => void handleOpenCliStatus()}
disabled={isLoadingCliStatus}
>
{isLoadingCliStatus ? "Loading..." : "View status"}
</Button>
</View>
</View>
{daemonVersionMismatch ? ( {daemonVersionMismatch ? (
<View style={styles.warningCard}> <View style={styles.warningCard}>
<Text style={styles.warningText}> <Text style={styles.warningText}>
App and daemon versions don't match. Update both to the same version for the best App and daemon versions don't match. Update both to the same version for the best
experience. experience.
</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,
};
}