diff --git a/.oxlintrc.json b/.oxlintrc.json index c82b517844..ae335c0e48 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -406,10 +406,7 @@ { "files": [ "packages/app/src/app/_layout.tsx", - "packages/app/src/app/pair-scan.tsx", "packages/app/src/components/adaptive-modal-sheet.tsx", - "packages/app/src/components/add-host-method-modal.tsx", - "packages/app/src/components/add-host-modal.tsx", "packages/app/src/components/agent-list.tsx", "packages/app/src/components/agent-status-dot.tsx", "packages/app/src/components/attachment-lightbox.tsx", @@ -428,7 +425,6 @@ "packages/app/src/components/hosts/host-picker.tsx", "packages/app/src/components/icons/paseo-logo.tsx", "packages/app/src/components/left-sidebar.tsx", - "packages/app/src/components/pair-link-modal.tsx", "packages/app/src/components/plan-card.tsx", "packages/app/src/components/provider-diagnostic-sheet.tsx", "packages/app/src/components/question-form-card.tsx", @@ -543,14 +539,10 @@ "packages/app/src/agent-skills/selection-sheet.tsx", "packages/app/src/agent-stream/chat-outline/rail.web.tsx", "packages/app/src/agent-stream/view.tsx", - "packages/app/src/app/pair-scan.tsx", "packages/app/src/changelog/internal/changelog-sheet.tsx", "packages/app/src/command-center/command-center.tsx", "packages/app/src/components/adaptive-modal-sheet.tsx", - "packages/app/src/components/add-host-method-modal.tsx", - "packages/app/src/components/add-host-modal.tsx", "packages/app/src/components/add-project-flow.tsx", - "packages/app/src/components/add-remote-ssh-host-modal.tsx", "packages/app/src/components/agent-list.tsx", "packages/app/src/components/app-diagnostic-sheet.tsx", "packages/app/src/components/attachment-lightbox.tsx", diff --git a/docs/design.md b/docs/design.md index a336b80a64..dbf4c028ec 100644 --- a/docs/design.md +++ b/docs/design.md @@ -35,7 +35,7 @@ The authored interface ramp uses a 14px base. New native installs default to 15p Weight has three tiers, applied by role: - **Screen titles** — the title in app chrome — use `` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. The New workspace hero is the only larger product title; it uses `fontSize["2xl"]` (`packages/app/src/screens/new-workspace-screen.tsx`). -- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`). +- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx:19-23`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`). - **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), ` {appVersionText} - - - - - - ); diff --git a/packages/app/src/hosts/add-host/index.tsx b/packages/app/src/hosts/add-host/index.tsx new file mode 100644 index 0000000000..42b37c6d8e --- /dev/null +++ b/packages/app/src/hosts/add-host/index.tsx @@ -0,0 +1,89 @@ +import { createContext, useCallback, useContext, useState, useMemo, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { useStore } from "zustand"; +import { AddHostMethodModal } from "./internal/add-host-method-modal"; +import { AddHostModal } from "./internal/add-host-modal"; +import { AddRemoteSshHostModal } from "./internal/add-remote-ssh-host-modal"; +import { PairLinkModal } from "./internal/pair-link-modal"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { PairScanContent } from "./internal/scan"; +import { createAddHostFlow, type AddHostOptions, type AddHostSession } from "./internal/model"; + +const Context = createContext | null>(null); + +/** Owns the flow outside screen and appearance lifetimes. Callers own success navigation. */ +export function AddHostProvider({ children }: { children: ReactNode }) { + const [flow] = useState(createAddHostFlow); + return {children}; +} + +export function AddHostFlowHost() { + const flow = useContext(Context); + if (!flow) throw new Error("AddHostFlowHost requires AddHostProvider"); + const session = useStore(flow.store, (state) => state.session); + return session ? : null; +} + +export function useAddHost({ method, onAdded }: AddHostOptions = {}) { + const flow = useContext(Context); + if (!flow) throw new Error("useAddHost requires AddHostProvider"); + return useCallback(() => flow.open({ method, onAdded }), [flow, method, onAdded]); +} + +function AddHostSessionView({ session }: { session: AddHostSession }) { + const { t } = useTranslation(); + const method = useStore(session.step, (state) => state.method); + const back = useCallback(() => session.choose(null), [session]); + const direct = useCallback(() => session.choose("direct"), [session]); + const ssh = useCallback(() => session.choose("ssh"), [session]); + const link = useCallback(() => session.choose("link"), [session]); + const scan = useCallback(() => session.choose("scan"), [session]); + const scanned = useCallback((serverId: string) => session.complete({ serverId }), [session]); + const header = useMemo( + () => ({ title: t("pairing.scan.title"), back: { onPress: back } }), + [t, back], + ); + return ( + <> + + + + + {method === "scan" ? ( + + + + ) : null} + + ); +} +const SCAN_SNAP_POINTS = ["90%"]; diff --git a/packages/app/src/components/add-host-method-modal.tsx b/packages/app/src/hosts/add-host/internal/add-host-method-modal.tsx similarity index 92% rename from packages/app/src/components/add-host-method-modal.tsx rename to packages/app/src/hosts/add-host/internal/add-host-method-modal.tsx index b97c529bef..35e61a5d17 100644 --- a/packages/app/src/components/add-host-method-modal.tsx +++ b/packages/app/src/hosts/add-host/internal/add-host-method-modal.tsx @@ -1,9 +1,10 @@ +import { PressHighlight } from "@/components/ui/press-highlight"; import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Pressable, Text, View } from "react-native"; +import { Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { QrCode, Link2, ClipboardPaste, Terminal } from "lucide-react-native"; -import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { isFdroidBuild } from "@/constants/build-profile"; import { isNative } from "@/constants/platform"; import { isElectronRuntime } from "@/desktop/host"; @@ -84,7 +85,7 @@ export function AddHostMethodModal({ onClose={onClose} testID="add-host-method-modal" > - - + {isElectronRuntime() ? ( - - + ) : null} {isNative && !isFdroidBuild ? ( - - + ) : null} - - + ); } diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/hosts/add-host/internal/add-host-modal.tsx similarity index 89% rename from packages/app/src/components/add-host-modal.tsx rename to packages/app/src/hosts/add-host/internal/add-host-modal.tsx index 5d8fc90cff..5379e5f4bd 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/hosts/add-host/internal/add-host-modal.tsx @@ -1,17 +1,22 @@ +import { PressHighlight } from "@/components/ui/press-highlight"; import { useCallback, useMemo, useReducer, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Alert, Pressable, Text, View } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Alert, Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native"; import type { HostProfile } from "@/types/host-connection"; import { useHosts, useHostMutations } from "@/runtime/host-runtime"; import { parseConnectionUri, serializeConnectionUri } from "@/utils/daemon-endpoints"; -import { buildConnectionFailureCopy } from "./connection-failure-copy"; -import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet"; +import { buildConnectionFailureCopy } from "@/components/connection-failure-copy"; +import { + AdaptiveModalSheet, + AdaptiveTextInput, + type SheetHeader, +} from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; -import { PairingTargetTracker } from "./pair-link-credentials"; -import { buildDirectConnectionUri } from "./direct-connection-host"; +import { PairingTargetTracker } from "@/components/pair-link-credentials"; +import { buildDirectConnectionUri } from "@/components/direct-connection-host"; const FLEX_ONE_STYLE = { flex: 1 } as const; @@ -188,7 +193,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod } function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostModalProps) { - const { theme } = useUnistyles(); const { t } = useTranslation(); const daemons = useHosts(); const { probeAndUpsertDirectConnection, beginLinkPairing } = useHostMutations(); @@ -207,10 +211,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0); const advancedTarget = useRef(new PairingTargetTracker("", true)); - const connectIcon = useMemo( - () => , - [theme.colors.accentForeground], - ); + const connectIcon = useMemo(() => , []); const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []); const portFieldStyle = useMemo(() => [styles.field, styles.portField], []); const checkboxStyle = useMemo( @@ -415,8 +416,8 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod setIsAdvancedOpen(false); }, [advancedUri, directConnectionLabels, host, isAdvancedOpen, password, port, useTls]); - const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight; - const PasswordIcon = isPasswordVisible ? EyeOff : Eye; + const AdvancedIcon = isAdvancedOpen ? ThemedChevronDown : ThemedChevronRight; + const PasswordIcon = isPasswordVisible ? ThemedEyeOff : ThemedEye; return ( {t("pairing.direct.fields.host")} - {t("pairing.direct.fields.port")} - - {useTls ? ( - + ) : null} {t("pairing.direct.fields.useSsl")} - + {t("pairing.direct.fields.password")} - - - - + + - - + {t("pairing.direct.advanced.label")} - + {isAdvancedOpen ? ( - ); } + +const ThemedLink2 = withUnistyles(Link2, (theme) => ({ color: theme.colors.accentForeground })); + +const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.accentForeground })); + +const ThemedChevronDown = withUnistyles(ChevronDown, (theme) => ({ + color: theme.colors.foregroundMuted, +})); + +const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({ + color: theme.colors.foregroundMuted, +})); + +const ThemedEye = withUnistyles(Eye, (theme) => ({ color: theme.colors.foregroundMuted })); + +const ThemedEyeOff = withUnistyles(EyeOff, (theme) => ({ color: theme.colors.foregroundMuted })); + +const ThemedAdaptiveTextInput = withUnistyles(AdaptiveTextInput, (theme) => ({ + placeholderTextColor: theme.colors.foregroundMuted, +})); diff --git a/packages/app/src/components/add-remote-ssh-host-modal.tsx b/packages/app/src/hosts/add-host/internal/add-remote-ssh-host-modal.tsx similarity index 97% rename from packages/app/src/components/add-remote-ssh-host-modal.tsx rename to packages/app/src/hosts/add-host/internal/add-remote-ssh-host-modal.tsx index c7e25bce64..b3d1cf7829 100644 --- a/packages/app/src/components/add-remote-ssh-host-modal.tsx +++ b/packages/app/src/hosts/add-host/internal/add-remote-ssh-host-modal.tsx @@ -1,7 +1,7 @@ +import { PressHighlight } from "@/components/ui/press-highlight"; import { useCallback, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { - Pressable, Text, View, type PressableStateCallbackType, @@ -23,7 +23,7 @@ import { import type { EditingTextInputHandle } from "@/components/ui/text-input"; import { useIsCompactFormFactor } from "@/constants/layout"; import { DaemonConnectionTestError } from "@/utils/test-daemon-connection"; -import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; const FLEX_ONE_STYLE = { flex: 1 } as const; const ThemedTerminal = withUnistyles(Terminal); @@ -174,11 +174,11 @@ export function AddRemoteSshHostModal({ } clear(); - onClose(); onSaved?.({ ...result, isNewHost: !hosts.some((profile) => profile.serverId === result.serverId), }); + onClose(); }, [clear, hosts, isSaving, onClose, onSaved, probeAndUpsertRemoteSshConnection, t]); const handleTargetChange = useCallback((value: string) => { targetRef.current = value; @@ -260,7 +260,7 @@ export function AddRemoteSshHostModal({ onSubmitEditing={handleSubmit} /> - {isPasswordVisible ? : } - + diff --git a/packages/app/src/hosts/add-host/internal/model.test.ts b/packages/app/src/hosts/add-host/internal/model.test.ts new file mode 100644 index 0000000000..a878a5b41d --- /dev/null +++ b/packages/app/src/hosts/add-host/internal/model.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { createAddHostFlow } from "./model"; + +describe("add host flow", () => { + it("closes before notifying the consumer and ignores duplicate completion", () => { + const flow = createAddHostFlow(); + const added: string[] = []; + flow.open({ + onAdded: (serverId) => { + expect(flow.store.getState().session).toBeNull(); + added.push(serverId); + }, + }); + const session = flow.store.getState().session!; + session.choose("direct"); + session.complete({ serverId: "new-host" }); + session.complete({ serverId: "new-host" }); + expect(added).toEqual(["new-host"]); + }); + + it("cancels without a success action and accepts a fresh request", () => { + const flow = createAddHostFlow(); + const added: string[] = []; + flow.open({ onAdded: (id) => added.push(id) }); + const cancelled = flow.store.getState().session!; + cancelled.choose("scan"); + cancelled.close(); + expect(flow.store.getState().session).toBeNull(); + flow.open({ method: "link" }); + const current = flow.store.getState().session!; + cancelled.complete({ serverId: "late-result" }); + cancelled.close(); + expect(flow.store.getState().session).toBe(current); + expect(current.step.getState()).toEqual({ method: "link" }); + expect(added).toEqual([]); + current.complete({ serverId: "new-host" }); + expect(flow.store.getState().session).toBeNull(); + }); + + it("returns to method selection within the same request", () => { + const flow = createAddHostFlow(); + flow.open(); + const session = flow.store.getState().session!; + session.choose("ssh"); + session.choose(null); + expect(flow.store.getState().session).toBe(session); + expect(session.step.getState()).toEqual({ method: null }); + }); +}); diff --git a/packages/app/src/hosts/add-host/internal/model.ts b/packages/app/src/hosts/add-host/internal/model.ts new file mode 100644 index 0000000000..614d9dddc0 --- /dev/null +++ b/packages/app/src/hosts/add-host/internal/model.ts @@ -0,0 +1,47 @@ +import { createStore, type StoreApi } from "zustand/vanilla"; + +export type AddHostMethod = "direct" | "ssh" | "link" | "scan"; +export interface AddHostOptions { + method?: AddHostMethod; + onAdded?: (serverId: string) => void; +} + +export interface AddHostSession { + id: number; + step: StoreApi<{ method: AddHostMethod | null }>; + choose(method: AddHostMethod | null): void; + close(): void; + complete(result: { serverId: string }): void; +} + +export function createAddHostFlow() { + const store = createStore<{ session: AddHostSession | null }>(() => ({ session: null })); + let nextId = 0; + function createSession(options: AddHostOptions) { + const step = createStore<{ method: AddHostMethod | null }>(() => ({ + method: options.method ?? null, + })); + const session = { + id: ++nextId, + step, + choose(method: AddHostMethod | null) { + step.setState({ method }); + }, + close() { + if (store.getState().session === session) store.setState({ session: null }); + }, + complete({ serverId }: { serverId: string }) { + if (store.getState().session !== session) return; + session.close(); + options.onAdded?.(serverId); + }, + }; + return session; + } + return { + store, + open(options: AddHostOptions = {}) { + store.setState({ session: createSession(options) }); + }, + }; +} diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/hosts/add-host/internal/pair-link-modal.tsx similarity index 92% rename from packages/app/src/components/pair-link-modal.tsx rename to packages/app/src/hosts/add-host/internal/pair-link-modal.tsx index 8ed9cefba9..f817bdc5cf 100644 --- a/packages/app/src/components/pair-link-modal.tsx +++ b/packages/app/src/hosts/add-host/internal/pair-link-modal.tsx @@ -1,16 +1,20 @@ import { useCallback, useMemo, useReducer, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Alert, Text, View } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { Link } from "lucide-react-native"; import type { HostProfile } from "@/types/host-connection"; import { useHosts, useHostMutations, type PasswordRequiredPairing } from "@/runtime/host-runtime"; import { parseRelayConnectionUri } from "@/utils/daemon-endpoints"; import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer"; -import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet"; +import { + AdaptiveModalSheet, + AdaptiveTextInput, + type SheetHeader, +} from "@/components/adaptive-modal-sheet"; import { getConnectionAuthFailureReason } from "@/utils/test-daemon-connection"; -import { PairingTargetTracker } from "./pair-link-credentials"; +import { PairingTargetTracker } from "@/components/pair-link-credentials"; import { Button } from "@/components/ui/button"; import type { EditingTextInputHandle } from "@/components/ui/text-input"; @@ -100,7 +104,6 @@ function PairLinkModalContent({ onCancel, onSaved, }: PairLinkModalProps) { - const { theme } = useUnistyles(); const { t } = useTranslation(); const daemons = useHosts(); const { beginLinkPairing } = useHostMutations(); @@ -126,10 +129,7 @@ function PairLinkModalContent({ resetPasswordInput(); }, []); - const pairIcon = useMemo( - () => , - [theme.colors.accentForeground], - ); + const pairIcon = useMemo(() => , []); const handleClose = useCallback(() => { if (isSaving) return; @@ -212,7 +212,7 @@ function PairLinkModalContent({ {t("pairing.link.label")} - {t("pairing.hostPassword.title", { host: parsedHostLabel(offerUrlRef.current) })} - ); } + +const ThemedLink = withUnistyles(Link, (theme) => ({ color: theme.colors.accentForeground })); + +const ThemedAdaptiveTextInput = withUnistyles(AdaptiveTextInput, (theme) => ({ + placeholderTextColor: theme.colors.foregroundMuted, +})); diff --git a/packages/app/src/hosts/pair-scan-model.test.ts b/packages/app/src/hosts/add-host/internal/pair-scan-model.test.ts similarity index 100% rename from packages/app/src/hosts/pair-scan-model.test.ts rename to packages/app/src/hosts/add-host/internal/pair-scan-model.test.ts diff --git a/packages/app/src/hosts/pair-scan-model.ts b/packages/app/src/hosts/add-host/internal/pair-scan-model.ts similarity index 100% rename from packages/app/src/hosts/pair-scan-model.ts rename to packages/app/src/hosts/add-host/internal/pair-scan-model.ts diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/hosts/add-host/internal/scan.tsx similarity index 71% rename from packages/app/src/app/pair-scan.tsx rename to packages/app/src/hosts/add-host/internal/scan.tsx index 5d352140ee..1d96e326df 100644 --- a/packages/app/src/app/pair-scan.tsx +++ b/packages/app/src/hosts/add-host/internal/scan.tsx @@ -1,17 +1,14 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; -import { Pressable, Text, View } from "react-native"; -import { useLocalSearchParams, useRouter, type Href } from "expo-router"; +import { Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet } from "react-native-unistyles"; import { CameraView, useCameraPermissions } from "expo-camera"; import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; -import { openPairScan } from "@/hosts/pair-scan-model"; -import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; +import { openPairScan } from "./pair-scan-model"; import { isWeb } from "@/constants/platform"; -import { BackHeader } from "@/components/headers/back-header"; -import { PairLinkModal } from "@/components/pair-link-modal"; +import { PairLinkModal } from "./pair-link-modal"; import { Button } from "@/components/ui/button"; const styles = StyleSheet.create((theme) => ({ @@ -113,50 +110,26 @@ const styles = StyleSheet.create((theme) => ({ }, })); -export default function PairScanScreen() { - const { theme } = useUnistyles(); +export function PairScanContent({ + onConnected, + onClose, +}: { + onConnected: (serverId: string) => void; + onClose: () => void; +}) { const { t } = useTranslation(); const insets = useSafeAreaInsets(); - const router = useRouter(); - const params = useLocalSearchParams<{ - source?: string; - }>(); - const source = typeof params.source === "string" ? params.source : "settings"; - const [permission, requestPermission] = useCameraPermissions(); - const navigateToPairedHost = useCallback( - (serverId: string) => { - if (source === "onboarding") { - router.replace(buildHostRootRoute(serverId)); - return; - } - router.replace(buildSettingsHostRoute(serverId)); - }, - [router, source], - ); - const [scan] = useState(() => openPairScan({ - importConnectionLink: (link) => - getHostRuntimeStore().importConnectionLink( - link, - source === "onboarding" ? "hostRoot" : "hostSettings", - ), - onConnected: navigateToPairedHost, + importConnectionLink: (link) => getHostRuntimeStore().importConnectionLink(link, "hostRoot"), + onConnected, }), ); const scanState = useSyncExternalStore(scan.subscribe, scan.getState, scan.getState); useEffect(() => () => scan.close(), [scan]); - const closeToSource = useCallback(() => { - try { - router.back(); - } catch { - router.replace("/" as Href); - } - }, [router]); - useEffect(() => { if (isWeb) return; if (permission && permission.granted) return; @@ -169,35 +142,29 @@ export default function PairScanScreen() { [scan], ); - const handleRouterBack = useCallback(() => router.back(), [router]); const savePasswordPairing = useCallback( - ({ serverId }: { serverId: string }) => navigateToPairedHost(serverId), - [navigateToPairedHost], + ({ serverId }: { serverId: string }) => onConnected(serverId), + [onConnected], ); const handleRequestPermission = useCallback(() => { void requestPermission(); }, [requestPermission]); const bodyStyle = useMemo( - () => [styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }], - [insets.bottom, theme.spacing], - ); - const helperTextStyle = useMemo( - () => [styles.helperText, { color: theme.colors.foreground }], - [theme.colors.foreground], + () => [styles.body, { paddingBottom: insets.bottom + 24 }], + [insets.bottom], ); if (isWeb) { return ( - {t("pairing.scan.webUnavailableTitle")} {t("pairing.scan.webUnavailableBody")} - - {t("pairing.scan.backToSettings")} - + @@ -208,16 +175,14 @@ export default function PairScanScreen() { return ( - - {!granted ? ( {t("pairing.scan.cameraPermissionTitle")} {t("pairing.scan.cameraPermissionBody")} - + ) : ( @@ -235,10 +200,10 @@ export default function PairScanScreen() { {scanState.status === "pairing" ? ( - {t("pairing.scan.pairing")} + {t("pairing.scan.pairing")} ) : null} {scanState.status === "stopped" && scanState.error ? ( - {scanState.error} + {scanState.error} ) : null} {scanState.status === "stopped" ? (