diff --git a/packages/app/e2e/daemon-connectivity.spec.ts b/packages/app/e2e/daemon-connectivity.spec.ts
index 641753877..201a9eff6 100644
--- a/packages/app/e2e/daemon-connectivity.spec.ts
+++ b/packages/app/e2e/daemon-connectivity.spec.ts
@@ -11,5 +11,5 @@ test('daemon is connected in settings', async ({ page }) => {
await openSettings(page);
await expect(page.getByText(`127.0.0.1:${daemonPort}`)).toBeVisible();
- await expect(page.getByText('Online', { exact: true }).first()).toBeVisible();
+ await expect(page.getByTestId('daemon-card-e2e-test-daemon').getByText('Online', { exact: true })).toBeVisible();
});
diff --git a/packages/app/e2e/host-onboarding-direct.spec.ts b/packages/app/e2e/host-onboarding-direct.spec.ts
new file mode 100644
index 000000000..3c7f77fac
--- /dev/null
+++ b/packages/app/e2e/host-onboarding-direct.spec.ts
@@ -0,0 +1,29 @@
+import { test, expect } from './fixtures';
+
+test('no hosts shows welcome; direct connection adds host and lands on agent create', async ({ page }) => {
+ const daemonPort = process.env.E2E_DAEMON_PORT;
+ if (!daemonPort) {
+ throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
+ }
+
+ await page.addInitScript(() => {
+ localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
+ localStorage.removeItem('@paseo:create-agent-preferences');
+ localStorage.removeItem('@paseo:settings');
+ });
+
+ await page.goto('/');
+
+ await expect(page.getByText('Welcome to Paseo', { exact: true })).toBeVisible();
+
+ await page.getByText('Direct connection', { exact: true }).click();
+
+ await page.getByPlaceholder('My Host').fill('E2E Host');
+ await page.getByPlaceholder('host:6767').fill(`127.0.0.1:${daemonPort}`);
+
+ await page.getByText('Connect & Save', { exact: true }).click();
+
+ await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
+ await expect(page.getByText('E2E Host', { exact: true })).toBeVisible();
+ await expect(page.getByText('Online', { exact: true })).toBeVisible({ timeout: 15000 });
+});
diff --git a/packages/app/e2e/manual-host-port.spec.ts b/packages/app/e2e/manual-host-port.spec.ts
index d55b1b13d..39c7b6bb8 100644
--- a/packages/app/e2e/manual-host-port.spec.ts
+++ b/packages/app/e2e/manual-host-port.spec.ts
@@ -14,16 +14,16 @@ test('manual host add accepts host:port only and persists endpoints', async ({ p
await page.goto('/settings');
await page.getByText('+ Add Host', { exact: true }).click();
- await page.getByText('Manual', { exact: true }).click();
+ await page.getByText('Direct connection', { exact: true }).click();
- const input = page.getByPlaceholder('localhost:6767');
+ const input = page.getByPlaceholder('host:6767');
await expect(input).toBeVisible();
await input.fill(`127.0.0.1:${daemonPort}`);
- await page.getByText('Add', { exact: true }).click();
+ await page.getByText('Connect & Save', { exact: true }).click();
- await expect(page.getByText(`127.0.0.1:${daemonPort}`, { exact: true }).first()).toBeVisible();
- await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 15000 });
+ await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
+ await expect(page.getByText('Online', { exact: true })).toBeVisible({ timeout: 15000 });
await page.waitForFunction(
(port) => {
diff --git a/packages/app/e2e/pairing-offer-parsing.spec.ts b/packages/app/e2e/pairing-offer-parsing.spec.ts
index 994fd9114..568dfe05a 100644
--- a/packages/app/e2e/pairing-offer-parsing.spec.ts
+++ b/packages/app/e2e/pairing-offer-parsing.spec.ts
@@ -35,8 +35,7 @@ test('pairing flow accepts #offer=ConnectionOfferV1 and stores sessionId + endpo
const offerUrl = `https://app.paseo.sh/#offer=${encodeBase64Url(JSON.stringify(offer))}`;
await page.getByText('+ Add Host', { exact: true }).click();
- await page.getByText('Pair', { exact: true }).click();
- await page.getByText('Paste link', { exact: true }).click();
+ await page.getByText('Paste pairing link', { exact: true }).click();
const input = page.getByPlaceholder('https://app.paseo.sh/#offer=...');
await expect(input).toBeVisible();
@@ -44,7 +43,7 @@ test('pairing flow accepts #offer=ConnectionOfferV1 and stores sessionId + endpo
await page.getByText('Pair', { exact: true }).click();
- await expect(page.getByText(`127.0.0.1:${daemonPort}`, { exact: true })).toBeVisible();
+ await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
await page.waitForFunction(
({ expected }) => {
diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index e2127035c..c32aa04fb 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -59,6 +59,7 @@ interface AppContainerProps {
function AppContainer({ children, selectedAgentId }: AppContainerProps) {
const { theme } = useUnistyles();
+ const { daemons } = useDaemonRegistry();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const openAgentList = usePanelStore((state) => state.openAgentList);
@@ -67,10 +68,16 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
- const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
+ const chromeEnabled = daemons.length > 0;
+ const isOpen = chromeEnabled
+ ? isMobile
+ ? mobileView === "agent-list"
+ : desktopAgentListOpen
+ : false;
// Cmd+B to toggle sidebar (web only)
useEffect(() => {
+ if (!chromeEnabled) return;
if (Platform.OS !== "web") return;
function handleKeyDown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key === "b") {
@@ -80,7 +87,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
- }, [toggleAgentList]);
+ }, [chromeEnabled, toggleAgentList]);
const {
translateX,
backdropOpacity,
@@ -98,7 +105,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
const openGesture = useMemo(
() =>
Gesture.Pan()
- .enabled(isMobile && !isOpen)
+ .enabled(chromeEnabled && isMobile && !isOpen)
.manualActivation(true)
// Fail if 10px vertical movement happens first (allow vertical scroll)
.failOffsetY([-10, 10])
@@ -153,7 +160,20 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
.onFinalize(() => {
isGesturing.value = false;
}),
- [isMobile, isOpen, windowWidth, translateX, backdropOpacity, animateToOpen, animateToClose, openAgentList, isGesturing, horizontalScroll?.isAnyScrolledRight, touchStartX]
+ [
+ chromeEnabled,
+ isMobile,
+ isOpen,
+ windowWidth,
+ translateX,
+ backdropOpacity,
+ animateToOpen,
+ animateToClose,
+ openAgentList,
+ isGesturing,
+ horizontalScroll?.isAnyScrolledRight,
+ touchStartX,
+ ]
);
// When sidebar is collapsed on desktop Tauri macOS, add left padding for traffic lights
@@ -163,12 +183,12 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
const content = (
- {!isMobile && }
+ {!isMobile && chromeEnabled && }
{children}
- {isMobile && }
+ {isMobile && chromeEnabled && }
);
diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx
index 580a9788f..038a63c80 100644
--- a/packages/app/src/app/index.tsx
+++ b/packages/app/src/app/index.tsx
@@ -26,9 +26,11 @@ import { FileDropZone } from "@/components/file-drop-zone";
import { useQuery } from "@tanstack/react-query";
import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
+import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { formatConnectionStatus } from "@/utils/daemons";
import { useSessionStore } from "@/stores/session-store";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
+import { WelcomeScreen } from "@/components/welcome-screen";
import type {
AgentProvider,
AgentSessionConfig,
@@ -90,6 +92,7 @@ export default function HomeScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const { connectionStates } = useDaemonConnections();
+ const { daemons } = useDaemonRegistry();
const params = useLocalSearchParams();
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
@@ -598,6 +601,16 @@ export default function HomeScreen() {
]
);
+ if (daemons.length === 0) {
+ return (
+ {
+ setSelectedServerIdFromUser(profile.id);
+ }}
+ />
+ );
+ }
+
return (
@@ -617,6 +630,9 @@ export default function HomeScreen() {
hostEntry?.status === "online" && styles.hostStatusDotOnline,
]}
/>
+ {hostStatus ? (
+ {hostStatus}
+ ) : null}
}
/>
@@ -842,6 +858,11 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
+ hostBadgeStatus: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.xs,
+ fontWeight: theme.fontWeight.medium,
+ },
hostStatusDot: {
width: 6,
height: 6,
diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/app/pair-scan.tsx
index 294c4bb6c..e1019fe8d 100644
--- a/packages/app/src/app/pair-scan.tsx
+++ b/packages/app/src/app/pair-scan.tsx
@@ -156,8 +156,8 @@ export default function PairScanScreen() {
try {
setIsPairing(true);
- await upsertDaemonFromOfferUrl(offerUrl);
- router.replace("/settings");
+ const profile = await upsertDaemonFromOfferUrl(offerUrl);
+ router.replace({ pathname: "/", params: { serverId: profile.id } });
} catch (error) {
lastScannedRef.current = null;
const message = error instanceof Error ? error.message : "Unable to pair host";
diff --git a/packages/app/src/app/settings.tsx b/packages/app/src/app/settings.tsx
index 9c5e07bed..a836982ef 100644
--- a/packages/app/src/app/settings.tsx
+++ b/packages/app/src/app/settings.tsx
@@ -22,7 +22,10 @@ import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons
import { theme as defaultTheme } from "@/styles/theme";
import { MenuHeader } from "@/components/headers/menu-header";
import { useSessionStore } from "@/stores/session-store";
-import { normalizeHostPort } from "@/utils/daemon-endpoints";
+import { AddHostMethodModal } from "@/components/add-host-method-modal";
+import { AddHostModal } from "@/components/add-host-modal";
+import { PairLinkModal } from "@/components/pair-link-modal";
+import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
const delay = (ms: number) =>
new Promise((resolve) => {
@@ -379,22 +382,16 @@ export default function SettingsScreen() {
const {
daemons,
isLoading: daemonLoading,
- addDaemon,
updateDaemon,
removeDaemon,
- upsertDaemonFromOfferUrl,
} = useDaemonRegistry();
const { connectionStates, updateConnectionStatus } = useDaemonConnections();
- const [isDaemonFormVisible, setIsDaemonFormVisible] = useState(false);
- const [daemonFormMode, setDaemonFormMode] = useState<"choose" | "manual" | "pair">("choose");
- const [pairSubMode, setPairSubMode] = useState<"choose" | "paste">("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 [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
+ const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
+ const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
+ const [editingDaemon, setEditingDaemon] = useState(null);
+ const [editLabel, setEditLabel] = useState("");
+ const [isSavingEdit, setIsSavingEdit] = useState(false);
const isLoading = settingsLoading || daemonLoading;
const isMountedRef = useRef(true);
@@ -421,95 +418,38 @@ export default function SettingsScreen() {
[]
);
- const handleOpenDaemonForm = useCallback((profile?: DaemonProfile) => {
- if (profile) {
- setDaemonFormMode("manual");
- setPairSubMode("choose");
- setDaemonForm({
- id: profile.id,
- label: profile.label,
- endpoint: profile.endpoints?.[0] ?? "",
- offerUrl: "",
- });
- } else {
- setDaemonFormMode("choose");
- setPairSubMode("choose");
- setDaemonForm({ id: null, label: "", endpoint: "", offerUrl: "" });
- }
- setIsDaemonFormVisible(true);
+ const handleEditDaemon = useCallback((profile: DaemonProfile) => {
+ setEditingDaemon(profile);
+ setEditLabel(profile.label ?? "");
}, []);
- const handleCloseDaemonForm = useCallback(() => {
- setIsDaemonFormVisible(false);
- setDaemonFormMode("choose");
- setPairSubMode("choose");
- setDaemonForm({ id: null, label: "", endpoint: "", offerUrl: "" });
- }, []);
+ const handleCloseEditDaemon = useCallback(() => {
+ if (isSavingEdit) return;
+ setEditingDaemon(null);
+ setEditLabel("");
+ }, [isSavingEdit]);
- const handleSubmitDaemonForm = useCallback(async () => {
- 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;
- }
+ const handleSaveEditDaemon = useCallback(async () => {
+ if (!editingDaemon) return;
+ if (isSavingEdit) 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);
- }
+ const nextLabel = editLabel.trim();
+ if (!nextLabel) {
+ Alert.alert("Label required", "Enter a label for this host.");
return;
}
- if (daemonFormMode === "pair") {
- if (pairSubMode !== "paste") {
- Alert.alert("Choose a method", "Select Scan QR or Paste link to pair.");
- return;
- }
- const offer = daemonForm.offerUrl.trim();
- if (!offer) {
- Alert.alert("Offer required", "Paste the pairing link (…/#offer=...)");
- return;
- }
- 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;
+ try {
+ setIsSavingEdit(true);
+ await updateDaemon(editingDaemon.id, { label: nextLabel });
+ handleCloseEditDaemon();
+ } catch (error) {
+ console.error("[Settings] Failed to rename host", error);
+ Alert.alert("Error", "Unable to save host");
+ } finally {
+ setIsSavingEdit(false);
}
-
- Alert.alert("Choose a method", "Select Pair or Manual to add a host.");
- }, [daemonForm, daemonFormMode, pairSubMode, addDaemon, updateDaemon, upsertDaemonFromOfferUrl, handleCloseDaemonForm]);
+ }, [editLabel, editingDaemon, handleCloseEditDaemon, isSavingEdit, updateDaemon]);
const handleRemoveDaemon = useCallback(
(profile: DaemonProfile) => {
@@ -642,7 +582,7 @@ export default function SettingsScreen() {
daemon={daemon}
connectionStatus={connectionStatus}
lastError={lastConnectionError}
- onEdit={handleOpenDaemonForm}
+ onEdit={handleEditDaemon}
onRemove={handleRemoveDaemon}
restartConfirmationMessage={restartConfirmationMessage}
waitForCondition={waitForCondition}
@@ -652,130 +592,75 @@ export default function SettingsScreen() {
})
)}
- {isDaemonFormVisible ? (
-
- {daemonForm.id ? "Edit Host" : "Add Host"}
-
- {daemonForm.id ? (
-
- Label
- setDaemonForm((prev) => ({ ...prev, label: text }))}
- placeholder="My Host"
- placeholderTextColor={defaultTheme.colors.mutedForeground}
- />
-
- ) : null}
-
- {daemonForm.id ? null : daemonFormMode === "choose" ? (
-
- {
- setDaemonFormMode("pair");
- setPairSubMode("choose");
- }}
- >
- Pair
-
- setDaemonFormMode("manual")}
- >
- Manual
-
-
- ) : null}
-
- {daemonFormMode === "manual" ? (
-
- Host
- setDaemonForm((prev) => ({ ...prev, endpoint: text }))}
- placeholder="localhost:6767"
- placeholderTextColor={defaultTheme.colors.mutedForeground}
- autoCapitalize="none"
- autoCorrect={false}
- keyboardType="url"
- />
-
- ) : null}
-
- {daemonFormMode === "pair" ? (
-
- Pairing
-
-
- {
- handleCloseDaemonForm();
- router.push("/pair-scan");
- }}
- >
- Scan QR
-
-
- setPairSubMode("paste")}
- >
- Paste link
-
-
-
- {pairSubMode === "paste" ? (
- setDaemonForm((prev) => ({ ...prev, offerUrl: text }))}
- placeholder="https://app.paseo.sh/#offer=..."
- placeholderTextColor={defaultTheme.colors.mutedForeground}
- autoCapitalize="none"
- autoCorrect={false}
- keyboardType="url"
- />
- ) : null}
-
- ) : null}
-
-
-
- Cancel
-
- {!daemonForm.id && daemonFormMode !== "choose" ? (
- {
- setDaemonFormMode("choose");
- setPairSubMode("choose");
- }}
- >
- Back
-
- ) : null}
-
-
- {isSavingDaemon ? "Saving..." : daemonForm.id ? "Save" : daemonFormMode === "pair" ? "Pair" : "Add"}
-
-
-
-
- ) : (
- handleOpenDaemonForm()}>
- + Add Host
-
- )}
+ setIsAddHostMethodVisible(true)}
+ >
+ + Add Host
+
+ setIsAddHostMethodVisible(false)}
+ onDirectConnection={() => setIsDirectHostVisible(true)}
+ onPasteLink={() => setIsPasteLinkVisible(true)}
+ onScanQr={() => router.push("/pair-scan")}
+ />
+
+ setIsDirectHostVisible(false)}
+ onSaved={(profile) => {
+ router.replace({ pathname: "/", params: { serverId: profile.id } });
+ }}
+ />
+
+ setIsPasteLinkVisible(false)}
+ onSaved={(profile) => {
+ router.replace({ pathname: "/", params: { serverId: profile.id } });
+ }}
+ />
+
+
+
+ Label
+
+
+
+
+
+ Cancel
+
+ void handleSaveEditDaemon()}
+ disabled={isSavingEdit}
+ >
+
+ {isSavingEdit ? "Saving..." : "Save"}
+
+
+
+
+
{/* Appearance */}
Appearance
@@ -1037,7 +922,7 @@ function DaemonCard({
: "rgba(161, 161, 170, 0.1)";
return (
-
+
{daemon.label}
diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx
new file mode 100644
index 000000000..4a21ba260
--- /dev/null
+++ b/packages/app/src/components/adaptive-modal-sheet.tsx
@@ -0,0 +1,224 @@
+import { useCallback, useEffect, useMemo, useRef } from "react";
+import type { ReactNode } from "react";
+import {
+ Modal,
+ Pressable,
+ ScrollView,
+ Text,
+ View,
+} from "react-native";
+import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
+import {
+ BottomSheetModal,
+ BottomSheetBackdrop,
+ BottomSheetScrollView,
+ type BottomSheetBackgroundProps,
+} from "@gorhom/bottom-sheet";
+import { X } from "lucide-react-native";
+
+const styles = StyleSheet.create((theme) => ({
+ desktopOverlay: {
+ flex: 1,
+ backgroundColor: "rgba(0,0,0,0.55)",
+ justifyContent: "center",
+ alignItems: "center",
+ padding: theme.spacing[6],
+ },
+ desktopCard: {
+ width: "100%",
+ maxWidth: 520,
+ maxHeight: "85%",
+ backgroundColor: theme.colors.surface1,
+ borderRadius: theme.borderRadius.xl,
+ borderWidth: 1,
+ borderColor: theme.colors.surface2,
+ overflow: "hidden",
+ },
+ header: {
+ paddingHorizontal: theme.spacing[6],
+ paddingVertical: theme.spacing[4],
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ borderBottomWidth: 1,
+ borderBottomColor: theme.colors.surface2,
+ },
+ title: {
+ color: theme.colors.foreground,
+ fontSize: theme.fontSize.lg,
+ fontWeight: theme.fontWeight.semibold,
+ },
+ closeButton: {
+ padding: theme.spacing[2],
+ borderRadius: theme.borderRadius.lg,
+ backgroundColor: theme.colors.surface2,
+ },
+ desktopScroll: {
+ flex: 1,
+ },
+ desktopContent: {
+ padding: theme.spacing[6],
+ gap: theme.spacing[4],
+ },
+ bottomSheetHandle: {
+ backgroundColor: theme.colors.surface2,
+ },
+ bottomSheetHeader: {
+ paddingHorizontal: theme.spacing[6],
+ paddingTop: theme.spacing[4],
+ paddingBottom: theme.spacing[3],
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ borderBottomWidth: 1,
+ borderBottomColor: theme.colors.surface2,
+ },
+ bottomSheetContent: {
+ padding: theme.spacing[6],
+ gap: theme.spacing[4],
+ },
+}));
+
+function SheetBackground({ style }: BottomSheetBackgroundProps) {
+ const { theme } = useUnistyles();
+ return (
+
+ );
+}
+
+export interface AdaptiveModalSheetProps {
+ title: string;
+ visible: boolean;
+ onClose: () => void;
+ children: ReactNode;
+ snapPoints?: string[];
+ testID?: string;
+}
+
+export function AdaptiveModalSheet({
+ title,
+ visible,
+ onClose,
+ children,
+ snapPoints,
+ testID,
+}: AdaptiveModalSheetProps) {
+ const { theme } = useUnistyles();
+ const isMobile =
+ UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
+ const sheetRef = useRef(null);
+ const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
+
+ useEffect(() => {
+ if (!isMobile) return;
+ if (visible) {
+ sheetRef.current?.present();
+ } else {
+ sheetRef.current?.dismiss();
+ }
+ }, [visible, isMobile]);
+
+ const handleSheetChange = useCallback(
+ (index: number) => {
+ if (index === -1) {
+ onClose();
+ }
+ },
+ [onClose]
+ );
+
+ const renderBackdrop = useCallback(
+ (props: React.ComponentProps) => (
+
+ ),
+ []
+ );
+
+ if (isMobile) {
+ return (
+
+
+ {title}
+
+
+
+
+
+ {children}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {title}
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/packages/app/src/components/add-host-method-modal.tsx b/packages/app/src/components/add-host-method-modal.tsx
new file mode 100644
index 000000000..97bdf7114
--- /dev/null
+++ b/packages/app/src/components/add-host-method-modal.tsx
@@ -0,0 +1,94 @@
+import { useCallback } from "react";
+import { Pressable, Text, View, Platform } from "react-native";
+import { StyleSheet, useUnistyles } from "react-native-unistyles";
+import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
+import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
+
+const styles = StyleSheet.create((theme) => ({
+ option: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[4],
+ padding: theme.spacing[4],
+ borderRadius: theme.borderRadius.xl,
+ backgroundColor: theme.colors.surface2,
+ borderWidth: 1,
+ borderColor: theme.colors.border,
+ },
+ optionText: {
+ color: theme.colors.foreground,
+ fontSize: theme.fontSize.base,
+ fontWeight: theme.fontWeight.semibold,
+ },
+ optionSubtext: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.sm,
+ marginTop: theme.spacing[1],
+ },
+ optionBody: {
+ flex: 1,
+ },
+}));
+
+export interface AddHostMethodModalProps {
+ visible: boolean;
+ onClose: () => void;
+ onDirectConnection: () => void;
+ onScanQr: () => void;
+ onPasteLink: () => void;
+}
+
+export function AddHostMethodModal({
+ visible,
+ onClose,
+ onDirectConnection,
+ onScanQr,
+ onPasteLink,
+}: AddHostMethodModalProps) {
+ const { theme } = useUnistyles();
+
+ const handleDirect = useCallback(() => {
+ onClose();
+ onDirectConnection();
+ }, [onClose, onDirectConnection]);
+
+ const handleScan = useCallback(() => {
+ onClose();
+ onScanQr();
+ }, [onClose, onScanQr]);
+
+ const handlePaste = useCallback(() => {
+ onClose();
+ onPasteLink();
+ }, [onClose, onPasteLink]);
+
+ return (
+
+
+
+
+ Direct connection
+ Enter a host and port.
+
+
+
+ {Platform.OS !== "web" ? (
+
+
+
+ Scan QR code
+ Use your camera to pair.
+
+
+ ) : null}
+
+
+
+
+ Paste pairing link
+ Works without a camera.
+
+
+
+ );
+}
diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx
new file mode 100644
index 000000000..2abc1c618
--- /dev/null
+++ b/packages/app/src/components/add-host-modal.tsx
@@ -0,0 +1,288 @@
+import { useCallback, useMemo, useRef, useState } from "react";
+import { Alert, Pressable, Text, TextInput, View } from "react-native";
+import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
+import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
+import { Link2 } from "lucide-react-native";
+import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
+import { normalizeHostPort } from "@/utils/daemon-endpoints";
+import { DaemonConnectionTestError, testDaemonEndpointConnection } from "@/utils/test-daemon-connection";
+import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
+
+const styles = StyleSheet.create((theme) => ({
+ field: {
+ gap: theme.spacing[2],
+ },
+ label: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.sm,
+ fontWeight: theme.fontWeight.medium,
+ },
+ input: {
+ backgroundColor: theme.colors.surface2,
+ borderRadius: theme.borderRadius.lg,
+ paddingHorizontal: theme.spacing[4],
+ paddingVertical: theme.spacing[3],
+ color: theme.colors.foreground,
+ borderWidth: 1,
+ borderColor: theme.colors.border,
+ },
+ actions: {
+ flexDirection: "row",
+ gap: theme.spacing[3],
+ marginTop: theme.spacing[2],
+ },
+ button: {
+ flex: 1,
+ paddingVertical: theme.spacing[3],
+ borderRadius: theme.borderRadius.lg,
+ alignItems: "center",
+ justifyContent: "center",
+ backgroundColor: theme.colors.surface2,
+ },
+ primaryButton: {
+ backgroundColor: theme.colors.palette.blue[500],
+ },
+ buttonText: {
+ color: theme.colors.foreground,
+ fontWeight: theme.fontWeight.semibold,
+ },
+ primaryButtonText: {
+ color: theme.colors.palette.white,
+ },
+ helper: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.sm,
+ },
+ error: {
+ color: theme.colors.destructive,
+ fontSize: theme.fontSize.sm,
+ },
+ connectRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: theme.spacing[2],
+ },
+}));
+
+function isHostPortOnly(raw: string): boolean {
+ return !raw.includes("://") && !raw.includes("/");
+}
+
+function normalizeTransportMessage(message: string | null | undefined): string | null {
+ if (!message) return null;
+ const trimmed = message.trim();
+ if (!trimmed) return null;
+ return trimmed;
+}
+
+function formatTechnicalTransportDetails(details: Array): string | null {
+ const unique = Array.from(
+ new Set(
+ details
+ .map((value) => normalizeTransportMessage(value))
+ .filter((value): value is string => Boolean(value))
+ .map((value) => value.trim())
+ .filter((value) => value.length > 0)
+ )
+ );
+
+ if (unique.length === 0) return null;
+
+ const allGeneric = unique.every((value) => {
+ const lower = value.toLowerCase();
+ return lower === "transport error" || lower === "transport closed";
+ });
+
+ if (allGeneric) {
+ return `${unique[0]} (no additional details provided)`;
+ }
+
+ return unique.join(" — ");
+}
+
+function buildConnectionFailureCopy(endpoint: string, error: unknown): { title: string; detail: string | null; raw: string | null } {
+ const title = `We failed to connect to ${endpoint}.`;
+
+ const raw = (() => {
+ if (error instanceof DaemonConnectionTestError) {
+ return (
+ formatTechnicalTransportDetails([error.reason, error.lastError]) ??
+ normalizeTransportMessage(error.message)
+ );
+ }
+ if (error instanceof Error) {
+ return normalizeTransportMessage(error.message);
+ }
+ return null;
+ })();
+
+ const rawLower = raw?.toLowerCase() ?? "";
+ let detail: string | null = null;
+
+ if (rawLower.includes("timed out")) {
+ detail = "Connection timed out. Check the host/port and your network.";
+ } else if (
+ rawLower.includes("econnrefused") ||
+ rawLower.includes("connection refused") ||
+ rawLower.includes("err_connection_refused")
+ ) {
+ detail = "Connection was refused. Is the daemon running on that host and port?";
+ } else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
+ detail = "Host not found. Check the hostname and try again.";
+ } else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
+ detail = "Host is unreachable. Check your network and firewall.";
+ } else if (rawLower.includes("certificate") || rawLower.includes("tls") || rawLower.includes("ssl")) {
+ detail = "TLS/certificate error. This app expects a daemon reachable over the local network or via relay.";
+ } else if (raw) {
+ detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
+ } else {
+ detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
+ }
+
+ return { title, detail, raw };
+}
+
+export interface AddHostModalProps {
+ visible: boolean;
+ onClose: () => void;
+ onSaved?: (profile: HostProfile) => void;
+}
+
+export function AddHostModal({ visible, onClose, onSaved }: AddHostModalProps) {
+ const { theme } = useUnistyles();
+ const { addDaemon } = useDaemonRegistry();
+ const isMobile =
+ UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
+
+ const InputComponent = useMemo(() => (isMobile ? BottomSheetTextInput : TextInput), [isMobile]);
+ const labelInputRef = useRef(null);
+ const hostInputRef = useRef(null);
+
+ const [label, setLabel] = useState("");
+ const [endpointRaw, setEndpointRaw] = useState("");
+ const [isSaving, setIsSaving] = useState(false);
+ const [errorMessage, setErrorMessage] = useState("");
+
+ const handleClose = useCallback(() => {
+ if (isSaving) return;
+ setLabel("");
+ setEndpointRaw("");
+ setErrorMessage("");
+ onClose();
+ }, [isSaving, onClose]);
+
+ const handleSave = useCallback(async () => {
+ if (isSaving) return;
+
+ const raw = endpointRaw.trim();
+ if (!raw) {
+ setErrorMessage("Host is required");
+ return;
+ }
+ if (!isHostPortOnly(raw)) {
+ setErrorMessage("Enter host:port only (no ws://, no /ws)");
+ return;
+ }
+
+ let endpoint: string;
+ try {
+ endpoint = normalizeHostPort(raw);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Invalid host:port";
+ setErrorMessage(message);
+ return;
+ }
+
+ try {
+ setIsSaving(true);
+ setErrorMessage("");
+
+ await testDaemonEndpointConnection(endpoint);
+
+ const profile = await addDaemon({
+ label: label.trim(),
+ endpoints: [endpoint],
+ });
+
+ onSaved?.(profile);
+ handleClose();
+ } catch (error) {
+ const { title, detail, raw } = buildConnectionFailureCopy(endpoint, error);
+ const combined =
+ raw && detail && raw !== detail
+ ? `${title}\n${detail}\nDetails: ${raw}`
+ : detail
+ ? `${title}\n${detail}`
+ : title;
+ setErrorMessage(combined);
+ if (!isMobile) {
+ // Desktop/web: also surface it as a dialog for quick visibility.
+ Alert.alert("Connection failed", combined);
+ }
+ } finally {
+ setIsSaving(false);
+ }
+ }, [addDaemon, endpointRaw, handleClose, isMobile, isSaving, label, onSaved]);
+
+ return (
+
+ Connect to a daemon by entering host:port.
+
+
+ Label (optional)
+ hostInputRef.current?.focus()}
+ />
+
+
+
+ Host
+ void handleSave()}
+ />
+ {errorMessage ? {errorMessage} : null}
+
+
+
+
+ Cancel
+
+ void handleSave()}
+ disabled={isSaving}
+ >
+
+
+
+ {isSaving ? "Connecting..." : "Connect & Save"}
+
+
+
+
+
+ );
+}
diff --git a/packages/app/src/components/multi-daemon-session-host.tsx b/packages/app/src/components/multi-daemon-session-host.tsx
index ad98f4629..a896a7d13 100644
--- a/packages/app/src/components/multi-daemon-session-host.tsx
+++ b/packages/app/src/components/multi-daemon-session-host.tsx
@@ -58,12 +58,15 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const candidates = useMemo(() => buildCandidateUrls(daemon), [daemon]);
const [activeIndex, setActiveIndex] = useState(0);
- const activeUrl = candidates[activeIndex] ?? candidates[0] ?? buildDaemonWebSocketUrl("localhost:6767");
+ const activeUrl = candidates[activeIndex] ?? candidates[0] ?? null;
const lastAttemptedUrlRef = useRef(null);
const pendingMetadataWriteRef = useRef(false);
useEffect(() => {
+ if (!activeUrl) {
+ return;
+ }
// If the active URL fell out of the candidate set (e.g. endpoints updated), snap back.
const idx = candidates.indexOf(activeUrl);
if (idx === -1) {
@@ -72,6 +75,10 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [candidates.join("|")]);
+ if (!activeUrl) {
+ return null;
+ }
+
const connection = connectionStates.get(daemon.id);
const status = connection?.status ?? "idle";
const lastError = connection?.lastError ?? null;
diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/components/pair-link-modal.tsx
new file mode 100644
index 000000000..dca6e7328
--- /dev/null
+++ b/packages/app/src/components/pair-link-modal.tsx
@@ -0,0 +1,157 @@
+import { useCallback, useMemo, useState } from "react";
+import { Alert, Pressable, Text, TextInput, View } from "react-native";
+import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
+import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
+import { Link } from "lucide-react-native";
+import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
+import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
+
+const styles = StyleSheet.create((theme) => ({
+ helper: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.sm,
+ },
+ field: {
+ gap: theme.spacing[2],
+ },
+ label: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.sm,
+ fontWeight: theme.fontWeight.medium,
+ },
+ input: {
+ backgroundColor: theme.colors.surface2,
+ borderRadius: theme.borderRadius.lg,
+ paddingHorizontal: theme.spacing[4],
+ paddingVertical: theme.spacing[3],
+ color: theme.colors.foreground,
+ borderWidth: 1,
+ borderColor: theme.colors.border,
+ },
+ error: {
+ color: theme.colors.destructive,
+ fontSize: theme.fontSize.sm,
+ },
+ actions: {
+ flexDirection: "row",
+ gap: theme.spacing[3],
+ marginTop: theme.spacing[2],
+ },
+ button: {
+ flex: 1,
+ paddingVertical: theme.spacing[3],
+ borderRadius: theme.borderRadius.lg,
+ alignItems: "center",
+ justifyContent: "center",
+ backgroundColor: theme.colors.surface2,
+ },
+ primaryButton: {
+ backgroundColor: theme.colors.palette.blue[500],
+ },
+ buttonText: {
+ color: theme.colors.foreground,
+ fontWeight: theme.fontWeight.semibold,
+ },
+ primaryButtonText: {
+ color: theme.colors.palette.white,
+ },
+ connectRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: theme.spacing[2],
+ },
+}));
+
+export interface PairLinkModalProps {
+ visible: boolean;
+ onClose: () => void;
+ onSaved?: (profile: HostProfile) => void;
+}
+
+export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps) {
+ const { theme } = useUnistyles();
+ const { upsertDaemonFromOfferUrl } = useDaemonRegistry();
+ const isMobile =
+ UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
+ const InputComponent = useMemo(() => (isMobile ? BottomSheetTextInput : TextInput), [isMobile]);
+
+ const [offerUrl, setOfferUrl] = useState("");
+ const [isSaving, setIsSaving] = useState(false);
+ const [errorMessage, setErrorMessage] = useState("");
+
+ const handleClose = useCallback(() => {
+ if (isSaving) return;
+ setOfferUrl("");
+ setErrorMessage("");
+ onClose();
+ }, [isSaving, onClose]);
+
+ const handleSave = useCallback(async () => {
+ if (isSaving) return;
+ const raw = offerUrl.trim();
+ if (!raw) {
+ setErrorMessage("Paste a pairing link (…/#offer=...)");
+ return;
+ }
+ if (!raw.includes("#offer=")) {
+ setErrorMessage("Link must include #offer=...");
+ return;
+ }
+
+ try {
+ setIsSaving(true);
+ setErrorMessage("");
+ const profile = await upsertDaemonFromOfferUrl(raw);
+ onSaved?.(profile);
+ handleClose();
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Unable to pair host";
+ setErrorMessage(message);
+ if (!isMobile) {
+ Alert.alert("Pairing failed", message);
+ }
+ } finally {
+ setIsSaving(false);
+ }
+ }, [handleClose, isMobile, isSaving, offerUrl, onSaved, upsertDaemonFromOfferUrl]);
+
+ return (
+
+ Paste the daemon’s pairing link.
+
+
+ Pairing link
+
+ {errorMessage ? {errorMessage} : null}
+
+
+
+
+ Cancel
+
+ void handleSave()}
+ disabled={isSaving}
+ >
+
+
+
+ {isSaving ? "Pairing..." : "Pair"}
+
+
+
+
+
+ );
+}
diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx
new file mode 100644
index 000000000..896442a4e
--- /dev/null
+++ b/packages/app/src/components/welcome-screen.tsx
@@ -0,0 +1,114 @@
+import { useState } from "react";
+import { Image, Pressable, Text, View, Platform } from "react-native";
+import { useRouter } from "expo-router";
+import { StyleSheet, useUnistyles } from "react-native-unistyles";
+import { QrCode, Link2 } from "lucide-react-native";
+import type { HostProfile } from "@/contexts/daemon-registry-context";
+import { AddHostModal } from "./add-host-modal";
+
+const styles = StyleSheet.create((theme) => ({
+ container: {
+ flex: 1,
+ backgroundColor: theme.colors.surface0,
+ padding: theme.spacing[8],
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ logo: {
+ width: 96,
+ height: 96,
+ marginBottom: theme.spacing[6],
+ },
+ title: {
+ color: theme.colors.foreground,
+ fontSize: theme.fontSize.xl,
+ fontWeight: theme.fontWeight.bold,
+ marginBottom: theme.spacing[3],
+ textAlign: "center",
+ },
+ subtitle: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.base,
+ textAlign: "center",
+ marginBottom: theme.spacing[8],
+ },
+ actions: {
+ width: "100%",
+ maxWidth: 420,
+ gap: theme.spacing[3],
+ },
+ actionButton: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: theme.spacing[3],
+ paddingVertical: theme.spacing[4],
+ borderRadius: theme.borderRadius.xl,
+ backgroundColor: theme.colors.surface2,
+ borderWidth: 1,
+ borderColor: theme.colors.border,
+ },
+ actionButtonPrimary: {
+ backgroundColor: theme.colors.palette.blue[500],
+ borderColor: theme.colors.palette.blue[500],
+ },
+ actionText: {
+ color: theme.colors.foreground,
+ fontSize: theme.fontSize.base,
+ fontWeight: theme.fontWeight.semibold,
+ },
+ actionTextPrimary: {
+ color: theme.colors.palette.white,
+ },
+}));
+
+export interface WelcomeScreenProps {
+ onHostAdded?: (profile: HostProfile) => void;
+}
+
+export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
+ const { theme } = useUnistyles();
+ const router = useRouter();
+ const [isDirectOpen, setIsDirectOpen] = useState(false);
+
+ return (
+
+
+ Welcome to Paseo
+ Add a host to start.
+
+
+ setIsDirectOpen(true)}
+ >
+
+ Direct connection
+
+
+ {Platform.OS !== "web" ? (
+ router.push("/pair-scan")}
+ >
+
+ Scan QR code
+
+ ) : null}
+
+
+ setIsDirectOpen(false)}
+ onSaved={(profile) => {
+ onHostAdded?.(profile);
+ router.replace({ pathname: "/", params: { serverId: profile.id } });
+ }}
+ />
+
+ );
+}
diff --git a/packages/app/src/contexts/daemon-registry-context.tsx b/packages/app/src/contexts/daemon-registry-context.tsx
index b1590e62b..4f2f1c009 100644
--- a/packages/app/src/contexts/daemon-registry-context.tsx
+++ b/packages/app/src/contexts/daemon-registry-context.tsx
@@ -13,10 +13,6 @@ import {
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
const LEGACY_SETTINGS_KEY = "@paseo:settings";
-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 HostRelayConfig = {
@@ -400,11 +396,8 @@ async function loadDaemonRegistryFromStorage(): Promise {
return envDefaults;
}
- 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;
+ // No implicit localhost fallback: a fresh install starts with zero hosts.
+ return [];
} catch (error) {
console.error("[DaemonRegistry] Failed to load daemon registry", error);
throw error;
@@ -412,6 +405,9 @@ async function loadDaemonRegistryFromStorage(): Promise {
}
export function buildDirectDaemonWsUrl(profile: HostProfile): string {
- const endpoint = profile.endpoints[0] ?? FALLBACK_DAEMON_ENDPOINT;
+ const endpoint = profile.endpoints[0];
+ if (!endpoint) {
+ throw new Error("Host profile has no endpoints");
+ }
return buildDaemonWebSocketUrl(endpoint);
}
diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts
new file mode 100644
index 000000000..a487e1073
--- /dev/null
+++ b/packages/app/src/utils/test-daemon-connection.ts
@@ -0,0 +1,102 @@
+import { DaemonClientV2 } from "@server/client/daemon-client-v2";
+import type { ConnectionState } from "@server/client/daemon-client-v2";
+import { buildDaemonWebSocketUrl } from "./daemon-endpoints";
+
+function normalizeNonEmptyString(value: unknown): string | null {
+ if (typeof value !== "string") return null;
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : null;
+}
+
+function pickBestReason(reason: string | null, lastError: string | null): string {
+ const genericReason =
+ reason &&
+ (reason.toLowerCase() === "transport error" || reason.toLowerCase() === "transport closed");
+ const genericLastError =
+ lastError &&
+ (lastError.toLowerCase() === "transport error" ||
+ lastError.toLowerCase() === "transport closed" ||
+ lastError.toLowerCase() === "unable to connect");
+
+ if (genericReason && lastError && !genericLastError) {
+ return lastError;
+ }
+ if (reason) return reason;
+ if (lastError) return lastError;
+ return "Unable to connect";
+}
+
+export class DaemonConnectionTestError extends Error {
+ reason: string | null;
+ lastError: string | null;
+
+ constructor(message: string, details: { reason: string | null; lastError: string | null }) {
+ super(message);
+ this.name = "DaemonConnectionTestError";
+ this.reason = details.reason;
+ this.lastError = details.lastError;
+ }
+}
+
+export async function testDaemonEndpointConnection(
+ endpoint: string,
+ options?: { timeoutMs?: number }
+): Promise {
+ const timeoutMs = options?.timeoutMs ?? 6000;
+ const url = buildDaemonWebSocketUrl(endpoint);
+
+ const client = new DaemonClientV2({
+ url,
+ suppressSendErrors: true,
+ });
+
+ try {
+ await new Promise((resolve, reject) => {
+ let cleanedUp = false;
+ let unsubscribe: (() => void) | null = null;
+
+ const cleanup = () => {
+ if (cleanedUp) return;
+ cleanedUp = true;
+ clearTimeout(timeout);
+ unsubscribe?.();
+ };
+
+ const finishOk = () => {
+ cleanup();
+ resolve();
+ };
+
+ const finishErr = (error: Error) => {
+ cleanup();
+ reject(error);
+ };
+
+ const timeout = setTimeout(() => {
+ finishErr(
+ new DaemonConnectionTestError("Connection timed out", {
+ reason: "Connection timed out",
+ lastError: client.lastError ?? null,
+ })
+ );
+ }, timeoutMs);
+
+ unsubscribe = client.subscribeConnectionStatus((state) => {
+ if (state.status === "connected") {
+ finishOk();
+ return;
+ }
+ if (state.status === "disconnected") {
+ const reason = normalizeNonEmptyString(state.reason);
+ const lastError = normalizeNonEmptyString(client.lastError);
+ const message = pickBestReason(reason, lastError);
+ finishErr(new DaemonConnectionTestError(message, { reason, lastError }));
+ }
+ });
+
+ void client.connect().catch(() => undefined);
+ });
+ } finally {
+ await client.close().catch(() => undefined);
+ }
+}
diff --git a/packages/server/src/client/daemon-client-v2.ts b/packages/server/src/client/daemon-client-v2.ts
index 16bd83617..b0f933965 100644
--- a/packages/server/src/client/daemon-client-v2.ts
+++ b/packages/server/src/client/daemon-client-v2.ts
@@ -223,6 +223,7 @@ export class DaemonClientV2 {
private connectionListeners: Set<(status: ConnectionState) => void> =
new Set();
private reconnectTimeout: ReturnType | null = null;
+ private pendingGenericTransportErrorTimeout: ReturnType | null = null;
private reconnectAttempt = 0;
private shouldReconnect = true;
private connectPromise: Promise | null = null;
@@ -296,12 +297,20 @@ export class DaemonClientV2 {
this.transportCleanup = [
transport.onOpen(() => {
+ if (this.pendingGenericTransportErrorTimeout) {
+ clearTimeout(this.pendingGenericTransportErrorTimeout);
+ this.pendingGenericTransportErrorTimeout = null;
+ }
this.lastErrorValue = null;
this.reconnectAttempt = 0;
this.updateConnectionState({ status: "connected" });
this.resolveConnect();
}),
transport.onClose((event) => {
+ if (this.pendingGenericTransportErrorTimeout) {
+ clearTimeout(this.pendingGenericTransportErrorTimeout);
+ this.pendingGenericTransportErrorTimeout = null;
+ }
const reason = describeTransportClose(event);
if (reason) {
this.lastErrorValue = reason;
@@ -314,11 +323,34 @@ export class DaemonClientV2 {
}),
transport.onError((event) => {
const reason = describeTransportError(event);
+ const isGeneric = reason === "Transport error";
+ // Browser WebSocket.onerror often provides no useful details and is followed
+ // by a close event (often with code 1006). Prefer surfacing the close details
+ // instead of immediately disconnecting with a generic "Transport error".
+ if (isGeneric) {
+ this.lastErrorValue ??= reason;
+ if (!this.pendingGenericTransportErrorTimeout) {
+ this.pendingGenericTransportErrorTimeout = setTimeout(() => {
+ this.pendingGenericTransportErrorTimeout = null;
+ if (
+ this.connectionState.status === "connected" ||
+ this.connectionState.status === "connecting"
+ ) {
+ this.lastErrorValue = reason;
+ this.updateConnectionState({ status: "disconnected", reason });
+ this.scheduleReconnect(reason);
+ }
+ }, 250);
+ }
+ return;
+ }
+
+ if (this.pendingGenericTransportErrorTimeout) {
+ clearTimeout(this.pendingGenericTransportErrorTimeout);
+ this.pendingGenericTransportErrorTimeout = null;
+ }
this.lastErrorValue = reason;
- this.updateConnectionState({
- status: "disconnected",
- reason,
- });
+ this.updateConnectionState({ status: "disconnected", reason });
this.scheduleReconnect(reason);
}),
transport.onMessage((data) => this.handleTransportMessage(data)),
@@ -1928,6 +1960,10 @@ export class DaemonClientV2 {
}
private cleanupTransport(): void {
+ if (this.pendingGenericTransportErrorTimeout) {
+ clearTimeout(this.pendingGenericTransportErrorTimeout);
+ this.pendingGenericTransportErrorTimeout = null;
+ }
for (const cleanup of this.transportCleanup) {
try {
cleanup();