From 9b82c02fd23d1f9f4f94ba63134070bb28d4249d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 24 Jan 2026 17:01:12 +0700 Subject: [PATCH] Add welcome onboarding and remove default localhost host --- packages/app/e2e/daemon-connectivity.spec.ts | 2 +- .../app/e2e/host-onboarding-direct.spec.ts | 29 ++ packages/app/e2e/manual-host-port.spec.ts | 10 +- .../app/e2e/pairing-offer-parsing.spec.ts | 5 +- packages/app/src/app/_layout.tsx | 32 +- packages/app/src/app/index.tsx | 21 ++ packages/app/src/app/pair-scan.tsx | 4 +- packages/app/src/app/settings.tsx | 321 ++++++------------ .../src/components/adaptive-modal-sheet.tsx | 224 ++++++++++++ .../src/components/add-host-method-modal.tsx | 94 +++++ .../app/src/components/add-host-modal.tsx | 288 ++++++++++++++++ .../components/multi-daemon-session-host.tsx | 9 +- .../app/src/components/pair-link-modal.tsx | 157 +++++++++ .../app/src/components/welcome-screen.tsx | 114 +++++++ .../src/contexts/daemon-registry-context.tsx | 16 +- .../app/src/utils/test-daemon-connection.ts | 102 ++++++ .../server/src/client/daemon-client-v2.ts | 44 ++- 17 files changed, 1222 insertions(+), 250 deletions(-) create mode 100644 packages/app/e2e/host-onboarding-direct.spec.ts create mode 100644 packages/app/src/components/adaptive-modal-sheet.tsx create mode 100644 packages/app/src/components/add-host-method-modal.tsx create mode 100644 packages/app/src/components/add-host-modal.tsx create mode 100644 packages/app/src/components/pair-link-modal.tsx create mode 100644 packages/app/src/components/welcome-screen.tsx create mode 100644 packages/app/src/utils/test-daemon-connection.ts 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();