Files
paseo/packages/app/src/screens/settings/host-page.tsx
T
40265782d4 feat: add remote daemon self-update from client (#1513)
* feat: add remote daemon self-update from client

Adds a daemon.update.request RPC that lets the client trigger a remote
daemon update over WebSocket. The daemon runs npm update -g @getpaseo/cli,
reports progress via daemon_update_progress status events, then restarts
through the supervisor to load the new code.

New feature flag server_info.features.daemonSelfUpdate gates the UI.
The UpdateDaemonCard appears on the host settings page when the daemon
supports self-update and the app/daemon versions differ.

* fix: clean up progress subscription on unmount, use typed phase cast

Address Greptile review:
- P1: store unsubscribe ref and clean up on component unmount to prevent
  listener leak during in-flight update
- P2: use DaemonUpdateProgressStatusPayload type instead of { phase?: string }

* fix: use npm install@latest and global concurrency guard

Address Greptile review:
- npm update -g can silently exit 0 without resolving major-version
  gaps; switch to npm install -g @getpaseo/cli@latest
- Move daemonUpdateInProgress from per-session instance to module-level
  variable so concurrent clients share the same guard

* feat(daemon): harden remote self-update

Probe the global npm CLI install before updating, keep request handling in a daemon self-update session controller, and emit scoped daemon.update.progress messages.

* test(daemon): make self-update paths platform-stable

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-26 16:13:29 +08:00

1854 lines
58 KiB
TypeScript

import {
ArrowDown,
ArrowUp,
ArrowUpToLine,
ChevronRight,
Globe,
Monitor,
Pencil,
Plus,
RotateCw,
SquareTerminal,
Trash2,
} from "lucide-react-native";
import type { TFunction } from "i18next";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import {
getTerminalProfileIcon,
resolveTerminalProfiles,
} from "@getpaseo/protocol/terminal-profiles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
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 {
ProfileDraft,
TerminalProfileEditModal,
} from "@/screens/settings/terminal-profile-edit-modal";
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";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHostMutations,
useHostRuntimeClient,
useHostRuntimeIsConnected,
useHostRuntimeSnapshot,
useHosts,
} from "@/runtime/host-runtime";
import { ProvidersSection } from "@/screens/settings/providers-section";
import { ProviderUsageSettingsSection } from "@/provider-usage/settings-section";
import { useProviderUsage } from "@/provider-usage/use-provider-usage";
import { SettingsSection } from "@/screens/settings/settings-section";
import { useSessionStore } from "@/stores/session-store";
import { settingsStyles } from "@/styles/settings";
import type { HostConnection, HostProfile } from "@/types/host-connection";
import { confirmDialog } from "@/utils/confirm-dialog";
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import { resolveAppVersion } from "@/utils/app-version";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
import { formatLatency } from "@/utils/latency";
import { ICON_SIZE } from "@/styles/theme";
import type { Theme } from "@/styles/theme";
import { getProviderIcon } from "@/components/provider-icons";
const ThemedArrowUp = withUnistyles(ArrowUp);
const ThemedArrowDown = withUnistyles(ArrowDown);
const ThemedProfilePencil = withUnistyles(Pencil);
const ThemedTrash2 = withUnistyles(Trash2);
const ThemedProfileSquareTerminal = withUnistyles(SquareTerminal);
const ThemedPlus = withUnistyles(Plus);
interface DynamicProviderIconProps {
iconKey: string;
size: number;
color?: string;
}
function DynamicProviderIcon({ iconKey, size, color = "" }: DynamicProviderIconProps) {
const Icon = getProviderIcon(iconKey);
return <Icon size={size} color={color} />;
}
const ThemedDynamicProviderIcon = withUnistyles(DynamicProviderIcon);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
const moveUpIcon = <ThemedArrowUp size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
const moveDownIcon = <ThemedArrowDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
const editProfileIcon = <ThemedProfilePencil size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
const removeProfileIcon = <ThemedTrash2 size={ICON_SIZE.sm} uniProps={destructiveColorMapping} />;
const addProfileIcon = <ThemedPlus size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
function formatHostConnectionLabel(connection: HostConnection, t: TFunction): string {
if (connection.type === "relay") {
return `${t("settings.host.badges.relay")} (${connection.relayEndpoint})`;
}
if (connection.type === "directSocket" || connection.type === "directPipe") {
return `${t("settings.host.badges.local")} (${connection.path})`;
}
return `TCP (${connection.endpoint})`;
}
function formatActiveConnectionBadge(
activeConnection: { type: HostConnection["type"]; display: string } | null,
theme: ReturnType<typeof useUnistyles>["theme"],
t: TFunction,
): { icon: React.ReactNode; text: string } | null {
if (!activeConnection) return null;
if (activeConnection.type === "relay") {
return {
icon: <Globe size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
text: t("settings.host.badges.relay"),
};
}
if (activeConnection.type === "directSocket" || activeConnection.type === "directPipe") {
return {
icon: <Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
text: t("settings.host.badges.local"),
};
}
return {
icon: <Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
text: activeConnection.display,
};
}
function formatDaemonVersionBadge(version: string | null): string | null {
const trimmed = version?.trim();
if (!trimmed) return null;
return trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
}
function useHostProfile(serverId: string): HostProfile | null {
const daemons = useHosts();
return daemons.find((entry) => entry.serverId === serverId) ?? null;
}
function HostNotFound() {
const { t } = useTranslation();
return (
<View>
<View style={EMPTY_CARD_STYLE}>
<Text style={styles.emptyText}>{t("settings.host.notFound")}</Text>
</View>
</View>
);
}
function HostStatusBadges({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const snapshot = useHostRuntimeSnapshot(serverId);
const daemonVersion = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.version ?? null,
);
const connectionStatus = snapshot?.connectionStatus ?? "connecting";
const activeConnection = snapshot?.activeConnection ?? null;
const statusLabel = formatConnectionStatus(connectionStatus);
const statusTone = getConnectionStatusTone(connectionStatus);
let statusColor: string;
if (statusTone === "success") {
statusColor = theme.colors.palette.green[400];
} else if (statusTone === "warning") {
statusColor = theme.colors.palette.amber[500];
} else if (statusTone === "error") {
statusColor = theme.colors.destructive;
} else {
statusColor = theme.colors.foregroundMuted;
}
let statusPillBg: string;
if (statusTone === "success") {
statusPillBg = "rgba(74, 222, 128, 0.1)";
} else if (statusTone === "warning") {
statusPillBg = "rgba(245, 158, 11, 0.1)";
} else if (statusTone === "error") {
statusPillBg = "rgba(248, 113, 113, 0.1)";
} else {
statusPillBg = "rgba(161, 161, 170, 0.1)";
}
const connectionBadge = formatActiveConnectionBadge(activeConnection, theme, t);
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
const statusPillStyle = useMemo(
() => [styles.statusPill, { backgroundColor: statusPillBg }],
[statusPillBg],
);
const statusDotStyle = useMemo(
() => [styles.statusDot, { backgroundColor: statusColor }],
[statusColor],
);
const statusTextStyle = useMemo(() => [styles.statusText, { color: statusColor }], [statusColor]);
return (
<View style={styles.identityBadges} testID="host-page-identity">
<View style={statusPillStyle}>
<View style={statusDotStyle} />
<Text style={statusTextStyle}>{statusLabel}</Text>
</View>
{connectionBadge ? (
<View style={styles.badgePill}>
{connectionBadge.icon}
<Text style={styles.badgeText} numberOfLines={1}>
{connectionBadge.text}
</Text>
</View>
) : null}
{versionBadgeText ? (
<View style={styles.badgePill}>
<Text style={styles.badgeText} numberOfLines={1}>
{versionBadgeText}
</Text>
</View>
) : null}
</View>
);
}
function HostConnectionError({ serverId }: { serverId: string }) {
const snapshot = useHostRuntimeSnapshot(serverId);
const lastError = snapshot?.lastError ?? null;
const connectionError =
typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
if (!connectionError) return null;
return <Text style={styles.errorText}>{connectionError}</Text>;
}
export function HostConnectionsPage({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const host = useHostProfile(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
if (!host) {
return <HostNotFound />;
}
return (
<View>
<HostConnectionError serverId={serverId} />
<ConnectionsSection host={host} />
{isLocalDaemon ? (
<SettingsSection title={t("settings.host.pairDevices.title")}>
<PairDeviceRow />
</SettingsSection>
) : null}
</View>
);
}
export function HostAgentsPage({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const host = useHostProfile(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
if (!host) {
return <HostNotFound />;
}
return (
<View>
{isConnected ? (
<SettingsSection title={t("settings.hostSections.agents")}>
<InjectPaseoToolsCard serverId={serverId} />
<AppendSystemPromptCard serverId={serverId} />
</SettingsSection>
) : (
<View style={EMPTY_CARD_STYLE}>
<Text style={styles.emptyText}>{t("settings.host.agents.unavailable")}</Text>
</View>
)}
</View>
);
}
export function HostWorkspacesPage({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const host = useHostProfile(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
if (!host) {
return <HostNotFound />;
}
return (
<View>
{isConnected ? (
<SettingsSection title={t("settings.hostSections.workspaces")}>
<AutoArchiveMergedWorkspacesCard serverId={serverId} />
</SettingsSection>
) : (
<View style={EMPTY_CARD_STYLE}>
<Text style={styles.emptyText}>{t("settings.host.workspaces.unavailable")}</Text>
</View>
)}
</View>
);
}
export function HostProvidersPage({ serverId }: { serverId: string }) {
const host = useHostProfile(serverId);
if (!host) {
return <HostNotFound />;
}
return (
<View>
<ProvidersSection serverId={serverId} />
</View>
);
}
export function HostUsagePage({ serverId }: { serverId: string }) {
const host = useHostProfile(serverId);
const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage(serverId);
const handleRefresh = useCallback(() => {
void refreshProviderUsage();
}, [refreshProviderUsage]);
if (!host) {
return <HostNotFound />;
}
return (
<View>
<ProviderUsageSettingsSection view={providerUsageView} onRefresh={handleRefresh} />
</View>
);
}
export function HostSettingsPage({
serverId,
onHostRemoved,
}: {
serverId: string;
onHostRemoved?: () => void;
}) {
const host = useHostProfile(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
if (!host) {
return <HostNotFound />;
}
return (
<View>
<View style={styles.daemonHeader}>
<Text style={styles.daemonHeaderLabel} numberOfLines={1}>
{host.label}
</Text>
<HostRenameButton host={host} />
</View>
<HostStatusBadges serverId={serverId} />
{isLocalDaemon ? <LocalDaemonSection /> : null}
{!isLocalDaemon ? <UpdateDaemonCard host={host} /> : null}
<RemoveHostSection host={host} isLocalDaemon={isLocalDaemon} onRemoved={onHostRemoved} />
</View>
);
}
export function HostRenameButton({ host }: { host: HostProfile }) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const { renameHost } = useHostMutations();
const [isEditing, setIsEditing] = useState(false);
const handleSubmit = useCallback(
async (value: string) => {
const nextLabel = value.trim();
if (nextLabel === host.label.trim()) return;
await renameHost(host.serverId, nextLabel);
},
[host.label, host.serverId, renameHost],
);
const openEditor = useCallback(() => setIsEditing(true), []);
const closeEditor = useCallback(() => setIsEditing(false), []);
return (
<>
<Pressable
onPress={openEditor}
hitSlop={8}
style={styles.identityEditButton}
accessibilityRole="button"
accessibilityLabel={t("settings.host.daemon.rename.editLabel")}
testID="host-page-label-edit-button"
>
<Pencil size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<AdaptiveRenameModal
visible={isEditing}
title={t("settings.host.daemon.rename.title")}
initialValue={host.label}
placeholder={t("settings.host.daemon.rename.placeholder")}
submitLabel={t("settings.host.daemon.rename.submit")}
onClose={closeEditor}
onSubmit={handleSubmit}
testID="host-page-rename-modal"
/>
</>
);
}
function ConnectionsSection({ host }: { host: HostProfile }) {
const { t } = useTranslation();
const { removeConnection } = useHostMutations();
const snapshot = useHostRuntimeSnapshot(host.serverId);
const probeByConnectionId = snapshot?.probeByConnectionId ?? new Map();
const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{
connectionId: string;
title: string;
} | null>(null);
const [isRemovingConnection, setIsRemovingConnection] = useState(false);
const removeConnectionHeader = useMemo<SheetHeader>(
() => ({ title: t("settings.host.connections.removeTitle") }),
[t],
);
const handleRequestRemove = useCallback(
(connection: HostConnection) => {
setPendingRemoveConnection({
connectionId: connection.id,
title: formatHostConnectionLabel(connection, t),
});
},
[t],
);
const handleCloseConfirm = useCallback(() => {
if (isRemovingConnection) return;
setPendingRemoveConnection(null);
}, [isRemovingConnection]);
const handleCancelConfirm = useCallback(() => {
setPendingRemoveConnection(null);
}, []);
const handleConfirmRemove = useCallback(() => {
if (!pendingRemoveConnection) return;
const { connectionId } = pendingRemoveConnection;
setIsRemovingConnection(true);
void removeConnection(host.serverId, connectionId)
.then(() => setPendingRemoveConnection(null))
.catch((error) => {
console.error("[HostPage] Failed to remove connection", error);
Alert.alert(
t("settings.host.connections.removeErrorTitle"),
t("settings.host.connections.removeErrorMessage"),
);
})
.finally(() => setIsRemovingConnection(false));
}, [pendingRemoveConnection, removeConnection, host.serverId, t]);
return (
<SettingsSection title={t("settings.host.connections.title")}>
<View style={settingsStyles.card} testID="host-page-connections-card">
{host.connections.map((conn, index) => {
const probe = probeByConnectionId.get(conn.id);
return (
<ConnectionRow
key={conn.id}
connection={conn}
showBorder={index > 0}
latencyMs={probe?.status === "available" ? probe.latencyMs : undefined}
latencyLoading={!probe || probe.status === "pending"}
latencyError={probe?.status === "unavailable"}
onRemove={handleRequestRemove}
/>
);
})}
</View>
{pendingRemoveConnection ? (
<AdaptiveModalSheet
header={removeConnectionHeader}
visible
onClose={handleCloseConfirm}
testID="remove-connection-confirm-modal"
>
<Text style={styles.confirmText}>
{t("settings.host.connections.removeMessage", {
name: pendingRemoveConnection.title,
})}
</Text>
<View style={styles.confirmActions}>
<Button
variant="secondary"
size="sm"
style={FLEX_1_STYLE}
onPress={handleCancelConfirm}
disabled={isRemovingConnection}
>
{t("common.actions.cancel")}
</Button>
<Button
variant="destructive"
size="sm"
style={FLEX_1_STYLE}
onPress={handleConfirmRemove}
disabled={isRemovingConnection}
testID="remove-connection-confirm"
>
{t("settings.host.connections.removeAction")}
</Button>
</View>
</AdaptiveModalSheet>
) : null}
</SettingsSection>
);
}
function ConnectionRow({
connection,
showBorder,
latencyMs,
latencyLoading,
latencyError,
onRemove,
}: {
connection: HostConnection;
showBorder: boolean;
latencyMs: number | null | undefined;
latencyLoading: boolean;
latencyError: boolean;
onRemove: (connection: HostConnection) => void;
}) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const title = formatHostConnectionLabel(connection, t);
const latencyText = (() => {
if (latencyLoading) return "...";
if (latencyError) return t("settings.host.connections.timeout");
if (latencyMs != null) return formatLatency(latencyMs);
return "—";
})();
const latencyColor = latencyError ? theme.colors.palette.red[300] : theme.colors.foregroundMuted;
const handlePressRemove = useCallback(() => {
onRemove(connection);
}, [onRemove, connection]);
const rowStyle = useMemo(
() => [settingsStyles.row, showBorder && settingsStyles.rowBorder],
[showBorder],
);
const latencyTextStyle = useMemo(
() => [styles.connectionLatency, { color: latencyColor }],
[latencyColor],
);
const destructiveTextStyle = useMemo(
() => ({ color: theme.colors.destructive }),
[theme.colors.destructive],
);
return (
<View style={rowStyle}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{title}
</Text>
</View>
<Text style={latencyTextStyle}>{latencyText}</Text>
<Button
variant="ghost"
size="sm"
textStyle={destructiveTextStyle}
onPress={handlePressRemove}
>
{t("settings.host.connections.removeAction")}
</Button>
</View>
);
}
const delay = (ms: number) =>
new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
function RestartDaemonCard({ host }: { host: HostProfile }) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const daemonClient = useHostRuntimeClient(host.serverId);
const isConnected = useHostRuntimeIsConnected(host.serverId);
const runtime = getHostRuntimeStore();
const [isRestarting, setIsRestarting] = useState(false);
const isMountedRef = useRef(true);
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
const isHostConnected = useCallback(
() => isHostRuntimeConnected(runtime.getSnapshot(host.serverId)),
[host.serverId, runtime],
);
const waitForCondition = useCallback(
async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isMountedRef.current) return false;
if (predicate()) return true;
await delay(intervalMs);
}
return predicate();
},
[],
);
const waitForDaemonRestart = useCallback(async () => {
const disconnectTimeoutMs = 7000;
const reconnectTimeoutMs = 30000;
if (isHostConnected()) {
await waitForCondition(() => !isHostConnected(), disconnectTimeoutMs);
}
const reconnected = await waitForCondition(() => isHostConnected(), reconnectTimeoutMs);
if (isMountedRef.current) {
setIsRestarting(false);
if (!reconnected) {
Alert.alert(
t("settings.host.daemon.restart.unableToReconnectTitle"),
t("settings.host.daemon.restart.unableToReconnectMessage", { name: host.label }),
);
}
}
}, [host.label, isHostConnected, t, waitForCondition]);
const handleRestart = useCallback(() => {
if (!daemonClient) {
Alert.alert(
t("settings.host.daemon.restart.unavailableTitle"),
t("settings.host.daemon.restart.unavailableMessage"),
);
return;
}
if (!isHostConnected()) {
Alert.alert(
t("settings.host.daemon.restart.offlineTitle"),
t("settings.host.daemon.restart.offlineMessage"),
);
return;
}
void confirmDialog({
title: t("settings.host.daemon.restart.confirmTitle", { name: host.label }),
message: t("settings.host.daemon.restart.confirmMessage"),
confirmLabel: t("settings.host.daemon.restart.confirm"),
cancelLabel: t("common.actions.cancel"),
destructive: true,
})
.then((confirmed) => {
if (!confirmed) return;
setIsRestarting(true);
void daemonClient
.restartServer(`settings_daemon_restart_${host.serverId}`)
.catch((error) => {
console.error(`[HostPage] Failed to restart daemon ${host.label}`, error);
if (!isMountedRef.current) return;
setIsRestarting(false);
Alert.alert(
t("settings.host.daemon.restart.requestFailedTitle"),
t("settings.host.daemon.restart.requestFailedMessage"),
);
});
void waitForDaemonRestart();
return;
})
.catch((error) => {
console.error(`[HostPage] Failed to open restart confirmation for ${host.label}`, error);
Alert.alert(
t("settings.host.daemon.restart.requestFailedTitle"),
t("settings.host.daemon.restart.dialogFailedMessage"),
);
});
}, [daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart]);
const restartIcon = useMemo(
() => <RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />,
[theme.iconSize.sm, theme.colors.foreground],
);
return (
<View style={settingsStyles.card} testID="host-page-restart-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.host.daemon.restart.title")}</Text>
<Text style={settingsStyles.rowHint}>{t("settings.host.daemon.restart.hint")}</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={restartIcon}
onPress={handleRestart}
disabled={isRestarting || !daemonClient || !isConnected}
testID="host-page-restart-button"
>
{isRestarting
? t("settings.host.daemon.restart.restarting")
: t("settings.host.daemon.restart.confirm")}
</Button>
</View>
</View>
);
}
function UpdateDaemonCard({ host }: { host: HostProfile }) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const daemonClient = useHostRuntimeClient(host.serverId);
const isConnected = useHostRuntimeIsConnected(host.serverId);
const runtime = getHostRuntimeStore();
const [isUpdating, setIsUpdating] = useState(false);
const [progressPhase, setProgressPhase] = useState<string | null>(null);
const isMountedRef = useRef(true);
const unsubscribeRef = useRef<(() => void) | null>(null);
const daemonVersion = useSessionStore(
(state) => state.sessions[host.serverId]?.serverInfo?.version ?? null,
);
const supportsSelfUpdate = useSessionStore(
(state) => state.sessions[host.serverId]?.serverInfo?.features?.daemonSelfUpdate === true,
);
const appVersion = resolveAppVersion();
const hasVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
useEffect(() => {
return () => {
isMountedRef.current = false;
unsubscribeRef.current?.();
};
}, []);
const isHostConnected = useCallback(
() => isHostRuntimeConnected(runtime.getSnapshot(host.serverId)),
[host.serverId, runtime],
);
const hasReconnectedAfter = useCallback(
(startGeneration: number | null) => {
const snapshot = runtime.getSnapshot(host.serverId);
if (!snapshot || !isHostRuntimeConnected(snapshot)) return false;
return startGeneration === null || snapshot.clientGeneration !== startGeneration;
},
[host.serverId, runtime],
);
const waitForCondition = useCallback(
async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isMountedRef.current) return false;
if (predicate()) return true;
await delay(intervalMs);
}
return predicate();
},
[],
);
const waitForDaemonRestart = useCallback(
async (startGeneration: number | null) => {
const disconnectTimeoutMs = 15000;
const reconnectTimeoutMs = 120000; // 2 minutes — npm update + restart can take a while
if (!hasReconnectedAfter(startGeneration) && isHostConnected()) {
await waitForCondition(
() => !isHostConnected() || hasReconnectedAfter(startGeneration),
disconnectTimeoutMs,
);
}
const reconnected =
hasReconnectedAfter(startGeneration) ||
(await waitForCondition(() => hasReconnectedAfter(startGeneration), reconnectTimeoutMs));
if (isMountedRef.current) {
setIsUpdating(false);
setProgressPhase(null);
if (!reconnected) {
Alert.alert(
t("settings.host.daemon.update.unableToReconnectTitle"),
t("settings.host.daemon.update.unableToReconnectMessage", { name: host.label }),
);
}
}
},
[hasReconnectedAfter, host.label, isHostConnected, t, waitForCondition],
);
const handleUpdate = useCallback(() => {
if (!daemonClient) {
Alert.alert(
t("settings.host.daemon.update.unavailableTitle"),
t("settings.host.daemon.update.unavailableMessage"),
);
return;
}
if (!isHostConnected()) {
Alert.alert(
t("settings.host.daemon.update.offlineTitle"),
t("settings.host.daemon.update.offlineMessage"),
);
return;
}
void confirmDialog({
title: t("settings.host.daemon.update.confirmTitle", { name: host.label }),
message: t("settings.host.daemon.update.confirmMessage"),
confirmLabel: t("settings.host.daemon.update.confirm"),
cancelLabel: t("common.actions.cancel"),
destructive: false,
})
.then((confirmed) => {
if (!confirmed) return;
const startGeneration = runtime.getSnapshot(host.serverId)?.clientGeneration ?? null;
setIsUpdating(true);
setProgressPhase(t("settings.host.daemon.update.phaseStarting"));
const requestId = `settings_daemon_update_${host.serverId}`;
const unsubscribe = daemonClient.on("daemon.update.progress", (message) => {
if (message.payload.requestId !== requestId) return;
if (!isMountedRef.current) return;
const { phase } = message.payload;
if (phase === "starting")
setProgressPhase(t("settings.host.daemon.update.phaseStarting"));
else if (phase === "downloading")
setProgressPhase(t("settings.host.daemon.update.phaseDownloading"));
else if (phase === "installing")
setProgressPhase(t("settings.host.daemon.update.phaseInstalling"));
else if (phase === "complete")
setProgressPhase(t("settings.host.daemon.update.phaseComplete"));
});
unsubscribeRef.current = unsubscribe;
void daemonClient
.updateDaemon(requestId)
.then((response) => {
unsubscribeRef.current = null;
unsubscribe();
if (!response.success) {
if (!isMountedRef.current) return undefined;
setIsUpdating(false);
setProgressPhase(null);
Alert.alert(
t("settings.host.daemon.update.requestFailedTitle"),
t("settings.host.daemon.update.requestFailedMessage", {
error: response.error ?? "Unknown error",
}),
);
return undefined;
}
// Update succeeded — wait for daemon to restart and reconnect
void waitForDaemonRestart(startGeneration);
return undefined;
})
.catch((error) => {
unsubscribeRef.current = null;
unsubscribe();
console.error(`[HostPage] Failed to update daemon ${host.label}`, error);
if (!isMountedRef.current) return;
setIsUpdating(false);
setProgressPhase(null);
Alert.alert(
t("settings.host.daemon.update.requestFailedTitle"),
t("settings.host.daemon.update.requestFailedMessage", {
error: error instanceof Error ? error.message : "Unknown error",
}),
);
});
return;
})
.catch((error) => {
console.error(`[HostPage] Failed to open update confirmation for ${host.label}`, error);
Alert.alert(
t("settings.host.daemon.update.requestFailedTitle"),
t("settings.host.daemon.update.dialogFailedMessage"),
);
});
}, [daemonClient, host.label, host.serverId, isHostConnected, runtime, t, waitForDaemonRestart]);
const updateIcon = useMemo(
() => <ArrowUpToLine size={theme.iconSize.sm} color={theme.colors.foreground} />,
[theme.iconSize.sm, theme.colors.foreground],
);
// Don't show if the daemon doesn't support self-update or versions match
if (!supportsSelfUpdate || !hasVersionMismatch) {
return null;
}
const buttonLabel = isUpdating
? (progressPhase ?? t("settings.host.daemon.update.updating"))
: t("settings.host.daemon.update.confirm");
return (
<View style={settingsStyles.card} testID="host-page-update-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.host.daemon.update.title")}</Text>
<Text style={settingsStyles.rowHint}>{t("settings.host.daemon.update.hint")}</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={updateIcon}
onPress={handleUpdate}
disabled={isUpdating || !daemonClient || !isConnected}
testID="host-page-update-button"
>
{buttonLabel}
</Button>
</View>
</View>
);
}
function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const isConnected = useHostRuntimeIsConnected(serverId);
const { config, patchConfig } = useDaemonConfig(serverId);
const handleValueChange = useCallback(
(next: boolean) => {
void patchConfig({
mcp: {
injectIntoAgents: next,
},
});
},
[patchConfig],
);
if (!isConnected) return null;
return (
<View style={settingsStyles.card} testID="host-page-inject-mcp-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>
{t("settings.host.orchestration.enableTools.title")}
</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.host.orchestration.enableTools.hint")}
</Text>
</View>
<Switch
value={config?.mcp.injectIntoAgents !== false}
onValueChange={handleValueChange}
accessibilityLabel={t("settings.host.orchestration.enableTools.accessibilityLabel")}
/>
</View>
</View>
);
}
function AutoArchiveMergedWorkspacesCard({ serverId }: { serverId: string }) {
const isConnected = useHostRuntimeIsConnected(serverId);
const { config, patchConfig } = useDaemonConfig(serverId);
const handleValueChange = useCallback(
(next: boolean) => {
void patchConfig({ autoArchiveAfterMerge: next }).catch((error) => {
console.error("[HostPage] Failed to update auto-archive after merge", error);
Alert.alert(
"Unable to update workspaces",
error instanceof Error ? error.message : String(error),
);
});
},
[patchConfig],
);
if (!isConnected) return null;
return (
<View style={settingsStyles.card} testID="host-page-auto-archive-merged-workspaces-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Archive merged PR workspaces</Text>
<Text style={settingsStyles.rowHint}>
Automatically archive clean Paseo workspaces after their pull request is merged
</Text>
</View>
<Switch
value={config?.autoArchiveAfterMerge === true}
onValueChange={handleValueChange}
accessibilityLabel="Archive merged PR workspaces"
testID="host-page-auto-archive-merged-workspaces-switch"
/>
</View>
</View>
);
}
function EnableTerminalAgentHooksCard({ serverId }: { serverId: string }) {
const isConnected = useHostRuntimeIsConnected(serverId);
const { config, patchConfig } = useDaemonConfig(serverId);
const handleValueChange = useCallback(
(next: boolean) => {
void patchConfig({ enableTerminalAgentHooks: next }).catch((error) => {
console.error("[HostPage] Failed to update terminal agent hooks", error);
Alert.alert(
"Unable to update terminal agent hooks",
error instanceof Error ? error.message : String(error),
);
});
},
[patchConfig],
);
if (!isConnected) return null;
return (
<View style={settingsStyles.card} testID="host-page-terminal-agent-hooks-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Enable terminal agent hooks</Text>
<Text style={settingsStyles.rowHint}>
Get notifications and status from terminal agents. This installs hooks in your agent
config files.
</Text>
</View>
<Switch
value={config?.enableTerminalAgentHooks === true}
onValueChange={handleValueChange}
accessibilityLabel="Enable terminal agent hooks"
testID="host-page-terminal-agent-hooks-switch"
/>
</View>
</View>
);
}
function AppendSystemPromptCard({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const isConnected = useHostRuntimeIsConnected(serverId);
const { config, patchConfig } = useDaemonConfig(serverId);
const persistedPrompt = config?.appendSystemPrompt ?? "";
const [draft, setDraft] = useState(persistedPrompt);
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const header = useMemo<SheetHeader>(
() => ({ title: t("settings.host.orchestration.systemPrompt.sheetTitle") }),
[t],
);
useEffect(() => {
setDraft(persistedPrompt);
}, [persistedPrompt]);
const hasChanges = draft !== persistedPrompt;
const handleOpen = useCallback(() => {
setDraft(persistedPrompt);
setIsEditing(true);
}, [persistedPrompt]);
const handleClose = useCallback(() => {
if (isSaving) return;
setDraft(persistedPrompt);
setIsEditing(false);
}, [isSaving, persistedPrompt]);
const handleSave = useCallback(() => {
setIsSaving(true);
void patchConfig({ appendSystemPrompt: draft })
.then(() => {
setIsEditing(false);
return;
})
.catch((error) => {
console.error("[HostPage] Failed to save append system prompt", error);
})
.finally(() => setIsSaving(false));
}, [draft, patchConfig]);
const handleReset = useCallback(() => {
setDraft(persistedPrompt);
}, [persistedPrompt]);
if (!isConnected) return null;
return (
<>
<View style={settingsStyles.card} testID="host-page-append-system-prompt-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>
{t("settings.host.orchestration.systemPrompt.title")}
</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.host.orchestration.systemPrompt.hint")}
</Text>
</View>
<Button
variant="outline"
size="sm"
onPress={handleOpen}
testID="host-page-append-system-prompt-edit"
>
{t("settings.host.orchestration.systemPrompt.edit")}
</Button>
</View>
</View>
{isEditing ? (
<AdaptiveModalSheet
header={header}
visible
onClose={handleClose}
testID="host-page-append-system-prompt-sheet"
desktopMaxWidth={560}
>
<SettingsTextAreaCard
testID="host-page-append-system-prompt-input"
accessibilityLabel={t("settings.host.orchestration.systemPrompt.accessibilityLabel")}
value={draft}
onChangeText={setDraft}
placeholder={t("settings.host.orchestration.systemPrompt.placeholder")}
/>
<View style={styles.appendPromptActions}>
<Button
variant="ghost"
size="sm"
onPress={handleReset}
disabled={!hasChanges || isSaving}
testID="host-page-append-system-prompt-reset"
>
{t("settings.host.orchestration.systemPrompt.reset")}
</Button>
<Button
variant="default"
size="sm"
onPress={handleSave}
disabled={!hasChanges || isSaving}
testID="host-page-append-system-prompt-save"
>
{isSaving
? t("settings.host.orchestration.systemPrompt.saving")
: t("settings.host.orchestration.systemPrompt.save")}
</Button>
</View>
</AdaptiveModalSheet>
) : null}
</>
);
}
function PairDeviceRow() {
const { t } = useTranslation();
const { theme } = useUnistyles();
const [isModalOpen, setIsModalOpen] = useState(false);
const handleOpen = useCallback(() => setIsModalOpen(true), []);
const handleClose = useCallback(() => setIsModalOpen(false), []);
return (
<View style={settingsStyles.card}>
<Pressable
style={settingsStyles.row}
onPress={handleOpen}
accessibilityRole="button"
testID="host-page-pair-device-row"
>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.host.pairDevices.rowTitle")}</Text>
<Text style={settingsStyles.rowHint}>{t("settings.host.pairDevices.rowHint")}</Text>
</View>
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<PairDeviceModal
visible={isModalOpen}
onClose={handleClose}
testID="host-page-pair-device-card"
/>
</View>
);
}
function RemoveHostSection({
host,
isLocalDaemon,
onRemoved,
}: {
host: HostProfile;
isLocalDaemon: boolean;
onRemoved?: () => void;
}) {
const { t } = useTranslation();
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 removeHostHeader = useMemo<SheetHeader>(
() => ({
title: isLocalDaemon
? t("settings.host.daemon.remove.localConfirmTitle")
: t("settings.host.daemon.remove.title"),
}),
[isLocalDaemon, t],
);
const destructiveTextStyle = useMemo(
() => ({ color: theme.colors.destructive }),
[theme.colors.destructive],
);
const handleOpenConfirm = useCallback(() => setIsConfirming(true), []);
const handleCloseConfirm = useCallback(() => {
if (isRemoving) return;
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);
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?.();
return;
})
.catch((error) => {
console.error("[HostPage] Failed to remove host", error);
Alert.alert(
t("settings.host.daemon.remove.errorTitle"),
isLocalDaemon
? t("settings.host.daemon.remove.localErrorMessage")
: t("settings.host.daemon.remove.errorMessage"),
);
})
.finally(() => setIsRemoving(false));
}, [
daemonStatus,
host.serverId,
isLocalDaemon,
onRemoved,
removeHost,
rollbackLocalhostRemoval,
setStatus,
t,
updateSettings,
]);
const removeIcon = useMemo(
() => <Trash2 size={theme.iconSize.sm} color={theme.colors.destructive} />,
[theme.iconSize.sm, theme.colors.destructive],
);
return (
<SettingsSection
title={t("settings.host.daemon.dangerZone")}
testID="host-page-remove-host-card"
>
<RestartDaemonCard host={host} />
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>
{isLocalDaemon
? t("settings.host.daemon.remove.localTitle")
: t("settings.host.daemon.remove.title")}
</Text>
<Text style={settingsStyles.rowHint}>
{isLocalDaemon
? t("settings.host.daemon.remove.localHint")
: t("settings.host.daemon.remove.hint")}
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={removeIcon}
textStyle={destructiveTextStyle}
onPress={handleOpenConfirm}
testID="host-page-remove-host-button"
>
{t("settings.host.connections.removeAction")}
</Button>
</View>
</View>
{isConfirming ? (
<AdaptiveModalSheet
header={removeHostHeader}
visible
onClose={handleCloseConfirm}
testID="remove-host-confirm-modal"
>
<Text style={styles.confirmText}>
{isLocalDaemon
? t("settings.host.daemon.remove.localConfirmMessage")
: t("settings.host.daemon.remove.confirmMessage", { name: host.label })}
</Text>
<View style={styles.confirmActions}>
<Button
variant="secondary"
size="sm"
style={FLEX_1_STYLE}
onPress={handleCancel}
disabled={isRemoving}
>
{t("common.actions.cancel")}
</Button>
<Button
variant="destructive"
size="sm"
style={FLEX_1_STYLE}
onPress={handleConfirmRemove}
disabled={isRemoving}
testID="remove-host-confirm"
>
{t("settings.host.connections.removeAction")}
</Button>
</View>
</AdaptiveModalSheet>
) : null}
</SettingsSection>
);
}
// ---------------------------------------------------------------------------
// Terminal Profiles
// ---------------------------------------------------------------------------
function generateProfileId(): string {
return `profile_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
}
function parseArgsString(raw: string): string[] | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
return trimmed
.split(/\s+/)
.map((s) => s.trim())
.filter(Boolean);
}
const EMPTY_PROFILE_DRAFT: ProfileDraft = { name: "", command: "", args: "" };
interface TerminalProfileRowProps {
profile: TerminalProfile;
isFirst: boolean;
isLast: boolean;
onEdit: (id: string) => void;
onRemove: (id: string) => void;
onMoveUp: (id: string) => void;
onMoveDown: (id: string) => void;
}
function TerminalProfileRow({
profile,
isFirst,
isLast,
onEdit,
onRemove,
onMoveUp,
onMoveDown,
}: TerminalProfileRowProps) {
const { t } = useTranslation();
const handleEdit = useCallback(() => onEdit(profile.id), [onEdit, profile.id]);
const handleRemove = useCallback(() => onRemove(profile.id), [onRemove, profile.id]);
const handleMoveUp = useCallback(() => onMoveUp(profile.id), [onMoveUp, profile.id]);
const handleMoveDown = useCallback(() => onMoveDown(profile.id), [onMoveDown, profile.id]);
const commandText =
profile.args && profile.args.length > 0
? `${profile.command} ${profile.args.join(" ")}`
: profile.command;
const rowStyle = useMemo(
() => [settingsStyles.row, !isFirst && settingsStyles.rowBorder, terminalProfileStyles.row],
[isFirst],
);
const icon = getTerminalProfileIcon(profile);
return (
<View style={rowStyle} testID={`terminal-profile-row-${profile.id}`}>
<View style={terminalProfileStyles.iconWrapper}>
{icon ? (
<ThemedDynamicProviderIcon
iconKey={icon}
size={ICON_SIZE.md}
uniProps={mutedColorMapping}
/>
) : (
<ThemedProfileSquareTerminal size={ICON_SIZE.md} uniProps={mutedColorMapping} />
)}
</View>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{profile.name}
</Text>
<Text style={settingsStyles.rowHint} numberOfLines={1}>
{commandText}
</Text>
</View>
<View style={terminalProfileStyles.rowActions}>
<Button
variant="ghost"
size="sm"
leftIcon={moveUpIcon}
onPress={handleMoveUp}
disabled={isFirst}
accessibilityLabel={t("settings.host.terminalProfiles.moveUp")}
testID={`terminal-profile-move-up-${profile.id}`}
/>
<Button
variant="ghost"
size="sm"
leftIcon={moveDownIcon}
onPress={handleMoveDown}
disabled={isLast}
accessibilityLabel={t("settings.host.terminalProfiles.moveDown")}
testID={`terminal-profile-move-down-${profile.id}`}
/>
<Button
variant="ghost"
size="sm"
leftIcon={editProfileIcon}
onPress={handleEdit}
accessibilityLabel={t("settings.host.terminalProfiles.editProfile")}
testID={`terminal-profile-edit-${profile.id}`}
/>
<Button
variant="ghost"
size="sm"
leftIcon={removeProfileIcon}
onPress={handleRemove}
accessibilityLabel={t("settings.host.terminalProfiles.remove")}
testID={`terminal-profile-remove-${profile.id}`}
/>
</View>
</View>
);
}
function TerminalProfilesSection({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const isConnected = useHostRuntimeIsConnected(serverId);
const { config, patchConfig } = useDaemonConfig(serverId);
const [editingProfile, setEditingProfile] = useState<{
id: string;
draft: ProfileDraft;
} | null>(null);
const [isAdding, setIsAdding] = useState(false);
const profiles = useMemo(
() => (config ? resolveTerminalProfiles(config.terminalProfiles) : null),
[config],
);
const saveProfiles = useCallback(
async (next: TerminalProfile[]) => {
await patchConfig({ terminalProfiles: next });
},
[patchConfig],
);
const handleAddOpen = useCallback(() => setIsAdding(true), []);
const handleAddClose = useCallback(() => setIsAdding(false), []);
const handleAddSave = useCallback(
async (draft: ProfileDraft) => {
const current = profiles ? [...profiles] : [];
const next: TerminalProfile[] = [
...current,
{
id: generateProfileId(),
name: draft.name,
command: draft.command,
args: parseArgsString(draft.args),
},
];
await saveProfiles(next);
setIsAdding(false);
},
[profiles, saveProfiles],
);
const handleEditOpen = useCallback(
(id: string) => {
const profile = profiles?.find((p) => p.id === id);
if (!profile) return;
setEditingProfile({
id,
draft: {
name: profile.name,
command: profile.command,
args: profile.args ? profile.args.join(" ") : "",
},
});
},
[profiles],
);
const handleEditClose = useCallback(() => setEditingProfile(null), []);
const handleEditSave = useCallback(
async (draft: ProfileDraft) => {
if (!editingProfile || !profiles) return;
const next: TerminalProfile[] = profiles.map((p) =>
p.id === editingProfile.id
? {
...p,
name: draft.name,
command: draft.command,
args: parseArgsString(draft.args),
}
: p,
);
await saveProfiles(next);
setEditingProfile(null);
},
[editingProfile, profiles, saveProfiles],
);
const handleRemove = useCallback(
(id: string) => {
const profile = profiles?.find((p) => p.id === id);
if (!profile) return;
void confirmDialog({
title: t("settings.host.terminalProfiles.removeConfirmTitle"),
message: t("settings.host.terminalProfiles.removeConfirmMessage", {
name: profile.name,
}),
confirmLabel: t("settings.host.terminalProfiles.remove"),
cancelLabel: t("common.actions.cancel"),
destructive: true,
}).then(async (confirmed) => {
if (!confirmed || !profiles) return;
try {
await saveProfiles(profiles.filter((p) => p.id !== id));
} catch (error) {
Alert.alert(
t("common.errors.unableToSave"),
error instanceof Error ? error.message : String(error),
);
}
return;
});
},
[profiles, saveProfiles, t],
);
const handleMoveUp = useCallback(
async (id: string) => {
if (!profiles) return;
const index = profiles.findIndex((p) => p.id === id);
if (index <= 0) return;
const next = [...profiles];
const [item] = next.splice(index, 1);
next.splice(index - 1, 0, item);
try {
await saveProfiles(next);
} catch (error) {
Alert.alert(
t("common.errors.unableToSave"),
error instanceof Error ? error.message : String(error),
);
}
},
[profiles, saveProfiles, t],
);
const handleMoveDown = useCallback(
async (id: string) => {
if (!profiles) return;
const index = profiles.findIndex((p) => p.id === id);
if (index < 0 || index >= profiles.length - 1) return;
const next = [...profiles];
const [item] = next.splice(index, 1);
next.splice(index + 1, 0, item);
try {
await saveProfiles(next);
} catch (error) {
Alert.alert(
t("common.errors.unableToSave"),
error instanceof Error ? error.message : String(error),
);
}
},
[profiles, saveProfiles, t],
);
const addButton = useMemo(
() => (
<Button
variant="ghost"
size="sm"
leftIcon={addProfileIcon}
onPress={handleAddOpen}
disabled={!isConnected || !profiles}
testID="terminal-profiles-add-button"
/>
),
[handleAddOpen, isConnected, profiles],
);
if (!isConnected) {
return (
<View style={settingsStyles.card} testID="terminal-profiles-unavailable">
<View style={terminalProfileStyles.emptyCard}>
<Text style={terminalProfileStyles.emptyText}>
{t("settings.host.terminalProfiles.unavailable")}
</Text>
</View>
</View>
);
}
return (
<>
<SettingsSection
title={t("settings.host.terminalProfiles.sectionTitle")}
trailing={addButton}
testID="terminal-profiles-section"
>
<View style={settingsStyles.card} testID="terminal-profiles-card">
{profiles && profiles.length > 0 ? (
profiles.map((profile, index) => (
<TerminalProfileRow
key={profile.id}
profile={profile}
isFirst={index === 0}
isLast={index === profiles.length - 1}
onEdit={handleEditOpen}
onRemove={handleRemove}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/>
))
) : (
<View style={terminalProfileStyles.emptyCard}>
<Text style={terminalProfileStyles.emptyText}>
{t("settings.host.terminalProfiles.emptyState")}
</Text>
</View>
)}
</View>
</SettingsSection>
<TerminalProfileEditModal
visible={isAdding}
title={t("settings.host.terminalProfiles.addProfileTitle")}
initialDraft={EMPTY_PROFILE_DRAFT}
onClose={handleAddClose}
onSave={handleAddSave}
testID="terminal-profile-edit-modal"
/>
{editingProfile ? (
<TerminalProfileEditModal
visible
title={t("settings.host.terminalProfiles.editProfileTitle")}
initialDraft={editingProfile.draft}
onClose={handleEditClose}
onSave={handleEditSave}
/>
) : null}
</>
);
}
export function HostTerminalsPage({ serverId }: { serverId: string }) {
const host = useHostProfile(serverId);
if (!host) {
return <HostNotFound />;
}
return (
<View>
<SettingsSection title="Terminal agents">
<EnableTerminalAgentHooksCard serverId={serverId} />
</SettingsSection>
<TerminalProfilesSection serverId={serverId} />
</View>
);
}
const terminalProfileStyles = StyleSheet.create((theme) => ({
row: {
gap: theme.spacing[2],
minHeight: 56,
},
iconWrapper: {
width: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
},
rowActions: {
flexDirection: "row",
alignItems: "center",
gap: 0,
},
emptyCard: {
padding: theme.spacing[4],
alignItems: "center",
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
}));
const styles = StyleSheet.create((theme) => ({
identityEditButton: {
padding: theme.spacing[1],
borderRadius: theme.borderRadius.md,
},
daemonHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
marginBottom: theme.spacing[4],
},
daemonHeaderLabel: {
flexShrink: 1,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
identityBadges: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexWrap: "wrap",
marginBottom: theme.spacing[6],
},
statusPill: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingHorizontal: theme.spacing[2],
paddingVertical: 4,
borderRadius: theme.borderRadius.full,
},
statusDot: {
width: 6,
height: 6,
borderRadius: theme.borderRadius.full,
},
statusText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
badgePill: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingHorizontal: theme.spacing[2],
paddingVertical: 4,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface3,
maxWidth: 200,
},
badgeText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
errorText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
marginBottom: theme.spacing[2],
},
connectionLatency: {
fontSize: theme.fontSize.sm,
marginRight: theme.spacing[2],
},
confirmText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
confirmActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginTop: theme.spacing[4],
},
appendPromptActions: {
flexDirection: "row",
justifyContent: "flex-end",
gap: theme.spacing[2],
},
emptyCard: {
padding: theme.spacing[4],
alignItems: "center",
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));
const FLEX_1_STYLE = { flex: 1 };
const EMPTY_CARD_STYLE = [settingsStyles.card, styles.emptyCard];