Skip to content

fix(mobile): polish native iOS onboarding and navigation (CODE-581) - #519

Open
Zerlight wants to merge 8 commits into
masterfrom
ruocheng/code-581
Open

fix(mobile): polish native iOS onboarding and navigation (CODE-581)#519
Zerlight wants to merge 8 commits into
masterfrom
ruocheng/code-581

Conversation

@Zerlight

@Zerlight Zerlight commented Sep 8, 2026

Copy link
Copy Markdown
Member

Summary

Polish mobile onboarding, host entry, and navigation with native iOS controls. Preserve first-run/back-navigation behavior, move settings into the header menu, and adapt titles and primary actions to the supported iOS generations.

Part 1 of the mobile stack: master#519#520#521. Base is the latest master, da9c0673. Merge in this order.

Refs CODE-581.

Verification

  • Repository format, lint, typecheck, and tests verified at the complete stack tip (33bd138a); this lower branch was not independently rerun.
  • Rebuilt the complete stack with Xcode 27 and launched iOS 18.5 and 26.3; native thread navigation and the mock chat flow rendered on both.
  • Onboarding and connect E2E flows are updated in this change. They were not rerun for this PR publication.

Checklist

  • pnpm check:ci and pnpm test pass at the complete stack tip
  • Affected mobile surface launched and observed at the stack tip
  • No wire protocol changes

Copilot AI lite review requested due to automatic review settings September 8, 2026 03:21
@linear-code

linear-code Bot commented Sep 8, 2026

Copy link
Copy Markdown

CODE-581

Copilot AI left a comment

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.

🟡 Changes recommended

The new primary-action registration currently uses unstable function/object identities that can cause repeated re-registration and potential render-loop behavior without the suggested fixes.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Polishes the mobile app’s onboarding and navigation by leaning on native iOS navigation chrome (including iOS 26 tab-bar behavior), moving Settings behind an overflow menu, and reshaping host onboarding into a dedicated “Add host” sheet flow.

Changes:

  • Introduces a “primary action” registry to drive iOS 26’s separated tab-bar slot and reuses it for trailing header actions (primary button + overflow → Settings).
  • Moves manual host entry out of the Connect screen into a dedicated /add-host form-sheet route, updating root stack options accordingly.
  • Updates i18n strings and E2E flows to match the new “Manage hosts” navigation and revised first-run/back behavior.
File summaries
File Description
packages/presentation/i18n/src/locales/zh-cn.ts Updates Connect title and adds new labels (cancel, more) for revised nav UI.
packages/presentation/i18n/src/locales/en.ts Same as zh-CN: Connect title and new labels for the overflow/menu and sheet.
apps/mobile/src/components/shell/use-trailing-actions.tsx Adds iOS native header menu + Android RN fallback for trailing actions.
apps/mobile/src/components/shell/use-stack-screen-options.ts Refactors shared stack header defaults and exports reusable visible/large-title presets.
apps/mobile/src/components/shell/root-navigator.tsx Configures /add-host as a form sheet with a visible header.
apps/mobile/src/components/shell/primary-action.ts Adds contexts + hooks for registering/reading a focused tab’s primary action.
apps/mobile/src/components/shell/primary-action-scope.tsx Implements the primary-action registry provider for the tabs subtree.
apps/mobile/src/components/shell/ios-26-navigation.ts Centralizes iOS 26 navigation detection gate.
apps/mobile/src/components/shell/brand-mark.tsx Adjusts brand mark rendering (continuous corner curve, decorative image semantics).
apps/mobile/src/components/settings/settings-screen.tsx Moves settings header behavior to shared options and removes host menu from header-left.
apps/mobile/src/components/connect/manual-host-section.tsx Replaces inline manual-entry form with a navigation row to /add-host.
apps/mobile/src/components/connect/add-host-screen.tsx Adds the new SwiftUI-based “Add host” sheet screen + header items.
apps/mobile/src/app/terminal-appearance.tsx Applies shared visible-header options.
apps/mobile/src/app/sign-in.tsx Reworks first-run sign-in UI with native-ish iOS styling and improved accessibility.
apps/mobile/src/app/settings.tsx Adds a standalone Settings route pushed from overflow, separate from tabs.
apps/mobile/src/app/session/[sessionId].tsx Applies shared visible-header options and adjusts list footer padding on iOS.
apps/mobile/src/app/connect.tsx Updates header options and removes disclosure-based manual host entry.
apps/mobile/src/app/add-host.tsx Adds the /add-host route entry.
apps/mobile/src/app/account.tsx Applies shared visible-header options.
apps/mobile/src/app/(tabs)/threads/index.tsx Registers a per-tab primary action and uses shared trailing actions + header options.
apps/mobile/src/app/(tabs)/threads/_layout.tsx Simplifies stack options usage for the Threads tab stack.
apps/mobile/src/app/(tabs)/terminals/index.tsx Registers a per-tab primary action and uses shared trailing actions + header options.
apps/mobile/src/app/(tabs)/terminals/_layout.tsx Simplifies stack options usage for the Terminals tab stack.
apps/mobile/src/app/(tabs)/settings/index.tsx Removes the Settings tab route.
apps/mobile/src/app/(tabs)/settings/_layout.tsx Removes the Settings tab stack layout.
apps/mobile/src/app/(tabs)/compose.ts Adds an unreachable route for the iOS 26 tab-bar “compose slot” trigger.
apps/mobile/src/app/(tabs)/_layout.tsx Adds PrimaryActionScope and iOS 26 “compose slot” trigger behavior.
apps/mobile/e2e/flows/settings.yaml Updates guard comments to reflect new navigation timing/structure.
apps/mobile/e2e/flows/first-run.yaml Updates assertions and back-navigation expectations for the revised first-run flow.
apps/mobile/e2e/flows/add-host.yaml Updates the flow for the new “Add host by URL” navigation + sheet screen.
Review details
  • Files reviewed: 30/30 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +44 to +52
const primaryAction: PrimaryAction | null =
connection?.status === 'ready'
? {
sf: 'plus',
icon: PlusIcon,
label: t('newTerminal'),
onPress: () => setSheetOpen(true),
}
: null;
Comment on lines +55 to +63
const primaryAction: PrimaryAction | null =
connection?.status === 'ready'
? {
sf: 'square.and.pencil',
icon: SquarePenIcon,
label: t('newThread'),
onPress: () => setSheetOpen(true),
}
: null;
Comment on lines +28 to +33
export function usePrimaryAction(tab: string, action: PrimaryAction | null): void {
const register = use(RegisterPrimaryActionContext);
useEffect(() => {
if (action) return register(tab, action);
}, [register, tab, action]);
}

@pullfrog pullfrog Bot left a comment

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 navigation restructure holds up — the concerns below are one real UI mislabel, one weakened E2E guard, and some polish.

Reviewed changes

  • Traced the new primary-action registry (primary-action.ts, primary-action-scope.tsx, (tabs)/_layout.tsx) and both registration sites against a React-Compiler-off render model.
  • Traced dismissTo('/threads') from the new add-host formSheet through expo-router's linkTogetNavigateActionfindDivergentStateStackRouter's POP_TO.
  • Traced the sign-in push/Redirect change against every entry path into /connect.
  • Re-read the three edited Maestro flows against the renamed i18n strings.
  • Ran the real gates on this branch: tsc --build --noEmit apps/mobile (exit 0) and eslint over apps/mobile/src/{app,components/shell,components/connect} (0 errors; the 2 warnings are pre-existing).

⚠️ This branch's gates were never run on this branch

The description says pnpm check:ci and pnpm test were verified at the stack tip 33bd138a, and that the onboarding and connect E2E flows "were not rerun for this PR publication." Since master#519#520#521 merge independently, #519 has to stand on its own.

I ran the JavaScript gates for you and they are clean on 83b8aa1tsc --build --noEmit apps/mobile exits 0, and eslint over the touched trees reports 0 errors (the no-console at (tabs)/_layout.tsx:30 and the set-state-in-effect at my-machines-section.tsx:26 both predate this branch).

What I could not cover is the part that matters most here. add-host.yaml, first-run.yaml, and settings.yaml are the only automated coverage for the flows this PR restructures — the manual-host DisclosureGroup becoming a formSheet, the skip-to-Connect back affordance, and the Settings tab becoming a root-stack screen. All three were edited in this PR and none were rerun. Two of them have specific problems noted inline. Please run the flows on this branch before merging.

ℹ️ Nitpicks

add-host-screen.tsx now duplicates useOpenHost's setLastActiveHostId + navigate pair rather than reusing it. The duplication is justified — the sheet needs dismissTo, not push — but the two "adopt a host" paths can now drift independently. A shared helper that takes the navigation verb would keep them honest.

The urlValid gate in the new sheet is a real behavior change worth calling out, even though I think it is the right trade. The deleted manual-host-section.tsx carried this note:

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.

Enabling the checkmark bar button off urlValid reintroduces exactly that dependency — the button stays disabled until an onTextChange lands in JS. With a native TextField that is effectively synchronous, so this is fine in practice, but the invariant the old comment protected is gone and nothing records that it was deliberate.

Everything else I chased turned out clean, listed here so nobody re-investigates: (tabs)/compose.ts does not leak as a phantom tab on pre-26 iOS (native tabs pass useOnlyUserDefinedScreens = true to withLayoutContext, so a route with no Trigger never reaches the navigator); the fresh primaryAction object literal in usePrimaryAction's dependency array does not loop despite React Compiler being off for apps/mobile (the context split keeps screens off PrimaryActionRegistryContext, and React Navigation's StaticContainer stops registry churn short of the screen bodies); dismissTo('/threads') from the formSheet resolves to payload name (tabs), not threads, so POP_TO finds it at index 0 and pops correctly; the appleAvailable === null spinner gate does not hang Android (expo-apple-authentication's stub resolves false); the new ListFooterComponent in session/[sessionId].tsx is correct because that FlatList is inverted; and en.ts / zh-cn.ts are the only locales, both updated.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment on lines +52 to +59
if (focused) focused.onPress();
else router.navigate('/threads');
},
}}
>
<NativeTabs.Trigger.Icon sf={focused?.sf ?? 'square.and.pencil'} />
<NativeTabs.Trigger.Label>
{focused?.label ?? tThreads('newThread')}

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.

# 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')

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.

# 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.

animation={{ scale: false }}
style={styles.manualButton}
variant="ghost"
onPress={() => router.push('/connect')}

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.

Minor: push here plus the focus-scoped Redirect above makes back-from-Connect flash this screen after sign-in.

Keeping sign-in beneath Connect is the point of the change and it is right for the skip path. But Redirect runs its router.replace inside useFocusEffect, not on mount, so it fires every time this screen regains focus. Sign in from Connect's SignInSection, then tap back: sign-in comes into view, account.status is now signed-in, the redirect fires, and the user is pushed forward onto a fresh /connect instance — losing the scroll position and any sheet state on the one they came from.

It is not a dead end and it does not accumulate (SignInSection hides once signed in, so the round trip is not repeatable), which is why this is a nit rather than a blocker. If you want it gone, dismissing back to the existing Connect rather than replacing into a new one would do it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants