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. */}