feat: implement daemon connectivity updates and relay protocol versioning

This commit is contained in:
Mohamed Boudra
2026-02-20 19:27:56 +07:00
parent 6dad3212da
commit 0350f00840
49 changed files with 4146 additions and 1351 deletions
@@ -76,9 +76,10 @@ import {
derivePendingPermissionKey,
normalizeAgentSnapshot,
} from "@/utils/agent-snapshots";
import { resolveProjectPlacement } from "@/utils/project-placement";
import { mergePendingCreateImages } from "@/utils/pending-create-images";
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
import type { DaemonClient, FetchAgentsEntry } from "@server/client/daemon-client";
import type { DaemonClient } from "@server/client/daemon-client";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import {
DropdownMenu,
@@ -128,7 +129,7 @@ export function AgentReadyScreen({
const isUnknownDaemon = Boolean(connectionServerId && !connection);
const connectionStatus: HostRuntimeConnectionStatus =
runtimeSnapshot?.connectionStatus ??
(isUnknownDaemon ? "offline" : "idle");
(isUnknownDaemon ? "offline" : "connecting");
const lastConnectionError = runtimeSnapshot?.lastError ?? null;
const isRuntimeSessionAvailable = Boolean(resolvedServerId && runtimeClient);
@@ -278,7 +279,6 @@ function AgentScreenContent({
const checkout = checkoutStatusQuery.status;
const resolveCachedCheckoutIsGit = useCallback(
(params: {
agentId?: string | null;
cwd?: string | null;
projectPlacementIsGit?: boolean;
checkoutStatusIsGit?: boolean;
@@ -287,19 +287,6 @@ function AgentScreenContent({
return params.projectPlacementIsGit;
}
const agentId = params.agentId?.trim();
if (agentId) {
const sidebarAgents = queryClient.getQueryData<{
entries: FetchAgentsEntry[];
}>(["sidebarAgentsList", serverId]);
const sidebarIsGit = sidebarAgents?.entries.find(
(entry) => entry.agent.id === agentId
)?.project?.checkout?.isGit;
if (typeof sidebarIsGit === "boolean") {
return sidebarIsGit;
}
}
const cwd = params.cwd?.trim();
if (!cwd) {
return null;
@@ -327,7 +314,6 @@ function AgentScreenContent({
?.agents?.get(resolvedAgentId);
const cwd = currentAgent?.cwd?.trim();
const isGit = resolveCachedCheckoutIsGit({
agentId: resolvedAgentId,
cwd,
projectPlacementIsGit: currentAgent?.projectPlacement?.checkout?.isGit,
checkoutStatusIsGit: checkout?.isGit,
@@ -394,7 +380,6 @@ function AgentScreenContent({
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
const cwd = agent?.cwd?.trim();
const isGit = resolveCachedCheckoutIsGit({
agentId: resolvedAgentId,
cwd,
projectPlacementIsGit: agent?.projectPlacement?.checkout?.isGit,
checkoutStatusIsGit: checkout?.isGit,
@@ -458,6 +443,7 @@ function AgentScreenContent({
? state.sessions[serverId]?.agentHistorySyncGeneration?.get(resolvedAgentId) ?? -1
: -1
);
const hasHydratedHistoryBefore = agentHistorySyncGeneration >= 0;
// Select raw pending permissions - filter with useMemo to avoid new Map on every render
const allPendingPermissions = useSessionStore(
@@ -517,6 +503,38 @@ function AgentScreenContent({
};
});
const handleHistorySyncFailure = useCallback(
({ origin, error }: { origin: "focus" | "entry"; error: unknown }) => {
if (resolvedAgentId) {
console.warn("[AgentScreen] history sync failed", {
origin,
agentId: resolvedAgentId,
error,
});
}
const message = toErrorMessage(error);
setMissingAgentState((prev) => {
if (prev.kind === "error" && prev.message === message) {
return prev;
}
return { kind: "error", message };
});
},
[resolvedAgentId]
);
const ensureInitializedWithSyncErrorHandling = useCallback(
(origin: "focus" | "entry") => {
if (!resolvedAgentId) {
return;
}
ensureAgentIsInitialized(resolvedAgentId).catch((error) => {
handleHistorySyncFailure({ origin, error });
});
},
[ensureAgentIsInitialized, handleHistorySyncFailure, resolvedAgentId]
);
useEffect(() => {
if (connectionStatus === "online") {
reconnectToastArmedRef.current = false;
@@ -534,25 +552,18 @@ function AgentScreenContent({
}
}, [connectionStatus, toast]);
useEffect(() => {
if (missingAgentState.kind !== "error") {
return;
}
toast.error("Failed to refresh agent. Retrying in background.");
}, [missingAgentState.kind, toast]);
useFocusEffect(
useCallback(() => {
if (!resolvedAgentId || !isConnected || !hasSession) {
return;
}
ensureAgentIsInitialized(resolvedAgentId).catch((error) => {
console.warn("[AgentScreen] focus sync failed", {
agentId: resolvedAgentId,
error,
});
});
}, [ensureAgentIsInitialized, hasSession, isConnected, resolvedAgentId])
ensureInitializedWithSyncErrorHandling("focus");
}, [
ensureInitializedWithSyncErrorHandling,
hasSession,
isConnected,
resolvedAgentId,
])
);
const isGitCheckout = activeExplorerCheckout?.isGit ?? false;
@@ -688,6 +699,7 @@ function AgentScreenContent({
isHistorySyncing,
needsAuthoritativeSync,
shouldUseOptimisticStream,
hasHydratedHistoryBefore,
},
});
@@ -769,15 +781,10 @@ function AgentScreenContent({
return;
}
ensureAgentIsInitialized(resolvedAgentId).catch((error) => {
console.warn("[AgentScreen] Agent initialization failed", {
agentId: resolvedAgentId,
error,
});
});
ensureInitializedWithSyncErrorHandling("entry");
}, [
resolvedAgentId,
ensureAgentIsInitialized,
ensureInitializedWithSyncErrorHandling,
hasSession,
isConnected,
needsAuthoritativeSync,
@@ -831,21 +838,28 @@ function AgentScreenContent({
return;
}
const normalized = normalizeAgentSnapshot(snapshot, serverId);
const hydrated = {
...normalized,
projectPlacement: resolveProjectPlacement({
projectPlacement: null,
cwd: normalized.cwd,
}),
};
setAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(normalized.id, normalized);
next.set(hydrated.id, hydrated);
return next;
});
setPendingPermissions(serverId, (prev) => {
const next = new Map(prev);
for (const [key, pending] of next.entries()) {
if (pending.agentId === normalized.id) {
if (pending.agentId === hydrated.id) {
next.delete(key);
}
}
for (const request of normalized.pendingPermissions) {
const key = derivePendingPermissionKey(normalized.id, request);
next.set(key, { key, agentId: normalized.id, request });
for (const request of hydrated.pendingPermissions) {
const key = derivePendingPermissionKey(hydrated.id, request);
next.set(key, { key, agentId: hydrated.id, request });
}
return next;
});
@@ -940,14 +954,31 @@ function AgentScreenContent({
[theme.colors.primary, toast]
);
const syncBannerMessage =
viewState.tag !== "ready"
? null
: viewState.syncStatus === "catching_up"
? "Refreshing agent history..."
: viewState.syncStatus === "sync_error"
? "Refresh delayed. Retrying in background."
: null;
const shouldEmitHistoryRefreshToast =
viewState.tag === "ready" &&
viewState.sync.status === "catching_up" &&
viewState.sync.shouldEmitHistoryRefreshToast;
const shouldEmitSyncErrorToast =
viewState.tag === "ready" &&
viewState.sync.status === "sync_error" &&
viewState.sync.shouldEmitSyncErrorToast;
useEffect(() => {
if (!shouldEmitHistoryRefreshToast) {
return;
}
toast.show("Refreshing agent history...", {
durationMs: 2200,
testID: "agent-history-refresh-toast",
});
}, [shouldEmitHistoryRefreshToast, toast]);
useEffect(() => {
if (!shouldEmitSyncErrorToast) {
return;
}
toast.error("Failed to refresh agent. Retrying in background.");
}, [shouldEmitSyncErrorToast, toast]);
if (viewState.tag === "not_found") {
return (
@@ -1188,12 +1219,6 @@ function AgentScreenContent({
}
/>
{syncBannerMessage ? (
<View style={styles.syncBanner} testID="agent-sync-banner">
<Text style={styles.syncBannerText}>{syncBannerMessage}</Text>
</View>
) : null}
{/* Content Area with Keyboard Animation */}
<View style={styles.contentContainer}>
<ReanimatedAnimated.View
@@ -1221,6 +1246,14 @@ function AgentScreenContent({
/>
)}
{viewState.tag === "ready" &&
viewState.sync.status === "catching_up" &&
viewState.sync.ui === "overlay" ? (
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
</View>
) : null}
</View>
</FileDropZone>
@@ -1357,24 +1390,20 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
overflow: "hidden",
},
syncBanner: {
marginHorizontal: theme.spacing[4],
marginTop: theme.spacing[1],
marginBottom: theme.spacing[2],
borderRadius: theme.borderRadius.full,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
},
syncBannerText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
content: {
flex: 1,
},
historySyncOverlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: theme.colors.surface0,
alignItems: "center",
justifyContent: "center",
zIndex: 40,
},
archivingOverlay: {
position: "absolute",
top: 0,
+33 -59
View File
@@ -12,17 +12,18 @@ import { router, useLocalSearchParams } from "expo-router";
import Constants from "expo-constants";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useQueries } from "@tanstack/react-query";
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2, Check } from "lucide-react-native";
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context";
import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
import { measureConnectionLatency } from "@/utils/test-daemon-connection";
import { confirmDialog } from "@/utils/confirm-dialog";
import { MenuHeader } from "@/components/headers/menu-header";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHostRuntimeSession,
} from "@/runtime/host-runtime";
import { AddHostMethodModal } from "@/components/add-host-method-modal";
import { AddHostModal } from "@/components/add-host-modal";
import { PairLinkModal } from "@/components/pair-link-modal";
@@ -457,7 +458,6 @@ export default function SettingsScreen() {
removeHost,
removeConnection,
} = useDaemonRegistry();
const { connectionStates } = useDaemonConnections();
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
@@ -723,17 +723,10 @@ export default function SettingsScreen() {
</View>
) : (
daemons.map((daemon) => {
const connection = connectionStates.get(daemon.serverId);
const connectionStatus = connection?.status ?? "idle";
const activeConnection = connection?.activeConnection ?? null;
const lastConnectionError = connection?.lastError ?? null;
return (
<DaemonCard
key={daemon.serverId}
daemon={daemon}
connectionStatus={connectionStatus}
activeConnection={activeConnection}
lastError={lastConnectionError}
onOpenSettings={handleEditDaemon}
/>
);
@@ -865,9 +858,6 @@ export default function SettingsScreen() {
<HostDetailModal
visible={Boolean(editingDaemonLive)}
host={editingDaemonLive}
connectionStatus={editingServerId ? (connectionStates.get(editingServerId)?.status ?? "idle") : "idle"}
activeConnection={editingServerId ? (connectionStates.get(editingServerId)?.activeConnection ?? null) : null}
lastError={editingServerId ? (connectionStates.get(editingServerId)?.lastError ?? null) : null}
isSaving={isSavingEdit}
onClose={handleCloseEditDaemon}
onSave={(label) => void handleSaveEditDaemon(label)}
@@ -983,9 +973,6 @@ export default function SettingsScreen() {
interface HostDetailModalProps {
visible: boolean;
host: HostProfile | null;
connectionStatus: ConnectionStatus;
activeConnection: ActiveConnection | null;
lastError: string | null;
isSaving: boolean;
onClose: () => void;
onSave: (label: string) => void;
@@ -1000,9 +987,6 @@ interface HostDetailModalProps {
function HostDetailModal({
visible,
host,
connectionStatus,
activeConnection,
lastError,
isSaving,
onClose,
onSave,
@@ -1018,45 +1002,37 @@ function HostDetailModal({
const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ serverId: string; connectionId: string; title: string } | null>(null);
const [isRemovingConnection, setIsRemovingConnection] = useState(false);
// Latency probes for each connection
// Read per-connection probes from host runtime snapshots.
const connections = host?.connections ?? [];
const latencyQueries = useQueries({
queries: connections.map((conn) => ({
queryKey: ["connection-latency", conn.id],
queryFn: () => measureConnectionLatency(conn, { serverId: host?.serverId }),
enabled: visible,
refetchInterval: 5_000,
staleTime: 4_000,
gcTime: 60_000,
retry: 1,
})),
});
const latencyByConnectionId = new Map(
connections.map((conn, i) => [conn.id, latencyQueries[i]] as const)
);
// Restart logic (moved from DaemonCard)
const { client: runtimeClient, isConnected } = useHostRuntimeSession(
const { snapshot: runtimeSnapshot, client: runtimeClient, isConnected } = useHostRuntimeSession(
host?.serverId ?? ""
);
const runtime = getHostRuntimeStore();
const daemonClient = runtimeClient;
const daemonVersion = useSessionStore((state) => host ? (state.sessions[host.serverId]?.serverInfo?.version ?? null) : null);
const isConnectedRef = useRef(isConnected);
const probeByConnectionId = runtimeSnapshot?.probeByConnectionId ?? new Map();
const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting";
const activeConnection = runtimeSnapshot?.activeConnection ?? null;
const lastError = runtimeSnapshot?.lastError ?? null;
const [isRestarting, setIsRestarting] = useState(false);
useEffect(() => {
isConnectedRef.current = isConnected;
}, [isConnected]);
const isHostConnected = useCallback(() => {
if (!host) {
return false;
}
return isHostRuntimeConnected(runtime.getSnapshot(host.serverId));
}, [host, runtime]);
const waitForDaemonRestart = useCallback(async () => {
const disconnectTimeoutMs = 7000;
const reconnectTimeoutMs = 30000;
if (isConnectedRef.current) {
await waitForCondition(() => !isConnectedRef.current, disconnectTimeoutMs);
if (isHostConnected()) {
await waitForCondition(() => !isHostConnected(), disconnectTimeoutMs);
}
const reconnected = await waitForCondition(() => isConnectedRef.current, reconnectTimeoutMs);
const reconnected = await waitForCondition(() => isHostConnected(), reconnectTimeoutMs);
if (isScreenMountedRef.current) {
setIsRestarting(false);
@@ -1067,12 +1043,12 @@ function HostDetailModal({
);
}
}
}, [host, isScreenMountedRef, waitForCondition]);
}, [host, isHostConnected, isScreenMountedRef, waitForCondition]);
const beginServerRestart = useCallback(() => {
if (!daemonClient || !host) return;
if (!isConnectedRef.current) {
if (!isHostConnected()) {
Alert.alert(
"Host offline",
"This host is offline. Paseo reconnects automatically—wait until it's back online before restarting."
@@ -1094,7 +1070,7 @@ function HostDetailModal({
});
void waitForDaemonRestart();
}, [daemonClient, host, isScreenMountedRef, waitForDaemonRestart]);
}, [daemonClient, host, isHostConnected, isScreenMountedRef, waitForDaemonRestart]);
const handleRestartPress = useCallback(() => {
if (!daemonClient || !host) {
@@ -1225,14 +1201,14 @@ function HostDetailModal({
<Text style={styles.label}>Connections</Text>
<View style={{ gap: 8 }}>
{host.connections.map((conn) => {
const latency = latencyByConnectionId.get(conn.id);
const probe = probeByConnectionId.get(conn.id);
return (
<ConnectionRow
key={conn.id}
connection={conn}
latencyMs={latency?.data ?? undefined}
latencyLoading={latency?.isLoading ?? false}
latencyError={latency?.isError ?? false}
latencyMs={probe?.status === "available" ? probe.latencyMs : undefined}
latencyLoading={!probe || probe.status === "pending"}
latencyError={probe?.status === "unavailable"}
onRemove={() => {
const title =
conn.type === "relay"
@@ -1272,7 +1248,7 @@ function HostDetailModal({
leading={<RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
status={isRestarting ? "pending" : "idle"}
pendingLabel="Restarting..."
disabled={!daemonClient || !isConnectedRef.current}
disabled={!daemonClient || !isConnected}
>
Restart daemon
</DropdownMenuItem>
@@ -1475,20 +1451,18 @@ function ConnectionRow({
interface DaemonCardProps {
daemon: HostProfile;
connectionStatus: ConnectionStatus;
activeConnection: ActiveConnection | null;
lastError: string | null;
onOpenSettings: (daemon: HostProfile) => void;
}
function DaemonCard({
daemon,
connectionStatus,
activeConnection,
lastError,
onOpenSettings,
}: DaemonCardProps) {
const { theme } = useUnistyles();
const { snapshot } = useHostRuntimeSession(daemon.serverId);
const connectionStatus = snapshot?.connectionStatus ?? "connecting";
const activeConnection = snapshot?.activeConnection ?? null;
const lastError = snapshot?.lastError ?? null;
const daemonVersion = useSessionStore(
useCallback(
(state) => state.sessions[daemon.serverId]?.serverInfo?.version ?? null,