Stop local daemon when removing localhost (#1297)

* fix(app): stop local daemon when removing localhost

* fix(app): handle daemon removal rollback

* fix(app): return listen address in desktop e2e mock

* fix(app): refresh daemon status after registration failure

* fix(app): unslop daemon status error coverage

* fix(app): preserve daemon toggle error context

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Matt Cowger
2026-06-04 22:30:40 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 9e2c75a8a3
commit b5192e577a
9 changed files with 341 additions and 61 deletions
+23 -12
View File
@@ -84,6 +84,7 @@ import {
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";
import { isElectronRuntime } from "@/desktop/host";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
@@ -133,6 +134,7 @@ interface SidebarSectionItem {
const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
{ id: "general", label: "General", icon: Settings },
{ id: "daemon", label: "Daemon", icon: Server, desktopOnly: true },
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard, desktopOnly: true },
{ id: "integrations", label: "Integrations", icon: Puzzle, desktopOnly: true },
@@ -155,6 +157,24 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [
{ id: "host", label: "Host", icon: Server },
];
function renderHostSettingsContent(
view: Extract<SettingsView, { kind: "host" }>,
onHostRemoved: () => void,
): ReactNode {
switch (view.section) {
case "connections":
return <HostConnectionsPage serverId={view.serverId} />;
case "agents":
return <HostAgentsPage serverId={view.serverId} />;
case "workspaces":
return <HostWorkspacesPage serverId={view.serverId} />;
case "providers":
return <HostProvidersPage serverId={view.serverId} />;
case "host":
return <HostSettingsPage serverId={view.serverId} onHostRemoved={onHostRemoved} />;
}
}
// ---------------------------------------------------------------------------
// Trigger + sidebar style helpers
// ---------------------------------------------------------------------------
@@ -1325,18 +1345,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
const content = (() => {
if (view.kind === "host") {
switch (view.section) {
case "connections":
return <HostConnectionsPage serverId={view.serverId} />;
case "agents":
return <HostAgentsPage serverId={view.serverId} />;
case "workspaces":
return <HostWorkspacesPage serverId={view.serverId} />;
case "providers":
return <HostProvidersPage serverId={view.serverId} />;
case "host":
return <HostSettingsPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
}
return renderHostSettingsContent(view, handleHostRemoved);
}
if (view.kind === "projects") {
return <ProjectsScreen view={view} />;
@@ -1356,6 +1365,8 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
/>
);
case "daemon":
return <LocalDaemonSection />;
case "appearance":
return <AppearanceSection />;
case "shortcuts":
+84 -10
View File
@@ -7,7 +7,10 @@ import { AdaptiveRenameModal } from "@/components/rename-modal";
import { SettingsTextAreaCard } from "@/components/settings-textarea";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { startDesktopDaemon, stopDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
@@ -72,7 +75,6 @@ function formatDaemonVersionBadge(version: string | null): string | null {
}
const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" };
const REMOVE_HOST_HEADER: SheetHeader = { title: "Remove host" };
function useHostProfile(serverId: string): HostProfile | null {
const daemons = useHosts();
@@ -276,7 +278,7 @@ export function HostSettingsPage({
{isLocalDaemon ? <LocalDaemonSection /> : null}
<RemoveHostSection host={host} onRemoved={onHostRemoved} />
<RemoveHostSection host={host} isLocalDaemon={isLocalDaemon} onRemoved={onHostRemoved} />
</View>
);
}
@@ -826,11 +828,22 @@ function PairDeviceRow() {
);
}
function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?: () => void }) {
function RemoveHostSection({
host,
isLocalDaemon,
onRemoved,
}: {
host: HostProfile;
isLocalDaemon: boolean;
onRemoved?: () => void;
}) {
const { theme } = useUnistyles();
const { removeHost } = useHostMutations();
const { updateSettings } = useDesktopSettings();
const { data: daemonStatusData, setStatus } = useDaemonStatus();
const [isConfirming, setIsConfirming] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
const daemonStatus = daemonStatusData?.status ?? null;
const destructiveTextStyle = useMemo(
() => ({ color: theme.colors.destructive }),
@@ -843,9 +856,45 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
setIsConfirming(false);
}, [isRemoving]);
const handleCancel = useCallback(() => setIsConfirming(false), []);
const rollbackLocalhostRemoval = useCallback(
async (shouldRestartDaemon: boolean) => {
await updateSettings({ daemon: { manageBuiltInDaemon: true } });
if (!shouldRestartDaemon) {
return;
}
setStatus(await startDesktopDaemon());
},
[setStatus, updateSettings],
);
const handleConfirmRemove = useCallback(() => {
setIsRemoving(true);
void removeHost(host.serverId)
const remove = async () => {
let didDisableDaemonManagement = false;
let didStopDaemon = false;
if (isLocalDaemon) {
try {
await updateSettings({ daemon: { manageBuiltInDaemon: false } });
didDisableDaemonManagement = true;
if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) {
setStatus(await stopDesktopDaemon());
didStopDaemon = true;
}
await removeHost(host.serverId);
} catch (error) {
if (didDisableDaemonManagement) {
try {
await rollbackLocalhostRemoval(didStopDaemon);
} catch (rollbackError) {
console.error("[HostPage] Failed to roll back localhost removal", rollbackError);
}
}
throw error;
}
return;
}
await removeHost(host.serverId);
};
void remove()
.then(() => {
setIsConfirming(false);
onRemoved?.();
@@ -853,10 +902,29 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
})
.catch((error) => {
console.error("[HostPage] Failed to remove host", error);
Alert.alert("Error", "Unable to remove host");
Alert.alert(
"Error",
isLocalDaemon ? "Unable to remove localhost connection" : "Unable to remove host",
);
})
.finally(() => setIsRemoving(false));
}, [host.serverId, onRemoved, removeHost]);
}, [
daemonStatus,
host.serverId,
isLocalDaemon,
onRemoved,
removeHost,
rollbackLocalhostRemoval,
setStatus,
updateSettings,
]);
const confirmationHeader = useMemo<SheetHeader>(
() => ({
title: isLocalDaemon ? "Remove localhost connection and stop daemon?" : "Remove host",
}),
[isLocalDaemon],
);
const removeIcon = useMemo(
() => <Trash2 size={theme.iconSize.sm} color={theme.colors.destructive} />,
@@ -870,9 +938,13 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Remove host</Text>
<Text style={settingsStyles.rowTitle}>
{isLocalDaemon ? "Remove localhost connection" : "Remove host"}
</Text>
<Text style={settingsStyles.rowHint}>
Removes this host and its saved connections from this device
{isLocalDaemon
? "Removes localhost from this device and stops the built-in daemon"
: "Removes this host and its saved connections from this device"}
</Text>
</View>
<Button
@@ -890,13 +962,15 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
{isConfirming ? (
<AdaptiveModalSheet
header={REMOVE_HOST_HEADER}
header={confirmationHeader}
visible
onClose={handleCloseConfirm}
testID="remove-host-confirm-modal"
>
<Text style={styles.confirmText}>
Remove {host.label}? This will delete its saved connections.
{isLocalDaemon
? "This will remove the localhost connection, turn off built-in daemon management, and stop the managed daemon. Remote hosts remain connected."
: `Remove ${host.label}? This will delete its saved connections.`}
</Text>
<View style={styles.confirmActions}>
<Button