diff --git a/apps/mobile/e2e/flows/add-host.yaml b/apps/mobile/e2e/flows/add-host.yaml index 675dbdd9a..8e0ed0d8f 100644 --- a/apps/mobile/e2e/flows/add-host.yaml +++ b/apps/mobile/e2e/flows/add-host.yaml @@ -18,16 +18,13 @@ appId: com.arcboxlabs.linkcode.mobile commands: - openLink: linkcode://connect - waitForAnimationToEnd - - assertVisible: 'Connect to a host' + - assertVisible: 'Manage hosts' - assertVisible: 'Add a host by URL' -# The manual form is a DisclosureGroup; signed out it starts expanded, but a previous run in this -# simulator may have collapsed it, so open it whenever the fields are not already on screen. -- runFlow: - when: - notVisible: 'Host URL' - commands: - - tapOn: 'Add a host by URL' +- tapOn: 'Add a host by URL' +- waitForAnimationToEnd +- assertVisible: 'Name' +- assertVisible: 'Host URL' - tapOn: id: 'host-name-input' @@ -35,15 +32,12 @@ appId: com.arcboxlabs.linkcode.mobile # Typing goes to whatever holds focus, so a tap that missed the field would silently type into # nothing; assert the field took it rather than discovering it three steps later. - assertVisible: 'Probe' -# SwiftUI scrolls the *focused* field clear of the keyboard, which leaves the next one under it; -# tapping a covered element lands on the keyboard, so bring the form up before reaching for it. +# SwiftUI scrolls the focused field clear of the keyboard, which can leave the next one under it. - scroll - tapOn: id: 'host-url-input' - inputText: 'http://127.0.0.1:19599' -# Submitting from the keyboard rather than the "Add host" button: the button sits under the -# keyboard, a tap on a covered element lands on the keyboard instead, and `hideKeyboard` has no -# effect on a SwiftUI form. This is the return key the field advertises with `submitLabel('go')`. +# The URL field advertises the system Go return key as its submit action. - pressKey: Enter # Naming the URL back is the assertion that matters: it can only come from text that made it diff --git a/apps/mobile/e2e/flows/first-run.yaml b/apps/mobile/e2e/flows/first-run.yaml index dac6788d5..ce25d698e 100644 --- a/apps/mobile/e2e/flows/first-run.yaml +++ b/apps/mobile/e2e/flows/first-run.yaml @@ -24,8 +24,14 @@ appId: com.arcboxlabs.linkcode.mobile # Skipping sign-in must reach host setup rather than dead-ending: a LAN/direct user never signs in. - tapOn: 'Skip — connect manually' -- assertVisible: 'Connect to a host' -# Assert what the screen always offers, not its empty state or the collapsed/expanded shape of the -# manual form: a simulator that has been used before carries saved hosts and remembers whether the -# form was open, and either would make this flow depend on leftover state. +- assertVisible: 'Manage hosts' +# Assert what the screen always offers rather than its saved-host state. - assertVisible: 'Sign in to reach your machines from anywhere through LinkCode Cloud.' + +# Skip pushes host setup above sign-in so the native back affordance returns to the OOBE instead of +# revealing an older copy of the same Connect screen. +- tapOn: + point: '6%,8%' +- assertVisible: 'Sign in with Apple' +- assertVisible: 'More sign-in options' +- assertNotVisible: 'Manage hosts' diff --git a/apps/mobile/e2e/flows/settings.yaml b/apps/mobile/e2e/flows/settings.yaml index 204d178f3..7c11e4c87 100644 --- a/apps/mobile/e2e/flows/settings.yaml +++ b/apps/mobile/e2e/flows/settings.yaml @@ -9,8 +9,8 @@ appId: com.arcboxlabs.linkcode.mobile - launchApp # A cold start redirects once the persisted host registry hydrates, which can land after the deep # link and replace the screen it opened; retrying re-issues the link past that window. -# The guard has to be a row, not the title: a host screen carries a Settings *tab*, so "Settings" -# alone is satisfied by the very redirect this retry exists to outlast. +# The guard is a row, not the title, so a pass needs the SwiftUI form itself — the navigation +# bar alone renders before the redirect this retry exists to outlast. - retry: maxRetries: 3 commands: diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index 6ce25bb38..9280c9a34 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -1,7 +1,11 @@ +import { USES_IOS_26_NAVIGATION } from '@mobile/components/shell/ios-26-navigation'; +import { usePrimaryActions } from '@mobile/components/shell/primary-action'; +import { PrimaryActionScope } from '@mobile/components/shell/primary-action-scope'; +import { router, useSegments } from 'expo-router'; import { NativeTabs } from 'expo-router/unstable-native-tabs'; import { useTranslations } from 'use-intl'; -/** The app's three top-level surfaces. `NativeTabs` is a real `UITabBarController`, so the iOS 26 +/** The app's top-level surfaces. `NativeTabs` is a real `UITabBarController`, so the iOS 26 * floating tab bar and its scroll-minimize behaviour come from UIKit rather than being drawn here. * * The tabs sit at the root and the host is a selection, not a parent route — switching hosts is a @@ -10,9 +14,21 @@ import { useTranslations } from 'use-intl'; * pushed screen, so pushing them from the root stack is the only way to keep the bar off a * composer or a terminal canvas. */ export default function TabsLayout(): React.ReactNode { + return ( + + + + ); +} + +function TabsNavigator(): React.ReactNode { const tThreads = useTranslations('mobile.sessions'); const tTerminals = useTranslations('mobile.terminals'); - const tSettings = useTranslations('mobile.settings'); + const actions = usePrimaryActions(); + // Runtime segments under this layout are ['(tabs)', ''] — wider than the untyped-routes + // 1-tuple, hence `.at`. Before hydration fall back to home. + const segments = useSegments(); + const focused = actions[segments.at(1) ?? 'threads'] ?? null; return ( @@ -24,10 +40,26 @@ export default function TabsLayout(): React.ReactNode { {tTerminals('title')} - - - {tSettings('title')} - + {/* iOS 26's separated tab-bar slot (the `search` role) carries the focused tab's primary + * action: `disabled` keeps native selection prevented while tabPress still reaches JS. */} + {USES_IOS_26_NAVIGATION ? ( + + + + {focused?.label ?? tThreads('newThread')} + + + ) : null} ); } diff --git a/apps/mobile/src/app/(tabs)/compose.ts b/apps/mobile/src/app/(tabs)/compose.ts new file mode 100644 index 000000000..837a7352e --- /dev/null +++ b/apps/mobile/src/app/(tabs)/compose.ts @@ -0,0 +1,6 @@ +/** Unreachable: the trigger is `disabled`, so native selection is prevented and its tabPress + * listener runs the focused tab's primary action instead. The file only gives the trigger a + * route. */ +export default function ComposeRoute(): React.ReactNode { + return null; +} diff --git a/apps/mobile/src/app/(tabs)/settings/_layout.tsx b/apps/mobile/src/app/(tabs)/settings/_layout.tsx deleted file mode 100644 index 11c0f97ab..000000000 --- a/apps/mobile/src/app/(tabs)/settings/_layout.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { useStackScreenOptions } from '@mobile/components/shell/use-stack-screen-options'; -import { Stack } from 'expo-router'; - -/** Deliberately ungated: this tab owns "Manage hosts", so it has to survive the host it is - * hosted under being unreachable — otherwise a bad host address is unrecoverable from the app. */ -export default function SettingsTabLayout(): React.ReactNode { - const screenOptions = useStackScreenOptions({ softHeaderEdge: true }); - - return ; -} diff --git a/apps/mobile/src/app/(tabs)/settings/index.tsx b/apps/mobile/src/app/(tabs)/settings/index.tsx deleted file mode 100644 index 17ee199f9..000000000 --- a/apps/mobile/src/app/(tabs)/settings/index.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { SettingsScreen } from '@mobile/components/settings/settings-screen'; - -/** Tab mount: ungated, so a host that cannot be reached still leaves "Manage hosts" in reach. */ -export default function SettingsTabRoute(): React.ReactNode { - return ; -} diff --git a/apps/mobile/src/app/(tabs)/terminals/_layout.tsx b/apps/mobile/src/app/(tabs)/terminals/_layout.tsx index e060515af..dbe5aac13 100644 --- a/apps/mobile/src/app/(tabs)/terminals/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/terminals/_layout.tsx @@ -4,7 +4,7 @@ import { Stack } from 'expo-router'; /** Ungated for the same reason as the threads tab: the screen gates its own body so the header * keeps carrying the host switcher when the host cannot be reached. */ export default function TerminalsTabLayout(): React.ReactNode { - const screenOptions = useStackScreenOptions({ softHeaderEdge: true }); + const screenOptions = useStackScreenOptions(); return ; } diff --git a/apps/mobile/src/app/(tabs)/terminals/index.tsx b/apps/mobile/src/app/(tabs)/terminals/index.tsx index 4b575ecbe..acdd0f672 100644 --- a/apps/mobile/src/app/(tabs)/terminals/index.tsx +++ b/apps/mobile/src/app/(tabs)/terminals/index.tsx @@ -14,7 +14,10 @@ import { repositoryLabel } from '@linkcode/ui/native'; import { NavigationRow } from '@mobile/components/form/navigation-row'; import { HostClientGate } from '@mobile/components/host/host-client-gate'; import { useHostMenuItems } from '@mobile/components/host/use-host-menu-items'; -import { HeaderIconButton } from '@mobile/components/shell/header-icon-button'; +import type { PrimaryAction } from '@mobile/components/shell/primary-action'; +import { usePrimaryAction } from '@mobile/components/shell/primary-action'; +import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; +import { useTrailingActions } from '@mobile/components/shell/use-trailing-actions'; import { NewTerminalSheet } from '@mobile/components/terminal/new-terminal-sheet'; import { useHostConnection } from '@mobile/runtime/host-connection'; import { Stack, useFocusEffect, useRouter } from 'expo-router'; @@ -38,26 +41,28 @@ export default function TerminalsRoute(): React.ReactNode { const connection = useHostConnection(); const [sheetOpen, setSheetOpen] = useState(false); + const primaryAction: PrimaryAction | null = + connection?.status === 'ready' + ? { + sf: 'plus', + icon: PlusIcon, + label: t('newTerminal'), + onPress: () => setSheetOpen(true), + } + : null; + usePrimaryAction('terminals', primaryAction); + const trailingActions = useTrailingActions(primaryAction); + // The flex container is load-bearing: a SwiftUI host left as the screen's direct child is - // proposed the whole window and paints straight over the large title. + // proposed the whole window and paints straight over the navigation header. return ( hostMenuItems, - headerRight: - connection?.status === 'ready' - ? () => ( - setSheetOpen(true)} - /> - ) - : undefined, + ...trailingActions, }} /> diff --git a/apps/mobile/src/app/(tabs)/threads/_layout.tsx b/apps/mobile/src/app/(tabs)/threads/_layout.tsx index f398b88e6..17aeb6808 100644 --- a/apps/mobile/src/app/(tabs)/threads/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/threads/_layout.tsx @@ -4,7 +4,7 @@ import { Stack } from 'expo-router'; /** Ungated on purpose: the screen gates its own body so the header — and the host switcher in it — * survives the selected host being unreachable, which is exactly when you need to switch. */ export default function ThreadsTabLayout(): React.ReactNode { - const screenOptions = useStackScreenOptions({ softHeaderEdge: true }); + const screenOptions = useStackScreenOptions(); return ; } diff --git a/apps/mobile/src/app/(tabs)/threads/index.tsx b/apps/mobile/src/app/(tabs)/threads/index.tsx index af6067352..f36a737a6 100644 --- a/apps/mobile/src/app/(tabs)/threads/index.tsx +++ b/apps/mobile/src/app/(tabs)/threads/index.tsx @@ -20,7 +20,10 @@ import { HostClientGate } from '@mobile/components/host/host-client-gate'; import { NewThreadSheet } from '@mobile/components/host/new-thread-sheet'; import { ThreadList } from '@mobile/components/host/thread-list/thread-list'; import { useHostMenuItems } from '@mobile/components/host/use-host-menu-items'; -import { HeaderIconButton } from '@mobile/components/shell/header-icon-button'; +import type { PrimaryAction } from '@mobile/components/shell/primary-action'; +import { usePrimaryAction } from '@mobile/components/shell/primary-action'; +import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; +import { useTrailingActions } from '@mobile/components/shell/use-trailing-actions'; import { useHostConnection } from '@mobile/runtime/host-connection'; import { captureMobileProductEvent } from '@mobile/runtime/product-analytics'; import { useWorkspaces } from '@mobile/runtime/use-workspaces'; @@ -49,24 +52,26 @@ export default function ThreadsRoute(): React.ReactNode { const connection = useHostConnection(); const [sheetOpen, setSheetOpen] = useState(false); + const primaryAction: PrimaryAction | null = + connection?.status === 'ready' + ? { + sf: 'square.and.pencil', + icon: SquarePenIcon, + label: t('newThread'), + onPress: () => setSheetOpen(true), + } + : null; + usePrimaryAction('threads', primaryAction); + const trailingActions = useTrailingActions(primaryAction); + return ( hostMenuItems, - headerRight: - connection?.status === 'ready' - ? () => ( - setSheetOpen(true)} - /> - ) - : undefined, + ...trailingActions, }} /> @@ -77,7 +82,7 @@ export default function ThreadsRoute(): React.ReactNode { } /** Threads inbox: sessions grouped by workspace (project) under collapsible headers, with the - * native search bar stacked under the large title. Empty workspace groups are hidden — the sheet + * native search bar stacked below the navigation bar. Empty workspace groups are hidden — the sheet * is where they surface. */ function ThreadsScreen({ sheetOpen, @@ -151,12 +156,11 @@ function ThreadsScreen({ return ( <> - {/* `stacked` keeps the field under the large title instead of collapsing into the iOS 26 - toolbar; the screen body is a SwiftUI host, so nothing here can drive hide-on-scroll. */} + {/* `stacked` keeps the field below the inline title instead of moving into the iOS 26 toolbar. */} - + {/* Form needs the viewport as its proposed size, otherwise it collapses to its content. */}
diff --git a/apps/mobile/src/app/add-host.tsx b/apps/mobile/src/app/add-host.tsx new file mode 100644 index 000000000..354abe205 --- /dev/null +++ b/apps/mobile/src/app/add-host.tsx @@ -0,0 +1,5 @@ +import { AddHostScreen } from '@mobile/components/connect/add-host-screen'; + +export default function AddHostRoute(): React.ReactNode { + return ; +} diff --git a/apps/mobile/src/app/connect.tsx b/apps/mobile/src/app/connect.tsx index 377b82a51..ddb66ce32 100644 --- a/apps/mobile/src/app/connect.tsx +++ b/apps/mobile/src/app/connect.tsx @@ -3,25 +3,25 @@ import { ManualHostSection } from '@mobile/components/connect/manual-host-sectio import { MyMachinesSection } from '@mobile/components/connect/my-machines-section'; import { SavedHostsSection } from '@mobile/components/connect/saved-hosts-section'; import { SignInSection } from '@mobile/components/connect/sign-in-section'; +import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; import { useCloudAccount } from '@mobile/runtime/cloud/account'; import { useHostRegistryStore } from '@mobile/stores/host-store'; import { Stack } from 'expo-router'; import { useTranslations } from 'use-intl'; -/** - * Machine list & host registry. Signed in, online machines lead and manual URL entry - * collapses into a disclosure row; signed out, a sign-in section leads and the form stays open. - */ export default function ConnectScreen(): React.ReactNode { const t = useTranslations('mobile.connect'); const account = useCloudAccount(); const hosts = useHostRegistryStore((state) => state.hosts); - const signedIn = account.status === 'signed-in'; - return ( <> - + {/* Form needs the viewport as its proposed size, otherwise it collapses to its content. */} @@ -33,8 +33,7 @@ export default function ConnectScreen(): React.ReactNode { {hosts.length > 0 ? : null} - {/* Signed out there is nothing else to connect with, so the form opens itself. */} - + diff --git a/apps/mobile/src/app/session/[sessionId].tsx b/apps/mobile/src/app/session/[sessionId].tsx index 3398a48af..7ae1558e1 100644 --- a/apps/mobile/src/app/session/[sessionId].tsx +++ b/apps/mobile/src/app/session/[sessionId].tsx @@ -14,11 +14,13 @@ import { SessionStatusChip } from '@mobile/components/conversation/session-statu import { TimelineItem } from '@mobile/components/conversation/timeline-item'; import { ToolDetailSheet } from '@mobile/components/conversation/tool-detail-sheet/tool-detail-sheet'; import { HostClientGate } from '@mobile/components/host/host-client-gate'; +import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; import { useSeededConversation } from '@mobile/runtime/use-seeded-conversation'; import { useSessionActions } from '@mobile/runtime/use-session-actions'; import { useSessionAutoResume } from '@mobile/runtime/use-session-auto-resume'; import * as Clipboard from 'expo-clipboard'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; +import { useHeaderHeight } from 'expo-router/react-navigation'; import { noop } from 'foxact/noop'; import { useThemeColor } from 'heroui-native'; import { EllipsisIcon } from 'lucide-react-native'; @@ -46,6 +48,7 @@ function SessionScreen(): React.ReactNode { const t = useTranslations('mobile.conversation'); const tChat = useTranslations('mobile.chat'); const insets = useSafeAreaInsets(); + const headerHeight = useHeaderHeight(); const muted = useThemeColor('muted'); const router = useRouter(); const { sessionId: rawSessionId, autoResume } = useLocalSearchParams<{ @@ -112,7 +115,7 @@ function SessionScreen(): React.ReactNode { ( @@ -144,6 +147,9 @@ function SessionScreen(): React.ReactNode { onPressTool={(toolCall) => setOpenToolCallId(toolCall.toolCallId)} /> )} + ListFooterComponent={ + process.env.EXPO_OS === 'ios' ? : null + } contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 12, gap: 12 }} className="flex-1" /> diff --git a/apps/mobile/src/app/settings.tsx b/apps/mobile/src/app/settings.tsx new file mode 100644 index 000000000..14af39444 --- /dev/null +++ b/apps/mobile/src/app/settings.tsx @@ -0,0 +1,7 @@ +import { SettingsScreen } from '@mobile/components/settings/settings-screen'; + +/** Pushed from the tab screens' overflow menu; ungated so "Manage hosts" stays reachable when + * the selected host is not. */ +export default function SettingsRoute(): React.ReactNode { + return ; +} diff --git a/apps/mobile/src/app/sign-in.tsx b/apps/mobile/src/app/sign-in.tsx index 8dc4f2890..4fd40694e 100644 --- a/apps/mobile/src/app/sign-in.tsx +++ b/apps/mobile/src/app/sign-in.tsx @@ -2,14 +2,104 @@ import { BrandMark } from '@mobile/components/shell/brand-mark'; import { signInToCloud, useCloudAccount } from '@mobile/runtime/cloud/account'; import { isAppleSignInCancel, signInWithApple } from '@mobile/runtime/cloud/idp'; import * as AppleAuthentication from 'expo-apple-authentication'; -import { Redirect, useRouter } from 'expo-router'; -import { noop } from 'foxact/noop'; -import { Button, Spinner } from 'heroui-native'; -import { useEffect, useState } from 'react'; -import { Text, useColorScheme, View } from 'react-native'; +import { Color, Redirect, useRouter } from 'expo-router'; +import { useEffect } from 'foxact/use-abortable-effect'; +import { Button } from 'heroui-native'; +import { useState } from 'react'; +import { + AccessibilityInfo, + ActivityIndicator, + Platform, + ScrollView, + StyleSheet, + Text, + useColorScheme, + View, +} from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTranslations } from 'use-intl'; +const iosColors = { + accent: Platform.OS === 'ios' ? Color.ios.systemBlue : undefined, +}; + +const iosStyles = StyleSheet.create({ + screen: Platform.OS === 'ios' ? { backgroundColor: Color.ios.systemBackground } : {}, + label: Platform.OS === 'ios' ? { color: Color.ios.label } : {}, + secondaryLabel: Platform.OS === 'ios' ? { color: Color.ios.secondaryLabel } : {}, + danger: Platform.OS === 'ios' ? { color: Color.ios.systemRed } : {}, + primaryButton: Platform.OS === 'ios' ? { backgroundColor: Color.ios.systemBlue } : {}, + primaryButtonLabel: Platform.OS === 'ios' ? { color: 'white' } : {}, + secondaryButton: Platform.OS === 'ios' ? { backgroundColor: Color.ios.secondarySystemFill } : {}, + accentLabel: Platform.OS === 'ios' ? { color: Color.ios.systemBlue } : {}, +}); + +const styles = StyleSheet.create({ + scrollContent: { + alignItems: 'center', + flexGrow: 1, + justifyContent: 'center', + paddingHorizontal: 24, + paddingVertical: 24, + }, + content: { + gap: 48, + maxWidth: 420, + width: '100%', + }, + hero: { + alignItems: 'center', + gap: 12, + }, + title: { + fontSize: 34, + fontWeight: '700', + lineHeight: 41, + }, + tagline: { + fontSize: 17, + lineHeight: 22, + maxWidth: 320, + }, + actions: { + gap: 12, + }, + status: { + alignItems: 'center', + justifyContent: 'center', + minHeight: 18, + }, + error: { + fontSize: 13, + lineHeight: 18, + }, + appleButton: { + height: 50, + width: '100%', + }, + authButton: { + borderRadius: 13, + height: 'auto', + minHeight: 50, + paddingHorizontal: 16, + paddingVertical: 13, + }, + manualButton: { + borderRadius: 13, + height: 'auto', + minHeight: 44, + paddingHorizontal: 16, + paddingVertical: 10, + }, + buttonLabel: { + fontSize: 17, + lineHeight: 22, + }, + disabled: { + opacity: 0.5, + }, +}); + /** * First-run welcome: native Apple sign-in when available, browser OAuth otherwise, * or skip to manual host setup. Signed-in visitors bounce to the machine list. @@ -22,21 +112,30 @@ export default function SignInScreen() { const account = useCloudAccount(); const [busy, setBusy] = useState(false); const [failed, setFailed] = useState(false); - const [appleAvailable, setAppleAvailable] = useState(false); + const [appleAvailable, setAppleAvailable] = useState(null); - useEffect(() => { - AppleAuthentication.isAvailableAsync().then(setAppleAvailable).catch(noop); + useEffect((signal) => { + void AppleAuthentication.isAvailableAsync() + .then((available) => { + if (!signal.aborted) setAppleAvailable(available); + }) + .catch(() => { + if (!signal.aborted) setAppleAvailable(false); + }); }, []); - if (account.status === 'loading') { + if (account.status === 'signed-in') return ; + if (appleAvailable === null || account.status === 'loading') { return ( - - - + + + ); } - if (account.status === 'signed-in') return ; const run = async (flow: () => Promise) => { setBusy(true); @@ -44,57 +143,124 @@ export default function SignInScreen() { try { await flow(); } catch (error) { - if (!isAppleSignInCancel(error)) setFailed(true); + if (!isAppleSignInCancel(error)) { + setFailed(true); + AccessibilityInfo.announceForAccessibility(t('error')); + } } finally { setBusy(false); } }; return ( - - - - LinkCode - - {t('tagline')} - - - - {failed ? {t('error')} : null} - {appleAvailable ? ( - + + + + LinkCode + + + {t('tagline')} + + + + + + {busy ? ( + + ) : failed ? ( + + {t('error')} + + ) : null} + + + {appleAvailable ? ( + { + if (!busy) void run(signInWithApple); + }} + /> + ) : null} + - + > + + {appleAvailable ? t('other') : t('signIn')} + + + + - + ); } diff --git a/apps/mobile/src/app/terminal-appearance.tsx b/apps/mobile/src/app/terminal-appearance.tsx index 00dfb247f..65fa7d439 100644 --- a/apps/mobile/src/app/terminal-appearance.tsx +++ b/apps/mobile/src/app/terminal-appearance.tsx @@ -17,6 +17,7 @@ import { strokeBorder, tag, } from '@expo/ui/swift-ui/modifiers'; +import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; import { resolveTerminalTheme, TERMINAL_COLOR_SCHEMES, @@ -65,7 +66,7 @@ export default function TerminalAppearanceScreen(): React.ReactNode { return ( <> - +
diff --git a/apps/mobile/src/components/connect/add-host-screen.tsx b/apps/mobile/src/components/connect/add-host-screen.tsx new file mode 100644 index 000000000..427f265c0 --- /dev/null +++ b/apps/mobile/src/components/connect/add-host-screen.tsx @@ -0,0 +1,102 @@ +import { Form, Host, HStack, Section, Text, TextField, useNativeState } from '@expo/ui/swift-ui'; +import { + autocorrectionDisabled, + keyboardType, + onSubmit, + submitLabel, + textContentType, + textInputAutocapitalization, +} from '@expo/ui/swift-ui/modifiers'; +import { HostUrlSchema, useHostRegistryStore } from '@mobile/stores/host-store'; +import { Stack, useRouter } from 'expo-router'; +import { useState } from 'react'; +import { useTranslations } from 'use-intl'; + +export function AddHostScreen(): React.ReactNode { + const t = useTranslations('mobile.connect'); + const router = useRouter(); + const addHost = useHostRegistryStore((state) => state.addHost); + const setLastActiveHostId = useHostRegistryStore((state) => state.setLastActiveHostId); + const name = useNativeState(''); + const url = useNativeState(''); + const [urlInvalid, setUrlInvalid] = useState(false); + const [urlValid, setUrlValid] = useState(false); + + const submit = () => { + const trimmedUrl = url.get().trim(); + if (!HostUrlSchema.safeParse(trimmedUrl).success) { + setUrlInvalid(true); + return; + } + const profile = addHost({ name: name.get().trim() || t('namePlaceholder'), url: trimmedUrl }); + setLastActiveHostId(profile.id); + router.dismissTo('/threads'); + }; + + return ( + <> + [ + { + type: 'button', + label: t('cancel'), + accessibilityLabel: t('cancel'), + icon: { type: 'sfSymbol', name: 'xmark' }, + onPress: () => router.dismiss(), + }, + ], + unstable_headerRightItems: () => [ + { + type: 'button', + label: t('add'), + accessibilityLabel: t('add'), + icon: { type: 'sfSymbol', name: 'checkmark' }, + variant: 'prominent', + disabled: !urlValid, + onPress: submit, + }, + ], + }} + /> + {/* Form needs the viewport as its proposed size, otherwise it collapses to its content. */} + + +
{urlInvalid ? t('invalidUrl') : t('emptyHint')}}> + {/* `LabeledContent` only gives the field its intrinsic width; the stack fills the row. */} + + {t('nameLabel')} + + + + {t('urlLabel')} + { + setUrlInvalid(false); + setUrlValid(HostUrlSchema.safeParse(text.trim()).success); + }} + modifiers={[ + textInputAutocapitalization('never'), + autocorrectionDisabled(), + keyboardType('url'), + textContentType('URL'), + submitLabel('go'), + onSubmit(submit), + ]} + /> + +
+ +
+ + ); +} diff --git a/apps/mobile/src/components/connect/manual-host-section.tsx b/apps/mobile/src/components/connect/manual-host-section.tsx index 6909bca85..b1ae4f5f0 100644 --- a/apps/mobile/src/components/connect/manual-host-section.tsx +++ b/apps/mobile/src/components/connect/manual-host-section.tsx @@ -1,95 +1,15 @@ -import { - Button, - DisclosureGroup, - HStack, - Section, - Text, - TextField, - useNativeState, -} from '@expo/ui/swift-ui'; -import { - autocorrectionDisabled, - keyboardType, - onSubmit, - submitLabel, - textContentType, - textInputAutocapitalization, -} from '@expo/ui/swift-ui/modifiers'; -import { useOpenHost } from '@mobile/runtime/use-open-host'; -import { HostUrlSchema, useHostRegistryStore } from '@mobile/stores/host-store'; -import { useState } from 'react'; +import { Section } from '@expo/ui/swift-ui'; +import { NavigationRow } from '@mobile/components/form/navigation-row'; +import { useRouter } from 'expo-router'; import { useTranslations } from 'use-intl'; -/** Manual host entry: add a daemon by URL and open it. */ -export function ManualHostSection({ - startsExpanded, -}: { - startsExpanded: boolean; -}): React.ReactNode { +export function ManualHostSection(): React.ReactNode { const t = useTranslations('mobile.connect'); - const openHost = useOpenHost(); - const addHost = useHostRegistryStore((state) => state.addHost); - - // Null until the user decides either way. Seeding `useState` from `startsExpanded` would freeze - // the value taken during the account's `loading` render, leaving a signed-in user's form open. - const [expanded, setExpanded] = useState(null); - const [urlInvalid, setUrlInvalid] = useState(false); - // The fields are backed by native state rather than mirrored into React: `get()` reads what - // the field itself holds, so submitting never depends on a change event reaching JS first. - const name = useNativeState(''); - const url = useNativeState(''); - - const submit = () => { - const trimmedUrl = url.get().trim(); - if (!HostUrlSchema.safeParse(trimmedUrl).success) { - setUrlInvalid(true); - return; - } - const profile = addHost({ name: name.get().trim() || t('namePlaceholder'), url: trimmedUrl }); - name.set(''); - url.set(''); - setUrlInvalid(false); - openHost(profile.id); - }; + const router = useRouter(); return ( -
{urlInvalid ? t('invalidUrl') : t('emptyHint')}}> - - {/* `LabeledContent` sizes the field to its text, leaving the rest of the row - untappable; an HStack lets the field take the remaining width. */} - - {t('nameLabel')} - - - - {t('urlLabel')} - setUrlInvalid(false)} - modifiers={[ - textInputAutocapitalization('never'), - autocorrectionDisabled(), - keyboardType('url'), - textContentType('URL'), - // The URL is the only required field, so the return key finishes the form. - submitLabel('go'), - onSubmit(submit), - ]} - /> - -