feat: migrate from wsUrl to endpoints + add relay transport and pairing

This commit is contained in:
Mohamed Boudra
2026-01-21 14:55:23 +07:00
parent ae9a98223f
commit ddbcc272f1
29 changed files with 1729 additions and 280 deletions
@@ -1,5 +1,126 @@
import { SessionProvider } from "@/contexts/session-context";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import {
useDaemonRegistry,
type HostProfile,
} from "@/contexts/daemon-registry-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints";
import { useEffect, useMemo, useRef, useState } from "react";
function buildCandidateUrls(daemon: HostProfile): string[] {
const endpoints = daemon.endpoints ?? [];
const sessionId = daemon.relay?.sessionId ?? null;
const lastKnownGood =
typeof daemon.metadata?.lastKnownGoodEndpoint === "string"
? (daemon.metadata.lastKnownGoodEndpoint as string)
: null;
const out: string[] = [];
const push = (url: string) => {
if (!out.includes(url)) out.push(url);
};
if (lastKnownGood) {
push(buildDaemonWebSocketUrl(lastKnownGood));
}
for (const endpoint of endpoints) {
push(buildDaemonWebSocketUrl(endpoint));
}
if (sessionId) {
if (lastKnownGood) {
push(buildRelayWebSocketUrl({ endpoint: lastKnownGood, sessionId }));
}
for (const endpoint of endpoints) {
push(buildRelayWebSocketUrl({ endpoint, sessionId }));
}
}
return out;
}
function extractEndpointFromUrl(url: string): string | null {
try {
const parsed = new URL(url);
const host = parsed.hostname;
const port = parsed.port ? Number(parsed.port) : parsed.protocol === "wss:" ? 443 : 80;
const isIpv6 = host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
} catch {
return null;
}
}
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const { connectionStates } = useDaemonConnections();
const { updateDaemon } = useDaemonRegistry();
const candidates = useMemo(() => buildCandidateUrls(daemon), [daemon]);
const [activeIndex, setActiveIndex] = useState(0);
const activeUrl = candidates[activeIndex] ?? candidates[0] ?? buildDaemonWebSocketUrl("localhost:6767");
const lastAttemptedUrlRef = useRef<string | null>(null);
const pendingMetadataWriteRef = useRef(false);
useEffect(() => {
// If the active URL fell out of the candidate set (e.g. endpoints updated), snap back.
const idx = candidates.indexOf(activeUrl);
if (idx === -1) {
setActiveIndex(0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [candidates.join("|")]);
const connection = connectionStates.get(daemon.id);
const status = connection?.status ?? "idle";
const lastError = connection?.lastError ?? null;
useEffect(() => {
if (!connection) return;
if (status === "online") {
if (pendingMetadataWriteRef.current) return;
const endpoint = extractEndpointFromUrl(activeUrl);
if (!endpoint) return;
if (daemon.metadata?.lastKnownGoodEndpoint === endpoint) return;
pendingMetadataWriteRef.current = true;
void updateDaemon(daemon.id, {
metadata: { ...(daemon.metadata ?? {}), lastKnownGoodEndpoint: endpoint },
}).finally(() => {
pendingMetadataWriteRef.current = false;
});
return;
}
if ((status === "error" || (status === "offline" && lastError)) && candidates.length > 1) {
if (lastAttemptedUrlRef.current === activeUrl) {
return;
}
lastAttemptedUrlRef.current = activeUrl;
if (activeIndex < candidates.length - 1) {
setActiveIndex((idx) => Math.min(idx + 1, candidates.length - 1));
}
}
}, [
activeIndex,
activeUrl,
candidates.length,
daemon.id,
daemon.metadata,
lastError,
status,
updateDaemon,
connection,
]);
return (
<SessionProvider key={`${daemon.id}:${activeUrl}`} serverUrl={activeUrl} serverId={daemon.id}>
{null}
</SessionProvider>
);
}
export function MultiDaemonSessionHost() {
const { daemons } = useDaemonRegistry();
@@ -10,9 +131,7 @@ export function MultiDaemonSessionHost() {
return (
<>
{daemons.map((daemon) => (
<SessionProvider key={daemon.id} serverUrl={daemon.wsUrl} serverId={daemon.id}>
{null}
</SessionProvider>
<ManagedDaemonSession key={daemon.id} daemon={daemon} />
))}
</>
);
+11 -36
View File
@@ -12,7 +12,6 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
import { Plus, Settings } from "lucide-react-native";
import { router } from "expo-router";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { AgentList } from "./agent-list";
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
@@ -25,30 +24,6 @@ interface SlidingSidebarProps {
selectedAgentId?: string;
}
function buildCreateAgentRouteFromSelectedAgentKey(selectedAgentKey?: string) {
if (!selectedAgentKey) {
return { pathname: "/" as const, params: {} as Record<string, string> };
}
const [serverId, agentId] = selectedAgentKey.split(":", 2);
if (!serverId || !agentId) {
return { pathname: "/" as const, params: {} as Record<string, string> };
}
const state = useSessionStore.getState();
const agent = state.sessions[serverId]?.agents.get(agentId);
if (!agent) {
return { pathname: "/" as const, params: {} as Record<string, string> };
}
const params: Record<string, string> = { serverId: agent.serverId };
if (agent.provider) params.provider = agent.provider;
if (agent.currentModeId) params.modeId = agent.currentModeId;
if (agent.model) params.model = agent.model;
if (agent.cwd) params.workingDir = agent.cwd;
return { pathname: "/" as const, params };
}
export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
@@ -106,20 +81,20 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
closeToAgent();
}, [closeToAgent]);
const handleCreateAgent = useCallback(() => {
router.push(buildCreateAgentRouteFromSelectedAgentKey(selectedAgentId));
}, [selectedAgentId]);
const handleCreateAgentClean = useCallback(() => {
router.push("/");
}, []);
// Mobile: close sidebar and navigate
const handleCreateAgentMobile = useCallback(() => {
const handleCreateAgentCleanMobile = useCallback(() => {
closeToAgent();
handleCreateAgent();
}, [closeToAgent, handleCreateAgent]);
handleCreateAgentClean();
}, [closeToAgent, handleCreateAgentClean]);
// Desktop: just navigate, don't close
const handleCreateAgentDesktop = useCallback(() => {
handleCreateAgent();
}, [handleCreateAgent]);
const handleCreateAgentCleanDesktop = useCallback(() => {
handleCreateAgentClean();
}, [handleCreateAgentClean]);
// Mobile: close sidebar and navigate
const handleSettingsMobile = useCallback(() => {
@@ -240,7 +215,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
hovered && styles.newAgentButtonHovered,
]}
testID="sidebar-new-agent"
onPress={handleCreateAgentMobile}
onPress={handleCreateAgentCleanMobile}
>
<Plus size={18} color={theme.colors.foreground} />
<Text style={styles.newAgentButtonText}>New Agent</Text>
@@ -292,7 +267,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
hovered && styles.newAgentButtonHovered,
]}
testID="sidebar-new-agent"
onPress={handleCreateAgentDesktop}
onPress={handleCreateAgentCleanDesktop}
>
<Plus size={18} color={theme.colors.foreground} />
<Text style={styles.newAgentButtonText}>New Agent</Text>