Files
orca/mobile/app/_layout.tsx
T
Jinwoo Hong f47f632bd7 feat(mobile): Home's New Workspace opens the desktop's create drawer over Home (native part ships with the next app build) (#31305)
* fix(mobile): the home screen's New Workspace opens the create sheet again

The native host-list route dropped the home screen's action=newWorktree when it built
the page's route, so the page opened as a plain list. The route now forwards it, and
the page names it back once applied so a reload does not reopen the sheet.

Reaches phones only with the next native build.

* test(mobile): drop the New Workspace tests that only re-check types and a null guard

* fix(mobile): Home's New Workspace opens the create sheet over Home

Replaces the action=newWorktree route hand-off. The quick action picks a
host (as Tasks does), then mounts the host's create sheet on Home; a
create opens the new workspace's session marked as just created.

The sheet controller becomes a plain visible/onClose component, and the
host screen's route-action state, the action route param and its builder
are deleted.

* fix(mobile): Home's create sheet seeds only proven worktree rows

Home's cold-start seed is a pathless resume row that the name suggester
cannot read, so the sheet now seeds from proven rows and is keyed by host.
The host screen gets one setter for the sheet's visibility and its ref.

* test(mobile): name the Home new-workspace test after the screen it renders

* proto(mobile): Home's New Workspace opens a native drawer hosting the page's create form

Home pushes /new-workspace/<host>, a react-native-screens formSheet over Home whose content is
MobileWebShellScreen for the page route /h/<host>/new-workspace. The page renders the shared
Create Workspace form with its drawers inline, so the form and its pickers fill the native
drawer. The shell's loading, download and failure states render inside the drawer. Prototype only.

* proto(mobile): the New Workspace drawer is the host screen's own sheet over a transparent modal

The native route is a transparentModal hosting the shell over Home; the page route renders the
host screen's NewWorktreeModal on a transparent page, so the drawer is the same component. While
the page loads, the shell shows a placeholder of that sheet (scrim, grabber, spinner or download
bar), and the page's sheet opens in place without its slide. A host that cannot serve the page
(old desktop, blocked, failed) opens the host list instead of a wall in a drawer.

* feat(mobile): Home's New Workspace opens the page's create drawer over a dimmed Home

A transparent native overlay route hosts the host picker and then the page's
/h/[hostId]/new-workspace route, which renders the same NewWorktreeModal as the
host screen's +. The native scrim matches the drawer backdrop and the page's
first drawer opens already dimmed, so Home stays dimmed throughout. Old desktops
without the route fall back to the host list.

* fix(mobile): one loading layer for Home's New Workspace overlay

The overlay route owns a single tappable loading layer (spinner, or the
download on a small card) that stays mounted from the pick until the page
paints, so the fade and spinner no longer restart per shell state. The shell
reports its load and shows the host list for anything it cannot open; the
picker hands the pick over at once and keeps sliding away. The page's first
drawer takes the dimmed backdrop once, and a gate's overlay pending view no
longer stacks a second scrim over a mounted stack. Native /h/[hostId]/new-workspace
redirects to the overlay. Drops the unused session-route params, the
clipboard grant, and pass-through tests.

* feat(mobile): New Workspace loads in a short drawer over an undimmed Home

While the page loads, the overlay shows one short native drawer in the shared
drawer chrome with a spinner or the download, and nothing dims Home: a tap
above it, a drag down or Back cancels. The page's real drawer then slides up
with its own backdrop exactly as on the host screen's +. Drops the native
scrim, the loading card and the already-dimmed backdrop handoff; the overlay's
WebView stays invisible until the page paints, since a fresh one draws opaque.

* feat(mobile): the New Workspace loading drawer dims Home like any drawer

The short loading drawer opens with the shared drawer backdrop and show
duration, and a tap on that backdrop, a drag down or Back cancels. It stays
in the screen's own window (a native Modal over the loading WebView stalls
the page) and goes in the frame the page paints: the shell reports its load
from a layout effect so the WebView shows and the drawer leaves together.

* fix(mobile): the page's first paint waits for a drawer opening with it

A drawer opening as its page screen mounts holds that screen's first-paint
report until its open animation ends, through a hold on the existing report
rather than a new bridge message. The New Workspace overlay's loading drawer
now stays until the create drawer is up, so a cold open no longer shows Home
undimmed between the two.

* refactor(mobile): the New Workspace overlay owns its exits; the loading drawer fades out

The shell no longer imports the feature: an overlay is a shell with
onOverlayLoad, which reports `unavailable` for what it cannot show, and the
overlay route opens the host list for every exit. HostProtocolGate takes one
transparentWhilePending flag. The loading drawer's drag handler follows a
stable cancel, and on paint the drawer fades out eased in, as the shell's page
cover does, because the WebView takes a frame or two to show the page after it
is revealed; removing it outright left Home undimmed for those frames.

* Revert "fix(mobile): the page's first paint waits for a drawer opening with it"

This reverts commit be0913b9d5.

* fix(mobile): Home's host picker leaves when there is no desktop to pick

An unreadable host catalog, or offered desktops that are all gone, left the
picker invisible inside the transparent overlay, holding a screen over Home
with nothing to dismiss it. The catalog read now resolves to no hosts on
failure, and an empty pick list goes back to Home.

* fix(mobile): Home's create drawer names SSH hosts as the host screen does

The page drawer opened from Home passed no host labels, so a project on an SSH
host showed its run target by raw host id. Reading the SSH target summaries
and display overrides moves into one function that both the host screen's
metadata fetch and the page drawer call.
2026-10-21 20:02:11 -04:00

266 lines
11 KiB
TypeScript

import { startAndroidForegroundPushPresentation } from '../src/notifications/android-foreground-push'
import { registerPushDismissalTask } from '../src/notifications/push-background-dismissal'
import { readNativeNotificationData } from '../src/notifications/native-notification-data'
import { setNotificationViewingWorkspace } from '../src/notifications/notification-viewing-policy'
import { useCallback, useEffect, useRef } from 'react'
import { View, StyleSheet } from 'react-native'
import { Stack, useRouter, useGlobalSearchParams, usePathname } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import * as SplashScreen from 'expo-splash-screen'
import * as Notifications from 'expo-notifications'
import * as Linking from 'expo-linking'
import { colors } from '../src/theme/mobile-theme'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { RpcClientProvider } from '../src/transport/client-context'
import { getNotificationNavigationTarget } from '../src/notifications/notification-routing'
import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route'
import {
isRemotePushTrigger,
pushNotificationRouteData,
foregroundNotificationBehavior
} from '../src/notifications/push-receive'
import { startPushTokenSync } from '../src/notifications/push-registration'
import { ensureDesktopNotificationChannel } from '../src/notifications/desktop-notification-channel'
import { loadHostCatalog } from '../src/transport/host-store'
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery'
import { appUpdateChecker } from '../src/app-update/app-update-runtime'
import { SideSafeArea } from '../src/layout/side-safe-area'
// Why: keeps the native splash screen visible until the React tree is mounted
// and ready to render. Without this the user sees a blank white/black frame
// between the native splash and the first React paint.
SplashScreen.preventAutoHideAsync()
// Why at boot and not only on subscribe: the gateway's FCM payload targets the
// 'orca-desktop' channel, and a background push can land before any socket has
// connected. Android drops a notification whose channel does not exist yet.
void ensureDesktopNotificationChannel().catch(() => {})
void registerPushDismissalTask().catch(() => {})
// Register before scheduling so foreground delivery uses the same suppression policy.
Notifications.setNotificationHandler({
handleNotification: foregroundNotificationBehavior
})
export default function RootLayout() {
const router = useRouter()
const pathname = usePathname()
const { hostId, worktreeId, executionHost } = useGlobalSearchParams<{
hostId?: string
worktreeId?: string
executionHost?: string
}>()
useEffect(() => {
setNotificationViewingWorkspace(
pathname.includes('/session/') && typeof hostId === 'string' && typeof worktreeId === 'string'
? { hostId, worktreeId, ...(typeof executionHost === 'string' ? { executionHost } : {}) }
: null
)
return () => setNotificationViewingWorkspace(null)
}, [pathname, hostId, worktreeId, executionHost])
const openNotificationRoute = useOpenNotificationRoute()
const handledNotificationIdsRef = useRef<Set<string>>(new Set())
useEffect(() => {
// Why: pairing publication is journaled across process death; startup must
// reconcile the server result before another scan can replace that journal.
void recoverMobileRelayPairing()
}, [])
// Why: a rolled APNs/FCM token stops delivering silently, so every paired host
// has to be re-registered with the new one as soon as the provider hands it over.
useEffect(() => startPushTokenSync(), [])
// Cold start, then foreground and timer checks on the desktop updater's cadence.
useEffect(() => appUpdateChecker.start(), [])
useEffect(() => startAndroidForegroundPushPresentation(), [])
// Why: route `orca://pair?...` deep links to the confirm screen so
// the same pairing flow runs whether the link arrived via QR scan,
// paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL
// covers cold-start (link tapped while app was closed); the listener
// covers warm-start (link tapped while app is in memory).
useEffect(() => {
function handleUrl(url: string) {
const code = extractPairingCodeFromUrl(url)
if (code) {
// Why: Android camera launches can leave Expo Router's unmatched
// `orca://pair` route underneath this screen; replacing keeps cancel
// and edge-back from revealing the router error page.
router.replace({ pathname: '/pair-confirm', params: { code } })
}
}
void Linking.getInitialURL().then((url) => {
if (url) {
handleUrl(url)
}
})
const sub = Linking.addEventListener('url', ({ url }) => handleUrl(url))
return () => sub.remove()
}, [router])
// ─── Notification tap routing ───
// Why: iOS delivers local notification taps through expo-notifications,
// not Linking. Route both cold-start and warm-start responses to the host
// and worktree that scheduled the notification.
useEffect(() => {
let disposed = false
function clearLastNotificationResponse() {
try {
Notifications.clearLastNotificationResponse()
} catch {
// Older native shells may not expose the clear API; duplicate guards
// still protect the current JS runtime.
}
}
function getInitialNotificationResponse(): Notifications.NotificationResponse | null {
try {
return Notifications.getLastNotificationResponse()
} catch {
return null
}
}
async function getNavigationTarget(notification: Notifications.Notification) {
const hosts = await loadHostCatalog().catch(() => null)
const data = readNativeNotificationData(notification.request)
// A gateway push names its host by key fingerprint, not by this device's hostId.
// With no catalog to resolve against, such a push stays unrouted instead of
// falling back to whatever hostId its raw data carries.
const routeData = pushNotificationRouteData(
data,
hosts ?? [],
isRemotePushTrigger(notification.request.trigger)
)
return getNotificationNavigationTarget(routeData, {
knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined,
credentialStatusByHostId: hosts
? new Map(hosts.map((host) => [host.id, host.credentialStatus]))
: undefined
})
}
async function handleNotificationResponse(response: Notifications.NotificationResponse) {
if (response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) {
clearLastNotificationResponse()
return
}
const notificationId = response.notification.request.identifier
if (handledNotificationIdsRef.current.has(notificationId)) {
return
}
handledNotificationIdsRef.current.add(notificationId)
// Why: RootLayout never unmounts, so cap this tap-dedup set (FIFO) rather
// than letting it grow one id per notification tapped for the app's life.
if (handledNotificationIdsRef.current.size > 256) {
const oldest = handledNotificationIdsRef.current.values().next().value
if (oldest !== undefined) {
handledNotificationIdsRef.current.delete(oldest)
}
}
const target = await getNavigationTarget(response.notification)
clearLastNotificationResponse()
if (disposed) {
return
}
if (target) {
openNotificationRoute(target)
}
}
const initialResponse = getInitialNotificationResponse()
if (initialResponse) {
void handleNotificationResponse(initialResponse)
}
const sub = Notifications.addNotificationResponseReceivedListener((response) => {
void handleNotificationResponse(response)
})
return () => {
disposed = true
sub.remove()
}
}, [openNotificationRoute])
// ─── End notification tap routing ───
// Why: hide the native splash only once the navigation Stack has been laid
// out — this is the earliest moment the user will see actual app content.
// Previously the splash hid when a placeholder View rendered, leaving a
// grey gap before the real screen appeared.
const onNavigatorLayout = useCallback(async () => {
await SplashScreen.hideAsync()
}, [])
return (
<RpcClientProvider>
<View style={styles.root} onLayout={onNavigatorLayout}>
<StatusBar style="light" />
<SideSafeArea>
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.bgPanel },
headerTintColor: colors.textPrimary,
headerTitleStyle: { fontSize: 16, fontWeight: '600' },
contentStyle: { backgroundColor: colors.bgBase },
headerShadowVisible: false
// Why: deliberately no `orientation` screenOption. react-native-screens
// has no value that respects the device rotation lock — even 'default'
// calls setRequestedOrientation(UNSPECIFIED) at runtime, overriding the
// manifest. Leaving it unset lets the manifest's "fullUser" (set by the
// android-respect-rotation-lock config plugin) honor the auto-rotate lock.
}}
>
<Stack.Screen
name="index"
options={{
headerShown: false,
headerTitle: () => <OrcaLogo size={22} />
}}
/>
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
<Stack.Screen name="pair" options={{ headerShown: false }} />
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
<Stack.Screen
name="mobile-onboarding"
options={{ headerShown: false, presentation: 'modal', gestureEnabled: false }}
/>
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
<Stack.Screen name="native-chat-settings" options={{ headerShown: false }} />
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
<Stack.Screen name="voice-settings" options={{ headerShown: false }} />
<Stack.Screen name="notifications" options={{ headerShown: false }} />
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
<Stack.Screen name="connection-log" options={{ headerShown: false }} />
<Stack.Screen name="about" options={{ headerShown: false }} />
<Stack.Screen name="h" options={{ headerShown: false }} />
<Stack.Screen
name="new-workspace"
options={{
headerShown: false,
// Draws nothing of its own: the drawer is the page's, over Home.
presentation: 'transparentModal',
animation: 'none',
contentStyle: { backgroundColor: 'transparent' }
}}
/>
</Stack>
</SideSafeArea>
</View>
</RpcClientProvider>
)
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: colors.bgBase
}
})