feat: migrate from wsUrl to endpoints + add relay transport and pairing
This commit is contained in:
@@ -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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user