Files
paseo/packages/app/src/desktop/components/desktop-updates-section.tsx
T
Mohamed Boudra 5b47b6594f refactor: replace settings buttons with switches and simplify provider sheet
Swap daemon management and keep-running-after-quit buttons for Switch
toggles. Remove the restart/start daemon control. Simplify the provider
diagnostic sheet to reuse shared settings styles and SettingsSection.
Drop the unused "sm" size variant from the Switch component. Remove
trailing periods from hint text for consistency.
2026-04-26 08:15:28 +07:00

604 lines
19 KiB
TypeScript

import React, { type ReactElement, useCallback, useMemo, useState } from "react";
import { ActivityIndicator, Alert, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section";
import { ArrowUpRight, Copy, FileText, Activity } from "lucide-react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { confirmDialog } from "@/utils/confirm-dialog";
import { openExternalUrl } from "@/utils/open-external-url";
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import {
getCliDaemonStatus,
shouldUseDesktopDaemon,
stopDesktopDaemon,
} from "@/desktop/daemon/desktop-daemon";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings";
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
import { resolveAppVersion } from "@/utils/app-version";
type DesktopDaemonSettings = DesktopSettings["daemon"];
function useDaemonManagementToggle(args: {
daemonStatus: DesktopDaemonStatus | null;
settings: DesktopDaemonSettings;
updateSettings: (next: Partial<DesktopDaemonSettings>) => Promise<unknown>;
setStatus: (status: DesktopDaemonStatus) => void;
refetch: () => void;
}) {
const { daemonStatus, settings, updateSettings, setStatus, refetch } = args;
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
const handleToggleDaemonManagement = useCallback(() => {
if (isUpdatingDaemonManagement) {
return;
}
if (!settings.manageBuiltInDaemon) {
setIsUpdatingDaemonManagement(true);
void updateSettings({ manageBuiltInDaemon: true })
.catch((error) => {
console.error("[Settings] Failed to update built-in daemon management", error);
Alert.alert("Error", "Unable to update built-in daemon management.");
})
.finally(() => {
setIsUpdatingDaemonManagement(false);
});
return;
}
void confirmDialog({
title: "Pause built-in daemon",
message:
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.",
confirmLabel: "Pause and stop",
cancelLabel: "Cancel",
destructive: true,
})
.then((confirmed) => {
if (!confirmed) {
return;
}
setIsUpdatingDaemonManagement(true);
const stopPromise =
daemonStatus?.status === "running"
? stopDesktopDaemon()
: Promise.resolve(daemonStatus ?? null);
void stopPromise
.then((newStatus) => {
if (newStatus) {
setStatus(newStatus);
}
return updateSettings({ manageBuiltInDaemon: false });
})
.then(() => {
refetch();
return;
})
.catch((error) => {
console.error("[Settings] Failed to pause built-in daemon management", error);
Alert.alert("Error", "Unable to pause built-in daemon management.");
})
.finally(() => {
setIsUpdatingDaemonManagement(false);
});
return;
})
.catch((error) => {
console.error("[Settings] Failed to open built-in daemon pause confirmation", error);
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
});
}, [
daemonStatus,
isUpdatingDaemonManagement,
refetch,
setStatus,
settings.manageBuiltInDaemon,
updateSettings,
]);
return { isUpdatingDaemonManagement, handleToggleDaemonManagement };
}
function useKeepRunningAfterQuitToggle(args: {
settings: DesktopDaemonSettings;
updateSettings: (next: Partial<DesktopDaemonSettings>) => Promise<unknown>;
}) {
const { settings, updateSettings } = args;
const [isUpdatingKeepRunningAfterQuit, setIsUpdatingKeepRunningAfterQuit] = useState(false);
const handleToggleKeepRunningAfterQuit = useCallback(() => {
setIsUpdatingKeepRunningAfterQuit(true);
void updateSettings({ keepRunningAfterQuit: !settings.keepRunningAfterQuit })
.catch((error) => {
console.error("[Settings] Failed to update desktop quit daemon behavior", error);
Alert.alert("Error", "Unable to update the desktop quit daemon behavior.");
})
.finally(() => {
setIsUpdatingKeepRunningAfterQuit(false);
});
}, [settings.keepRunningAfterQuit, updateSettings]);
return { isUpdatingKeepRunningAfterQuit, handleToggleKeepRunningAfterQuit };
}
function useDaemonCliStatusModal() {
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
const handleOpenCliStatus = useCallback(async () => {
setIsLoadingCliStatus(true);
try {
setCliStatusOutput(await getCliDaemonStatus());
setIsCliStatusModalOpen(true);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setCliStatusOutput(`Failed to fetch daemon status: ${message}`);
setIsCliStatusModalOpen(true);
} finally {
setIsLoadingCliStatus(false);
}
}, []);
const handleCopyCliStatus = useCallback(() => {
if (!cliStatusOutput) {
return;
}
void Clipboard.setStringAsync(cliStatusOutput)
.then(() => {
Alert.alert("Copied", "Status copied to clipboard.");
return;
})
.catch((error) => {
console.error("[Settings] Failed to copy daemon status", error);
});
}, [cliStatusOutput]);
const handleCloseCliStatusModal = useCallback(() => setIsCliStatusModalOpen(false), []);
return {
cliStatusOutput,
isCliStatusModalOpen,
isLoadingCliStatus,
handleCopyCliStatus,
handleOpenCliStatus,
handleCloseCliStatusModal,
};
}
function useDaemonLogsModal(daemonLogs: { logPath?: string } | null) {
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const handleCopyLogPath = useCallback(() => {
const logPath = daemonLogs?.logPath;
if (!logPath) {
return;
}
void Clipboard.setStringAsync(logPath)
.then(() => {
Alert.alert("Copied", "Log path copied.");
return;
})
.catch((error) => {
console.error("[Settings] Failed to copy log path", error);
Alert.alert("Error", "Unable to copy log path.");
});
}, [daemonLogs?.logPath]);
const handleOpenLogs = useCallback(() => {
if (!daemonLogs) {
return;
}
setIsLogsModalOpen(true);
}, [daemonLogs]);
const handleCloseLogsModal = useCallback(() => setIsLogsModalOpen(false), []);
return { isLogsModalOpen, handleCopyLogPath, handleOpenLogs, handleCloseLogsModal };
}
interface DaemonLogsModalProps {
visible: boolean;
onClose: () => void;
daemonLogs: { logPath?: string; contents?: string } | null;
}
function DaemonLogsModal({ visible, onClose, daemonLogs }: DaemonLogsModalProps) {
return (
<AdaptiveModalSheet
visible={visible}
onClose={onClose}
title="Daemon logs"
testID="managed-daemon-logs-dialog"
snapPoints={LOGS_MODAL_SNAP_POINTS}
>
<View style={styles.modalBody}>
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable"}</Text>
<Text style={styles.logOutput} selectable>
{daemonLogs?.contents?.length ? daemonLogs.contents : "(log file is empty)"}
</Text>
</View>
</AdaptiveModalSheet>
);
}
interface DaemonCliStatusModalProps {
visible: boolean;
onClose: () => void;
cliStatusOutput: string | null;
onCopy: () => void;
}
function DaemonCliStatusModal({
visible,
onClose,
cliStatusOutput,
onCopy,
}: DaemonCliStatusModalProps) {
return (
<AdaptiveModalSheet
visible={visible}
onClose={onClose}
title="Daemon status"
testID="daemon-cli-status-dialog"
snapPoints={CLI_STATUS_MODAL_SNAP_POINTS}
>
<View style={styles.modalBody}>
<Text style={styles.logOutput} selectable>
{cliStatusOutput ?? ""}
</Text>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={onClose}>
Close
</Button>
<Button size="sm" onPress={onCopy}>
Copy
</Button>
</View>
</View>
</AdaptiveModalSheet>
);
}
interface DaemonInfoCardProps {
daemonStatusStateText: string;
daemonStatusDetailText: string;
isDaemonManagementPaused: boolean;
copyIcon: ReactElement;
fileTextIcon: ReactElement;
activityIcon: ReactElement;
handleToggleDaemonManagement: () => void;
isUpdatingDaemonManagement: boolean;
keepRunningAfterQuit: boolean;
handleToggleKeepRunningAfterQuit: () => void;
isUpdatingKeepRunningAfterQuit: boolean;
daemonLogs: { logPath?: string } | null;
handleCopyLogPath: () => void;
handleOpenLogs: () => void;
handleRunCliStatus: () => void;
isLoadingCliStatus: boolean;
}
function DaemonInfoCard(props: DaemonInfoCardProps) {
const {
daemonStatusStateText,
daemonStatusDetailText,
isDaemonManagementPaused,
copyIcon,
fileTextIcon,
activityIcon,
handleToggleDaemonManagement,
isUpdatingDaemonManagement,
keepRunningAfterQuit,
handleToggleKeepRunningAfterQuit,
isUpdatingKeepRunningAfterQuit,
daemonLogs,
handleCopyLogPath,
handleOpenLogs,
handleRunCliStatus,
isLoadingCliStatus,
} = props;
return (
<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>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Manage built-in daemon</Text>
<Text style={settingsStyles.rowHint}>Let Paseo start and stop the built-in daemon</Text>
</View>
<Switch
value={!isDaemonManagementPaused}
onValueChange={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement}
accessibilityLabel="Manage built-in daemon"
/>
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Keep daemon running after quit</Text>
<Text style={settingsStyles.rowHint}>Daemon keeps running when you quit Paseo</Text>
</View>
<Switch
value={keepRunningAfterQuit}
onValueChange={handleToggleKeepRunningAfterQuit}
disabled={isUpdatingKeepRunningAfterQuit}
accessibilityLabel="Keep daemon running after quit"
/>
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Log file</Text>
<Text style={settingsStyles.rowHint}>
{daemonLogs?.logPath ?? "Log path unavailable"}
</Text>
</View>
<View style={styles.actionGroup}>
{daemonLogs?.logPath ? (
<Button variant="outline" size="sm" leftIcon={copyIcon} onPress={handleCopyLogPath}>
Copy path
</Button>
) : null}
<Button
variant="outline"
size="sm"
leftIcon={fileTextIcon}
onPress={handleOpenLogs}
disabled={!daemonLogs}
>
Open logs
</Button>
</View>
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<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={activityIcon}
onPress={handleRunCliStatus}
disabled={isLoadingCliStatus}
>
{isLoadingCliStatus ? "Loading..." : "View status"}
</Button>
</View>
</View>
);
}
export function LocalDaemonSection() {
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
const appVersion = resolveAppVersion();
const { settings, updateSettings, isLoading: isLoadingSettings } = useDesktopSettings();
const daemonSettings = settings.daemon;
const updateDaemonSettings = useCallback(
(updates: Partial<DesktopDaemonSettings>) => updateSettings({ daemon: updates }),
[updateSettings],
);
const { data, isLoading, error: statusError, setStatus, refetch } = useDaemonStatus();
const daemonStatus = data?.status ?? null;
const daemonLogs = data?.logs ?? null;
const daemonVersion = daemonStatus?.version ?? null;
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
const daemonStatusStateText =
statusError ?? (daemonStatus?.status === "running" ? daemonStatus.status : "not running");
const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : "—"}`;
const isDaemonManagementPaused = !daemonSettings.manageBuiltInDaemon;
const { isUpdatingDaemonManagement, handleToggleDaemonManagement } = useDaemonManagementToggle({
daemonStatus,
settings: daemonSettings,
updateSettings: updateDaemonSettings,
setStatus,
refetch,
});
const { isUpdatingKeepRunningAfterQuit, handleToggleKeepRunningAfterQuit } =
useKeepRunningAfterQuitToggle({
settings: daemonSettings,
updateSettings: updateDaemonSettings,
});
const { isLogsModalOpen, handleCopyLogPath, handleOpenLogs, handleCloseLogsModal } =
useDaemonLogsModal(daemonLogs);
const {
cliStatusOutput,
isCliStatusModalOpen,
isLoadingCliStatus,
handleCopyCliStatus,
handleOpenCliStatus,
handleCloseCliStatusModal,
} = useDaemonCliStatusModal();
const handleRunCliStatus = useCallback(() => {
void handleOpenCliStatus();
}, [handleOpenCliStatus]);
const handleOpenAdvancedSettings = useCallback(
() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL),
[],
);
const advancedSettingsIcon = useMemo(
() => <ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[theme.iconSize.sm, theme.colors.foregroundMuted],
);
const copyIcon = useMemo(
() => <Copy size={theme.iconSize.sm} color={theme.colors.foreground} />,
[theme.iconSize.sm, theme.colors.foreground],
);
const fileTextIcon = useMemo(
() => <FileText size={theme.iconSize.sm} color={theme.colors.foreground} />,
[theme.iconSize.sm, theme.colors.foreground],
);
const activityIcon = useMemo(
() => <Activity size={theme.iconSize.sm} color={theme.colors.foreground} />,
[theme.iconSize.sm, theme.colors.foreground],
);
const advancedSettingsButton = useMemo(
() => (
<Button
variant="ghost"
size="sm"
leftIcon={advancedSettingsIcon}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={handleOpenAdvancedSettings}
accessibilityLabel="Open advanced daemon settings"
>
Advanced settings
</Button>
),
[advancedSettingsIcon, handleOpenAdvancedSettings],
);
if (!showSection) {
return null;
}
return (
<SettingsSection
title="Daemon"
trailing={advancedSettingsButton}
testID="host-page-daemon-lifecycle-card"
>
{isLoading || isLoadingSettings ? (
<View style={LOADING_CARD_STYLE}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
) : (
<>
<DaemonInfoCard
daemonStatusStateText={daemonStatusStateText}
daemonStatusDetailText={daemonStatusDetailText}
isDaemonManagementPaused={isDaemonManagementPaused}
copyIcon={copyIcon}
fileTextIcon={fileTextIcon}
activityIcon={activityIcon}
handleToggleDaemonManagement={handleToggleDaemonManagement}
isUpdatingDaemonManagement={isUpdatingDaemonManagement}
keepRunningAfterQuit={daemonSettings.keepRunningAfterQuit}
handleToggleKeepRunningAfterQuit={handleToggleKeepRunningAfterQuit}
isUpdatingKeepRunningAfterQuit={isUpdatingKeepRunningAfterQuit}
daemonLogs={daemonLogs}
handleCopyLogPath={handleCopyLogPath}
handleOpenLogs={handleOpenLogs}
handleRunCliStatus={handleRunCliStatus}
isLoadingCliStatus={isLoadingCliStatus}
/>
{daemonVersionMismatch ? (
<View style={styles.warningCard}>
<Text style={styles.warningText}>
{
"App and daemon versions don't match. Update both to the same version for the best experience."
}
</Text>
</View>
) : null}
</>
)}
<DaemonLogsModal
visible={isLogsModalOpen}
onClose={handleCloseLogsModal}
daemonLogs={daemonLogs}
/>
<DaemonCliStatusModal
visible={isCliStatusModalOpen}
onClose={handleCloseCliStatusModal}
cliStatusOutput={cliStatusOutput}
onCopy={handleCopyCliStatus}
/>
</SettingsSection>
);
}
const ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration";
const styles = StyleSheet.create((theme) => ({
actionGroup: {
flexDirection: "row",
gap: theme.spacing[2],
flexWrap: "wrap",
justifyContent: "flex-end",
},
loadingCard: {
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[6],
},
statusValueGroup: {
alignItems: "flex-end",
gap: 2,
},
valueText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
valueSubtext: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
warningCard: {
marginTop: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.palette.amber[500],
backgroundColor: "rgba(245, 158, 11, 0.12)",
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
},
warningText: {
color: theme.colors.palette.amber[500],
fontSize: theme.fontSize.xs,
},
modalBody: {
gap: theme.spacing[3],
paddingBottom: theme.spacing[2],
},
logOutput: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
lineHeight: 18,
},
modalActions: {
flexDirection: "row",
justifyContent: "flex-end",
gap: theme.spacing[2],
},
}));
const LOADING_CARD_STYLE = [settingsStyles.card, styles.loadingCard];
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
const LOGS_MODAL_SNAP_POINTS = ["70%", "92%"];
const CLI_STATUS_MODAL_SNAP_POINTS = ["60%", "85%"];