feat: migrate from wsUrl to endpoints + add relay transport and pairing
This commit is contained in:
@@ -16,6 +16,7 @@ import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState, useEffect, type ReactNode, useMemo } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import * as Linking from "expo-linking";
|
||||
import { SlidingSidebar } from "@/components/sliding-sidebar";
|
||||
import { DownloadToast } from "@/components/download-toast";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
@@ -184,7 +185,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
|
||||
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
const { settings, isLoading: settingsLoading } = useAppSettings();
|
||||
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
|
||||
const { daemons, isLoading: registryLoading, upsertDaemonFromOfferUrl } = useDaemonRegistry();
|
||||
const isLoading = settingsLoading || registryLoading;
|
||||
|
||||
// Apply theme setting on mount and when it changes
|
||||
@@ -202,11 +203,43 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
return <LoadingView />;
|
||||
}
|
||||
|
||||
if (daemons.length === 0) {
|
||||
return <MissingDaemonView />;
|
||||
}
|
||||
return (
|
||||
<RealtimeProvider>
|
||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertDaemonFromOfferUrl} />
|
||||
{children}
|
||||
</RealtimeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return <RealtimeProvider>{children}</RealtimeProvider>;
|
||||
function OfferLinkListener({
|
||||
upsertDaemonFromOfferUrl,
|
||||
}: {
|
||||
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<unknown>;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const handleUrl = (url: string | null) => {
|
||||
if (!url) return;
|
||||
if (!url.includes("#offer=")) return;
|
||||
void upsertDaemonFromOfferUrl(url).catch((error) => {
|
||||
if (cancelled) return;
|
||||
console.warn("[Linking] Failed to import pairing offer", error);
|
||||
});
|
||||
};
|
||||
|
||||
void Linking.getInitialURL().then(handleUrl).catch(() => undefined);
|
||||
|
||||
const subscription = Linking.addEventListener("url", (event) => {
|
||||
handleUrl(event.url);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
subscription.remove();
|
||||
};
|
||||
}, [upsertDaemonFromOfferUrl]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
GitBranch,
|
||||
Folder,
|
||||
RotateCcw,
|
||||
PlusIcon,
|
||||
PanelRight,
|
||||
} from "lucide-react-native";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
@@ -561,27 +560,6 @@ function AgentScreenContent({
|
||||
refreshAgent({ agentId: resolvedAgentId });
|
||||
}, [handleCloseMenu, resolvedAgentId, refreshAgent]);
|
||||
|
||||
const handleCreateNewAgent = useCallback(() => {
|
||||
if (!agent) {
|
||||
return;
|
||||
}
|
||||
handleCloseMenu();
|
||||
const params: Record<string, string> = { serverId };
|
||||
if (agent.cwd) {
|
||||
params.workingDir = agent.cwd;
|
||||
}
|
||||
if (agent.provider) {
|
||||
params.provider = agent.provider;
|
||||
}
|
||||
if (agent.currentModeId) {
|
||||
params.modeId = agent.currentModeId;
|
||||
}
|
||||
if (agentModel) {
|
||||
params.model = agentModel;
|
||||
}
|
||||
router.push({ pathname: "/", params });
|
||||
}, [agent, agentModel, handleCloseMenu, router, serverId]);
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -747,10 +725,6 @@ function AgentScreenContent({
|
||||
<Folder size={16} color={theme.colors.foreground} />
|
||||
<Text style={styles.menuItemText}>Browse Files</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={handleCreateNewAgent} style={styles.menuItem}>
|
||||
<PlusIcon size={16} color={theme.colors.foreground} />
|
||||
<Text style={styles.menuItemText}>New Agent</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleRefreshAgent}
|
||||
style={[
|
||||
|
||||
@@ -571,7 +571,7 @@ export default function HomeScreen() {
|
||||
<View style={styles.dropdownSheetList}>
|
||||
{Array.from(connectionStates.values()).map(({ daemon, status }) => {
|
||||
const isSelected = daemon.id === selectedServerId;
|
||||
const label = daemon.label ?? daemon.wsUrl ?? daemon.id;
|
||||
const label = daemon.label ?? daemon.endpoints?.[0] ?? daemon.id;
|
||||
return (
|
||||
<Pressable
|
||||
key={daemon.id}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { theme as defaultTheme } from "@/styles/theme";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
||||
import { buildDaemonWebSocketUrl, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
|
||||
const delay = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -380,10 +381,23 @@ type DaemonTestState = {
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings();
|
||||
const { daemons, isLoading: daemonLoading, addDaemon, updateDaemon, removeDaemon } = useDaemonRegistry();
|
||||
const {
|
||||
daemons,
|
||||
isLoading: daemonLoading,
|
||||
addDaemon,
|
||||
updateDaemon,
|
||||
removeDaemon,
|
||||
upsertDaemonFromOfferUrl,
|
||||
} = useDaemonRegistry();
|
||||
const { connectionStates, updateConnectionStatus } = useDaemonConnections();
|
||||
const [isDaemonFormVisible, setIsDaemonFormVisible] = useState(false);
|
||||
const [daemonForm, setDaemonForm] = useState<{ id: string | null; label: string; wsUrl: string }>({ id: null, label: "", wsUrl: "" });
|
||||
const [daemonFormMode, setDaemonFormMode] = useState<"choose" | "manual" | "pair">("choose");
|
||||
const [daemonForm, setDaemonForm] = useState<{
|
||||
id: string | null;
|
||||
label: string;
|
||||
endpoint: string;
|
||||
offerUrl: string;
|
||||
}>({ id: null, label: "", endpoint: "", offerUrl: "" });
|
||||
const [isSavingDaemon, setIsSavingDaemon] = useState(false);
|
||||
const [daemonTestStates, setDaemonTestStates] = useState<Map<string, DaemonTestState>>(() => new Map());
|
||||
const isLoading = settingsLoading || daemonLoading;
|
||||
@@ -448,51 +462,86 @@ export default function SettingsScreen() {
|
||||
|
||||
const handleOpenDaemonForm = useCallback((profile?: DaemonProfile) => {
|
||||
if (profile) {
|
||||
setDaemonFormMode("manual");
|
||||
setDaemonForm({
|
||||
id: profile.id,
|
||||
label: profile.label,
|
||||
wsUrl: profile.wsUrl,
|
||||
endpoint: profile.endpoints?.[0] ?? "",
|
||||
offerUrl: "",
|
||||
});
|
||||
} else {
|
||||
setDaemonForm({ id: null, label: "", wsUrl: "" });
|
||||
setDaemonFormMode("choose");
|
||||
setDaemonForm({ id: null, label: "", endpoint: "", offerUrl: "" });
|
||||
}
|
||||
setIsDaemonFormVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseDaemonForm = useCallback(() => {
|
||||
setIsDaemonFormVisible(false);
|
||||
setDaemonForm({ id: null, label: "", wsUrl: "" });
|
||||
setDaemonFormMode("choose");
|
||||
setDaemonForm({ id: null, label: "", endpoint: "", offerUrl: "" });
|
||||
}, []);
|
||||
|
||||
const handleSubmitDaemonForm = useCallback(async () => {
|
||||
if (!daemonForm.label.trim()) {
|
||||
Alert.alert("Label required", "Please enter a label for the host.");
|
||||
return;
|
||||
}
|
||||
if (!validateServerUrl(daemonForm.wsUrl)) {
|
||||
Alert.alert("Invalid URL", "Host URL must be ws:// or wss://");
|
||||
if (daemonFormMode === "manual") {
|
||||
const raw = daemonForm.endpoint.trim();
|
||||
if (raw.includes("://") || raw.includes("/")) {
|
||||
Alert.alert("Invalid host", "Manual hosts must be entered as host:port (no ws://, no /ws).");
|
||||
return;
|
||||
}
|
||||
|
||||
let endpoint: string;
|
||||
try {
|
||||
endpoint = normalizeHostPort(raw);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid host:port";
|
||||
Alert.alert("Invalid host", message);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSavingDaemon(true);
|
||||
const payload = {
|
||||
label: daemonForm.label.trim(),
|
||||
endpoints: [endpoint],
|
||||
};
|
||||
if (daemonForm.id) {
|
||||
await updateDaemon(daemonForm.id, payload);
|
||||
} else {
|
||||
await addDaemon(payload);
|
||||
}
|
||||
handleCloseDaemonForm();
|
||||
} catch (error) {
|
||||
console.error("[Settings] Failed to save daemon", error);
|
||||
Alert.alert("Error", "Unable to save host");
|
||||
} finally {
|
||||
setIsSavingDaemon(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSavingDaemon(true);
|
||||
const payload = {
|
||||
label: daemonForm.label.trim(),
|
||||
wsUrl: daemonForm.wsUrl.trim(),
|
||||
};
|
||||
if (daemonForm.id) {
|
||||
await updateDaemon(daemonForm.id, payload);
|
||||
} else {
|
||||
await addDaemon(payload);
|
||||
if (daemonFormMode === "pair") {
|
||||
const offer = daemonForm.offerUrl.trim();
|
||||
if (!offer) {
|
||||
Alert.alert("Offer required", "Paste the pairing link (…/#offer=...)");
|
||||
return;
|
||||
}
|
||||
handleCloseDaemonForm();
|
||||
} catch (error) {
|
||||
console.error("[Settings] Failed to save daemon", error);
|
||||
Alert.alert("Error", "Unable to save host");
|
||||
} finally {
|
||||
setIsSavingDaemon(false);
|
||||
try {
|
||||
setIsSavingDaemon(true);
|
||||
await upsertDaemonFromOfferUrl(offer);
|
||||
handleCloseDaemonForm();
|
||||
} catch (error) {
|
||||
console.error("[Settings] Failed to pair host", error);
|
||||
const message = error instanceof Error ? error.message : "Unable to pair host";
|
||||
Alert.alert("Error", message);
|
||||
} finally {
|
||||
setIsSavingDaemon(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}, [daemonForm, addDaemon, updateDaemon, handleCloseDaemonForm]);
|
||||
|
||||
Alert.alert("Choose a method", "Select Pair or Manual to add a host.");
|
||||
}, [daemonForm, daemonFormMode, addDaemon, updateDaemon, upsertDaemonFromOfferUrl, handleCloseDaemonForm]);
|
||||
|
||||
const handleRemoveDaemon = useCallback(
|
||||
(profile: DaemonProfile) => {
|
||||
@@ -529,11 +578,12 @@ export default function SettingsScreen() {
|
||||
|
||||
const handleTestDaemonConnection = useCallback(
|
||||
async (profile: DaemonProfile) => {
|
||||
const url = profile.wsUrl;
|
||||
if (!validateServerUrl(url)) {
|
||||
Alert.alert("Invalid URL", "Host URL must be ws:// or wss://");
|
||||
const endpoint = profile.endpoints?.[0] ?? null;
|
||||
if (!endpoint) {
|
||||
Alert.alert("Missing host", "This host has no endpoints configured.");
|
||||
return;
|
||||
}
|
||||
const url = buildDaemonWebSocketUrl(endpoint);
|
||||
updateDaemonTestState(profile.id, { status: "testing" });
|
||||
updateConnectionStatus(profile.id, { status: "connecting" });
|
||||
try {
|
||||
@@ -576,15 +626,6 @@ export default function SettingsScreen() {
|
||||
[updateSettings]
|
||||
);
|
||||
|
||||
function validateServerUrl(url: string): boolean {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.protocol === "ws:" || urlObj.protocol === "wss:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
Alert.alert(
|
||||
"Reset Settings",
|
||||
@@ -666,40 +707,85 @@ export default function SettingsScreen() {
|
||||
{isDaemonFormVisible ? (
|
||||
<View style={styles.formCard}>
|
||||
<Text style={styles.formTitle}>{daemonForm.id ? "Edit Host" : "Add Host"}</Text>
|
||||
<View style={styles.formField}>
|
||||
<Text style={styles.label}>Label</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={daemonForm.label}
|
||||
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, label: text }))}
|
||||
placeholder="My Host"
|
||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.formField}>
|
||||
<Text style={styles.label}>Host URL</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.inputUrl]}
|
||||
value={daemonForm.wsUrl}
|
||||
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, wsUrl: text }))}
|
||||
placeholder="wss://example.com/ws"
|
||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{daemonForm.id ? (
|
||||
<View style={styles.formField}>
|
||||
<Text style={styles.label}>Label</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={daemonForm.label}
|
||||
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, label: text }))}
|
||||
placeholder="My Host"
|
||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{daemonForm.id ? null : daemonFormMode === "choose" ? (
|
||||
<View style={styles.formActionsRow}>
|
||||
<Pressable
|
||||
style={[styles.formButton, styles.formButtonPrimary]}
|
||||
onPress={() => setDaemonFormMode("pair")}
|
||||
>
|
||||
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>Pair</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.formButton, styles.formButtonPrimary]}
|
||||
onPress={() => setDaemonFormMode("manual")}
|
||||
>
|
||||
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>Manual</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{daemonFormMode === "manual" ? (
|
||||
<View style={styles.formField}>
|
||||
<Text style={styles.label}>Host</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.inputUrl]}
|
||||
value={daemonForm.endpoint}
|
||||
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, endpoint: text }))}
|
||||
placeholder="localhost:6767"
|
||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{daemonFormMode === "pair" ? (
|
||||
<View style={styles.formField}>
|
||||
<Text style={styles.label}>Pairing Link</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.inputUrl]}
|
||||
value={daemonForm.offerUrl}
|
||||
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, offerUrl: text }))}
|
||||
placeholder="https://app.paseo.sh/#offer=..."
|
||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.formActionsRow}>
|
||||
<Pressable style={styles.formButton} onPress={handleCloseDaemonForm}>
|
||||
<Text style={styles.formButtonText}>Cancel</Text>
|
||||
</Pressable>
|
||||
{!daemonForm.id && daemonFormMode !== "choose" ? (
|
||||
<Pressable style={styles.formButton} onPress={() => setDaemonFormMode("choose")}>
|
||||
<Text style={styles.formButtonText}>Back</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable
|
||||
style={[styles.formButton, styles.formButtonPrimary, isSavingDaemon && styles.hostActionDisabled]}
|
||||
onPress={handleSubmitDaemonForm}
|
||||
disabled={isSavingDaemon}
|
||||
>
|
||||
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>
|
||||
{isSavingDaemon ? "Saving..." : daemonForm.id ? "Save" : "Add"}
|
||||
{isSavingDaemon ? "Saving..." : daemonForm.id ? "Save" : daemonFormMode === "pair" ? "Pair" : "Add"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -853,6 +939,7 @@ function DaemonCard({
|
||||
isScreenMountedRef,
|
||||
}: DaemonCardProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const directWsUrl = buildDaemonWebSocketUrl(daemon.endpoints?.[0] ?? "localhost:6767");
|
||||
const statusLabel = formatConnectionStatus(connectionStatus);
|
||||
const statusTone = getConnectionStatusTone(connectionStatus);
|
||||
const statusColor =
|
||||
@@ -890,7 +977,7 @@ function DaemonCard({
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
await testServerConnection(daemon.wsUrl);
|
||||
await testServerConnection(directWsUrl);
|
||||
const reconnected = await waitForCondition(() => isConnectedRef.current, reconnectTimeoutMs);
|
||||
|
||||
if (isScreenMountedRef.current) {
|
||||
@@ -921,7 +1008,7 @@ function DaemonCard({
|
||||
await delay(retryDelayMs);
|
||||
}
|
||||
}
|
||||
}, [daemon.label, daemon.wsUrl, isScreenMountedRef, testServerConnection, waitForCondition]);
|
||||
}, [daemon.label, directWsUrl, isScreenMountedRef, testServerConnection, waitForCondition]);
|
||||
|
||||
const beginServerRestart = useCallback(() => {
|
||||
if (!restartServerFn) {
|
||||
@@ -1017,7 +1104,7 @@ function DaemonCard({
|
||||
<Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.hostUrl}>{daemon.wsUrl}</Text>
|
||||
<Text style={styles.hostUrl}>{daemon.endpoints?.[0] ?? ""}</Text>
|
||||
{connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null}
|
||||
{testState && testState.status !== "idle" ? (
|
||||
<Text style={[styles.testResultText, { color: testResultColor }]}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,40 +2,67 @@ import { createContext, useCallback, useContext } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
buildDaemonWebSocketUrl,
|
||||
decodeOfferFragmentPayload,
|
||||
deriveLabelFromEndpoint,
|
||||
extractHostPortFromWebSocketUrl,
|
||||
normalizeHostPort,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
|
||||
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
|
||||
const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
||||
const FALLBACK_DAEMON_URL = "ws://localhost:6767/ws";
|
||||
const DEFAULT_DAEMONS: Array<{ label: string; wsUrl: string; restUrl?: string | null }> = [
|
||||
{ label: "localhost", wsUrl: "ws://localhost:6767/ws" },
|
||||
const FALLBACK_DAEMON_ENDPOINT = "localhost:6767";
|
||||
const DEFAULT_HOSTS: Array<{ label: string; endpoint: string }> = [
|
||||
{ label: "localhost", endpoint: "localhost:6767" },
|
||||
];
|
||||
const DAEMON_REGISTRY_QUERY_KEY = ["daemon-registry"];
|
||||
|
||||
export type DaemonProfile = {
|
||||
export type HostRelayConfig = {
|
||||
endpoint: string;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export type HostProfile = {
|
||||
id: string;
|
||||
label: string;
|
||||
wsUrl: string;
|
||||
restUrl?: string | null;
|
||||
endpoints: string[];
|
||||
daemonPublicKeyB64?: string;
|
||||
relay?: HostRelayConfig | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type CreateDaemonInput = {
|
||||
// Backward compatibility with older imports.
|
||||
export type DaemonProfile = HostProfile;
|
||||
|
||||
type CreateHostInput = {
|
||||
label: string;
|
||||
wsUrl: string;
|
||||
restUrl?: string | null;
|
||||
endpoints: string[];
|
||||
};
|
||||
|
||||
type UpdateDaemonInput = Partial<Omit<DaemonProfile, "id" | "createdAt">>;
|
||||
type UpdateHostInput = Partial<Omit<HostProfile, "id" | "createdAt">>;
|
||||
|
||||
const ConnectionOfferV1Schema = z.object({
|
||||
v: z.literal(1),
|
||||
sessionId: z.string().min(1),
|
||||
endpoints: z.array(z.string().min(1)).min(1),
|
||||
daemonPublicKeyB64: z.string().min(1),
|
||||
});
|
||||
|
||||
export type ConnectionOfferV1 = z.infer<typeof ConnectionOfferV1Schema>;
|
||||
|
||||
interface DaemonRegistryContextValue {
|
||||
daemons: DaemonProfile[];
|
||||
daemons: HostProfile[];
|
||||
isLoading: boolean;
|
||||
error: unknown | null;
|
||||
addDaemon: (input: CreateDaemonInput) => Promise<DaemonProfile>;
|
||||
updateDaemon: (id: string, updates: UpdateDaemonInput) => Promise<void>;
|
||||
addDaemon: (input: CreateHostInput) => Promise<HostProfile>;
|
||||
updateDaemon: (id: string, updates: UpdateHostInput) => Promise<void>;
|
||||
removeDaemon: (id: string) => Promise<void>;
|
||||
upsertDaemonFromOffer: (offer: ConnectionOfferV1) => Promise<HostProfile>;
|
||||
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<HostProfile>;
|
||||
}
|
||||
|
||||
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null);
|
||||
@@ -58,25 +85,26 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
|
||||
const persist = useCallback(
|
||||
async (profiles: DaemonProfile[]) => {
|
||||
queryClient.setQueryData<DaemonProfile[]>(DAEMON_REGISTRY_QUERY_KEY, profiles);
|
||||
async (profiles: HostProfile[]) => {
|
||||
queryClient.setQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY, profiles);
|
||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(profiles));
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
const readDaemons = useCallback(() => {
|
||||
return queryClient.getQueryData<DaemonProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons;
|
||||
return queryClient.getQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons;
|
||||
}, [queryClient, daemons]);
|
||||
|
||||
const addDaemon = useCallback(async (input: CreateDaemonInput) => {
|
||||
const addDaemon = useCallback(async (input: CreateHostInput) => {
|
||||
const existing = readDaemons();
|
||||
const timestamp = new Date().toISOString();
|
||||
const profile: DaemonProfile = {
|
||||
const profile: HostProfile = {
|
||||
id: generateDaemonId(),
|
||||
label: input.label.trim() || deriveLabelFromUrl(input.wsUrl),
|
||||
wsUrl: input.wsUrl,
|
||||
restUrl: input.restUrl ?? null,
|
||||
label: input.label.trim() || deriveLabelFromEndpoint(input.endpoints[0] ?? ""),
|
||||
endpoints: input.endpoints.map((endpoint) => normalizeHostPort(endpoint)),
|
||||
daemonPublicKeyB64: undefined,
|
||||
relay: null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
metadata: null,
|
||||
@@ -87,7 +115,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
return profile;
|
||||
}, [persist, readDaemons]);
|
||||
|
||||
const updateDaemon = useCallback(async (id: string, updates: UpdateDaemonInput) => {
|
||||
const updateDaemon = useCallback(async (id: string, updates: UpdateHostInput) => {
|
||||
const next = readDaemons().map((daemon) =>
|
||||
daemon.id === id
|
||||
? {
|
||||
@@ -102,10 +130,67 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const removeDaemon = useCallback(async (id: string) => {
|
||||
const remaining = readDaemons().filter((daemon) => daemon.id !== id);
|
||||
const next = remaining.length > 0 ? remaining : [createProfile("Local Host", FALLBACK_DAEMON_URL)];
|
||||
await persist(next);
|
||||
await persist(remaining);
|
||||
}, [persist, readDaemons]);
|
||||
|
||||
const upsertDaemonFromOffer = useCallback(
|
||||
async (offer: ConnectionOfferV1) => {
|
||||
const existing = readDaemons();
|
||||
const now = new Date().toISOString();
|
||||
const normalizedEndpoints = offer.endpoints.map((endpoint) => normalizeHostPort(endpoint));
|
||||
const relayEndpoint = normalizedEndpoints[normalizedEndpoints.length - 1];
|
||||
|
||||
const matchIndex = existing.findIndex((daemon) => daemon.daemonPublicKeyB64 === offer.daemonPublicKeyB64);
|
||||
if (matchIndex !== -1) {
|
||||
const updated: HostProfile = {
|
||||
...existing[matchIndex],
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
endpoints: normalizedEndpoints,
|
||||
relay: { endpoint: relayEndpoint, sessionId: offer.sessionId },
|
||||
updatedAt: now,
|
||||
};
|
||||
const next = [...existing];
|
||||
next[matchIndex] = updated;
|
||||
await persist(next);
|
||||
return updated;
|
||||
}
|
||||
|
||||
const profile: HostProfile = {
|
||||
id: generateDaemonId(),
|
||||
label: deriveLabelFromEndpoint(normalizedEndpoints[0] ?? "Unnamed Host"),
|
||||
endpoints: normalizedEndpoints,
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
relay: { endpoint: relayEndpoint, sessionId: offer.sessionId },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
metadata: null,
|
||||
};
|
||||
|
||||
const next = [...existing, profile];
|
||||
await persist(next);
|
||||
return profile;
|
||||
},
|
||||
[persist, readDaemons]
|
||||
);
|
||||
|
||||
const upsertDaemonFromOfferUrl = useCallback(
|
||||
async (offerUrlOrFragment: string) => {
|
||||
const marker = "#offer=";
|
||||
const idx = offerUrlOrFragment.indexOf(marker);
|
||||
if (idx === -1) {
|
||||
throw new Error("Missing #offer= fragment");
|
||||
}
|
||||
const encoded = offerUrlOrFragment.slice(idx + marker.length).trim();
|
||||
if (!encoded) {
|
||||
throw new Error("Offer payload is empty");
|
||||
}
|
||||
const payload = decodeOfferFragmentPayload(encoded);
|
||||
const offer = ConnectionOfferV1Schema.parse(payload);
|
||||
return upsertDaemonFromOffer(offer);
|
||||
},
|
||||
[upsertDaemonFromOffer]
|
||||
);
|
||||
|
||||
const value: DaemonRegistryContextValue = {
|
||||
daemons,
|
||||
isLoading: isPending,
|
||||
@@ -113,6 +198,8 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
addDaemon,
|
||||
updateDaemon,
|
||||
removeDaemon,
|
||||
upsertDaemonFromOffer,
|
||||
upsertDaemonFromOfferUrl,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -127,22 +214,14 @@ function generateDaemonId(): string {
|
||||
return `daemon_${Date.now().toString(36)}_${random}`;
|
||||
}
|
||||
|
||||
function deriveLabelFromUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname || "Unnamed Host";
|
||||
} catch {
|
||||
return "Unnamed Host";
|
||||
}
|
||||
}
|
||||
|
||||
function createProfile(label: string, wsUrl: string): DaemonProfile {
|
||||
function createProfile(label: string, endpoint: string): HostProfile {
|
||||
const timestamp = new Date().toISOString();
|
||||
return {
|
||||
id: generateDaemonId(),
|
||||
label,
|
||||
wsUrl,
|
||||
restUrl: null,
|
||||
endpoints: [normalizeHostPort(endpoint)],
|
||||
daemonPublicKeyB64: undefined,
|
||||
relay: null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
metadata: null,
|
||||
@@ -151,14 +230,14 @@ function createProfile(label: string, wsUrl: string): DaemonProfile {
|
||||
|
||||
type EnvDaemonConfig = {
|
||||
label?: string;
|
||||
wsUrl: string;
|
||||
restUrl?: string | null;
|
||||
endpoint?: string;
|
||||
wsUrl?: string;
|
||||
};
|
||||
|
||||
function parseEnvDaemonDefaults(): DaemonProfile[] {
|
||||
function parseEnvDaemonDefaults(): HostProfile[] {
|
||||
const envDaemons = (() => {
|
||||
// Primary: allow a JSON array string like
|
||||
// EXPO_PUBLIC_DAEMONS='[{"label":"Host","wsUrl":"ws://10.0.0.1:6767/ws"}]'
|
||||
// EXPO_PUBLIC_DAEMONS='[{"label":"Host","endpoint":"10.0.0.1:6767"}]'
|
||||
const jsonList = process.env.EXPO_PUBLIC_DAEMONS;
|
||||
if (jsonList) {
|
||||
try {
|
||||
@@ -178,7 +257,6 @@ function parseEnvDaemonDefaults(): DaemonProfile[] {
|
||||
{
|
||||
label: process.env.EXPO_PUBLIC_DAEMON_LABEL,
|
||||
wsUrl: singleWsUrl,
|
||||
restUrl: process.env.EXPO_PUBLIC_DAEMON_REST_URL,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -188,23 +266,120 @@ function parseEnvDaemonDefaults(): DaemonProfile[] {
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
return envDaemons
|
||||
.filter((entry) => typeof entry?.wsUrl === "string" && entry.wsUrl.trim().length > 0)
|
||||
.map((entry) => ({
|
||||
id: generateDaemonId(),
|
||||
label: entry.label?.trim() || deriveLabelFromUrl(entry.wsUrl),
|
||||
wsUrl: entry.wsUrl.trim(),
|
||||
restUrl: entry.restUrl ?? null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
metadata: null,
|
||||
}));
|
||||
.map((entry): HostProfile | null => {
|
||||
const endpoint = (() => {
|
||||
if (typeof entry.endpoint === "string" && entry.endpoint.trim().length > 0) {
|
||||
return entry.endpoint.trim();
|
||||
}
|
||||
if (typeof entry.wsUrl === "string" && entry.wsUrl.trim().length > 0) {
|
||||
try {
|
||||
return extractHostPortFromWebSocketUrl(entry.wsUrl.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
if (!endpoint) return null;
|
||||
|
||||
return {
|
||||
id: generateDaemonId(),
|
||||
label: entry.label?.trim() || deriveLabelFromEndpoint(endpoint),
|
||||
endpoints: [normalizeHostPort(endpoint)],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
metadata: null,
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is HostProfile => entry !== null);
|
||||
}
|
||||
|
||||
async function loadDaemonRegistryFromStorage(): Promise<DaemonProfile[]> {
|
||||
type LegacyDaemonProfile = {
|
||||
id: string;
|
||||
label: string;
|
||||
wsUrl: string;
|
||||
restUrl?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
function isLegacyDaemonProfile(value: unknown): value is LegacyDaemonProfile {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const obj = value as Record<string, unknown>;
|
||||
return typeof obj.id === "string" && typeof obj.wsUrl === "string" && typeof obj.label === "string";
|
||||
}
|
||||
|
||||
function isHostProfile(value: unknown): value is HostProfile {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const obj = value as Record<string, unknown>;
|
||||
return typeof obj.id === "string" && typeof obj.label === "string" && Array.isArray(obj.endpoints);
|
||||
}
|
||||
|
||||
function migrateLegacyToHostProfile(legacy: LegacyDaemonProfile): HostProfile {
|
||||
const endpoint = extractHostPortFromWebSocketUrl(legacy.wsUrl);
|
||||
return {
|
||||
id: legacy.id,
|
||||
label: legacy.label,
|
||||
endpoints: [normalizeHostPort(endpoint)],
|
||||
daemonPublicKeyB64: undefined,
|
||||
relay: null,
|
||||
createdAt: legacy.createdAt,
|
||||
updatedAt: legacy.updatedAt,
|
||||
metadata: legacy.metadata ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as DaemonProfile[];
|
||||
const parsed = JSON.parse(stored) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
if (parsed.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const hasLegacy = parsed.some((entry) => isLegacyDaemonProfile(entry));
|
||||
const hasNew = parsed.some((entry) => isHostProfile(entry));
|
||||
|
||||
if (hasNew && !hasLegacy) {
|
||||
return parsed as HostProfile[];
|
||||
}
|
||||
|
||||
const migrated = parsed
|
||||
.map((entry) => {
|
||||
if (isHostProfile(entry)) {
|
||||
const endpoints = entry.endpoints
|
||||
.map((endpoint) => {
|
||||
try {
|
||||
return normalizeHostPort(String(endpoint));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((endpoint): endpoint is string => endpoint !== null);
|
||||
if (endpoints.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return { ...entry, endpoints } as HostProfile;
|
||||
}
|
||||
if (isLegacyDaemonProfile(entry)) {
|
||||
try {
|
||||
return migrateLegacyToHostProfile(entry);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((entry): entry is HostProfile => entry !== null);
|
||||
|
||||
if (migrated.length > 0) {
|
||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated));
|
||||
return migrated;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const legacy = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY);
|
||||
@@ -212,7 +387,8 @@ async function loadDaemonRegistryFromStorage(): Promise<DaemonProfile[]> {
|
||||
const legacyParsed = JSON.parse(legacy) as Record<string, unknown>;
|
||||
const legacyUrl = typeof legacyParsed.serverUrl === "string" ? legacyParsed.serverUrl : null;
|
||||
if (legacyUrl) {
|
||||
const migrated = [createProfile("Primary Host", legacyUrl)];
|
||||
const endpoint = extractHostPortFromWebSocketUrl(legacyUrl);
|
||||
const migrated = [createProfile("Primary Host", endpoint)];
|
||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated));
|
||||
return migrated;
|
||||
}
|
||||
@@ -224,9 +400,9 @@ async function loadDaemonRegistryFromStorage(): Promise<DaemonProfile[]> {
|
||||
return envDefaults;
|
||||
}
|
||||
|
||||
const fallback = DEFAULT_DAEMONS.length > 0
|
||||
? DEFAULT_DAEMONS.map((entry) => createProfile(entry.label, entry.wsUrl))
|
||||
: [createProfile("Local Host", FALLBACK_DAEMON_URL)];
|
||||
const fallback = DEFAULT_HOSTS.length > 0
|
||||
? DEFAULT_HOSTS.map((entry) => createProfile(entry.label, entry.endpoint))
|
||||
: [createProfile("Local Host", FALLBACK_DAEMON_ENDPOINT)];
|
||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(fallback));
|
||||
return fallback;
|
||||
} catch (error) {
|
||||
@@ -234,3 +410,8 @@ async function loadDaemonRegistryFromStorage(): Promise<DaemonProfile[]> {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDirectDaemonWsUrl(profile: HostProfile): string {
|
||||
const endpoint = profile.endpoints[0] ?? FALLBACK_DAEMON_ENDPOINT;
|
||||
return buildDaemonWebSocketUrl(endpoint);
|
||||
}
|
||||
|
||||
@@ -289,8 +289,8 @@ export function useAgentFormState(
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for preferences to load before first resolution
|
||||
if (isPreferencesLoading && !hasResolvedRef.current) {
|
||||
// Wait for preferences to load before first resolution, unless explicit URL overrides exist.
|
||||
if (isPreferencesLoading && !hasResolvedRef.current && !combinedInitialValues) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -325,6 +325,25 @@ export function useAgentFormState(
|
||||
formState,
|
||||
]);
|
||||
|
||||
// Persist inferred serverId so reloads keep the selection (e.g. URL serverId or first-time load).
|
||||
useEffect(() => {
|
||||
if (!isVisible || !isCreateFlow) return;
|
||||
if (isPreferencesLoading) return;
|
||||
if (userModified.serverId) return;
|
||||
const serverId = formState.serverId;
|
||||
if (!serverId) return;
|
||||
if (preferences?.serverId === serverId) return;
|
||||
void updatePreferences({ serverId });
|
||||
}, [
|
||||
isVisible,
|
||||
isCreateFlow,
|
||||
isPreferencesLoading,
|
||||
userModified.serverId,
|
||||
formState.serverId,
|
||||
preferences?.serverId,
|
||||
updatePreferences,
|
||||
]);
|
||||
|
||||
// Provider model request timers
|
||||
const providerModelRequestTimersRef = useRef<
|
||||
Map<string, ReturnType<typeof setTimeout>>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { AppState } from "react-native";
|
||||
import { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
||||
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
|
||||
|
||||
function runDaemonRequest(label: string, promise: Promise<unknown>): void {
|
||||
void promise.catch((error) => {
|
||||
@@ -10,11 +11,16 @@ function runDaemonRequest(label: string, promise: Promise<unknown>): void {
|
||||
|
||||
export function useDaemonClient(url: string): DaemonClientV2 {
|
||||
const client = useMemo(
|
||||
() =>
|
||||
new DaemonClientV2({
|
||||
() => {
|
||||
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
||||
return new DaemonClientV2({
|
||||
url,
|
||||
suppressSendErrors: true,
|
||||
}),
|
||||
...(tauriTransportFactory
|
||||
? { transportFactory: tauriTransportFactory }
|
||||
: {}),
|
||||
});
|
||||
},
|
||||
[url]
|
||||
);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { File as FSFile, Paths } from "expo-file-system";
|
||||
import * as LegacyFileSystem from "expo-file-system/legacy";
|
||||
import * as Sharing from "expo-sharing";
|
||||
import type { DaemonProfile } from "@/contexts/daemon-registry-context";
|
||||
import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
|
||||
|
||||
interface DownloadProgress {
|
||||
percent: number;
|
||||
@@ -246,14 +247,14 @@ type DownloadTarget = {
|
||||
};
|
||||
|
||||
function resolveDaemonDownloadTarget(daemon?: DaemonProfile): DownloadTarget {
|
||||
const rawUrl = daemon?.restUrl ?? daemon?.wsUrl;
|
||||
if (!rawUrl) {
|
||||
const endpoint = daemon?.endpoints?.[0] ?? null;
|
||||
if (!endpoint) {
|
||||
return { baseUrl: null, authHeader: null, authCredentials: null };
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
parsed = new URL(buildDaemonWebSocketUrl(endpoint));
|
||||
} catch {
|
||||
return { baseUrl: null, authHeader: null, authCredentials: null };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
export type HostPortParts = {
|
||||
host: string;
|
||||
port: number;
|
||||
isIpv6: boolean;
|
||||
};
|
||||
|
||||
function decodeBase64UrlToUtf8(input: string): string {
|
||||
const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
|
||||
return Buffer.from(padded, "base64").toString("utf8");
|
||||
}
|
||||
|
||||
export function decodeOfferFragmentPayload(encoded: string): unknown {
|
||||
const json = decodeBase64UrlToUtf8(encoded);
|
||||
return JSON.parse(json) as unknown;
|
||||
}
|
||||
|
||||
export function parseHostPort(input: string): HostPortParts {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Host is required");
|
||||
}
|
||||
|
||||
// IPv6: [::1]:6767
|
||||
if (trimmed.startsWith("[")) {
|
||||
const match = trimmed.match(/^\[([^\]]+)\]:(\d{1,5})$/);
|
||||
if (!match) {
|
||||
throw new Error("Invalid host:port (expected [::1]:6767)");
|
||||
}
|
||||
const host = match[1].trim();
|
||||
const port = Number(match[2]);
|
||||
if (!host) throw new Error("Host is required");
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error("Port must be between 1 and 65535");
|
||||
}
|
||||
return { host, port, isIpv6: true };
|
||||
}
|
||||
|
||||
const match = trimmed.match(/^(.+):(\d{1,5})$/);
|
||||
if (!match) {
|
||||
throw new Error("Invalid host:port (expected localhost:6767)");
|
||||
}
|
||||
const host = match[1].trim();
|
||||
const port = Number(match[2]);
|
||||
if (!host) throw new Error("Host is required");
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error("Port must be between 1 and 65535");
|
||||
}
|
||||
return { host, port, isIpv6: false };
|
||||
}
|
||||
|
||||
export function normalizeHostPort(input: string): string {
|
||||
const { host, port, isIpv6 } = parseHostPort(input);
|
||||
if (isIpv6) {
|
||||
return `[${host}]:${port}`;
|
||||
}
|
||||
return `${host}:${port}`;
|
||||
}
|
||||
|
||||
export function extractHostPortFromWebSocketUrl(wsUrl: string): string {
|
||||
const parsed = new URL(wsUrl);
|
||||
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
|
||||
throw new Error("Invalid WebSocket URL protocol");
|
||||
}
|
||||
if (parsed.pathname.replace(/\/+$/, "") !== "/ws") {
|
||||
throw new Error("Invalid WebSocket URL (expected /ws path)");
|
||||
}
|
||||
|
||||
const host = parsed.hostname;
|
||||
const port = parsed.port ? Number(parsed.port) : parsed.protocol === "wss:" ? 443 : 80;
|
||||
if (!host) {
|
||||
throw new Error("Invalid WebSocket URL (missing hostname)");
|
||||
}
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error("Invalid WebSocket URL (invalid port)");
|
||||
}
|
||||
|
||||
const isIpv6 = host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
|
||||
const hostPort = isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
|
||||
return hostPort;
|
||||
}
|
||||
|
||||
function shouldUseSecureWebSocket(port: number): boolean {
|
||||
return port === 443;
|
||||
}
|
||||
|
||||
export function buildDaemonWebSocketUrl(endpoint: string): string {
|
||||
const { host, port, isIpv6 } = parseHostPort(endpoint);
|
||||
const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
|
||||
const hostPart = isIpv6 ? `[${host}]` : host;
|
||||
return `${protocol}://${hostPart}:${port}/ws`;
|
||||
}
|
||||
|
||||
export function buildRelayWebSocketUrl(params: { endpoint: string; sessionId: string }): string {
|
||||
const { host, port, isIpv6 } = parseHostPort(params.endpoint);
|
||||
const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
|
||||
const hostPart = isIpv6 ? `[${host}]` : host;
|
||||
const url = new URL(`${protocol}://${hostPart}:${port}/ws`);
|
||||
url.searchParams.set("session", params.sessionId);
|
||||
url.searchParams.set("role", "client");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function deriveLabelFromEndpoint(endpoint: string): string {
|
||||
try {
|
||||
const { host } = parseHostPort(endpoint);
|
||||
return host || "Unnamed Host";
|
||||
} catch {
|
||||
return "Unnamed Host";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client-v2";
|
||||
|
||||
type TauriWebSocketMessage =
|
||||
| { type: "Text"; data: string }
|
||||
| { type: "Binary"; data: number[] }
|
||||
| { type: "Ping"; data: number[] }
|
||||
| { type: "Pong"; data: number[] }
|
||||
| { type: "Close"; data: { code: number; reason: string } | null };
|
||||
|
||||
type TauriWebSocketConnection = {
|
||||
addListener(cb: (msg: TauriWebSocketMessage) => void): () => void;
|
||||
send(message: string | number[] | TauriWebSocketMessage): Promise<void>;
|
||||
disconnect(): Promise<void>;
|
||||
};
|
||||
|
||||
type TauriWebSocketModule = {
|
||||
connect(url: string, config?: unknown): Promise<TauriWebSocketConnection>;
|
||||
};
|
||||
|
||||
function isTauriEnvironment(): boolean {
|
||||
return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined;
|
||||
}
|
||||
|
||||
function getTauriWebSocketModule(): TauriWebSocketModule | null {
|
||||
if (!isTauriEnvironment()) {
|
||||
return null;
|
||||
}
|
||||
const ws = (window as any).__TAURI__?.websocket;
|
||||
if (ws && typeof ws.connect === "function") {
|
||||
return ws as TauriWebSocketModule;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | null {
|
||||
if (!isTauriEnvironment()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ({ url }) => {
|
||||
let ws: TauriWebSocketConnection | null = null;
|
||||
let wsListenerCleanup: (() => void) | null = null;
|
||||
let disposed = false;
|
||||
let opened = false;
|
||||
let closeEmitted = false;
|
||||
|
||||
const openHandlers = new Set<() => void>();
|
||||
const closeHandlers = new Set<(event?: unknown) => void>();
|
||||
const errorHandlers = new Set<(event?: unknown) => void>();
|
||||
const messageHandlers = new Set<(data: unknown) => void>();
|
||||
|
||||
const pendingSends: string[] = [];
|
||||
let closeRequested: { code?: number; reason?: string } | null = null;
|
||||
|
||||
const emitOpen = () => {
|
||||
if (disposed || opened) return;
|
||||
opened = true;
|
||||
for (const handler of openHandlers) {
|
||||
try {
|
||||
handler();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const emitClose = (event?: unknown) => {
|
||||
if (disposed || closeEmitted) return;
|
||||
closeEmitted = true;
|
||||
for (const handler of closeHandlers) {
|
||||
try {
|
||||
handler(event);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const emitError = (event?: unknown) => {
|
||||
if (disposed) return;
|
||||
for (const handler of errorHandlers) {
|
||||
try {
|
||||
handler(event);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const emitMessage = (data: unknown) => {
|
||||
if (disposed) return;
|
||||
for (const handler of messageHandlers) {
|
||||
try {
|
||||
handler(data);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
let module: TauriWebSocketModule | null = getTauriWebSocketModule();
|
||||
if (!module) {
|
||||
const start = Date.now();
|
||||
while (!module && Date.now() - start < 2000) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
module = getTauriWebSocketModule();
|
||||
}
|
||||
}
|
||||
if (!module) {
|
||||
throw new Error(
|
||||
"Tauri WebSocket plugin is not available (expected window.__TAURI__.websocket). Did you enable tauri-plugin-websocket and websocket:default capability?"
|
||||
);
|
||||
}
|
||||
|
||||
const socket = await module.connect(url);
|
||||
if (disposed) {
|
||||
try {
|
||||
await socket.disconnect();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ws = socket;
|
||||
|
||||
wsListenerCleanup = ws.addListener((msg) => {
|
||||
if (msg.type === "Text") {
|
||||
emitMessage({ data: msg.data });
|
||||
return;
|
||||
}
|
||||
if (msg.type === "Close") {
|
||||
emitClose(msg.data ?? undefined);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
emitOpen();
|
||||
|
||||
while (pendingSends.length > 0) {
|
||||
const next = pendingSends.shift();
|
||||
if (!next) break;
|
||||
try {
|
||||
await ws.send(next);
|
||||
} catch (error) {
|
||||
emitError(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (closeRequested) {
|
||||
try {
|
||||
await ws.disconnect();
|
||||
} catch (error) {
|
||||
emitError(error);
|
||||
} finally {
|
||||
emitClose(closeRequested);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
emitError(error);
|
||||
}
|
||||
};
|
||||
|
||||
void connect();
|
||||
|
||||
const transport: DaemonTransport = {
|
||||
send: (data) => {
|
||||
if (disposed) return;
|
||||
if (!ws) {
|
||||
pendingSends.push(data);
|
||||
return;
|
||||
}
|
||||
void ws.send(data).catch((error) => emitError(error));
|
||||
},
|
||||
close: (code?: number, reason?: string) => {
|
||||
if (disposed) return;
|
||||
closeRequested = { code, reason };
|
||||
if (!ws) return;
|
||||
void ws
|
||||
.disconnect()
|
||||
.catch((error) => emitError(error))
|
||||
.finally(() => emitClose(closeRequested));
|
||||
},
|
||||
onMessage: (handler) => {
|
||||
messageHandlers.add(handler);
|
||||
return () => messageHandlers.delete(handler);
|
||||
},
|
||||
onOpen: (handler) => {
|
||||
openHandlers.add(handler);
|
||||
if (opened) {
|
||||
try {
|
||||
handler();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
return () => openHandlers.delete(handler);
|
||||
},
|
||||
onClose: (handler) => {
|
||||
closeHandlers.add(handler);
|
||||
if (closeEmitted) {
|
||||
try {
|
||||
handler(closeRequested ?? undefined);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
return () => closeHandlers.delete(handler);
|
||||
},
|
||||
onError: (handler) => {
|
||||
errorHandlers.add(handler);
|
||||
return () => errorHandlers.delete(handler);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...transport,
|
||||
close: (code?: number, reason?: string) => {
|
||||
transport.close(code, reason);
|
||||
disposed = true;
|
||||
try {
|
||||
wsListenerCleanup?.();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
wsListenerCleanup = null;
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user