diff --git a/mobile/app/h/[hostId]/edit.tsx b/mobile/app/h/[hostId]/edit.tsx
index a0867c4bbb4..f0cdd8eaaf2 100644
--- a/mobile/app/h/[hostId]/edit.tsx
+++ b/mobile/app/h/[hostId]/edit.tsx
@@ -21,8 +21,18 @@ import { resolveHostEndpointEdit } from '../../../src/transport/host-endpoint-ed
import { usePrimeHosts, useRefreshHostClient } from '../../../src/transport/host-client-hooks'
import type { HostProfile } from '../../../src/transport/types'
import { hostOs } from '../../../src/platform/host-os'
+import { SideSafeArea } from '../../../src/layout/side-safe-area'
-export default function EditHostScreen() {
+// The host group leaves the Duo's status-bar rail to its pages; this native screen pads it.
+export default function EditHostRoute() {
+ return (
+
+
+
+ )
+}
+
+function EditHostScreen() {
useMobileColorScheme()
const router = useRouter()
const insets = useSafeAreaInsets()
diff --git a/mobile/app/h/_layout.tsx b/mobile/app/h/_layout.tsx
index 6f603072d3a..75a6a6a44c5 100644
--- a/mobile/app/h/_layout.tsx
+++ b/mobile/app/h/_layout.tsx
@@ -6,7 +6,12 @@ import { themedStyleSheet } from '../../src/theme/themed-styles'
import { useMobileColorScheme } from '../../src/theme/use-mobile-color-scheme'
import { useResponsiveLayout } from '../../src/layout/responsive-layout'
import {
- HOST_SIDEBAR_DEFAULT_WIDTH,
+ defaultHostSidebarWidth,
+ SPLIT_DETAIL_MIN_WIDTH
+} from '../../src/layout/responsive-layout-metrics'
+import { useSafeAreaInsets } from 'react-native-safe-area-context'
+import { useReleasedSideRail } from '../../src/layout/side-safe-area'
+import {
HOST_SIDEBAR_MAX_WIDTH,
HOST_SIDEBAR_MIN_WIDTH,
loadHostSidebarWidth,
@@ -18,17 +23,16 @@ import { HostStack } from '../../src/navigation/host-stack'
import { HostSidebarRevealContext } from '../../src/layout/host-sidebar-reveal'
import { HostAreaServingContext } from '../../src/mobile-web-shell/host-area-serving'
import { usePageOwnsHostArea } from '../../src/mobile-web-shell/page-owns-host-area'
+import { useReportedPageLocation } from '../../src/mobile-web-shell/page-location-report'
import { isOverlayPageRoute } from '../../src/new-workspace/overlay-page-route'
-// Keep at least this much room for the detail pane when resizing the sidebar.
-const MIN_DETAIL_WIDTH = 320
const RESIZE_EDGE_WIDTH = 24
// Clamp restored sidebar width to current window so a smaller device retains usable detail space.
function clampSidebarToWindow(width: number, windowWidth: number): number {
const hardMax = Math.max(
HOST_SIDEBAR_MIN_WIDTH,
- Math.min(HOST_SIDEBAR_MAX_WIDTH, windowWidth - MIN_DETAIL_WIDTH)
+ Math.min(HOST_SIDEBAR_MAX_WIDTH, windowWidth - SPLIT_DETAIL_MIN_WIDTH)
)
return Math.min(hardMax, Math.max(HOST_SIDEBAR_MIN_WIDTH, Math.round(width)))
}
@@ -41,7 +45,12 @@ export default function HostGroupLayout() {
const pathname = usePathname()
const overlayRoute = isOverlayPageRoute(pathname)
const [sidebarOpen, setSidebarOpen] = useState(true)
- const [sidebarWidth, setSidebarWidth] = useState(HOST_SIDEBAR_DEFAULT_WIDTH)
+ // Keep the dragged width and clamp per render so a fold cannot shrink it for good.
+ const [savedSidebarWidth, setSidebarWidth] = useState(null)
+ const sidebarWidth = clampSidebarToWindow(
+ savedSidebarWidth ?? defaultHostSidebarWidth(windowWidth),
+ windowWidth
+ )
// Refs keep once-created PanResponder handlers reading current widths.
const widthRef = useRef(sidebarWidth)
@@ -54,7 +63,7 @@ export default function HostGroupLayout() {
let stale = false
void loadHostSidebarWidth().then((saved) => {
if (!stale) {
- setSidebarWidth(clampSidebarToWindow(saved, windowWidthRef.current))
+ setSidebarWidth(saved)
}
})
return () => {
@@ -62,21 +71,21 @@ export default function HostGroupLayout() {
}
}, [])
- // Re-clamp on fold/rotation/split-screen to preserve MIN_DETAIL_WIDTH.
- useEffect(() => {
- setSidebarWidth((current) => clampSidebarToWindow(current, windowWidth))
- }, [windowWidth])
-
+ // Host screens are pages that draw in the Duo's status-bar rail; the native sidebar pads itself.
+ useReleasedSideRail(Platform.OS !== 'web')
+ const railLeft = useSafeAreaInsets().left
const hideSidebar = useCallback(() => setSidebarOpen(false), [])
const revealSidebar = useCallback(() => setSidebarOpen(true), [])
const [hostAreaServing, setHostAreaServing] = useState(false)
const pageOwnsHostArea = usePageOwnsHostArea()
+ useReportedPageLocation()
// One owner: the page only with its init fact; natively all but a host route its page is serving.
const sidebarDrawnHere =
Platform.OS === 'web' ? pageOwnsHostArea : !(pathname === `/h/${hostId}` && hostAreaServing)
const showSidebar = isWideLayout && !!hostId && sidebarDrawnHere
const detailHasContent = !!hostId && pathname !== `/h/${hostId}`
- const canCollapseSidebar = showSidebar && detailHasContent
+ // Only the page's session header can reveal it again; a native sidebar beside a page detail could never come back.
+ const canCollapseSidebar = Platform.OS === 'web' && showSidebar && detailHasContent
// Base host detail is a placeholder; hiding its sidebar would leave nothing useful.
useEffect(() => {
@@ -114,11 +123,12 @@ export default function HostGroupLayout() {
{showSidebar && sidebarOpen ? (
-
+
{/* Edge drag handle captures Android gestures above the list. */}
diff --git a/mobile/src/host-edit-route-accessibility.test.ts b/mobile/src/host-edit-route-accessibility.test.ts
index 568ef5ff523..3590e30df21 100644
--- a/mobile/src/host-edit-route-accessibility.test.ts
+++ b/mobile/src/host-edit-route-accessibility.test.ts
@@ -27,6 +27,9 @@ vi.mock('react-native', () => ({
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 })
}))
+vi.mock('./layout/side-safe-area', () => ({
+ SideSafeArea: ({ children }: { children: unknown }) => children
+}))
vi.mock('expo-router', () => ({
useLocalSearchParams: () => ({ hostId: 'host-1' }),
diff --git a/mobile/src/host-edit-save-flow.test.ts b/mobile/src/host-edit-save-flow.test.ts
index 9f32d686dbd..68202f0b7b6 100644
--- a/mobile/src/host-edit-save-flow.test.ts
+++ b/mobile/src/host-edit-save-flow.test.ts
@@ -27,6 +27,9 @@ vi.mock('react-native', () => ({
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 })
}))
+vi.mock('./layout/side-safe-area', () => ({
+ SideSafeArea: ({ children }: { children: unknown }) => children
+}))
vi.mock('expo-router', () => ({
useLocalSearchParams: () => ({ hostId: dependencies.hostId }),
diff --git a/mobile/src/host-screen/host-rail-actions.tsx b/mobile/src/host-screen/host-rail-actions.tsx
new file mode 100644
index 00000000000..fd6e759eff3
--- /dev/null
+++ b/mobile/src/host-screen/host-rail-actions.tsx
@@ -0,0 +1,189 @@
+import { Pressable, StyleSheet, View } from 'react-native'
+import {
+ List,
+ Plus,
+ Search,
+ SquareTerminal,
+ UserCircle,
+ X,
+ type LucideIcon
+} from 'lucide-react-native'
+import { themedStyleSheet } from '../theme/themed-styles'
+import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
+import { colors, spacing } from '../theme/mobile-theme'
+import type { HostScreenController } from './use-host-screen-controller'
+
+type HostListAction = {
+ id: 'accounts' | 'tasks' | 'search' | 'floating' | 'new'
+ label: string
+ icon: LucideIcon
+ onPress: () => void
+ disabled: boolean
+}
+
+// One list for the sidebar's toolbar row and the side rail, so both offer the same actions.
+export function hostListActions(controller: HostScreenController): HostListAction[] {
+ const { actions, connState, floatingWorkspaceEnabled, hostId, state } = controller
+ const offline = connState !== 'connected'
+ const hostPath = `/h/${encodeURIComponent(hostId)}`
+ return [
+ {
+ id: 'accounts',
+ label: 'Accounts',
+ icon: UserCircle,
+ onPress: () => actions.navigateFromHostList(`${hostPath}/accounts`),
+ disabled: offline
+ },
+ {
+ id: 'tasks',
+ label: 'Tasks',
+ icon: List,
+ onPress: () => actions.navigateFromHostList(`${hostPath}/tasks`),
+ disabled: offline
+ },
+ ...(floatingWorkspaceEnabled
+ ? [
+ {
+ id: 'floating' as const,
+ label: 'Floating Workspace',
+ icon: SquareTerminal,
+ onPress: actions.openFloatingWorkspace,
+ disabled: offline
+ }
+ ]
+ : []),
+ {
+ id: 'new',
+ label: 'New workspace',
+ icon: Plus,
+ onPress: actions.openNewWorktreeModal,
+ disabled: offline
+ },
+ {
+ id: 'search',
+ label: state.showSearch ? 'Close search' : 'Search workspaces',
+ icon: state.showSearch ? X : Search,
+ onPress: () => state.setShowSearch((shown) => !shown),
+ disabled: false
+ }
+ ]
+}
+
+function RailIcon({ action }: { action: HostListAction }) {
+ useMobileColorScheme()
+ return (
+ [
+ styles.icon,
+ pressed && styles.pressed,
+ action.disabled && styles.disabled
+ ]}
+ onPress={action.onPress}
+ disabled={action.disabled}
+ accessibilityRole="button"
+ accessibilityLabel={action.label}
+ >
+
+
+ )
+}
+
+function pick(all: HostListAction[], ids: HostListAction['id'][]): HostListAction[] {
+ return ids.flatMap((id) => all.filter((action) => action.id === id))
+}
+
+// Beside the sidebar: navigation, then the workspace tools, then New as the one filled button.
+// On the cover the list keeps its own New button, and search sits at the bottom within thumb reach.
+export function HostRailActions({
+ controller,
+ placement
+}: {
+ controller: HostScreenController
+ placement: 'split' | 'cover'
+}) {
+ useMobileColorScheme()
+ const all = hostListActions(controller)
+ if (placement === 'cover') {
+ return (
+
+
+ {pick(all, ['accounts', 'tasks', 'floating']).map((action) => (
+
+ ))}
+
+ {pick(all, ['search']).map((action) => (
+
+ ))}
+
+ )
+ }
+ const create = all.find((action) => action.id === 'new')
+ return (
+
+ {pick(all, ['accounts', 'tasks', 'search', 'floating']).map((action) => (
+
+ {action.id === 'floating' ? : null}
+
+
+ ))}
+ {create ? (
+ [
+ styles.create,
+ pressed && styles.createPressed,
+ create.disabled && styles.disabled
+ ]}
+ onPress={create.onPress}
+ disabled={create.disabled}
+ accessibilityRole="button"
+ accessibilityLabel={create.label}
+ >
+
+
+ ) : null}
+
+ )
+}
+
+const styles = themedStyleSheet(() => ({
+ rail: {
+ alignItems: 'center',
+ gap: spacing.sm
+ },
+ coverRail: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingBottom: spacing.sm
+ },
+ icon: {
+ width: 44,
+ height: 44,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: 22
+ },
+ pressed: {
+ backgroundColor: colors.bgRaised
+ },
+ disabled: {
+ opacity: 0.4
+ },
+ divider: {
+ width: 32,
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: colors.borderSubtle
+ },
+ create: {
+ marginTop: spacing.xs,
+ width: 52,
+ height: 52,
+ borderRadius: 26,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: colors.surfaceBright
+ },
+ createPressed: {
+ opacity: 0.85
+ }
+}))
diff --git a/mobile/src/host-screen/host-screen-header.tsx b/mobile/src/host-screen/host-screen-header.tsx
index 3eb67e3e838..b2a275e2ac7 100644
--- a/mobile/src/host-screen/host-screen-header.tsx
+++ b/mobile/src/host-screen/host-screen-header.tsx
@@ -8,7 +8,6 @@ import {
ChevronLeft,
List,
PanelLeftClose,
- Plus,
Search,
SquareTerminal,
UserCircle,
@@ -20,6 +19,8 @@ import { useCalmConnectionState } from '../transport/calm-connection-state'
import { colors } from '../theme/mobile-theme'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { hostScreenStyles as styles } from './host-screen-styles'
+import { HostRailActions, hostListActions } from './host-rail-actions'
+import { useSideRailClaim, useSideRailEdge } from '../layout/side-rail'
import type { HostScreenController } from './use-host-screen-controller'
function isErrorVerdict(v: ConnectionVerdict): boolean {
@@ -43,6 +44,14 @@ export function HostScreenHeader({ controller }: { controller: HostScreenControl
} = controller
// Why: a drop healed in a few seconds must not flash the host dot amber.
const presentedConnState = useCalmConnectionState(connState, lastConnectedAt)
+ // The Duo's side rail takes the list's icons: on the cover always, beside the sidebar only while the detail pane is empty.
+ const railEdge = useSideRailEdge()
+ const railPlacement =
+ railEdge === null ? null : !embedded ? 'cover' : controller.actionsInRail ? 'split' : null
+ const railActions = railPlacement !== null
+ useSideRailClaim(
+ railPlacement ? : null
+ )
return (
@@ -85,7 +94,7 @@ export function HostScreenHeader({ controller }: { controller: HostScreenControl
Reconnect
) : null}
- {!embedded && floatingWorkspaceEnabled ? (
+ {!embedded && floatingWorkspaceEnabled && !railActions ? (
-
-
- actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/accounts`)
- }
- disabled={connState !== 'connected'}
- accessibilityRole="button"
- accessibilityLabel="Accounts"
- >
-
-
-
- actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/tasks`)}
- disabled={connState !== 'connected'}
- accessibilityRole="button"
- accessibilityLabel="Tasks"
- >
-
-
-
- {floatingWorkspaceEnabled ? (
-
-
-
- ) : null}
-
-
-
-
-
- state.setShowSearch((s) => !s)}
- accessibilityRole="button"
- accessibilityLabel={state.showSearch ? 'Close search' : 'Search workspaces'}
- >
- {state.showSearch ? (
-
- ) : (
-
- )}
-
-
+ {railActions ? null : (
+
+ {hostListActions(controller).map((action) => (
+
+
+
+ ))}
+
+ )}
) : (
@@ -245,47 +200,52 @@ export function HostScreenHeader({ controller }: { controller: HostScreenControl
+ {railActions ? null : (
+ <>
+
+ actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/accounts`)
+ }
+ disabled={connState !== 'connected'}
+ accessibilityRole="button"
+ accessibilityLabel="Accounts"
+ >
+
+
-
- actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/accounts`)
- }
- disabled={connState !== 'connected'}
- accessibilityRole="button"
- accessibilityLabel="Accounts"
- >
-
-
+
+ actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/tasks`)
+ }
+ disabled={connState !== 'connected'}
+ accessibilityRole="button"
+ accessibilityLabel="Tasks"
+ >
+
+
- actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/tasks`)}
- disabled={connState !== 'connected'}
- accessibilityRole="button"
- accessibilityLabel="Tasks"
- >
-
-
-
- state.setShowSearch((s) => !s)}
- accessibilityRole="button"
- accessibilityLabel={state.showSearch ? 'Close search' : 'Search workspaces'}
- >
- {state.showSearch ? (
-
- ) : (
-
- )}
-
+ state.setShowSearch((s) => !s)}
+ accessibilityRole="button"
+ accessibilityLabel={state.showSearch ? 'Close search' : 'Search workspaces'}
+ >
+ {state.showSearch ? (
+
+ ) : (
+
+ )}
+
+ >
+ )}
)}
diff --git a/mobile/src/host-screen/use-host-screen-controller.ts b/mobile/src/host-screen/use-host-screen-controller.ts
index 4854a21a692..1fc27a1bde8 100644
--- a/mobile/src/host-screen/use-host-screen-controller.ts
+++ b/mobile/src/host-screen/use-host-screen-controller.ts
@@ -36,12 +36,15 @@ export type HostScreenProps = {
// Layout-mounted sidebars lack route params; receive them explicitly.
hostId?: string
onHideSidebar?: () => void
+ // The detail pane is empty, so a side rail beside it may carry the sidebar's actions.
+ actionsInRail?: boolean
}
export function useHostScreenController({
embedded = false,
hostId: hostIdProp,
- onHideSidebar
+ onHideSidebar,
+ actionsInRail = false
}: HostScreenProps = {}) {
const params = useLocalSearchParams<{ hostId: string; notice?: string }>()
const hostId = hostIdProp ?? params.hostId
@@ -177,6 +180,7 @@ export function useHostScreenController({
noticeParam,
now,
onHideSidebar,
+ actionsInRail,
reconnectAttempts,
relayRecovery,
routeNotice,
diff --git a/mobile/src/layout/host-sidebar-reveal.test.tsx b/mobile/src/layout/host-sidebar-reveal.test.tsx
index a1398595cb4..2e52daaaadf 100644
--- a/mobile/src/layout/host-sidebar-reveal.test.tsx
+++ b/mobile/src/layout/host-sidebar-reveal.test.tsx
@@ -20,7 +20,10 @@ vi.mock('react-native', () => ({
PanResponder: { create: () => ({ panHandlers: {} }) },
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
}))
-vi.mock('react-native-safe-area-context', () => ({ SafeAreaView: 'SafeAreaView' }))
+vi.mock('react-native-safe-area-context', () => ({
+ SafeAreaView: 'SafeAreaView',
+ useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 })
+}))
vi.mock('lucide-react-native', () => ({
ChevronLeft: 'ChevronLeft',
Folder: 'Folder',
@@ -41,7 +44,6 @@ vi.mock('./responsive-layout', () => ({
useResponsiveLayout: () => ({ isWideLayout: true, width: 1024 })
}))
vi.mock('../storage/preferences', () => ({
- HOST_SIDEBAR_DEFAULT_WIDTH: 320,
HOST_SIDEBAR_MIN_WIDTH: 240,
HOST_SIDEBAR_MAX_WIDTH: 480,
loadHostSidebarWidth: async () => 320,
@@ -58,7 +60,8 @@ vi.mock('../host-screen/HostScreen', () => ({
})
}))
vi.mock('../navigation/host-stack', () => ({
- HostStack: () => createElement(MobileSessionHeader, { controller: headerController() })
+ HostStack: () =>
+ createElement(MobileSessionHeader, { controller: headerController(), backInRail: false })
}))
vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: () => null }))
vi.mock('../platform/haptics', () => ({ triggerMediumImpact: () => {} }))
diff --git a/mobile/src/layout/responsive-layout-metrics.test.ts b/mobile/src/layout/responsive-layout-metrics.test.ts
index 235b5339ed8..81b64c255d8 100644
--- a/mobile/src/layout/responsive-layout-metrics.test.ts
+++ b/mobile/src/layout/responsive-layout-metrics.test.ts
@@ -21,6 +21,20 @@ describe('responsive layout metrics', () => {
})
})
+ it('splits an unfolded iPhone Duo in both holds and keeps its cover phone-like', () => {
+ expect(getResponsiveLayoutMetrics(951, 669)).toMatchObject({ isWideLayout: true })
+ expect(getResponsiveLayoutMetrics(669, 951)).toMatchObject({ isWideLayout: true })
+ expect(getResponsiveLayoutMetrics(466, 678)).toMatchObject({
+ isTabletLayout: false,
+ isWideLayout: false,
+ isLandscapePhone: false
+ })
+ expect(getResponsiveLayoutMetrics(678, 466)).toMatchObject({
+ isWideLayout: false,
+ isLandscapePhone: true
+ })
+ })
+
it('keeps narrow iPad split windows phone-like', () => {
expect(getResponsiveLayoutMetrics(560, 1024)).toMatchObject({
isTabletLayout: false,
diff --git a/mobile/src/layout/responsive-layout-metrics.ts b/mobile/src/layout/responsive-layout-metrics.ts
index c6520c47a37..6a7cb0b74c7 100644
--- a/mobile/src/layout/responsive-layout-metrics.ts
+++ b/mobile/src/layout/responsive-layout-metrics.ts
@@ -1,7 +1,20 @@
import { spacing } from '../theme/mobile-theme'
-// Use actual window size so narrow iPad splits keep phone-like layouts.
-const WIDE_LAYOUT_MIN_WIDTH = 700
+// Host split geometry; a detail pane narrower than the narrowest iPhone cramps chat and terminals.
+export const HOST_SIDEBAR_MIN_WIDTH = 280
+export const SPLIT_DETAIL_MIN_WIDTH = 375
+const HOST_SIDEBAR_DEFAULT_MAX_WIDTH = 340
+
+// 30% of the window until the user drags it: room for workspace names, the rest goes to the session.
+export function defaultHostSidebarWidth(windowWidth: number): number {
+ return Math.min(
+ HOST_SIDEBAR_DEFAULT_MAX_WIDTH,
+ Math.max(HOST_SIDEBAR_MIN_WIDTH, Math.round(windowWidth * 0.3))
+ )
+}
+
+// Use actual window size so narrow iPad splits keep phone-like layouts; unfolded foldables split in both orientations.
+const WIDE_LAYOUT_MIN_WIDTH = HOST_SIDEBAR_MIN_WIDTH + SPLIT_DETAIL_MIN_WIDTH
// Require room in both dimensions so rotating a landscape phone cannot switch it to tablet UI.
const TABLET_LAYOUT_MIN_SHORT_SIDE = 600
diff --git a/mobile/src/layout/side-rail.test.tsx b/mobile/src/layout/side-rail.test.tsx
new file mode 100644
index 00000000000..2e5bd231a2b
--- /dev/null
+++ b/mobile/src/layout/side-rail.test.tsx
@@ -0,0 +1,67 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { SideRailOnTopContext, SideRailProvider, useSideRailClaim } from './side-rail'
+
+vi.mock('react-native', () => ({
+ View: 'View',
+ StyleSheet: { create: (styles: unknown) => styles }
+}))
+
+function RailLabel(_props: { text: string }) {
+ return null
+}
+
+function Claimant({ label }: { label: string | null }) {
+ useSideRailClaim(label === null ? null : createElement(RailLabel, { text: label }))
+ return null
+}
+
+function screen(label: string | null, onTop: boolean) {
+ return createElement(
+ SideRailOnTopContext.Provider,
+ { value: onTop },
+ createElement(Claimant, { label })
+ )
+}
+
+describe('side rail', () => {
+ let renderer: ReactTestRenderer | null = null
+ afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ })
+
+ function render(edge: 'right' | null, ...screens: ReturnType[]) {
+ const tree = (
+
+ {screens}
+
+ )
+ act(() => {
+ if (renderer) {
+ renderer.update(tree)
+ } else {
+ renderer = create(tree)
+ }
+ })
+ }
+
+ function railText(): string[] {
+ return (renderer?.root.findAllByType(RailLabel) ?? []).map((node) => node.props.text)
+ }
+
+ it('shows the top screen, never one a stack keeps beneath it', () => {
+ render('right', screen('host actions', true), screen('keys', false))
+ expect(railText()).toEqual(['host actions'])
+ })
+
+ it('gives the rail back when a claim ends, and draws nothing without a rail', () => {
+ render('right', screen('keys', true))
+ expect(railText()).toEqual(['keys'])
+ render('right', screen(null, true))
+ expect(railText()).toEqual([])
+ render(null, screen('keys', true))
+ expect(railText()).toEqual([])
+ })
+})
diff --git a/mobile/src/layout/side-rail.tsx b/mobile/src/layout/side-rail.tsx
new file mode 100644
index 00000000000..2cee230c8af
--- /dev/null
+++ b/mobile/src/layout/side-rail.tsx
@@ -0,0 +1,130 @@
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useId,
+ useMemo,
+ useState,
+ useSyncExternalStore,
+ type ReactNode
+} from 'react'
+import { StyleSheet, View } from 'react-native'
+
+export type SideRailEdge = 'left' | 'right'
+
+// Only a vertical status bar (iPhone Duo: 84pt) is this wide; landscape Dynamic Island insets stop at 62pt.
+const SIDE_RAIL_MIN_INSET = 72
+// Clears the clock and signal cluster at the top of the bar (it ends near 97pt unfolded and 147pt on the cover).
+const SIDE_RAIL_TOP_CLEARANCE = 156
+
+export function sideRailEdge(insets: { left: number; right: number }): SideRailEdge | null {
+ if (insets.right >= SIDE_RAIL_MIN_INSET) {
+ return 'right'
+ }
+ return insets.left >= SIDE_RAIL_MIN_INSET ? 'left' : null
+}
+
+type Claim = { id: string; node: ReactNode }
+
+// Latest claim wins; screens claim only while focused, so a covered screen never shows through.
+function createSideRailStore() {
+ let claims: Claim[] = []
+ const listeners = new Set<() => void>()
+ const emit = () => listeners.forEach((listener) => listener())
+ return {
+ subscribe: (listener: () => void) => {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+ },
+ top: () => claims.at(-1)?.node ?? null,
+ set: (id: string, node: ReactNode) => {
+ const index = claims.findIndex((claim) => claim.id === id)
+ claims =
+ index === -1
+ ? [...claims, { id, node }]
+ : claims.map((claim, i) => (i === index ? { id, node } : claim))
+ emit()
+ },
+ release: (id: string) => {
+ claims = claims.filter((claim) => claim.id !== id)
+ emit()
+ }
+ }
+}
+
+type SideRailStore = ReturnType
+
+const SideRailContext = createContext<{ edge: SideRailEdge | null; store: SideRailStore } | null>(
+ null
+)
+
+// False for a screen a stack keeps mounted beneath the top one; its claim must not show over the screen on top.
+export const SideRailOnTopContext = createContext(true)
+
+// Which edge has a rail this screen may fill, or null when the window has none.
+export function useSideRailEdge(): SideRailEdge | null {
+ return useContext(SideRailContext)?.edge ?? null
+}
+
+// Show node in the rail while it is non-null and its screen is on top; pass null when there is nothing for it.
+export function useSideRailClaim(node: ReactNode | null): void {
+ const rail = useContext(SideRailContext)
+ const onTop = useContext(SideRailOnTopContext)
+ const id = useId()
+ const store = rail?.edge && onTop ? rail.store : null
+ const claiming = store !== null && node !== null
+ // Update in place so a re-render keeps the claim's position; release only when the claim ends.
+ useEffect(() => {
+ if (store !== null && node !== null) {
+ store.set(id, node)
+ }
+ }, [store, id, node])
+ useEffect(() => {
+ if (store === null || !claiming) {
+ return
+ }
+ return () => store.release(id)
+ }, [store, id, claiming])
+}
+
+// Owned by SideSafeArea: it already pads the rail edge, so the rail is that padding.
+export function SideRailProvider({
+ edge,
+ width,
+ bottom,
+ children
+}: {
+ edge: SideRailEdge | null
+ width: number
+ bottom: number
+ children: ReactNode
+}) {
+ const [store] = useState(createSideRailStore)
+ const value = useMemo(() => ({ edge, store }), [edge, store])
+ return (
+
+ {children}
+ {edge ? (
+
+
+
+ ) : null}
+
+ )
+}
+
+function SideRailSlot({ store }: { store: SideRailStore }) {
+ const node = useSyncExternalStore(store.subscribe, store.top, store.top)
+ return node
+}
+
+const styles = StyleSheet.create({
+ rail: {
+ position: 'absolute',
+ top: SIDE_RAIL_TOP_CLEARANCE,
+ bottom: 0
+ }
+})
diff --git a/mobile/src/layout/side-safe-area.tsx b/mobile/src/layout/side-safe-area.tsx
index fa7e6c4f36d..c7c1c48f861 100644
--- a/mobile/src/layout/side-safe-area.tsx
+++ b/mobile/src/layout/side-safe-area.tsx
@@ -1,4 +1,13 @@
-import { createContext, useContext, useMemo, type ReactNode } from 'react'
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type Dispatch,
+ type ReactNode,
+ type SetStateAction
+} from 'react'
import { Platform, StyleSheet, View } from 'react-native'
import {
SafeAreaFrameContext,
@@ -8,39 +17,67 @@ import {
useSafeAreaInsets,
type EdgeInsets
} from 'react-native-safe-area-context'
+import { SideRailProvider, sideRailEdge } from './side-rail'
const WindowSafeAreaInsetsContext = createContext(null)
+const SideRailReleaseContext = createContext> | null>(null)
// Pad side cutouts/navigation bars once, then expose the padded frame and insets so descendants neither pad twice nor size past it.
+// Nesting one is safe: it pads only what an outer one left unpadded.
export function SideSafeArea({ children }: { children: ReactNode }) {
const insets = useSafeAreaInsets()
const frame = useSafeAreaFrame()
- const areaInsets = useMemo(() => ({ ...insets, left: 0, right: 0 }), [insets])
+ const [releases, setReleases] = useState(0)
+ const railEdge = sideRailEdge(insets)
+ const released = releases > 0 ? railEdge : null
+ const left = released === 'left' ? 0 : insets.left
+ const right = released === 'right' ? 0 : insets.right
+ const areaInsets = useMemo(
+ () => ({ ...insets, left: insets.left - left, right: insets.right - right }),
+ [insets, left, right]
+ )
const areaFrame = useMemo(
- () => ({
- ...frame,
- x: frame.x + insets.left,
- width: Math.max(0, frame.width - insets.left - insets.right)
- }),
- [frame, insets.left, insets.right]
+ () => ({ ...frame, x: frame.x + left, width: Math.max(0, frame.width - left - right) }),
+ [frame, left, right]
)
const area = (
- {children}
+
+ {children}
+
)
// Native SafeAreaView measures its nearest native provider, so nest one inside the padding; web reads the adjusted context.
return (
-
- {Platform.OS === 'web' ? area : {area}}
+
+
+ {Platform.OS === 'web' ? area : {area}}
+
)
}
+// A layout whose screens reach the Duo's status-bar rail themselves keeps it unpadded while mounted.
+// Pages then get the rail's width as an inset and draw in it; native views beneath wrap themselves in SideSafeArea.
+export function useReleasedSideRail(release: boolean): void {
+ const setReleases = useContext(SideRailReleaseContext)
+ useEffect(() => {
+ if (!release || setReleases === null) {
+ return
+ }
+ setReleases((count) => count + 1)
+ return () => setReleases((count) => count - 1)
+ }, [release, setReleases])
+}
+
// Modals own a separate window outside SideSafeArea and need the original window insets.
export function useWindowSafeAreaInsets(): EdgeInsets {
const insets = useSafeAreaInsets()
diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx
index 386eb9ff504..28f59029af7 100644
--- a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx
+++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx
@@ -1,5 +1,5 @@
-import { useEffect, useState, type ReactNode } from 'react'
-import { Linking, Pressable, Text, View } from 'react-native'
+import { useEffect, useState } from 'react'
+import { Linking, View } from 'react-native'
import { useNavigation, useNavigationContainerRef, useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import {
@@ -7,17 +7,12 @@ import {
parseMobileWebShellLoadState
} from '../../modules/orca-mobile-web-shell/src'
import { HostRouteNoticeBanner } from '../components/HostRouteNoticeBanner'
-import { ProtocolBlockScreen } from '../components/ProtocolBlockScreen'
-import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+import { colors } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import type { BridgeInitRoute } from './bridge/bridge-envelope'
import type { BridgeClearableRouteParam } from './bridge/bridge-route-update'
-import type {
- MobileWebShellFailureCause,
- MobileWebShellSessionState,
- MobileWebShellUpdateNotice
-} from './mobile-web-shell-session-contract'
+import type { MobileWebShellUpdateNotice } from './mobile-web-shell-session-contract'
import { useMobileWebShellDroppedFrames } from './mobile-web-shell-dev-facts'
import { ShellDevFacts } from './ShellDevFacts'
import { cancelledShellNavigationTarget } from './cancelled-navigation-target'
@@ -34,32 +29,16 @@ import { usePageHostSnapshot } from './use-page-host-snapshot'
import { useReportedHostAreaServing } from './host-area-serving'
import { isHostRouteOf } from './page-route-policy'
import { nativeStackReturn } from './native-stack-return'
-import { PageRouteUnavailableScreen } from './PageRouteUnavailableScreen'
+import { usePageLocationOnFold } from './page-location-on-fold'
import { useResponsiveLayout } from '../layout/responsive-layout'
import { storageReadParamsSchema, type BridgeNativeVerb } from './bridge/bridge-native-verbs'
-import { SHELL_OPENING_LABEL, ShellPageCover, ShellWaitingFrame } from './ShellWaitingFrame'
-import { ShellDownloadProgress } from './ShellDownloadProgress'
+import { SHELL_OPENING_LABEL, ShellPageCover } from './ShellWaitingFrame'
import { useReportedShellOverlayLoad, type ShellOverlayLoad } from './shell-overlay-load'
import { pageSafeAreaInsets, usePublishedSafeAreaInsets } from './page-safe-area-insets'
+import { ShellStateScreen } from './ShellStateScreen'
import { hostProfileUnreadableLine, pageFaultLine } from './shell-lifecycle-log'
import { recordShellLifecycleLine } from './shell-lifecycle-line-recorder'
-function failureMessage(reason: MobileWebShellFailureCause): string {
- switch (reason) {
- case 'isolation-unavailable':
- return "This device's WebView is too old to open the workspace safely."
- case 'download-failed':
- return 'The latest app could not be downloaded from this host.'
- case 'status-unreadable':
- return "Could not read this host's status. Go back and reopen it."
- case 'render-process-gone':
- return 'The workspace stopped responding.'
- case 'generation-unreadable':
- case 'document-load-failed':
- return 'The downloaded app could not be opened.'
- }
-}
-
// Do not promise a retry: the shell schedules no second update attempt.
function updateNoticeMessage(notice: MobileWebShellUpdateNotice): string {
switch (notice) {
@@ -68,47 +47,6 @@ function updateNoticeMessage(notice: MobileWebShellUpdateNotice): string {
}
}
-function Centered({ children }: { children: ReactNode }) {
- useMobileColorScheme()
- return {children}
-}
-
-function Waiting({ label }: { label: string }) {
- return (
-
-
-
- )
-}
-
-function Failed({
- state,
- onRetry
-}: {
- state: Extract
- onRetry: () => void
-}) {
- useMobileColorScheme()
- // Retry cannot change a settled isolation gate or give an unsupported WebView the missing fence.
- const retryable = state.reason !== 'isolation-unavailable' && state.reason !== 'status-unreadable'
- return (
-
-
- {failureMessage(state.reason)}
-
- {retryable ? (
- [styles.retryButton, pressed && styles.pressed]}
- testID="mobile-web-shell-retry"
- onPress={onRetry}
- >
- Try again
-
- ) : null}
-
- )
-}
-
export type MobileWebShellScreenProps = {
hostId: string
// Overlay callers draw waiting/exits; stay transparent until the page paints its drawer.
@@ -190,6 +128,38 @@ export function MobileWebShellScreen({
console.warn('[web-shell] could not open a URL for the page', { url, error })
})
}
+ // Push so Back reveals the mounted page without downloading or handshaking again, unless the page replaced itself.
+ const openPageRoute = (href: string, replace = false) => {
+ if (overlay) {
+ return void router.replace(href)
+ }
+ // Return to an existing screen rather than pushing a duplicate over the live one.
+ const rootState = navigationContainer.getRootState()
+ const onStack = rootState ? nativeStackReturn(rootState, href) : null
+ if (onStack === 'current') {
+ return
+ }
+ if (onStack) {
+ navigation.dispatch({
+ type: 'POP',
+ payload: { count: onStack.count },
+ target: onStack.target,
+ source: onStack.source
+ })
+ return
+ }
+ // The wide host route owns the session area; dismissTo pops to it or replaces after a cold deep link, avoiding a second copy.
+ if (isWideLayout && isHostRouteOf(href, hostId)) {
+ router.dismissTo(href)
+ return
+ }
+ return void (replace ? router.replace(href) : router.push(href))
+ }
+ const holdPageLocation = usePageLocationOnFold(
+ isWideLayout,
+ () => navigation.isFocused(),
+ openPageRoute
+ )
const bridge = useMobileWebShellBridge({
hostId,
@@ -221,6 +191,7 @@ export function MobileWebShellScreen({
// Keep the cover until the page reports its own first frame.
onPagePainted: reportPagePainted,
onPageBackClaim: reportPageBackClaim,
+ onPageLocation: (href) => holdPageLocation(href, ownsHostArea),
onRouteParamClear: (param, value) => {
onRouteParamClear?.(param, value)
},
@@ -229,33 +200,7 @@ export function MobileWebShellScreen({
console.warn('[web-shell] refused to open this screen', issue)
reportShellFailure('document-load-failed')
},
- // Push so Back reveals the mounted page without downloading or handshaking again, unless the page replaced itself.
- onNavigate: (href: string, replace: boolean) => {
- if (overlay) {
- return void router.replace(href)
- }
- // Return to an existing screen rather than pushing a duplicate over the live one.
- const rootState = navigationContainer.getRootState()
- const onStack = rootState ? nativeStackReturn(rootState, href) : null
- if (onStack === 'current') {
- return
- }
- if (onStack) {
- navigation.dispatch({
- type: 'POP',
- payload: { count: onStack.count },
- target: onStack.target,
- source: onStack.source
- })
- return
- }
- // The wide host route owns the session area; dismissTo pops to it or replaces after a cold deep link, avoiding a second copy.
- if (isWideLayout && isHostRouteOf(href, hostId)) {
- router.dismissTo(href)
- return
- }
- return void (replace ? router.replace(href) : router.push(href))
- },
+ onNavigate: openPageRoute,
serveNativeVerb,
onExternalLink: openUrlForPage,
// Reuse the app’s platform haptics mapping; device failures are already swallowed there.
@@ -302,37 +247,16 @@ export function MobileWebShellScreen({
if (overlay && state.kind !== 'ready') {
return null
}
- if (state.kind === 'route-unavailable') {
- return
- }
- if (state.kind === 'wall') {
- return
- }
- if (state.kind === 'failed') {
- return
- }
- if (state.kind === 'offline') {
- return (
-
-
- Connect to this host to download the latest app
-
-
- )
- }
- if (state.kind === 'fetching') {
- return (
-
-
-
- )
- }
if (state.kind !== 'ready') {
- return
+ return (
+
+ )
}
return (
({
},
unpaintedOverlay: {
opacity: 0
- },
- centered: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: colors.bgBase,
- paddingHorizontal: spacing.lg
- },
- waitingLabel: {
- fontSize: typography.bodySize,
- color: colors.textSecondary,
- marginTop: spacing.md,
- textAlign: 'center'
- },
- failedMessage: {
- fontSize: typography.bodySize,
- color: colors.textPrimary,
- textAlign: 'center',
- marginBottom: spacing.lg
- },
- retryButton: {
- backgroundColor: colors.bgRaised,
- paddingVertical: spacing.sm + 2,
- paddingHorizontal: spacing.lg,
- borderRadius: radii.button
- },
- retryLabel: {
- fontSize: typography.bodySize,
- fontWeight: '600',
- color: colors.textPrimary
- },
- pressed: {
- opacity: 0.7
}
}))
diff --git a/mobile/src/mobile-web-shell/ShellStateScreen.tsx b/mobile/src/mobile-web-shell/ShellStateScreen.tsx
new file mode 100644
index 00000000000..d6d39a69553
--- /dev/null
+++ b/mobile/src/mobile-web-shell/ShellStateScreen.tsx
@@ -0,0 +1,156 @@
+import type { ReactNode } from 'react'
+import { Pressable, Text, View } from 'react-native'
+import { ProtocolBlockScreen } from '../components/ProtocolBlockScreen'
+import { SideSafeArea } from '../layout/side-safe-area'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+import { themedStyleSheet } from '../theme/themed-styles'
+import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
+import type { HostDisplaySource } from '../transport/use-host-display'
+import type { DownloadProgressFeed } from './download-progress-feed'
+import type {
+ MobileWebShellFailureCause,
+ MobileWebShellSessionState
+} from './mobile-web-shell-session-contract'
+import { PageRouteUnavailableScreen } from './PageRouteUnavailableScreen'
+import { ShellDownloadProgress } from './ShellDownloadProgress'
+import { SHELL_OPENING_LABEL, ShellWaitingFrame } from './ShellWaitingFrame'
+
+function failureMessage(reason: MobileWebShellFailureCause): string {
+ switch (reason) {
+ case 'isolation-unavailable':
+ return "This device's WebView is too old to open the workspace safely."
+ case 'download-failed':
+ return 'The latest app could not be downloaded from this host.'
+ case 'status-unreadable':
+ return "Could not read this host's status. Go back and reopen it."
+ case 'render-process-gone':
+ return 'The workspace stopped responding.'
+ case 'generation-unreadable':
+ case 'document-load-failed':
+ return 'The downloaded app could not be opened.'
+ }
+}
+
+function Centered({ children }: { children: ReactNode }) {
+ useMobileColorScheme()
+ return {children}
+}
+
+function Failed({
+ state,
+ onRetry
+}: {
+ state: Extract
+ onRetry: () => void
+}) {
+ useMobileColorScheme()
+ // Retry cannot change a settled isolation gate or give an unsupported WebView the missing fence.
+ const retryable = state.reason !== 'isolation-unavailable' && state.reason !== 'status-unreadable'
+ return (
+
+
+ {failureMessage(state.reason)}
+
+ {retryable ? (
+ [styles.retryButton, pressed && styles.pressed]}
+ testID="mobile-web-shell-retry"
+ onPress={onRetry}
+ >
+ Try again
+
+ ) : null}
+
+ )
+}
+
+function StateView({
+ hostId,
+ state,
+ onRetry,
+ downloadProgress,
+ host
+}: {
+ hostId: string
+ state: Exclude
+ onRetry: () => void
+ downloadProgress: DownloadProgressFeed
+ host: HostDisplaySource | null
+}) {
+ switch (state.kind) {
+ case 'route-unavailable':
+ return
+ case 'wall':
+ return
+ case 'failed':
+ return
+ case 'offline':
+ return (
+
+
+ Connect to this host to download the latest app
+
+
+ )
+ case 'fetching':
+ return (
+
+
+
+ )
+ case 'checking':
+ case 'activating':
+ return (
+
+
+
+ )
+ }
+}
+
+// What the shell shows natively until the page is ready; only the page draws in a released Duo rail, so these pad it.
+export function ShellStateScreen(props: Parameters[0]) {
+ return (
+
+
+
+ )
+}
+
+const styles = themedStyleSheet(() => ({
+ centered: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: colors.bgBase,
+ paddingHorizontal: spacing.lg
+ },
+ waitingLabel: {
+ fontSize: typography.bodySize,
+ color: colors.textSecondary,
+ marginTop: spacing.md,
+ textAlign: 'center'
+ },
+ failedMessage: {
+ fontSize: typography.bodySize,
+ color: colors.textPrimary,
+ textAlign: 'center',
+ marginBottom: spacing.lg
+ },
+ retryButton: {
+ backgroundColor: colors.bgRaised,
+ paddingVertical: spacing.sm + 2,
+ paddingHorizontal: spacing.lg,
+ borderRadius: radii.button
+ },
+ retryLabel: {
+ fontSize: typography.bodySize,
+ fontWeight: '600',
+ color: colors.textPrimary
+ },
+ pressed: {
+ opacity: 0.7
+ }
+}))
diff --git a/mobile/src/mobile-web-shell/bridge-host-contract.ts b/mobile/src/mobile-web-shell/bridge-host-contract.ts
index fc607310408..2b7649f07a1 100644
--- a/mobile/src/mobile-web-shell/bridge-host-contract.ts
+++ b/mobile/src/mobile-web-shell/bridge-host-contract.ts
@@ -93,6 +93,8 @@ export type BridgeHostOptions = {
onPagePainted: () => void
// Required for native interception: only a live claim owns Back. Document-ending paths publish false so callers need not infer staleness.
onPageBackClaim: (claimed: boolean) => void
+ // Where a host-area page navigated inside its own document; the native route never moves for those hops.
+ onPageLocation: (href: string) => void
// Clear a consumed one-shot param only if its value still matches; a newer tap may already have replaced it (ruling 34).
onRouteParamClear: (param: BridgeClearableRouteParam, value: string) => void
// Escalate invalid shell routes visibly; a page-only console warning would leave a blank document retrying forever.
diff --git a/mobile/src/mobile-web-shell/bridge-host-notify.ts b/mobile/src/mobile-web-shell/bridge-host-notify.ts
index 182cb8101a9..1c636e1d7fa 100644
--- a/mobile/src/mobile-web-shell/bridge-host-notify.ts
+++ b/mobile/src/mobile-web-shell/bridge-host-notify.ts
@@ -9,6 +9,7 @@ import {
import { BRIDGE_HAPTICS_NOTIFY } from './bridge/bridge-haptics-notify'
import { bridgeNotifyRefusal } from './bridge/bridge-notify-grants'
import { BRIDGE_BACK_CLAIM_NOTIFY } from './bridge/bridge-page-back'
+import { BRIDGE_PAGE_LOCATION } from './bridge/bridge-page-location'
import { BRIDGE_PAGE_PAINTED } from './bridge/bridge-page-painted'
import { BRIDGE_ROUTE_PARAM_CLEAR } from './bridge/bridge-route-update'
import type { BridgeHostOptions } from './bridge-host-contract'
@@ -37,6 +38,10 @@ export function createBridgeNotifyForwarder(args: {
options.onPagePainted()
return
}
+ if (message.name === BRIDGE_PAGE_LOCATION) {
+ options.onPageLocation(message.href)
+ return
+ }
if (message.name === BRIDGE_BACK_CLAIM_NOTIFY) {
// Forward Back claims to the screen that owns the native key; this host serves only its document.
args.onBackClaim(message.claimed)
diff --git a/mobile/src/mobile-web-shell/bridge-host-test-harness.ts b/mobile/src/mobile-web-shell/bridge-host-test-harness.ts
index 8d30f55d0e7..9a8a6d6b00c 100644
--- a/mobile/src/mobile-web-shell/bridge-host-test-harness.ts
+++ b/mobile/src/mobile-web-shell/bridge-host-test-harness.ts
@@ -132,6 +132,7 @@ export function harness(
pageReadies += 1
},
onPageBackClaim: (claimed) => backClaims.push(claimed),
+ onPageLocation: () => {},
onPagePainted: () => {
pagePaints += 1
},
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts
index 534b31ab551..8922f11f925 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts
@@ -14,6 +14,7 @@ import {
} from './bridge-haptics-notify'
import { captureBridgeError } from './bridge-error-capture'
import { BRIDGE_BACK_CLAIM_NOTIFY } from './bridge-page-back'
+import { BRIDGE_PAGE_LOCATION } from './bridge-page-location'
import { BRIDGE_PAGE_PAINTED } from './bridge-page-painted'
// Ungated calls before init expose mount-order bugs; calls after close stay inert for teardown compatibility. Gated calls return false when no session/grant exists.
@@ -39,6 +40,7 @@ export type BridgeClientNotifications = {
notifyPageFault: (error: unknown) => boolean
notifyPagePainted: () => void
notifyBackClaim: (claimed: boolean) => void
+ notifyPageLocation: (href: string) => void
}
export function createBridgeClientNotifications(
@@ -123,6 +125,9 @@ export function createBridgeClientNotifications(
},
notifyBackClaim: (claimed) => {
post({ v: BRIDGE_PROTOCOL_VERSION, type: 'notify', name: BRIDGE_BACK_CLAIM_NOTIFY, claimed })
+ },
+ notifyPageLocation: (href) => {
+ post({ v: BRIDGE_PROTOCOL_VERSION, type: 'notify', name: BRIDGE_PAGE_LOCATION, href })
}
}
}
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-native-verb-call.ts b/mobile/src/mobile-web-shell/bridge/bridge-native-verb-call.ts
new file mode 100644
index 00000000000..2a8f2aa7df4
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge/bridge-native-verb-call.ts
@@ -0,0 +1,21 @@
+import type { RpcResponse, RpcSuccess } from '../../transport/types'
+import { BridgeClientNotNativeVerbError } from './bridge-client-errors'
+import { isBridgeNativeMethod, type BridgeNativeVerb } from './bridge-native-verbs'
+
+export function callBridgeNativeVerb(
+ sendRequest: (method: string, params: unknown) => Promise,
+ verb: BridgeNativeVerb,
+ params: unknown
+): Promise {
+ // Validate the typed verb at runtime too; widened callers must not turn this into an untracked raw desktop RPC port.
+ if (!isBridgeNativeMethod(verb)) {
+ return Promise.reject(new BridgeClientNotNativeVerbError(verb))
+ }
+ return sendRequest(verb, params).then((reply) => {
+ // Native refusals reject as error frames and never forward to desktop, so the return has no RpcFailure arm.
+ if (!reply.ok) {
+ throw new Error(reply.error.message)
+ }
+ return reply
+ })
+}
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-notify-envelope.ts b/mobile/src/mobile-web-shell/bridge/bridge-notify-envelope.ts
index f688b85361e..70a6b454e8c 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-notify-envelope.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-notify-envelope.ts
@@ -2,6 +2,7 @@ import { z } from 'zod'
import { BridgeErrorCaptureSchema } from './bridge-error-capture'
import { BRIDGE_HAPTICS_NOTIFY_FIELDS } from './bridge-haptics-notify'
import { BRIDGE_BACK_CLAIM_NOTIFY } from './bridge-page-back'
+import { BRIDGE_PAGE_LOCATION } from './bridge-page-location'
import { BRIDGE_PAGE_PAINTED } from './bridge-page-painted'
import { BRIDGE_CLEARABLE_ROUTE_PARAMS, BRIDGE_ROUTE_PARAM_CLEAR } from './bridge-route-update'
import {
@@ -98,6 +99,13 @@ export const BridgeNotifySchema = z.discriminatedUnion('name', [
type: z.literal('notify'),
name: z.literal(BRIDGE_BACK_CLAIM_NOTIFY),
claimed: z.boolean()
+ }),
+ // A host-area page navigates in its own document; report where so a fold can reopen it natively.
+ z.object({
+ v: versionSchema,
+ type: z.literal('notify'),
+ name: z.literal(BRIDGE_PAGE_LOCATION),
+ href: z.string().min(1).max(BRIDGE_MAX_ROUTE_HREF_CHARS).regex(BRIDGE_ROUTE_HREF_PATTERN)
})
])
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts b/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts
index 9244bb908ee..ca8eebc0baf 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts
@@ -6,6 +6,7 @@ import {
} from './bridge-envelope'
import { BRIDGE_HAPTICS_GRANT, BRIDGE_HAPTICS_NOTIFY } from './bridge-haptics-notify'
import { BRIDGE_BACK_CLAIM_NOTIFY } from './bridge-page-back'
+import { BRIDGE_PAGE_LOCATION } from './bridge-page-location'
import { BRIDGE_PAGE_PAINTED } from './bridge-page-painted'
import { BRIDGE_ROUTE_PARAM_CLEAR } from './bridge-route-update'
@@ -19,6 +20,7 @@ const BRIDGE_NOTIFY_GRANTS: Readonly> =
[BRIDGE_ROUTE_PARAM_CLEAR]: null,
[BRIDGE_PAGE_PAINTED]: null,
[BRIDGE_BACK_CLAIM_NOTIFY]: null,
+ [BRIDGE_PAGE_LOCATION]: null,
navigate: 'navigate',
[BRIDGE_NAVIGATE_BACK_NOTIFY]: 'navigate',
storage: 'storage',
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-page-location.ts b/mobile/src/mobile-web-shell/bridge/bridge-page-location.ts
new file mode 100644
index 00000000000..cfd4a525826
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge/bridge-page-location.ts
@@ -0,0 +1 @@
+export const BRIDGE_PAGE_LOCATION = 'location'
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts b/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts
index afbb7926979..188080bb861 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts
@@ -222,6 +222,7 @@ export function createBridgePortPair(
pagePaints += 1
},
onPageBackClaim: (claimed) => backClaims.push(claimed),
+ onPageLocation: () => {},
onRouteParamClear: (param, value) => routeParamClears.push({ param, value }),
onRouteRefused: (issue) => routeRefusals.push(issue),
onDiagnostic: (diagnostic) => hostDiagnostics.push(diagnostic)
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts
index e1392225ce6..400e4cbbfd1 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts
@@ -14,7 +14,6 @@ import { createBridgeInitHandshake, createPageReadyFrame } from './bridge-client
import {
BridgeClientCapExceededError,
BridgeClientClosedError,
- BridgeClientNotNativeVerbError,
BridgeClientNotReadyError,
BridgeRequestOversizedError,
BridgeSendFailedError,
@@ -28,7 +27,8 @@ import { createPageBackConsumers, type PageBackConsumer } from './page-back-cons
import { BridgeClientRequests } from './bridge-client-requests'
import { readBridgeRequestFrameOptions, readBridgeRpcEnvelope } from './bridge-client-rpc-envelope'
import { BridgeClientSubscriptions } from './bridge-client-subscriptions'
-import { isBridgeNativeMethod, type BridgeNativeVerb } from './bridge-native-verbs'
+import type { BridgeNativeVerb } from './bridge-native-verbs'
+import { callBridgeNativeVerb } from './bridge-native-verb-call'
import { BRIDGE_ROUTE_PARAM_CLEAR, type BridgeClearableRouteParam } from './bridge-route-update'
import {
BRIDGE_PROTOCOL_VERSION,
@@ -80,6 +80,8 @@ export type BridgeRpcClient = RpcClient & {
callNativeVerb: (verb: BridgeNativeVerb, params: unknown) => Promise
// Only the page can report a painted tree; WebView commit and ready happen before that proof exists.
notifyPagePainted: () => void
+ // Older shells refuse the frame with a diagnostic only; they reopen the host route after a fold.
+ notifyPageLocation: (href: string) => void
// Claim Back until disposal; invoke consumers newest-first with false fallthrough. Return unhandled presses to the shell so a race with sheet closure cannot swallow navigation.
claimBack: (consumer: PageBackConsumer) => () => void
notifyStorageWrite: (key: string, value: string | null) => boolean
@@ -319,23 +321,12 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp
notifyNavigate: notifications.notifyNavigate,
notifyNavigateBack: notifications.notifyNavigateBack,
notifyExternalLink: notifications.notifyExternalLink,
- callNativeVerb: (verb, params) => {
- // Validate the typed verb at runtime too; widened callers must not turn this into an untracked raw desktop RPC port.
- if (!isBridgeNativeMethod(verb)) {
- return Promise.reject(new BridgeClientNotNativeVerbError(verb))
- }
- return sendRequest(verb, params).then((reply) => {
- // Native refusals reject as error frames and never forward to desktop, so the return has no RpcFailure arm.
- if (!reply.ok) {
- throw new Error(reply.error.message)
- }
- return reply
- })
- },
+ callNativeVerb: (verb, params) => callBridgeNativeVerb(sendRequest, verb, params),
notifyStorageWrite: notifications.notifyStorageWrite,
notifyHaptics: notifications.notifyHaptics,
notifyPageFault: notifications.notifyPageFault,
notifyPagePainted: notifications.notifyPagePainted,
+ notifyPageLocation: notifications.notifyPageLocation,
claimBack: back.claim,
close,
onReady: shellSession.onReady,
diff --git a/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx b/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx
index 116d0f24b99..cb93df8c6c3 100644
--- a/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx
+++ b/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx
@@ -78,9 +78,16 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
default: { getItem: async () => null, setItem: async () => {} }
}))
vi.mock('lucide-react-native', () => ({ X: 'Icon' }))
-vi.mock('react-native-safe-area-context', () => ({
- useSafeAreaInsets: () => ({ bottom: 8, left: 0, right: 0, top: 44 })
-}))
+vi.mock('react-native-safe-area-context', async () => {
+ const { createContext } = await import('react')
+ return {
+ useSafeAreaInsets: () => ({ bottom: 8, left: 0, right: 0, top: 44 }),
+ useSafeAreaFrame: () => ({ x: 0, y: 0, width: 390, height: 844 }),
+ SafeAreaFrameContext: createContext(null),
+ SafeAreaInsetsContext: createContext(null),
+ SafeAreaProvider: ({ children }: { children: unknown }) => children
+ }
+})
vi.mock('expo-router', () => ({
router: { replace: vi.fn() },
useLocalSearchParams: () => dependencies.params,
diff --git a/mobile/src/mobile-web-shell/encoded-route-href.test.ts b/mobile/src/mobile-web-shell/encoded-route-href.test.ts
new file mode 100644
index 00000000000..a8b287fa924
--- /dev/null
+++ b/mobile/src/mobile-web-shell/encoded-route-href.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest'
+import { encodedRouteHref } from './encoded-route-href'
+
+describe('encodedRouteHref', () => {
+ it('keeps a worktree id with slashes inside its own segment', () => {
+ expect(
+ encodedRouteHref(['h', '[hostId]', 'session', '[worktreeId]'], {
+ hostId: 'host-1',
+ worktreeId: 'repo::/private/tmp/wt/fold-layout'
+ })
+ ).toBe('/h/host-1/session/repo%3A%3A%2Fprivate%2Ftmp%2Fwt%2Ffold-layout')
+ })
+
+ it('drops groups and spreads catch-all values', () => {
+ expect(
+ encodedRouteHref(['(app)', 'h', '[hostId]', '[...page]'], {
+ hostId: 'h 1',
+ page: ['a', 'b/c']
+ })
+ ).toBe('/h/h%201/a/b%2Fc')
+ })
+})
diff --git a/mobile/src/mobile-web-shell/encoded-route-href.ts b/mobile/src/mobile-web-shell/encoded-route-href.ts
new file mode 100644
index 00000000000..547920d94ea
--- /dev/null
+++ b/mobile/src/mobile-web-shell/encoded-route-href.ts
@@ -0,0 +1,19 @@
+type RouteParams = Readonly>
+
+// expo-router's pathname joins raw param values; a worktree id holds slashes, so encode each dynamic segment or the native router matches another screen.
+export function encodedRouteHref(segments: readonly string[], params: RouteParams): string {
+ const parts = segments.flatMap((segment) => {
+ if (segment.startsWith('(') && segment.endsWith(')')) {
+ return []
+ }
+ const rest = /^\[\.\.\.(.+)\]$/.exec(segment)?.[1]
+ const name = rest ?? /^\[(.+)\]$/.exec(segment)?.[1]
+ if (name === undefined) {
+ return [segment]
+ }
+ const value = params[name]
+ const values = value === undefined ? [] : Array.isArray(value) ? value : [value]
+ return values.map(encodeURIComponent)
+ })
+ return `/${parts.join('/')}`
+}
diff --git a/mobile/src/mobile-web-shell/host-area-fold.test.tsx b/mobile/src/mobile-web-shell/host-area-fold.test.tsx
new file mode 100644
index 00000000000..74906da3471
--- /dev/null
+++ b/mobile/src/mobile-web-shell/host-area-fold.test.tsx
@@ -0,0 +1,115 @@
+// Folding a foldable while its host-area page shows a session must keep that session, not drop to the list beneath.
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const harness = await vi.hoisted(async () => await import('./mobile-web-shell-screen-test-harness'))
+const dependencies = vi.hoisted(() => harness.createScreenDependencies())
+const { screenModuleMocks } = await vi.hoisted(
+ async () => await import('./mobile-web-shell-screen-test-mocks')
+)
+const mocks = vi.hoisted(() => screenModuleMocks(dependencies))
+const device = vi.hoisted(() => ({ width: 951, height: 669 }))
+
+vi.mock('react-native', () => ({
+ ...mocks['react-native'](),
+ useWindowDimensions: () => ({ width: device.width, height: device.height })
+}))
+vi.mock('expo-router', mocks['expo-router'])
+vi.mock('expo-clipboard', mocks['expo-clipboard'])
+vi.mock('expo-haptics', mocks['expo-haptics'])
+vi.mock('expo-document-picker', mocks['expo-document-picker'])
+vi.mock('@orca/expo-two-way-audio', mocks['@orca/expo-two-way-audio'])
+vi.mock('expo-keep-awake', mocks['expo-keep-awake'])
+vi.mock('expo-image-picker', mocks['expo-image-picker'])
+vi.mock('expo-file-system', mocks['expo-file-system'])
+vi.mock('lucide-react-native', mocks['lucide-react-native'])
+vi.mock('react-native-safe-area-context', mocks['react-native-safe-area-context'])
+vi.mock('../../modules/orca-mobile-web-shell/src', mocks['../../modules/orca-mobile-web-shell/src'])
+vi.mock('../app-update/use-wall-app-update', mocks['../app-update/use-wall-app-update'])
+vi.mock('../transport/host-client-hooks', mocks['../transport/host-client-hooks'])
+vi.mock('./use-page-host-snapshot', mocks['./use-page-host-snapshot'])
+vi.mock('./use-mobile-web-shell-session', mocks['./use-mobile-web-shell-session'])
+
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { clientFrame, createFakeRpcClient } from './bridge-host-test-fakes'
+import { MobileWebShellScreen } from './MobileWebShellScreen'
+
+const HOST = '/h/host-1'
+const DETAIL = '/h/host-1/session/wt-1'
+const UNFOLDED = { width: 951, height: 669 }
+const COVER = { width: 466, height: 678 }
+
+const hostEntry = { key: 'entry-0', name: '[hostId]/index', params: { hostId: 'host-1' } }
+const sessionEntry = {
+ key: 'entry-1',
+ name: '[hostId]/session/[worktreeId]',
+ params: { hostId: 'host-1', worktreeId: 'wt-1' }
+}
+
+function stackOf(routes: readonly (typeof hostEntry | typeof sessionEntry)[]) {
+ const hostStack = { key: 'host-stack', index: routes.length - 1, routes: [...routes] }
+ return { key: 'root-stack', index: 0, routes: [{ key: 'h', name: 'h', state: hostStack }] }
+}
+
+async function foldAfterPageShows(location: string | null, focused = true): Promise {
+ Object.assign(device, UNFOLDED)
+ dependencies.ownsHostArea = true
+ dependencies.screenFocused = focused
+ // Unfocused means a native session was pushed over this host-route shell.
+ dependencies.rootState = focused ? stackOf([hostEntry]) : stackOf([hostEntry, sessionEntry])
+ dependencies.client = createFakeRpcClient()
+ dependencies.state = harness.readyState('session-one')
+ dependencies.pageRoutes = ['/h/[hostId]', '/h/[hostId]/session/[worktreeId]']
+ const screen = () =>
+ createElement(MobileWebShellScreen, { hostId: 'host-1', route: { pathname: HOST } })
+ const mounted: { tree: ReactTestRenderer | null } = { tree: null }
+ await act(async () => {
+ mounted.tree = create(screen())
+ })
+ const view = mounted.tree?.root.findAll((node) => String(node.type) === 'ShellViewProbe')[0]
+ await act(async () => {
+ view?.props.onBridgeMessage({ nativeEvent: { json: clientFrame({ type: 'ready' }) } })
+ if (location !== null) {
+ view?.props.onBridgeMessage({
+ nativeEvent: { json: clientFrame({ type: 'notify', name: 'location', href: location }) }
+ })
+ }
+ })
+ Object.assign(device, COVER)
+ dependencies.ownsHostArea = false
+ await act(async () => {
+ mounted.tree?.update(screen())
+ })
+ act(() => mounted.tree?.unmount())
+}
+
+beforeEach(() => {
+ harness.resetScreenDependencies(dependencies)
+})
+
+afterEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('folding while the host-area page shows a session', () => {
+ it('reopens that session natively on the cover screen', async () => {
+ await foldAfterPageShows(DETAIL)
+ expect(dependencies.push.mock.calls).toEqual([[DETAIL]])
+ })
+
+ it('stays on the list when the page was showing the list', async () => {
+ await foldAfterPageShows(HOST)
+ expect(dependencies.push).not.toHaveBeenCalled()
+ })
+
+ it('leaves the stack alone from a host-route shell covered by a pushed session', async () => {
+ await foldAfterPageShows(HOST, false)
+ expect(dependencies.push).not.toHaveBeenCalled()
+ expect(dependencies.dispatch).not.toHaveBeenCalled()
+ })
+
+ it('stays on the list for an older page that never reports where it is', async () => {
+ await foldAfterPageShows(null)
+ expect(dependencies.push).not.toHaveBeenCalled()
+ })
+})
diff --git a/mobile/src/mobile-web-shell/host-area-in-page-cleanup.test.tsx b/mobile/src/mobile-web-shell/host-area-in-page-cleanup.test.tsx
index b398697c2cc..aa9f2c275af 100644
--- a/mobile/src/mobile-web-shell/host-area-in-page-cleanup.test.tsx
+++ b/mobile/src/mobile-web-shell/host-area-in-page-cleanup.test.tsx
@@ -25,6 +25,9 @@ vi.mock('../session/mobile-session-styles', () => ({ styles: { container: {}, ka
vi.mock('../session/MobileSessionHeader', () => ({ MobileSessionHeader: () => null }))
vi.mock('../session/MobileSessionContentRow', () => ({ MobileSessionContentRow: () => null }))
vi.mock('../session/MobileSessionSheets', () => ({ MobileSessionSheets: () => null }))
+vi.mock('../session/session-side-rail', () => ({
+ useSessionSideRail: () => ({ backInRail: false, keysInRail: false })
+}))
vi.mock('expo-keep-awake', () => ({
activateKeepAwakeAsync: () => Promise.resolve(),
deactivateKeepAwake: () => Promise.resolve()
diff --git a/mobile/src/mobile-web-shell/host-area-sidebar-matrix.test.tsx b/mobile/src/mobile-web-shell/host-area-sidebar-matrix.test.tsx
index f4035ab6e4a..42f740798c7 100644
--- a/mobile/src/mobile-web-shell/host-area-sidebar-matrix.test.tsx
+++ b/mobile/src/mobile-web-shell/host-area-sidebar-matrix.test.tsx
@@ -81,7 +81,6 @@ vi.mock('../theme/mobile-theme', async (original) => ({
colors: {}
}))
vi.mock('../storage/preferences', () => ({
- HOST_SIDEBAR_DEFAULT_WIDTH: 320,
HOST_SIDEBAR_MAX_WIDTH: 480,
HOST_SIDEBAR_MIN_WIDTH: 240,
loadHostSidebarWidth: async () => 320,
diff --git a/mobile/src/mobile-web-shell/host-area-two-hosts.test.tsx b/mobile/src/mobile-web-shell/host-area-two-hosts.test.tsx
index e01a9bc607f..cdd86a729c9 100644
--- a/mobile/src/mobile-web-shell/host-area-two-hosts.test.tsx
+++ b/mobile/src/mobile-web-shell/host-area-two-hosts.test.tsx
@@ -60,7 +60,6 @@ vi.mock('../theme/mobile-theme', async (original) => ({
colors: {}
}))
vi.mock('../storage/preferences', () => ({
- HOST_SIDEBAR_DEFAULT_WIDTH: 320,
HOST_SIDEBAR_MAX_WIDTH: 480,
HOST_SIDEBAR_MIN_WIDTH: 240,
loadHostSidebarWidth: async () => 320,
diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-harness.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-harness.tsx
index 1cc00ff5a9e..79c319fa791 100644
--- a/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-harness.tsx
+++ b/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-harness.tsx
@@ -47,6 +47,8 @@ export type ScreenDependencies = {
backHandlers: Map boolean>
setScreenOptions: Mock
backClaimed: boolean
+ ownsHostArea: boolean
+ screenFocused: boolean
platform: 'ios' | 'android'
reportPageBackClaim: Mock
sendBackDelivers: boolean
@@ -103,6 +105,8 @@ export function createScreenDependencies(): ScreenDependencies {
backHandlers: new Map(),
setScreenOptions: vi.fn(),
backClaimed: false,
+ ownsHostArea: false,
+ screenFocused: true,
platform: 'ios',
reportPageBackClaim: vi.fn(),
sendBackDelivers: true,
@@ -141,6 +145,8 @@ export function resetScreenDependencies(dependencies: ScreenDependencies): void
dependencies.setScreenOptions.mockReset()
dependencies.reportPageBackClaim.mockReset()
dependencies.backClaimed = false
+ dependencies.ownsHostArea = false
+ dependencies.screenFocused = true
dependencies.platform = 'ios'
dependencies.sendBackDelivers = true
dependencies.backSends = 0
diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx
index 7330aca27c8..720a5550ada 100644
--- a/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx
+++ b/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx
@@ -98,9 +98,16 @@ export function screenModuleMocks(dependencies: ScreenDependencies) {
Paths: { cache: 'file:///cache' }
}),
'lucide-react-native': () => ({ X: 'Icon' }),
- 'react-native-safe-area-context': () => ({
- useSafeAreaInsets: () => ({ bottom: 8, left: 0, right: 0, top: 44 })
- }),
+ 'react-native-safe-area-context': async () => {
+ const { createContext } = await import('react')
+ return {
+ useSafeAreaInsets: () => ({ bottom: 8, left: 0, right: 0, top: 44 }),
+ useSafeAreaFrame: () => ({ x: 0, y: 0, width: 390, height: 844 }),
+ SafeAreaFrameContext: createContext(null),
+ SafeAreaInsetsContext: createContext(null),
+ SafeAreaProvider: ({ children }: { children: unknown }) => children
+ }
+ },
'expo-router': () => ({
router: { replace: vi.fn() },
useRouter: () => ({
@@ -111,7 +118,8 @@ export function screenModuleMocks(dependencies: ScreenDependencies) {
usePathname: () => dependencies.pathname,
useNavigation: () => ({
setOptions: dependencies.setScreenOptions,
- dispatch: dependencies.dispatch
+ dispatch: dependencies.dispatch,
+ isFocused: () => dependencies.screenFocused
}),
useNavigationContainerRef: () => ({ getRootState: () => dependencies.rootState }),
useFocusEffect: (effect: () => undefined | (() => void)) => React.useEffect(effect, [effect])
@@ -184,7 +192,8 @@ export function screenModuleMocks(dependencies: ScreenDependencies) {
reportPageBackClaim: dependencies.reportPageBackClaim,
pageReady: dependencies.pageReady,
pageFrame: dependencies.pageFrame,
- backClaimed: dependencies.backClaimed
+ backClaimed: dependencies.backClaimed,
+ ownsHostArea: dependencies.ownsHostArea
})
})
}
diff --git a/mobile/src/mobile-web-shell/page-location-on-fold.ts b/mobile/src/mobile-web-shell/page-location-on-fold.ts
new file mode 100644
index 00000000000..6bb268c33b9
--- /dev/null
+++ b/mobile/src/mobile-web-shell/page-location-on-fold.ts
@@ -0,0 +1,31 @@
+import { useEffect, useRef } from 'react'
+
+// A host-area page's own hops never move the native route; reopen the last one natively once the layout narrows (a fold), or the phone shows the list beneath.
+// Only the focused screen acts: a host-route shell left under a pushed session also folds, and must not pop the stack back to itself.
+export function usePageLocationOnFold(
+ isWideLayout: boolean,
+ isFocused: () => boolean,
+ openPageRoute: (href: string) => void
+): (href: string, ownsHostArea: boolean) => void {
+ // Refresh after commit, before the fold effect below reads them; a discarded render must not swap the handlers.
+ const latest = useRef({ openPageRoute, isFocused })
+ useEffect(() => {
+ latest.current = { openPageRoute, isFocused }
+ })
+ const heldRef = useRef(null)
+ const wasWideRef = useRef(isWideLayout)
+ useEffect(() => {
+ if (wasWideRef.current === isWideLayout) {
+ return
+ }
+ wasWideRef.current = isWideLayout
+ const held = heldRef.current
+ heldRef.current = null
+ if (!isWideLayout && held !== null && latest.current.isFocused()) {
+ latest.current.openPageRoute(held)
+ }
+ }, [isWideLayout])
+ return (href, ownsHostArea) => {
+ heldRef.current = ownsHostArea ? href : null
+ }
+}
diff --git a/mobile/src/mobile-web-shell/page-location-report.ts b/mobile/src/mobile-web-shell/page-location-report.ts
new file mode 100644
index 00000000000..5a2e94d9ee3
--- /dev/null
+++ b/mobile/src/mobile-web-shell/page-location-report.ts
@@ -0,0 +1,2 @@
+// Native routes are the location already; only a page reports its own hops.
+export function useReportedPageLocation(): void {}
diff --git a/mobile/src/mobile-web-shell/page-location-report.web.ts b/mobile/src/mobile-web-shell/page-location-report.web.ts
new file mode 100644
index 00000000000..38f2881950a
--- /dev/null
+++ b/mobile/src/mobile-web-shell/page-location-report.web.ts
@@ -0,0 +1,22 @@
+import { useEffect } from 'react'
+import { useGlobalSearchParams, useSegments } from 'expo-router'
+import { normalizeExecutionHostId } from '../../../src/shared/execution-host'
+import { firstParam } from '../navigation/route-param-reader'
+import { workspaceRouteHref } from '../navigation/workspace-execution-host'
+import { usePageBridgeClientIfPresent } from '../transport/client-context.web'
+import { encodedRouteHref } from './encoded-route-href'
+
+// Report in-document hops while this page owns the host area, so a fold can reopen the same screen natively.
+// Keep executionHost so the reopened workspace stays on its server; one-shot params like paneKey are not replayed.
+export function useReportedPageLocation(): void {
+ const client = usePageBridgeClientIfPresent()
+ const ownsHostArea = client?.getShellSession()?.ownsHostArea === true
+ const params = useGlobalSearchParams()
+ const executionHost = normalizeExecutionHostId(firstParam(params.executionHost)) ?? undefined
+ const href = workspaceRouteHref(encodedRouteHref(useSegments(), params), executionHost)
+ useEffect(() => {
+ if (client && ownsHostArea) {
+ client.notifyPageLocation(href)
+ }
+ }, [client, ownsHostArea, href])
+}
diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts
index da5a8c9a0de..f2be240221f 100644
--- a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts
+++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts
@@ -170,7 +170,8 @@ function Harness(props: {
onPagePainted: () => {
props.probe.paints += 1
},
- onPageBackClaim: (claimed) => props.probe.backClaims.push(claimed)
+ onPageBackClaim: (claimed) => props.probe.backClaims.push(claimed),
+ onPageLocation: () => {}
})
props.probe.view = view
return props.session.kind === 'ready'
diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts
index d118a692907..9f72914e921 100644
--- a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts
+++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts
@@ -77,6 +77,7 @@ export type MobileWebShellBridgeArgs = {
onPagePainted: () => void
// Document resets explicitly publish false so retired pages cannot retain Back.
onPageBackClaim: (claimed: boolean) => void
+ onPageLocation: (href: string) => void
onRouteParamClear: (param: BridgeClearableRouteParam, value: string) => void
onRouteRefused: (issue: string) => void
onBinaryFramesDropped: (total: number) => void
@@ -156,7 +157,8 @@ export function useMobileWebShellBridge(args: MobileWebShellBridgeArgs): MobileW
onBinaryFramesDropped: (total) => argsRef.current.onBinaryFramesDropped(total),
onPageReady: () => argsRef.current.onPageReady(),
onPagePainted: () => argsRef.current.onPagePainted(),
- onPageBackClaim: (claimed) => argsRef.current.onPageBackClaim(claimed)
+ onPageBackClaim: (claimed) => argsRef.current.onPageBackClaim(claimed),
+ onPageLocation: (href) => argsRef.current.onPageLocation(href)
})
hostRef.current = { sessionId, host }
// Reset host-owned drop totals on rebuild so a new lower count cannot look like frames being recovered.
diff --git a/mobile/src/navigation/host-stack.web.tsx b/mobile/src/navigation/host-stack.web.tsx
index d3513510099..e980b95c115 100644
--- a/mobile/src/navigation/host-stack.web.tsx
+++ b/mobile/src/navigation/host-stack.web.tsx
@@ -6,6 +6,7 @@ import { themedStyleSheet } from '../theme/themed-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { HOST_STACK_SCREENS, type HostStackAnimation } from './host-stack-screens'
import { isOverlayPageRoute } from '../new-workspace/overlay-page-route'
+import { SideRailOnTopContext } from '../layout/side-rail'
// Expo native-stack’s web view ignores animation and switches display; reuse StackRouter with a view that retains popped screens through their exit slide.
export function HostStack({ animation }: { animation: HostStackAnimation }) {
@@ -98,7 +99,9 @@ function HostStackView({ animation }: { animation: HostStackAnimation }) {
motion={leaving ? 'exit' : entering ? 'enter' : null}
onSettled={settle}
>
- {descriptor?.render()}
+
+ {descriptor?.render()}
+
)
})}
diff --git a/mobile/src/session/MobileSessionAccessoryKeys.tsx b/mobile/src/session/MobileSessionAccessoryKeys.tsx
new file mode 100644
index 00000000000..9ad8f5f3d66
--- /dev/null
+++ b/mobile/src/session/MobileSessionAccessoryKeys.tsx
@@ -0,0 +1,231 @@
+import { View, Text, ScrollView, Pressable } from 'react-native'
+import {
+ ChevronDown,
+ ChevronsRight,
+ Keyboard as KeyboardIcon,
+ Monitor,
+ Plus,
+ Smartphone
+} from 'lucide-react-native'
+import { triggerMediumImpact } from '../platform/haptics'
+import { createTerminalLiveAccessoryInput } from '../terminal/terminal-live-accessory-input'
+import { keepHeldPressThroughLongPress } from './held-press-long-press'
+import { isTerminalPhoneDisplayMode } from './mobile-session-route-helpers'
+import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
+import { colors } from '../theme/mobile-theme'
+import { styles } from './mobile-session-styles'
+import { useKeyboardPersistingTaps } from '../platform/keyboard-persisting-taps'
+import type { MobileSessionController } from './use-mobile-session-controller'
+
+// The terminal's key strip: a row above the input, or a column in the Duo's side rail.
+export function MobileSessionAccessoryKeys({
+ controller,
+ vertical = false
+}: {
+ controller: MobileSessionController
+ vertical?: boolean
+}) {
+ useMobileColorScheme()
+ const {
+ activeHandle,
+ customKeys,
+ setShowCustomKeyModal,
+ setDeleteKeyTarget,
+ visibleBuiltInAccessoryKeys,
+ terminalModes,
+ canPaste,
+ canSend,
+ canCompose,
+ liveInputEnabled,
+ toggleDisplayMode,
+ handleAccessoryKey,
+ dismissSoftwareKeyboard,
+ toggleLiveInput,
+ stopAccessoryRepeat,
+ startAccessoryRepeat,
+ handlePaste,
+ keyboardLift
+ } = controller
+ const keepsKeyboard = useKeyboardPersistingTaps('always')
+ return (
+
+ {/* Why: fixed keyboard escape hatch; outside ScrollView + shortcut path so it can't scroll away or be hidden (#5106). */}
+ {keyboardLift > 0 && (
+ [
+ vertical ? styles.keyboardDismissKeyVertical : styles.keyboardDismissKey,
+ pressed && styles.accessoryKeyPressed
+ ]}
+ onPress={dismissSoftwareKeyboard}
+ hitSlop={8}
+ accessibilityRole="button"
+ accessibilityLabel="Dismiss keyboard"
+ accessibilityHint="Hides the software keyboard and keeps the current terminal session open."
+ >
+
+
+
+
+
+ )}
+ {/* Why: default tap handling makes the first accessory-key tap dismiss the keyboard and get swallowed (#5106). */}
+
+ [
+ styles.accessoryKey,
+ vertical && styles.accessoryKeyVertical,
+ pressed && styles.accessoryKeyPressed,
+ !canSend && styles.accessoryKeyDisabled
+ ]}
+ disabled={!canSend}
+ onPress={() => {
+ if (activeHandle) {
+ void toggleDisplayMode(activeHandle)
+ }
+ }}
+ accessibilityLabel={
+ isTerminalPhoneDisplayMode(activeHandle, terminalModes)
+ ? 'Switch to desktop mode'
+ : 'Switch to phone mode'
+ }
+ >
+ {isTerminalPhoneDisplayMode(activeHandle, terminalModes) ? (
+
+ ) : (
+
+ )}
+
+ [
+ styles.accessoryKey,
+ vertical && styles.accessoryKeyVertical,
+ liveInputEnabled && styles.accessoryKeyActive,
+ pressed && styles.accessoryKeyPressed,
+ !canCompose && styles.accessoryKeyDisabled
+ ]}
+ // Why: offline, live mode is dead but the buffered box still composes — keep the escape hatch tappable (#6713).
+ disabled={!canCompose}
+ onPress={toggleLiveInput}
+ accessibilityLabel={
+ liveInputEnabled ? 'Switch to buffered command input' : 'Switch to live terminal input'
+ }
+ >
+
+
+ {canPaste && (
+ [
+ styles.accessoryKey,
+ vertical && styles.accessoryKeyVertical,
+ vertical && styles.accessoryKeyVertical,
+ pressed && styles.accessoryKeyPressed,
+ !canSend && styles.accessoryKeyDisabled
+ ]}
+ disabled={!canSend}
+ onPress={() => void handlePaste()}
+ accessibilityLabel="Paste from clipboard"
+ >
+
+ Paste
+
+
+ )}
+ {visibleBuiltInAccessoryKeys.map((key) => (
+ [
+ styles.accessoryKey,
+ vertical && styles.accessoryKeyVertical,
+ vertical && styles.accessoryKeyVertical,
+ pressed && styles.accessoryKeyPressed,
+ !canSend && styles.accessoryKeyDisabled
+ ]}
+ disabled={!canSend}
+ onPressIn={() => {
+ if (!key.repeatable) {
+ return
+ }
+ const input = createTerminalLiveAccessoryInput(key)
+ void handleAccessoryKey(input)
+ startAccessoryRepeat(input)
+ }}
+ onPressOut={() => {
+ if (key.repeatable) {
+ stopAccessoryRepeat()
+ }
+ }}
+ onPress={() => {
+ if (key.repeatable) {
+ return
+ }
+ void handleAccessoryKey(createTerminalLiveAccessoryInput(key))
+ }}
+ onLongPress={key.repeatable ? keepHeldPressThroughLongPress : undefined}
+ accessibilityLabel={key.accessibilityLabel ?? `Send ${key.label}`}
+ >
+
+ {key.label}
+
+
+ ))}
+ {customKeys.map((key) => (
+ [
+ styles.accessoryKey,
+ vertical && styles.accessoryKeyVertical,
+ vertical && styles.accessoryKeyVertical,
+ styles.customAccessoryKey,
+ pressed && styles.accessoryKeyPressed,
+ !canSend && styles.accessoryKeyDisabled
+ ]}
+ disabled={!canSend}
+ onPress={() => void handleAccessoryKey({ bytes: key.bytes })}
+ onLongPress={() => {
+ triggerMediumImpact()
+ setDeleteKeyTarget(key)
+ }}
+ delayLongPress={400}
+ accessibilityLabel={`Send ${key.label}`}
+ >
+
+ {key.label}
+
+
+ ))}
+ [
+ styles.accessoryKey,
+ vertical && styles.accessoryKeyVertical,
+ pressed && styles.accessoryKeyPressed
+ ]}
+ onPress={() => setShowCustomKeyModal(true)}
+ accessibilityLabel="Add custom shortcut"
+ >
+
+
+
+
+ )
+}
diff --git a/mobile/src/session/MobileSessionCommandDock.tsx b/mobile/src/session/MobileSessionCommandDock.tsx
index 4008604894e..a3b0c22b590 100644
--- a/mobile/src/session/MobileSessionCommandDock.tsx
+++ b/mobile/src/session/MobileSessionCommandDock.tsx
@@ -1,44 +1,41 @@
-import { View, Text, ScrollView, TextInput, Pressable } from 'react-native'
+import { View, TextInput, Pressable } from 'react-native'
import { hostOs } from '../platform/host-os'
-import {
- ArrowUp,
- ChevronDown,
- ChevronsRight,
- Keyboard as KeyboardIcon,
- Monitor,
- Plus,
- Smartphone
-} from 'lucide-react-native'
-import { triggerMediumImpact } from '../platform/haptics'
-import { createTerminalLiveAccessoryInput } from '../terminal/terminal-live-accessory-input'
+import { ArrowUp, Keyboard as KeyboardIcon } from 'lucide-react-native'
import {
getTerminalCommandKeyboardType,
getTerminalLiveInputKeyboardType
} from '../terminal/terminal-keyboard-type'
import { MobileTerminalLiveInputStatus } from './MobileTerminalLiveInputStatus'
import { MobileTerminalInputActions } from './MobileTerminalInputActions'
-import { keepHeldPressThroughLongPress } from './held-press-long-press'
-import { isTerminalPhoneDisplayMode } from './mobile-session-route-helpers'
+import { MobileSessionAccessoryKeys } from './MobileSessionAccessoryKeys'
import { colors } from '../theme/mobile-theme'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { styles } from './mobile-session-styles'
-import { useKeyboardPersistingTaps } from '../platform/keyboard-persisting-taps'
import type { MobileSessionController } from './use-mobile-session-controller'
-export function MobileSessionCommandDock({ controller }: { controller: MobileSessionController }) {
+// Markdown, file, browser and chat tabs bring their own input; only terminals get the dock and its keys.
+export function sessionCommandDockShown(controller: MobileSessionController): boolean {
+ return (
+ !controller.activeMarkdownTab &&
+ !controller.activeFileTab &&
+ !controller.activeBrowserTab &&
+ !controller.showNativeChat
+ )
+}
+
+export function MobileSessionCommandDock({
+ controller,
+ keysInRail
+}: {
+ controller: MobileSessionController
+ keysInRail: boolean
+}) {
useMobileColorScheme()
const {
insets,
bufferedTerminalDraftState,
autocompleteEnabled,
liveInputCapture,
- activeHandle,
- customKeys,
- setShowCustomKeyModal,
- setDeleteKeyTarget,
- visibleBuiltInAccessoryKeys,
- terminalModes,
- canPaste,
dictationMode,
dictationEnabled,
bindCommandField,
@@ -50,33 +47,18 @@ export function MobileSessionCommandDock({ controller }: { controller: MobileSes
canCompose,
liveInputEnabled,
focusLiveInput,
- showNativeChat,
dictation,
cancelDictation,
handleDictationToggle,
handleDictationPressIn,
handleDictationPressOut,
- toggleDisplayMode,
handleSend,
- handleAccessoryKey,
- dismissSoftwareKeyboard,
- toggleLiveInput,
- stopAccessoryRepeat,
- startAccessoryRepeat,
- handlePaste,
isAttaching,
attachImage,
- activeMarkdownTab,
- activeFileTab,
- activeBrowserTab,
keyboardLift
} = controller
- const accessoryBarKeepsKeyboard = useKeyboardPersistingTaps('always')
return (
- !activeMarkdownTab &&
- !activeFileTab &&
- !activeBrowserTab &&
- !showNativeChat && (
+ sessionCommandDockShown(controller) && (
{}
-
- {/* Why: fixed keyboard escape hatch; outside ScrollView + shortcut path so it can't scroll away or be hidden (#5106). */}
- {keyboardLift > 0 && (
- [
- styles.keyboardDismissKey,
- pressed && styles.accessoryKeyPressed
- ]}
- onPress={dismissSoftwareKeyboard}
- hitSlop={8}
- accessibilityRole="button"
- accessibilityLabel="Dismiss keyboard"
- accessibilityHint="Hides the software keyboard and keeps the current terminal session open."
- >
-
-
-
-
-
- )}
- {/* Why: default tap handling makes the first accessory-key tap dismiss the keyboard and get swallowed (#5106). */}
-
- [
- styles.accessoryKey,
- pressed && styles.accessoryKeyPressed,
- !canSend && styles.accessoryKeyDisabled
- ]}
- disabled={!canSend}
- onPress={() => {
- if (activeHandle) {
- void toggleDisplayMode(activeHandle)
- }
- }}
- accessibilityLabel={
- isTerminalPhoneDisplayMode(activeHandle, terminalModes)
- ? 'Switch to desktop mode'
- : 'Switch to phone mode'
- }
- >
- {isTerminalPhoneDisplayMode(activeHandle, terminalModes) ? (
-
- ) : (
-
- )}
-
- [
- styles.accessoryKey,
- liveInputEnabled && styles.accessoryKeyActive,
- pressed && styles.accessoryKeyPressed,
- !canCompose && styles.accessoryKeyDisabled
- ]}
- // Why: offline, live mode is dead but the buffered box still composes — keep the escape hatch tappable (#6713).
- disabled={!canCompose}
- onPress={toggleLiveInput}
- accessibilityLabel={
- liveInputEnabled
- ? 'Switch to buffered command input'
- : 'Switch to live terminal input'
- }
- >
-
-
- {canPaste && (
- [
- styles.accessoryKey,
- pressed && styles.accessoryKeyPressed,
- !canSend && styles.accessoryKeyDisabled
- ]}
- disabled={!canSend}
- onPress={() => void handlePaste()}
- accessibilityLabel="Paste from clipboard"
- >
-
- Paste
-
-
- )}
- {visibleBuiltInAccessoryKeys.map((key) => (
- [
- styles.accessoryKey,
- pressed && styles.accessoryKeyPressed,
- !canSend && styles.accessoryKeyDisabled
- ]}
- disabled={!canSend}
- onPressIn={() => {
- if (!key.repeatable) {
- return
- }
- const input = createTerminalLiveAccessoryInput(key)
- void handleAccessoryKey(input)
- startAccessoryRepeat(input)
- }}
- onPressOut={() => {
- if (key.repeatable) {
- stopAccessoryRepeat()
- }
- }}
- onPress={() => {
- if (key.repeatable) {
- return
- }
- void handleAccessoryKey(createTerminalLiveAccessoryInput(key))
- }}
- onLongPress={key.repeatable ? keepHeldPressThroughLongPress : undefined}
- accessibilityLabel={key.accessibilityLabel ?? `Send ${key.label}`}
- >
-
- {key.label}
-
-
- ))}
- {customKeys.map((key) => (
- [
- styles.accessoryKey,
- styles.customAccessoryKey,
- pressed && styles.accessoryKeyPressed,
- !canSend && styles.accessoryKeyDisabled
- ]}
- disabled={!canSend}
- onPress={() => void handleAccessoryKey({ bytes: key.bytes })}
- onLongPress={() => {
- triggerMediumImpact()
- setDeleteKeyTarget(key)
- }}
- delayLongPress={400}
- accessibilityLabel={`Send ${key.label}`}
- >
-
- {key.label}
-
-
- ))}
- [styles.accessoryKey, pressed && styles.accessoryKeyPressed]}
- onPress={() => setShowCustomKeyModal(true)}
- accessibilityLabel="Add custom shortcut"
- >
-
-
-
-
-
+ {keysInRail ? null : }
{}
{liveInputEnabled ? (
diff --git a/mobile/src/session/MobileSessionContentRow.tsx b/mobile/src/session/MobileSessionContentRow.tsx
index 1d981f16b68..2e8b1e0969f 100644
--- a/mobile/src/session/MobileSessionContentRow.tsx
+++ b/mobile/src/session/MobileSessionContentRow.tsx
@@ -9,7 +9,13 @@ import type { MobileSessionController } from './use-mobile-session-controller'
import { MobileSessionActiveContent } from './MobileSessionActiveContent'
import { MobileSessionCommandDock } from './MobileSessionCommandDock'
-export function MobileSessionContentRow({ controller }: { controller: MobileSessionController }) {
+export function MobileSessionContentRow({
+ controller,
+ keysInRail
+}: {
+ controller: MobileSessionController
+ keysInRail: boolean
+}) {
useMobileColorScheme()
const {
hostId,
@@ -44,7 +50,7 @@ export function MobileSessionContentRow({ controller }: { controller: MobileSess
) : null}
{/* Why: translate instead of resize so keyboard toggles don't trigger a server-side PTY viewport change. */}
-
+
{canDockPanel && activePanel !== null && (
) : null}
-
+ {backInRail ? null : (
+
+ )}
-
+
{}
-
+
diff --git a/mobile/src/session/mobile-session-command-input-styles.ts b/mobile/src/session/mobile-session-command-input-styles.ts
index 8781f90439b..383640fabf6 100644
--- a/mobile/src/session/mobile-session-command-input-styles.ts
+++ b/mobile/src/session/mobile-session-command-input-styles.ts
@@ -131,6 +131,35 @@ export const mobileSessionCommandInputStyles = themedStyleSheet(() => ({
borderRadius: radii.button,
minWidth: 36
},
+ // The Duo side rail column: full-width keys with room for a thumb.
+ accessoryRail: {
+ flex: 1,
+ minHeight: 0
+ },
+ accessoryScrollVertical: {
+ flex: 1
+ },
+ // Keys span the 84pt rail less 4pt each side, so the widest label (Shift+Tab) fits.
+ accessoryContentVertical: {
+ paddingHorizontal: spacing.xs,
+ paddingVertical: spacing.xs,
+ gap: spacing.sm
+ },
+ accessoryKeyVertical: {
+ minHeight: 44,
+ justifyContent: 'center',
+ paddingHorizontal: 2,
+ borderRadius: radii.composerItem
+ },
+ keyboardDismissKeyVertical: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginHorizontal: spacing.xs,
+ marginBottom: spacing.sm,
+ minHeight: 44,
+ backgroundColor: colors.bgRaised,
+ borderRadius: radii.composerItem
+ },
keyboardDismissGlyph: {
alignItems: 'center',
height: 18,
diff --git a/mobile/src/session/mobile-session-toast-owner-cleanup.test.tsx b/mobile/src/session/mobile-session-toast-owner-cleanup.test.tsx
index 3ba4220962b..08045e0aa1f 100644
--- a/mobile/src/session/mobile-session-toast-owner-cleanup.test.tsx
+++ b/mobile/src/session/mobile-session-toast-owner-cleanup.test.tsx
@@ -51,6 +51,9 @@ vi.mock('./mobile-session-styles', () => ({ styles: { container: {}, kavInner: {
vi.mock('./MobileSessionHeader', () => ({ MobileSessionHeader: () => null }))
vi.mock('./MobileSessionContentRow', () => ({ MobileSessionContentRow: () => null }))
vi.mock('./MobileSessionSheets', () => ({ MobileSessionSheets: () => null }))
+vi.mock('./session-side-rail', () => ({
+ useSessionSideRail: () => ({ backInRail: false, keysInRail: false })
+}))
import { useMobileSessionFeedbackCapabilities } from './use-mobile-session-feedback-capabilities'
import { useMobileSessionAccessorySelection } from './use-mobile-session-accessory-selection'
import { MobileSessionSurface } from './MobileSessionSurface'
diff --git a/mobile/src/session/session-side-rail.tsx b/mobile/src/session/session-side-rail.tsx
new file mode 100644
index 00000000000..ac8dd631312
--- /dev/null
+++ b/mobile/src/session/session-side-rail.tsx
@@ -0,0 +1,73 @@
+import { Pressable, View } from 'react-native'
+import { ChevronLeft } from 'lucide-react-native'
+import { useResponsiveLayout } from '../layout/responsive-layout'
+import { useSideRailClaim, useSideRailEdge } from '../layout/side-rail'
+import { themedStyleSheet } from '../theme/themed-styles'
+import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
+import { colors, spacing } from '../theme/mobile-theme'
+import { MobileSessionAccessoryKeys } from './MobileSessionAccessoryKeys'
+import { sessionCommandDockShown } from './MobileSessionCommandDock'
+import type { MobileSessionController } from './use-mobile-session-controller'
+
+export type SessionRailPlacement = { backInRail: boolean; keysInRail: boolean }
+
+// The Duo's side rail takes what would cost the session rows: the terminal keys, and Back on the cover, where no sidebar sits beside it.
+export function useSessionSideRail(controller: MobileSessionController): SessionRailPlacement {
+ const edge = useSideRailEdge()
+ const { isWideLayout } = useResponsiveLayout()
+ const backInRail = edge !== null && !isWideLayout
+ const keysInRail = edge !== null && sessionCommandDockShown(controller)
+ useSideRailClaim(
+ backInRail || keysInRail ? (
+
+ ) : null
+ )
+ return { backInRail, keysInRail }
+}
+
+function SessionRail({
+ controller,
+ back,
+ keys
+}: {
+ controller: MobileSessionController
+ back: boolean
+ keys: boolean
+}) {
+ useMobileColorScheme()
+ return (
+
+ {back ? (
+ [styles.back, pressed && styles.backPressed]}
+ onPress={controller.requestLeaveSession}
+ accessibilityRole="button"
+ accessibilityLabel="Back to worktrees"
+ >
+
+
+ ) : null}
+ {keys ? : null}
+
+ )
+}
+
+const styles = themedStyleSheet(() => ({
+ rail: {
+ flex: 1,
+ alignItems: 'stretch',
+ gap: spacing.md
+ },
+ back: {
+ alignSelf: 'center',
+ width: 44,
+ height: 44,
+ borderRadius: 22,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: colors.bgRaised
+ },
+ backPressed: {
+ backgroundColor: colors.borderSubtle
+ }
+}))
diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts
index 21bdeaffec6..18eeb08fabc 100644
--- a/mobile/src/storage/preferences.test.ts
+++ b/mobile/src/storage/preferences.test.ts
@@ -479,18 +479,18 @@ describe('host sidebar width preference', () => {
expect(clampHostSidebarWidth(337.6)).toBe(338)
})
- it('falls back to the default width for missing, invalid, or unreadable storage', async () => {
+ it('has no saved width for missing, invalid, or unreadable storage', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
- await expect(loadHostSidebarWidth()).resolves.toBe(340)
+ await expect(loadHostSidebarWidth()).resolves.toBeNull()
vi.mocked(AsyncStorage.getItem).mockResolvedValue('not-a-number')
- await expect(loadHostSidebarWidth()).resolves.toBe(340)
+ await expect(loadHostSidebarWidth()).resolves.toBeNull()
vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable'))
- await expect(loadHostSidebarWidth()).resolves.toBe(340)
+ await expect(loadHostSidebarWidth()).resolves.toBeNull()
})
it('loads and persists clamped sidebar widths', async () => {
diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts
index 1096983aa75..fc245ed31c6 100644
--- a/mobile/src/storage/preferences.ts
+++ b/mobile/src/storage/preferences.ts
@@ -1,6 +1,7 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { persistMirrored } from './mirrored-storage-keys'
import { TERMINAL_TEXT_SCALES } from '../terminal/terminal-text-scales'
+import { HOST_SIDEBAR_MIN_WIDTH } from '../layout/responsive-layout-metrics'
const PINS_PREFIX = 'orca:pins:'
// Push-service consent is independent of legacy socket notifications.
@@ -158,26 +159,20 @@ export async function saveDisabledTerminalLiveInputHandles(
const SIDEBAR_WIDTH_KEY = 'orca:hostSidebarWidth'
// Caller also caps this tablet/foldable sidebar against the window to preserve detail space.
-export const HOST_SIDEBAR_MIN_WIDTH = 280
+export { HOST_SIDEBAR_MIN_WIDTH }
export const HOST_SIDEBAR_MAX_WIDTH = 560
-export const HOST_SIDEBAR_DEFAULT_WIDTH = 340
export function clampHostSidebarWidth(width: number): number {
- if (!Number.isFinite(width)) {
- return HOST_SIDEBAR_DEFAULT_WIDTH
- }
return Math.min(HOST_SIDEBAR_MAX_WIDTH, Math.max(HOST_SIDEBAR_MIN_WIDTH, Math.round(width)))
}
-export async function loadHostSidebarWidth(): Promise {
+// Null until the user drags the sidebar; the layout then sizes it from the window.
+export async function loadHostSidebarWidth(): Promise {
try {
- const raw = await AsyncStorage.getItem(SIDEBAR_WIDTH_KEY)
- if (raw === null) {
- return HOST_SIDEBAR_DEFAULT_WIDTH
- }
- return clampHostSidebarWidth(Number(raw))
+ const width = Number((await AsyncStorage.getItem(SIDEBAR_WIDTH_KEY)) ?? Number.NaN)
+ return Number.isFinite(width) ? clampHostSidebarWidth(width) : null
} catch {
- return HOST_SIDEBAR_DEFAULT_WIDTH
+ return null
}
}