Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 7 additions & 13 deletions apps/mobile/e2e/flows/add-host.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,32 +18,26 @@ 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'
- inputText: 'Probe'
# 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
Expand Down
14 changes: 10 additions & 4 deletions apps/mobile/e2e/flows/first-run.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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%'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A percentage tap for the back chevron will silently start hitting the wrong thing.

Every other interaction in this file selects by visible text, and the header comments elsewhere in the suite are explicit about not depending on incidental screen state. point: '6%,8%' bakes in the navigation bar's height and the chevron's inset — both of which move with the device, the status-bar height, Dynamic Type, and the iOS 26 bar metrics this PR is specifically adapting to. When it drifts it will not fail loudly; it will tap whatever is at that coordinate and fail on a confusing downstream assertion.

Maestro can target the back button by accessibility identifier. Since /connect is pushed from /sign-in, the chevron carries a back label — prefer tapOn: { id: '...' } against that over a coordinate. If the minimal back-button display mode leaves nothing addressable, adding an explicit accessibility label to the Connect screen's back affordance is a better trade than the coordinate.

- assertVisible: 'Sign in with Apple'
- assertVisible: 'More sign-in options'
- assertNotVisible: 'Manage hosts'
4 changes: 2 additions & 2 deletions apps/mobile/e2e/flows/settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rename to Manage hosts makes this guard satisfiable by the exact screen it exists to exclude.

The guard below is assertVisible: 'Manage hosts'. Settings has a Manage hosts row, which is what this comment is describing — but as of en.ts in this PR, the Connect screen's navigation-bar title is also Manage hosts. So if the hydration redirect wins and lands the app on /connect, assertVisible: 'Manage hosts' passes against Connect's title bar, the retry loop exits satisfied, and the flow then fails at assertVisible: 'Settings' on line 21 with no retries left. That is the flaky failure the retry was written to prevent.

The new wording ("a pass needs the SwiftUI form itself — the navigation bar alone renders before the redirect") is now the opposite of what the string does: a navigation bar alone is sufficient to satisfy it.

Switch the guard to a string that only Settings has — Terminal appearance and Share usage analytics are both already asserted further down, so either is free.

Suggested change
# bar alone renders before the redirect this retry exists to outlast.
# The guard is a row unique to Settings, not the title: Connect's navigation bar now also reads
# "Manage hosts", so a title match would be satisfied by the redirect this retry exists to outlast.

(and change line 19's assertVisible to 'Terminal appearance')

- retry:
maxRetries: 3
commands:
Expand Down
44 changes: 38 additions & 6 deletions apps/mobile/src/app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 (
<PrimaryActionScope>
<TabsNavigator />
</PrimaryActionScope>
);
}

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)', '<tab>'] — 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 (
<NativeTabs>
Expand All @@ -24,10 +40,26 @@ export default function TabsLayout(): React.ReactNode {
<NativeTabs.Trigger.Icon sf="apple.terminal" />
<NativeTabs.Trigger.Label>{tTerminals('title')}</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="settings">
<NativeTabs.Trigger.Icon sf="gearshape" />
<NativeTabs.Trigger.Label>{tSettings('title')}</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
{/* 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 ? (
<NativeTabs.Trigger
name="compose"
role="search"
disabled
listeners={{
tabPress() {
if (focused) focused.onPress();
else router.navigate('/threads');
},
}}
>
<NativeTabs.Trigger.Icon sf={focused?.sf ?? 'square.and.pencil'} />
<NativeTabs.Trigger.Label>
{focused?.label ?? tThreads('newThread')}
Comment on lines +52 to +59

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback is unconditionally the Threads action, so on the Terminals tab it advertises the wrong thing.

focused is actions[segments.at(1) ?? 'threads'] ?? null, and TerminalsRoute only registers when connection?.status === 'ready'. Stand on Terminals with the host still connecting, unreachable, or not yet selected — the common first-run state — and actions['terminals'] is undefined, so focused is null. The separated slot then renders square.and.pencil with the label "New thread", and tapping it jumps the user out of Terminals over to /threads.

The comment two lines up says the slot "carries the focused tab's primary action", which is what makes this read as unintended rather than a deliberate default.

The fix is to make the placeholder follow the segment rather than hardcoding the Threads one — pick the icon and label from the current tab, and keep router.navigate pointed at the tab you are already on so a disabled action is inert instead of navigational. segments.at(1) is already in hand for that.

</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
) : null}
</NativeTabs>
);
}
6 changes: 6 additions & 0 deletions apps/mobile/src/app/(tabs)/compose.ts
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 0 additions & 10 deletions apps/mobile/src/app/(tabs)/settings/_layout.tsx

This file was deleted.

6 changes: 0 additions & 6 deletions apps/mobile/src/app/(tabs)/settings/index.tsx

This file was deleted.

2 changes: 1 addition & 1 deletion apps/mobile/src/app/(tabs)/terminals/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Stack screenOptions={screenOptions} />;
}
33 changes: 19 additions & 14 deletions apps/mobile/src/app/(tabs)/terminals/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Comment on lines +44 to +52
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 (
<View className="flex-1 bg-background">
<Stack.Screen
options={{
headerShown: true,
headerLargeTitle: true,
...VISIBLE_HEADER_OPTIONS,
title: t('title'),
unstable_headerLeftItems: () => hostMenuItems,
headerRight:
connection?.status === 'ready'
? () => (
<HeaderIconButton
icon={PlusIcon}
label={t('newTerminal')}
onPress={() => setSheetOpen(true)}
/>
)
: undefined,
...trailingActions,
}}
/>
<HostClientGate>
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/app/(tabs)/threads/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Stack screenOptions={screenOptions} />;
}
38 changes: 21 additions & 17 deletions apps/mobile/src/app/(tabs)/threads/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Comment on lines +55 to +63
usePrimaryAction('threads', primaryAction);
const trailingActions = useTrailingActions(primaryAction);

return (
<View className="flex-1 bg-background">
<Stack.Screen
options={{
headerShown: true,
headerLargeTitle: true,
...VISIBLE_HEADER_OPTIONS,
title: t('title'),
unstable_headerLeftItems: () => hostMenuItems,
headerRight:
connection?.status === 'ready'
? () => (
<HeaderIconButton
icon={SquarePenIcon}
label={t('newThread')}
onPress={() => setSheetOpen(true)}
/>
)
: undefined,
...trailingActions,
}}
/>
<HostClientGate>
Expand All @@ -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,
Expand Down Expand Up @@ -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. */}
<Stack.SearchBar
placeholder={t('searchPlaceholder')}
placement="stacked"
hideWhenScrolling={false}
hideWhenScrolling

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This flip drops a documented constraint that still appears to hold, with nothing recording what invalidated it.

The line it replaces was hideWhenScrolling={false}, justified by: the screen body is a SwiftUI host, so nothing here can drive hide-on-scroll. That justification is still true on this branch — the body below is <Host useViewportSizeMeasurement> wrapping a SwiftUI List, not a UIScrollView the UINavigationController knows about.

UIKit's hidesSearchBarWhenScrolling = true starts the field collapsed and only reveals it when the tracked scroll view reaches the top. With no tracked scroll view, the plausible outcome is a search field that never appears at all, which would make thread search unreachable rather than just differently animated.

If iOS 26 plus placement="stacked" changes this and you have watched the field behave correctly on a real 26 build, that is a fine answer — but it needs to be the replacement comment, because the next person will otherwise re-derive the original constraint and flip it back. If it has only been observed on 18.5, please check 26 before merging.

hideNavigationBar={false}
autoCapitalize="none"
onChangeText={onSearchChange}
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/app/account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -16,7 +17,7 @@ export default function AccountScreen(): React.ReactNode {

return (
<>
<Stack.Screen options={{ headerShown: true, title: t('title') }} />
<Stack.Screen options={{ ...VISIBLE_HEADER_OPTIONS, title: t('title') }} />
{/* Form needs the viewport as its proposed size, otherwise it collapses to its content. */}
<Host style={{ flex: 1 }} useViewportSizeMeasurement>
<Form>
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/app/add-host.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AddHostScreen } from '@mobile/components/connect/add-host-screen';

export default function AddHostRoute(): React.ReactNode {
return <AddHostScreen />;
}
17 changes: 8 additions & 9 deletions apps/mobile/src/app/connect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
<Stack.Screen options={{ headerShown: true, headerLargeTitle: true, title: t('title') }} />
<Stack.Screen
options={{
...VISIBLE_HEADER_OPTIONS,
title: t('title'),
}}
/>
{/* Form needs the viewport as its proposed size, otherwise it collapses to its content. */}
<Host style={{ flex: 1 }} useViewportSizeMeasurement>
<Form>
Expand All @@ -33,8 +33,7 @@ export default function ConnectScreen(): React.ReactNode {

{hosts.length > 0 ? <SavedHostsSection /> : null}

{/* Signed out there is nothing else to connect with, so the form opens itself. */}
<ManualHostSection startsExpanded={!signedIn} />
<ManualHostSection />
</Form>
</Host>
</>
Expand Down
Loading
Loading