From 5a51efeb7752608582010b36f1911498005fa752 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Thu, 13 Aug 2026 01:20:46 +0800 Subject: [PATCH 1/8] refactor(mobile): make sign-in feel native on iOS --- apps/mobile/src/app/sign-in.tsx | 276 ++++++++++++++---- .../src/components/shell/brand-mark.tsx | 3 +- 2 files changed, 223 insertions(+), 56 deletions(-) diff --git a/apps/mobile/src/app/sign-in.tsx b/apps/mobile/src/app/sign-in.tsx index 8dc4f2890..506aa84bd 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/components/shell/brand-mark.tsx b/apps/mobile/src/components/shell/brand-mark.tsx index a10c140de..dd409c5f1 100644 --- a/apps/mobile/src/components/shell/brand-mark.tsx +++ b/apps/mobile/src/components/shell/brand-mark.tsx @@ -13,11 +13,12 @@ export function BrandMark({ size = 96 }: { size?: number }): React.ReactNode { width: size, height: size, borderRadius: size * 0.22, + borderCurve: 'continuous', borderWidth: StyleSheet.hairlineWidth, borderColor: 'rgba(0, 0, 0, 0.1)', }} > - + ); } From a97214e043240442b87d28fd08aa1a34c447e4c5 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Thu, 13 Aug 2026 22:41:45 +0800 Subject: [PATCH 2/8] fix(mobile): preserve OOBE in connect history --- apps/mobile/e2e/flows/first-run.yaml | 8 ++++++++ apps/mobile/src/app/sign-in.tsx | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/mobile/e2e/flows/first-run.yaml b/apps/mobile/e2e/flows/first-run.yaml index dac6788d5..ff6d16a7f 100644 --- a/apps/mobile/e2e/flows/first-run.yaml +++ b/apps/mobile/e2e/flows/first-run.yaml @@ -29,3 +29,11 @@ appId: com.arcboxlabs.linkcode.mobile # 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: '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: 'Connect to a host' diff --git a/apps/mobile/src/app/sign-in.tsx b/apps/mobile/src/app/sign-in.tsx index 506aa84bd..4fd40694e 100644 --- a/apps/mobile/src/app/sign-in.tsx +++ b/apps/mobile/src/app/sign-in.tsx @@ -250,7 +250,7 @@ export default function SignInScreen() { animation={{ scale: false }} style={styles.manualButton} variant="ghost" - onPress={() => router.replace('/connect')} + onPress={() => router.push('/connect')} > Date: Fri, 14 Aug 2026 00:16:17 +0800 Subject: [PATCH 3/8] fix(mobile): use inline Threads title on iOS 26 --- apps/mobile/src/app/(tabs)/threads/index.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/app/(tabs)/threads/index.tsx b/apps/mobile/src/app/(tabs)/threads/index.tsx index af6067352..84a619fc2 100644 --- a/apps/mobile/src/app/(tabs)/threads/index.tsx +++ b/apps/mobile/src/app/(tabs)/threads/index.tsx @@ -27,9 +27,12 @@ import { useWorkspaces } from '@mobile/runtime/use-workspaces'; import { Stack, useRouter } from 'expo-router'; import { SquarePenIcon } from 'lucide-react-native'; import { useCallback, useState } from 'react'; -import { View } from 'react-native'; +import { Platform, View } from 'react-native'; import { useTranslations } from 'use-intl'; +const USES_INLINE_NAVIGATION_TITLE = + Platform.OS === 'ios' && Number.parseInt(Platform.Version, 10) >= 26; + /** Taken from the search bar itself: RN's own replacement for the event it declares carries no text. */ type SearchBarChangeEvent = Parameters< NonNullable['onChangeText']> @@ -54,7 +57,7 @@ export default function ThreadsRoute(): React.ReactNode { hostMenuItems, headerRight: @@ -77,7 +80,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,8 +154,8 @@ 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 either title style instead of moving into the iOS 26 toolbar; + the screen body is a SwiftUI host, so nothing here can drive hide-on-scroll. */} Date: Sat, 15 Aug 2026 00:14:01 +0800 Subject: [PATCH 4/8] feat(mobile): per-tab primary action in the iOS 26 tab-bar slot --- apps/mobile/src/app/(tabs)/_layout.tsx | 39 ++++++++++++++++++- apps/mobile/src/app/(tabs)/compose.ts | 6 +++ .../mobile/src/app/(tabs)/terminals/index.tsx | 22 +++++++++-- apps/mobile/src/app/(tabs)/threads/index.tsx | 29 +++++++++----- .../src/components/shell/ios-26-navigation.ts | 6 +++ .../components/shell/primary-action-scope.tsx | 26 +++++++++++++ .../src/components/shell/primary-action.ts | 37 ++++++++++++++++++ 7 files changed, 151 insertions(+), 14 deletions(-) create mode 100644 apps/mobile/src/app/(tabs)/compose.ts create mode 100644 apps/mobile/src/components/shell/ios-26-navigation.ts create mode 100644 apps/mobile/src/components/shell/primary-action-scope.tsx create mode 100644 apps/mobile/src/components/shell/primary-action.ts diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index 6ce25bb38..6f9a2b7d4 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,22 @@ 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 ( @@ -28,6 +45,26 @@ export default function TabsLayout(): React.ReactNode { {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)/terminals/index.tsx b/apps/mobile/src/app/(tabs)/terminals/index.tsx index 4b575ecbe..3ddd6ed70 100644 --- a/apps/mobile/src/app/(tabs)/terminals/index.tsx +++ b/apps/mobile/src/app/(tabs)/terminals/index.tsx @@ -15,6 +15,9 @@ 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 { USES_IOS_26_NAVIGATION } from '@mobile/components/shell/ios-26-navigation'; +import type { PrimaryAction } from '@mobile/components/shell/primary-action'; +import { usePrimaryAction } from '@mobile/components/shell/primary-action'; import { NewTerminalSheet } from '@mobile/components/terminal/new-terminal-sheet'; import { useHostConnection } from '@mobile/runtime/host-connection'; import { Stack, useFocusEffect, useRouter } from 'expo-router'; @@ -38,6 +41,17 @@ 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); + // 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. return ( @@ -49,12 +63,12 @@ export default function TerminalsRoute(): React.ReactNode { title: t('title'), unstable_headerLeftItems: () => hostMenuItems, headerRight: - connection?.status === 'ready' + !USES_IOS_26_NAVIGATION && primaryAction ? () => ( setSheetOpen(true)} + icon={primaryAction.icon} + label={primaryAction.label} + onPress={primaryAction.onPress} /> ) : undefined, diff --git a/apps/mobile/src/app/(tabs)/threads/index.tsx b/apps/mobile/src/app/(tabs)/threads/index.tsx index 84a619fc2..0330f40bf 100644 --- a/apps/mobile/src/app/(tabs)/threads/index.tsx +++ b/apps/mobile/src/app/(tabs)/threads/index.tsx @@ -21,18 +21,18 @@ 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 { USES_IOS_26_NAVIGATION } from '@mobile/components/shell/ios-26-navigation'; +import type { PrimaryAction } from '@mobile/components/shell/primary-action'; +import { usePrimaryAction } from '@mobile/components/shell/primary-action'; import { useHostConnection } from '@mobile/runtime/host-connection'; import { captureMobileProductEvent } from '@mobile/runtime/product-analytics'; import { useWorkspaces } from '@mobile/runtime/use-workspaces'; import { Stack, useRouter } from 'expo-router'; import { SquarePenIcon } from 'lucide-react-native'; import { useCallback, useState } from 'react'; -import { Platform, View } from 'react-native'; +import { View } from 'react-native'; import { useTranslations } from 'use-intl'; -const USES_INLINE_NAVIGATION_TITLE = - Platform.OS === 'ios' && Number.parseInt(Platform.Version, 10) >= 26; - /** Taken from the search bar itself: RN's own replacement for the event it declares carries no text. */ type SearchBarChangeEvent = Parameters< NonNullable['onChangeText']> @@ -52,21 +52,32 @@ 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); + return ( hostMenuItems, headerRight: - connection?.status === 'ready' + !USES_IOS_26_NAVIGATION && primaryAction ? () => ( setSheetOpen(true)} + icon={primaryAction.icon} + label={primaryAction.label} + onPress={primaryAction.onPress} /> ) : undefined, diff --git a/apps/mobile/src/components/shell/ios-26-navigation.ts b/apps/mobile/src/components/shell/ios-26-navigation.ts new file mode 100644 index 000000000..1865c4398 --- /dev/null +++ b/apps/mobile/src/components/shell/ios-26-navigation.ts @@ -0,0 +1,6 @@ +import { Platform } from 'react-native'; + +/** iOS 26 reshapes navigation chrome: inline titles, glass-grouped native bar items, and the + * tab bar's separated trailing slot. Pre-26 iOS and Android keep the classic arrangement. */ +export const USES_IOS_26_NAVIGATION = + Platform.OS === 'ios' && Number.parseInt(Platform.Version, 10) >= 26; diff --git a/apps/mobile/src/components/shell/primary-action-scope.tsx b/apps/mobile/src/components/shell/primary-action-scope.tsx new file mode 100644 index 000000000..fa7e239b0 --- /dev/null +++ b/apps/mobile/src/components/shell/primary-action-scope.tsx @@ -0,0 +1,26 @@ +import type { + PrimaryActionRegistry, + RegisterPrimaryAction, +} from '@mobile/components/shell/primary-action'; +import { + PrimaryActionRegistryContext, + RegisterPrimaryActionContext, +} from '@mobile/components/shell/primary-action'; +import { useCallback, useState } from 'react'; + +/** Owns the tab → action registry. Wraps the tab navigator so the screens below declare their + * action and the tab bar above them reads the focused one. */ +export function PrimaryActionScope({ children }: React.PropsWithChildren): React.ReactNode { + const [actions, setActions] = useState({}); + const register = useCallback((tab, action) => { + setActions((previous) => ({ ...previous, [tab]: action })); + return () => { + setActions(({ [tab]: _removed, ...rest }) => rest); + }; + }, []); + return ( + + {children} + + ); +} diff --git a/apps/mobile/src/components/shell/primary-action.ts b/apps/mobile/src/components/shell/primary-action.ts new file mode 100644 index 000000000..7de7fa4e0 --- /dev/null +++ b/apps/mobile/src/components/shell/primary-action.ts @@ -0,0 +1,37 @@ +import type { LucideIcon } from 'lucide-react-native'; +import { createContext, use, useEffect } from 'react'; +import type { SFSymbol } from 'sf-symbols-typescript'; + +/** The one creation action a tab screen offers while focused. On iOS 26 the tab bar's separated + * slot carries it; pre-26 iOS and Android render it as a trailing header button instead. */ +export interface PrimaryAction { + /** SF symbol for the native surfaces (tab-bar slot, header bar items). */ + sf: SFSymbol; + /** Lucide twin for the RN header fallback on Android. */ + icon: LucideIcon; + label: string; + onPress: () => void; +} + +export type PrimaryActionRegistry = Readonly>; +export type RegisterPrimaryAction = (tab: string, action: PrimaryAction) => () => void; + +/** Provided by {@link PrimaryActionScope}; split so screens registering never re-render when the + * registry itself changes. */ +export const RegisterPrimaryActionContext = createContext(() => { + throw new Error('usePrimaryAction requires a PrimaryActionScope ancestor'); +}); +export const PrimaryActionRegistryContext = createContext({}); + +/** Declares the action `tab` offers; pass null while it is unavailable (host not ready). The + * entry lives exactly as long as the screen and the availability window. */ +export function usePrimaryAction(tab: string, action: PrimaryAction | null): void { + const register = use(RegisterPrimaryActionContext); + useEffect(() => { + if (action) return register(tab, action); + }, [register, tab, action]); +} + +export function usePrimaryActions(): PrimaryActionRegistry { + return use(PrimaryActionRegistryContext); +} From b15e70713cd9e1bf601a75f5281f7ecf9e78fcb1 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Sat, 15 Aug 2026 00:30:32 +0800 Subject: [PATCH 5/8] feat(mobile): move settings behind a header overflow menu --- apps/mobile/e2e/flows/settings.yaml | 4 +- apps/mobile/src/app/(tabs)/_layout.tsx | 5 -- .../src/app/(tabs)/settings/_layout.tsx | 10 --- apps/mobile/src/app/(tabs)/settings/index.tsx | 6 -- .../mobile/src/app/(tabs)/terminals/index.tsx | 15 +---- apps/mobile/src/app/(tabs)/threads/index.tsx | 14 +--- apps/mobile/src/app/settings.tsx | 7 ++ .../components/settings/settings-screen.tsx | 7 +- .../components/shell/use-trailing-actions.tsx | 64 +++++++++++++++++++ packages/presentation/i18n/src/locales/en.ts | 1 + .../presentation/i18n/src/locales/zh-cn.ts | 1 + 11 files changed, 83 insertions(+), 51 deletions(-) delete mode 100644 apps/mobile/src/app/(tabs)/settings/_layout.tsx delete mode 100644 apps/mobile/src/app/(tabs)/settings/index.tsx create mode 100644 apps/mobile/src/app/settings.tsx create mode 100644 apps/mobile/src/components/shell/use-trailing-actions.tsx 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 6f9a2b7d4..9280c9a34 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -24,7 +24,6 @@ export default function TabsLayout(): React.ReactNode { 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. @@ -41,10 +40,6 @@ function TabsNavigator(): 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 ? ( 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/index.tsx b/apps/mobile/src/app/(tabs)/terminals/index.tsx index 3ddd6ed70..3d04d5dc0 100644 --- a/apps/mobile/src/app/(tabs)/terminals/index.tsx +++ b/apps/mobile/src/app/(tabs)/terminals/index.tsx @@ -14,10 +14,9 @@ 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 { USES_IOS_26_NAVIGATION } from '@mobile/components/shell/ios-26-navigation'; import type { PrimaryAction } from '@mobile/components/shell/primary-action'; import { usePrimaryAction } from '@mobile/components/shell/primary-action'; +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'; @@ -51,6 +50,7 @@ export default function TerminalsRoute(): React.ReactNode { } : 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. @@ -62,16 +62,7 @@ export default function TerminalsRoute(): React.ReactNode { headerLargeTitle: true, title: t('title'), unstable_headerLeftItems: () => hostMenuItems, - headerRight: - !USES_IOS_26_NAVIGATION && primaryAction - ? () => ( - - ) - : undefined, + ...trailingActions, }} /> diff --git a/apps/mobile/src/app/(tabs)/threads/index.tsx b/apps/mobile/src/app/(tabs)/threads/index.tsx index 0330f40bf..3ddc11cd5 100644 --- a/apps/mobile/src/app/(tabs)/threads/index.tsx +++ b/apps/mobile/src/app/(tabs)/threads/index.tsx @@ -20,10 +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 { USES_IOS_26_NAVIGATION } from '@mobile/components/shell/ios-26-navigation'; import type { PrimaryAction } from '@mobile/components/shell/primary-action'; import { usePrimaryAction } from '@mobile/components/shell/primary-action'; +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'; @@ -62,6 +62,7 @@ export default function ThreadsRoute(): React.ReactNode { } : null; usePrimaryAction('threads', primaryAction); + const trailingActions = useTrailingActions(primaryAction); return ( @@ -71,16 +72,7 @@ export default function ThreadsRoute(): React.ReactNode { headerLargeTitleEnabled: !USES_IOS_26_NAVIGATION, title: t('title'), unstable_headerLeftItems: () => hostMenuItems, - headerRight: - !USES_IOS_26_NAVIGATION && primaryAction - ? () => ( - - ) - : undefined, + ...trailingActions, }} /> 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/components/settings/settings-screen.tsx b/apps/mobile/src/components/settings/settings-screen.tsx index d8be4a089..c4961fff7 100644 --- a/apps/mobile/src/components/settings/settings-screen.tsx +++ b/apps/mobile/src/components/settings/settings-screen.tsx @@ -2,7 +2,6 @@ import { Form, Host, Link, Picker, Section, Text, Toggle, VStack } from '@expo/u import { disabled, font, foregroundStyle, pickerStyle, tag } from '@expo/ui/swift-ui/modifiers'; import { AgentKindSchema, WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; import { NavigationRow } from '@mobile/components/form/navigation-row'; -import { useHostMenuItems } from '@mobile/components/host/use-host-menu-items'; import { useCloudAccount } from '@mobile/runtime/cloud/account'; import { disableDeviceNotifications, @@ -32,14 +31,13 @@ const SUPPORT_URL = 'https://linkcode.ai/support'; const SECONDARY = foregroundStyle({ type: 'hierarchical', style: 'secondary' }); /** App settings: account + host management entries plus the About/contract summary. Nothing here is - * host-scoped, and the tab is deliberately ungated — this is where "Manage hosts" lives, so it has - * to survive the selected host being unreachable. */ + * host-scoped, and the screen is deliberately ungated — this is where "Manage hosts" lives, so it + * has to survive the selected host being unreachable. */ export function SettingsScreen(): React.ReactNode { const t = useTranslations('mobile.settings'); const tAbout = useTranslations('mobile.about'); const router = useRouter(); const account = useCloudAccount(); - const hostMenuItems = useHostMenuItems(); const productAnalyticsEnabled = useAnalyticsPreferenceStore((state) => state.enabled); const themePreference = useSettingsStore((state) => state.themePreference); const notificationsEnabled = useSettingsStore((state) => state.notificationsEnabled); @@ -85,7 +83,6 @@ export function SettingsScreen(): React.ReactNode { headerShown: true, headerLargeTitle: true, title: t('title'), - unstable_headerLeftItems: () => hostMenuItems, }} /> {/* Form needs the viewport as its proposed size, otherwise it collapses to its content. */} diff --git a/apps/mobile/src/components/shell/use-trailing-actions.tsx b/apps/mobile/src/components/shell/use-trailing-actions.tsx new file mode 100644 index 000000000..58af300c5 --- /dev/null +++ b/apps/mobile/src/components/shell/use-trailing-actions.tsx @@ -0,0 +1,64 @@ +import { HeaderIconButton } from '@mobile/components/shell/header-icon-button'; +import { USES_IOS_26_NAVIGATION } from '@mobile/components/shell/ios-26-navigation'; +import type { PrimaryAction } from '@mobile/components/shell/primary-action'; +import type { NativeStackHeaderItem, NativeStackNavigationOptions } from 'expo-router'; +import { useRouter } from 'expo-router'; +import { EllipsisIcon } from 'lucide-react-native'; +import { Platform, View } from 'react-native'; +import { useTranslations } from 'use-intl'; + +type TrailingHeaderOptions = Pick< + NativeStackNavigationOptions, + 'headerRight' | 'unstable_headerRightItems' +>; + +/** Trailing navigation-bar chrome for the tab screens: the screen's primary action — except on + * iOS 26, whose tab-bar slot already carries it — then the overflow menu that leads to Settings. + * Native bar items are iOS-only, so Android keeps RN header buttons. */ +export function useTrailingActions(primary: PrimaryAction | null): TrailingHeaderOptions { + const t = useTranslations('mobile.settings'); + const router = useRouter(); + + if (Platform.OS === 'ios') { + const items: NativeStackHeaderItem[] = []; + if (!USES_IOS_26_NAVIGATION && primary) { + items.push({ + type: 'button', + label: primary.label, + icon: { type: 'sfSymbol', name: primary.sf }, + onPress: primary.onPress, + }); + } + items.push({ + type: 'menu', + label: t('more'), + icon: { type: 'sfSymbol', name: 'ellipsis' }, + menu: { + items: [ + { + type: 'action', + label: t('title'), + icon: { type: 'sfSymbol', name: 'gearshape' }, + onPress: () => router.push('/settings'), + }, + ], + }, + }); + return { unstable_headerRightItems: () => items }; + } + + return { + headerRight: () => ( + + {primary ? ( + + ) : null} + router.push('/settings')} + /> + + ), + }; +} diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 02a181740..a5603b927 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -1414,6 +1414,7 @@ export const en = { }, settings: { title: 'Settings', + more: 'More', signIn: 'Sign in to LinkCode Cloud', manageHosts: 'Manage hosts', terminalAppearance: 'Terminal appearance', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 68d11d116..92759320a 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -1370,6 +1370,7 @@ export const zhCN = { }, settings: { title: '设置', + more: '更多', signIn: '登录 LinkCode Cloud', manageHosts: '管理 host', terminalAppearance: '终端外观', From 6d6b543cc85f638368a64a3f9ec913207e400f5f Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Sat, 15 Aug 2026 11:22:44 +0800 Subject: [PATCH 6/8] fix(mobile): use compact titles for primary tabs --- apps/mobile/src/app/(tabs)/terminals/index.tsx | 4 ++-- apps/mobile/src/app/(tabs)/threads/index.tsx | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/app/(tabs)/terminals/index.tsx b/apps/mobile/src/app/(tabs)/terminals/index.tsx index 3d04d5dc0..757f602d0 100644 --- a/apps/mobile/src/app/(tabs)/terminals/index.tsx +++ b/apps/mobile/src/app/(tabs)/terminals/index.tsx @@ -53,13 +53,13 @@ export default function TerminalsRoute(): React.ReactNode { 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, ...trailingActions, diff --git a/apps/mobile/src/app/(tabs)/threads/index.tsx b/apps/mobile/src/app/(tabs)/threads/index.tsx index 3ddc11cd5..b950fc48c 100644 --- a/apps/mobile/src/app/(tabs)/threads/index.tsx +++ b/apps/mobile/src/app/(tabs)/threads/index.tsx @@ -20,7 +20,6 @@ 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 { USES_IOS_26_NAVIGATION } from '@mobile/components/shell/ios-26-navigation'; import type { PrimaryAction } from '@mobile/components/shell/primary-action'; import { usePrimaryAction } from '@mobile/components/shell/primary-action'; import { useTrailingActions } from '@mobile/components/shell/use-trailing-actions'; @@ -69,7 +68,7 @@ export default function ThreadsRoute(): React.ReactNode { hostMenuItems, ...trailingActions, @@ -157,7 +156,7 @@ function ThreadsScreen({ return ( <> - {/* `stacked` keeps the field below either title style instead of moving into the iOS 26 toolbar; + {/* `stacked` keeps the field below the inline title instead of moving into the iOS 26 toolbar; the screen body is a SwiftUI host, so nothing here can drive hide-on-scroll. */} Date: Sat, 15 Aug 2026 12:25:56 +0800 Subject: [PATCH 7/8] fix(mobile): use native search scrolling behavior --- apps/mobile/src/app/(tabs)/threads/index.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/app/(tabs)/threads/index.tsx b/apps/mobile/src/app/(tabs)/threads/index.tsx index b950fc48c..f21c553ea 100644 --- a/apps/mobile/src/app/(tabs)/threads/index.tsx +++ b/apps/mobile/src/app/(tabs)/threads/index.tsx @@ -156,12 +156,11 @@ function ThreadsScreen({ return ( <> - {/* `stacked` keeps the field below the inline title instead of moving 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. */} Date: Sat, 15 Aug 2026 13:30:04 +0800 Subject: [PATCH 8/8] feat(mobile): move host entry into native sheet --- apps/mobile/e2e/flows/add-host.yaml | 20 ++-- apps/mobile/e2e/flows/first-run.yaml | 8 +- .../src/app/(tabs)/terminals/_layout.tsx | 2 +- .../mobile/src/app/(tabs)/terminals/index.tsx | 4 +- .../mobile/src/app/(tabs)/threads/_layout.tsx | 2 +- apps/mobile/src/app/(tabs)/threads/index.tsx | 4 +- apps/mobile/src/app/account.tsx | 3 +- apps/mobile/src/app/add-host.tsx | 5 + apps/mobile/src/app/connect.tsx | 17 ++- apps/mobile/src/app/session/[sessionId].tsx | 8 +- apps/mobile/src/app/terminal-appearance.tsx | 3 +- .../components/connect/add-host-screen.tsx | 102 ++++++++++++++++++ .../connect/manual-host-section.tsx | 94 ++-------------- .../components/settings/settings-screen.tsx | 6 +- .../src/components/shell/root-navigator.tsx | 19 +++- .../shell/use-stack-screen-options.ts | 52 +++++---- packages/presentation/i18n/src/locales/en.ts | 3 +- .../presentation/i18n/src/locales/zh-cn.ts | 3 +- 18 files changed, 204 insertions(+), 151 deletions(-) create mode 100644 apps/mobile/src/app/add-host.tsx create mode 100644 apps/mobile/src/components/connect/add-host-screen.tsx 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 ff6d16a7f..ce25d698e 100644 --- a/apps/mobile/e2e/flows/first-run.yaml +++ b/apps/mobile/e2e/flows/first-run.yaml @@ -24,10 +24,8 @@ 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 @@ -36,4 +34,4 @@ appId: com.arcboxlabs.linkcode.mobile point: '6%,8%' - assertVisible: 'Sign in with Apple' - assertVisible: 'More sign-in options' -- assertNotVisible: 'Connect to a host' +- assertNotVisible: 'Manage hosts' 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 757f602d0..acdd0f672 100644 --- a/apps/mobile/src/app/(tabs)/terminals/index.tsx +++ b/apps/mobile/src/app/(tabs)/terminals/index.tsx @@ -16,6 +16,7 @@ import { HostClientGate } from '@mobile/components/host/host-client-gate'; import { useHostMenuItems } from '@mobile/components/host/use-host-menu-items'; 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'; @@ -58,8 +59,7 @@ export default function TerminalsRoute(): React.ReactNode { hostMenuItems, ...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 f21c553ea..f36a737a6 100644 --- a/apps/mobile/src/app/(tabs)/threads/index.tsx +++ b/apps/mobile/src/app/(tabs)/threads/index.tsx @@ -22,6 +22,7 @@ import { ThreadList } from '@mobile/components/host/thread-list/thread-list'; import { useHostMenuItems } from '@mobile/components/host/use-host-menu-items'; 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'; @@ -67,8 +68,7 @@ export default function ThreadsRoute(): React.ReactNode { hostMenuItems, ...trailingActions, diff --git a/apps/mobile/src/app/account.tsx b/apps/mobile/src/app/account.tsx index e56edba84..fb11505b1 100644 --- a/apps/mobile/src/app/account.tsx +++ b/apps/mobile/src/app/account.tsx @@ -2,6 +2,7 @@ import { Button, Form, Host, ProgressView, Section } from '@expo/ui/swift-ui'; import { DeleteAccountSection } from '@mobile/components/account/delete-account-section'; import { DevicesSection } from '@mobile/components/account/devices-section'; import { ProfileRow } from '@mobile/components/account/profile-row'; +import { VISIBLE_HEADER_OPTIONS } from '@mobile/components/shell/use-stack-screen-options'; import { signOutOfCloud, useCloudAccount } from '@mobile/runtime/cloud/account'; import { Redirect, Stack } from 'expo-router'; import { Alert } from 'react-native'; @@ -16,7 +17,7 @@ export default function AccountScreen(): React.ReactNode { return ( <> - + {/* 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/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), - ]} - /> - -