feat(mobile): support the iPhone Duo folded and unfolded, with controls in its side status bar (#34484)

This commit is contained in:
Kelvin Amoaba
2026-10-27 22:16:33 +00:00
committed by GitHub
parent ef21574a41
commit 941f4ece37
50 changed files with 1503 additions and 581 deletions
+11 -1
View File
@@ -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 (
<SideSafeArea>
<EditHostScreen />
</SideSafeArea>
)
}
function EditHostScreen() {
useMobileColorScheme()
const router = useRouter()
const insets = useSafeAreaInsets()
+23 -13
View File
@@ -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<number | null>(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() {
<HostProtocolGate hostId={hostId} transparentWhilePending={overlayRoute}>
<View style={[styles.row, overlayRoute && styles.transparent]}>
{showSidebar && sidebarOpen ? (
<View style={[styles.sidebar, { width: sidebarWidth }]}>
<View style={[styles.sidebar, { width: sidebarWidth, paddingLeft: railLeft }]}>
<HostScreen
embedded
hostId={hostId}
onHideSidebar={canCollapseSidebar ? hideSidebar : undefined}
actionsInRail={!detailHasContent}
/>
{/* Edge drag handle captures Android gestures above the list. */}
<View style={styles.resizeHandle} {...resizer.panHandlers} />
@@ -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' }),
+3
View File
@@ -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 }),
@@ -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 (
<Pressable
style={({ pressed }) => [
styles.icon,
pressed && styles.pressed,
action.disabled && styles.disabled
]}
onPress={action.onPress}
disabled={action.disabled}
accessibilityRole="button"
accessibilityLabel={action.label}
>
<action.icon size={22} color={colors.textSecondary} />
</Pressable>
)
}
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 (
<View style={styles.coverRail}>
<View style={styles.rail}>
{pick(all, ['accounts', 'tasks', 'floating']).map((action) => (
<RailIcon key={action.id} action={action} />
))}
</View>
{pick(all, ['search']).map((action) => (
<RailIcon key={action.id} action={action} />
))}
</View>
)
}
const create = all.find((action) => action.id === 'new')
return (
<View style={styles.rail}>
{pick(all, ['accounts', 'tasks', 'search', 'floating']).map((action) => (
<View key={action.id} style={styles.rail}>
{action.id === 'floating' ? <View style={styles.divider} /> : null}
<RailIcon action={action} />
</View>
))}
{create ? (
<Pressable
style={({ pressed }) => [
styles.create,
pressed && styles.createPressed,
create.disabled && styles.disabled
]}
onPress={create.onPress}
disabled={create.disabled}
accessibilityRole="button"
accessibilityLabel={create.label}
>
<Plus size={24} color={colors.bgBase} strokeWidth={2.2} />
</Pressable>
) : null}
</View>
)
}
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
}
}))
+83 -123
View File
@@ -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 ? <HostRailActions controller={controller} placement={railPlacement} /> : null
)
return (
<View style={styles.topChrome}>
@@ -85,7 +94,7 @@ export function HostScreenHeader({ controller }: { controller: HostScreenControl
<Text style={styles.reconnectButtonText}>Reconnect</Text>
</Pressable>
) : null}
{!embedded && floatingWorkspaceEnabled ? (
{!embedded && floatingWorkspaceEnabled && !railActions ? (
<Pressable
style={[
styles.floatingWorkspaceHeaderButton,
@@ -142,88 +151,34 @@ export function HostScreenHeader({ controller }: { controller: HostScreenControl
</Pressable>
</View>
<View style={styles.embeddedToolbarRow}>
<Pressable
style={[
styles.embeddedToolbarIconButton,
connState !== 'connected' && styles.toolbarIconDisabled
]}
onPress={() =>
actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/accounts`)
}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel="Accounts"
>
<UserCircle
size={16}
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
/>
</Pressable>
<Pressable
style={[
styles.embeddedToolbarIconButton,
connState !== 'connected' && styles.toolbarIconDisabled
]}
onPress={() => actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/tasks`)}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel="Tasks"
>
<List
size={16}
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
/>
</Pressable>
{floatingWorkspaceEnabled ? (
<Pressable
style={[
styles.embeddedToolbarIconButton,
connState !== 'connected' && styles.toolbarIconDisabled
]}
onPress={actions.openFloatingWorkspace}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel="Floating Workspace"
>
<SquareTerminal
size={18}
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
/>
</Pressable>
) : null}
<Pressable
style={[
styles.embeddedToolbarIconButton,
connState !== 'connected' && styles.toolbarIconDisabled
]}
onPress={actions.openNewWorktreeModal}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel="New workspace"
>
<Plus
size={16}
color={connState === 'connected' ? colors.textPrimary : colors.textMuted}
/>
</Pressable>
<Pressable
style={styles.embeddedToolbarIconButton}
onPress={() => state.setShowSearch((s) => !s)}
accessibilityRole="button"
accessibilityLabel={state.showSearch ? 'Close search' : 'Search workspaces'}
>
{state.showSearch ? (
<X size={16} color={colors.textSecondary} />
) : (
<Search size={16} color={colors.textSecondary} />
)}
</Pressable>
</View>
{railActions ? null : (
<View style={styles.embeddedToolbarRow}>
{hostListActions(controller).map((action) => (
<Pressable
key={action.id}
style={[
styles.embeddedToolbarIconButton,
action.disabled && styles.toolbarIconDisabled
]}
onPress={action.onPress}
disabled={action.disabled}
accessibilityRole="button"
accessibilityLabel={action.label}
>
<action.icon
size={action.id === 'floating' ? 18 : 16}
color={
action.disabled
? colors.textMuted
: action.id === 'new'
? colors.textPrimary
: colors.textSecondary
}
/>
</Pressable>
))}
</View>
)}
</View>
) : (
<View style={styles.toolbar}>
@@ -245,47 +200,52 @@ export function HostScreenHeader({ controller }: { controller: HostScreenControl
</Pressable>
<View style={styles.toolbarSpacer} />
{railActions ? null : (
<>
<Pressable
style={styles.searchToggle}
onPress={() =>
actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/accounts`)
}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel="Accounts"
>
<UserCircle
size={16}
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
/>
</Pressable>
<Pressable
style={styles.searchToggle}
onPress={() =>
actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/accounts`)
}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel="Accounts"
>
<UserCircle
size={16}
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
/>
</Pressable>
<Pressable
style={styles.searchToggle}
onPress={() =>
actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/tasks`)
}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel="Tasks"
>
<List
size={16}
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
/>
</Pressable>
<Pressable
style={styles.searchToggle}
onPress={() => actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/tasks`)}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel="Tasks"
>
<List
size={16}
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
/>
</Pressable>
<Pressable
style={styles.searchToggle}
onPress={() => state.setShowSearch((s) => !s)}
accessibilityRole="button"
accessibilityLabel={state.showSearch ? 'Close search' : 'Search workspaces'}
>
{state.showSearch ? (
<X size={16} color={colors.textSecondary} />
) : (
<Search size={16} color={colors.textSecondary} />
)}
</Pressable>
<Pressable
style={styles.searchToggle}
onPress={() => state.setShowSearch((s) => !s)}
accessibilityRole="button"
accessibilityLabel={state.showSearch ? 'Close search' : 'Search workspaces'}
>
{state.showSearch ? (
<X size={16} color={colors.textSecondary} />
) : (
<Search size={16} color={colors.textSecondary} />
)}
</Pressable>
</>
)}
</View>
)}
</View>
@@ -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,
@@ -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: () => {} }))
@@ -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,
+15 -2
View File
@@ -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
+67
View File
@@ -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<typeof screen>[]) {
const tree = (
<SideRailProvider edge={edge} width={84} bottom={34}>
{screens}
</SideRailProvider>
)
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([])
})
})
+130
View File
@@ -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<typeof createSideRailStore>
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 (
<SideRailContext.Provider value={value}>
{children}
{edge ? (
<View
style={[styles.rail, { [edge]: 0, width, paddingBottom: bottom }]}
pointerEvents="box-none"
>
<SideRailSlot store={store} />
</View>
) : null}
</SideRailContext.Provider>
)
}
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
}
})
+48 -11
View File
@@ -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<EdgeInsets | null>(null)
const SideRailReleaseContext = createContext<Dispatch<SetStateAction<number>> | 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 = (
<WindowSafeAreaInsetsContext.Provider value={insets}>
<SafeAreaFrameContext.Provider value={areaFrame}>
<SafeAreaInsetsContext.Provider value={areaInsets}>
{children}
<SideRailReleaseContext.Provider value={setReleases}>
{children}
</SideRailReleaseContext.Provider>
</SafeAreaInsetsContext.Provider>
</SafeAreaFrameContext.Provider>
</WindowSafeAreaInsetsContext.Provider>
)
// Native SafeAreaView measures its nearest native provider, so nest one inside the padding; web reads the adjusted context.
return (
<View style={[styles.area, { paddingLeft: insets.left, paddingRight: insets.right }]}>
{Platform.OS === 'web' ? area : <SafeAreaProvider>{area}</SafeAreaProvider>}
<View style={[styles.area, { paddingLeft: left, paddingRight: right }]}>
<SideRailProvider
edge={released ? null : railEdge}
width={railEdge ? insets[railEdge] : 0}
bottom={insets.bottom}
>
{Platform.OS === 'web' ? area : <SafeAreaProvider>{area}</SafeAreaProvider>}
</SideRailProvider>
</View>
)
}
// 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()
@@ -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 <View style={styles.centered}>{children}</View>
}
function Waiting({ label }: { label: string }) {
return (
<Centered>
<ShellWaitingFrame label={label} />
</Centered>
)
}
function Failed({
state,
onRetry
}: {
state: Extract<MobileWebShellSessionState, { kind: 'failed' }>
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 (
<Centered>
<Text style={styles.failedMessage} testID="mobile-web-shell-failed">
{failureMessage(state.reason)}
</Text>
{retryable ? (
<Pressable
style={({ pressed }) => [styles.retryButton, pressed && styles.pressed]}
testID="mobile-web-shell-retry"
onPress={onRetry}
>
<Text style={styles.retryLabel}>Try again</Text>
</Pressable>
) : null}
</Centered>
)
}
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 <PageRouteUnavailableScreen hostId={hostId} />
}
if (state.kind === 'wall') {
return <ProtocolBlockScreen verdict={state.verdict} />
}
if (state.kind === 'failed') {
return <Failed state={state} onRetry={retry} />
}
if (state.kind === 'offline') {
return (
<Centered>
<Text style={styles.waitingLabel} testID="mobile-web-shell-offline">
Connect to this host to download the latest app
</Text>
</Centered>
)
}
if (state.kind === 'fetching') {
return (
<Centered>
<ShellDownloadProgress
state={state}
feed={downloadProgress}
host={snapshot?.host ?? null}
/>
</Centered>
)
}
if (state.kind !== 'ready') {
return <Waiting label={state.kind === 'activating' ? SHELL_OPENING_LABEL : 'Checking host'} />
return (
<ShellStateScreen
hostId={hostId}
state={state}
onRetry={retry}
downloadProgress={downloadProgress}
host={snapshot?.host ?? null}
/>
)
}
return (
<View
@@ -404,38 +328,5 @@ const styles = themedStyleSheet(() => ({
},
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
}
}))
@@ -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 <View style={styles.centered}>{children}</View>
}
function Failed({
state,
onRetry
}: {
state: Extract<MobileWebShellSessionState, { kind: 'failed' }>
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 (
<Centered>
<Text style={styles.failedMessage} testID="mobile-web-shell-failed">
{failureMessage(state.reason)}
</Text>
{retryable ? (
<Pressable
style={({ pressed }) => [styles.retryButton, pressed && styles.pressed]}
testID="mobile-web-shell-retry"
onPress={onRetry}
>
<Text style={styles.retryLabel}>Try again</Text>
</Pressable>
) : null}
</Centered>
)
}
function StateView({
hostId,
state,
onRetry,
downloadProgress,
host
}: {
hostId: string
state: Exclude<MobileWebShellSessionState, { kind: 'ready' }>
onRetry: () => void
downloadProgress: DownloadProgressFeed
host: HostDisplaySource | null
}) {
switch (state.kind) {
case 'route-unavailable':
return <PageRouteUnavailableScreen hostId={hostId} />
case 'wall':
return <ProtocolBlockScreen verdict={state.verdict} />
case 'failed':
return <Failed state={state} onRetry={onRetry} />
case 'offline':
return (
<Centered>
<Text style={styles.waitingLabel} testID="mobile-web-shell-offline">
Connect to this host to download the latest app
</Text>
</Centered>
)
case 'fetching':
return (
<Centered>
<ShellDownloadProgress state={state} feed={downloadProgress} host={host} />
</Centered>
)
case 'checking':
case 'activating':
return (
<Centered>
<ShellWaitingFrame
label={state.kind === 'activating' ? SHELL_OPENING_LABEL : 'Checking host'}
/>
</Centered>
)
}
}
// 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<typeof StateView>[0]) {
return (
<SideSafeArea>
<StateView {...props} />
</SideSafeArea>
)
}
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
}
}))
@@ -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.
@@ -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)
@@ -132,6 +132,7 @@ export function harness(
pageReadies += 1
},
onPageBackClaim: (claimed) => backClaims.push(claimed),
onPageLocation: () => {},
onPagePainted: () => {
pagePaints += 1
},
@@ -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 })
}
}
}
@@ -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<RpcResponse>,
verb: BridgeNativeVerb,
params: unknown
): Promise<RpcSuccess> {
// 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
})
}
@@ -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)
})
])
@@ -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<Record<BridgeNotifyName, string | null>> =
[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',
@@ -0,0 +1 @@
export const BRIDGE_PAGE_LOCATION = 'location'
@@ -222,6 +222,7 @@ export function createBridgePortPair<TRpc extends RpcClient>(
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)
@@ -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<RpcSuccess>
// 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,
@@ -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,
@@ -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')
})
})
@@ -0,0 +1,19 @@
type RouteParams = Readonly<Record<string, string | string[] | undefined>>
// 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('/')}`
}
@@ -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<void> {
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()
})
})
@@ -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()
@@ -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,
@@ -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,
@@ -47,6 +47,8 @@ export type ScreenDependencies = {
backHandlers: Map<string, () => 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
@@ -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
})
})
}
@@ -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<string | null>(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
}
}
@@ -0,0 +1,2 @@
// Native routes are the location already; only a page reports its own hops.
export function useReportedPageLocation(): void {}
@@ -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])
}
@@ -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'
@@ -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.
+4 -1
View File
@@ -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()}
<SideRailOnTopContext.Provider value={route.key === topKey}>
{descriptor?.render()}
</SideRailOnTopContext.Provider>
</StackScreen>
)
})}
@@ -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 (
<View ref={keepsKeyboard} style={vertical ? styles.accessoryRail : styles.accessoryBar}>
{/* Why: fixed keyboard escape hatch; outside ScrollView + shortcut path so it can't scroll away or be hidden (#5106). */}
{keyboardLift > 0 && (
<Pressable
style={({ pressed }) => [
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."
>
<View style={styles.keyboardDismissGlyph}>
<KeyboardIcon size={15} color={colors.textSecondary} strokeWidth={2} />
<ChevronDown
size={10}
color={colors.textSecondary}
strokeWidth={2.5}
style={styles.keyboardDismissChevron}
/>
</View>
</Pressable>
)}
{/* Why: default tap handling makes the first accessory-key tap dismiss the keyboard and get swallowed (#5106). */}
<ScrollView
style={vertical ? styles.accessoryScrollVertical : styles.accessoryScroll}
horizontal={!vertical}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
contentContainerStyle={vertical ? styles.accessoryContentVertical : styles.accessoryContent}
keyboardShouldPersistTaps="always"
>
<Pressable
style={({ pressed }) => [
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) ? (
<Monitor size={14} color={canSend ? colors.textSecondary : colors.textMuted} />
) : (
<Smartphone size={14} color={canSend ? colors.textSecondary : colors.textMuted} />
)}
</Pressable>
<Pressable
style={({ pressed }) => [
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'
}
>
<ChevronsRight
size={14}
color={
liveInputEnabled
? colors.bgBase
: canCompose
? colors.textSecondary
: colors.textMuted
}
/>
</Pressable>
{canPaste && (
<Pressable
style={({ pressed }) => [
styles.accessoryKey,
vertical && styles.accessoryKeyVertical,
vertical && styles.accessoryKeyVertical,
pressed && styles.accessoryKeyPressed,
!canSend && styles.accessoryKeyDisabled
]}
disabled={!canSend}
onPress={() => void handlePaste()}
accessibilityLabel="Paste from clipboard"
>
<Text style={[styles.accessoryKeyText, !canSend && styles.accessoryKeyTextDisabled]}>
Paste
</Text>
</Pressable>
)}
{visibleBuiltInAccessoryKeys.map((key) => (
<Pressable
key={key.id}
style={({ pressed }) => [
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}`}
>
<Text style={[styles.accessoryKeyText, !canSend && styles.accessoryKeyTextDisabled]}>
{key.label}
</Text>
</Pressable>
))}
{customKeys.map((key) => (
<Pressable
key={key.id}
style={({ pressed }) => [
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}`}
>
<Text style={[styles.accessoryKeyText, !canSend && styles.accessoryKeyTextDisabled]}>
{key.label}
</Text>
</Pressable>
))}
<Pressable
style={({ pressed }) => [
styles.accessoryKey,
vertical && styles.accessoryKeyVertical,
pressed && styles.accessoryKeyPressed
]}
onPress={() => setShowCustomKeyModal(true)}
accessibilityLabel="Add custom shortcut"
>
<Plus size={14} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
</ScrollView>
</View>
)
}
+22 -214
View File
@@ -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) && (
<View
style={[
styles.commandDock,
@@ -84,181 +66,7 @@ export function MobileSessionCommandDock({ controller }: { controller: MobileSes
]}
>
{}
<View ref={accessoryBarKeepsKeyboard} style={styles.accessoryBar}>
{/* Why: fixed keyboard escape hatch; outside ScrollView + shortcut path so it can't scroll away or be hidden (#5106). */}
{keyboardLift > 0 && (
<Pressable
style={({ pressed }) => [
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."
>
<View style={styles.keyboardDismissGlyph}>
<KeyboardIcon size={15} color={colors.textSecondary} strokeWidth={2} />
<ChevronDown
size={10}
color={colors.textSecondary}
strokeWidth={2.5}
style={styles.keyboardDismissChevron}
/>
</View>
</Pressable>
)}
{/* Why: default tap handling makes the first accessory-key tap dismiss the keyboard and get swallowed (#5106). */}
<ScrollView
style={styles.accessoryScroll}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.accessoryContent}
keyboardShouldPersistTaps="always"
>
<Pressable
style={({ pressed }) => [
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) ? (
<Monitor size={14} color={canSend ? colors.textSecondary : colors.textMuted} />
) : (
<Smartphone size={14} color={canSend ? colors.textSecondary : colors.textMuted} />
)}
</Pressable>
<Pressable
style={({ pressed }) => [
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'
}
>
<ChevronsRight
size={14}
color={
liveInputEnabled
? colors.bgBase
: canCompose
? colors.textSecondary
: colors.textMuted
}
/>
</Pressable>
{canPaste && (
<Pressable
style={({ pressed }) => [
styles.accessoryKey,
pressed && styles.accessoryKeyPressed,
!canSend && styles.accessoryKeyDisabled
]}
disabled={!canSend}
onPress={() => void handlePaste()}
accessibilityLabel="Paste from clipboard"
>
<Text
style={[styles.accessoryKeyText, !canSend && styles.accessoryKeyTextDisabled]}
>
Paste
</Text>
</Pressable>
)}
{visibleBuiltInAccessoryKeys.map((key) => (
<Pressable
key={key.id}
style={({ pressed }) => [
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}`}
>
<Text
style={[styles.accessoryKeyText, !canSend && styles.accessoryKeyTextDisabled]}
>
{key.label}
</Text>
</Pressable>
))}
{customKeys.map((key) => (
<Pressable
key={key.id}
style={({ pressed }) => [
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}`}
>
<Text
style={[styles.accessoryKeyText, !canSend && styles.accessoryKeyTextDisabled]}
>
{key.label}
</Text>
</Pressable>
))}
<Pressable
style={({ pressed }) => [styles.accessoryKey, pressed && styles.accessoryKeyPressed]}
onPress={() => setShowCustomKeyModal(true)}
accessibilityLabel="Add custom shortcut"
>
<Plus size={14} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
</ScrollView>
</View>
{keysInRail ? null : <MobileSessionAccessoryKeys controller={controller} />}
{}
{liveInputEnabled ? (
<View style={[styles.inputBar, styles.liveInputBar]}>
@@ -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}
<MobileSessionActiveContent controller={controller} />
{/* Why: translate instead of resize so keyboard toggles don't trigger a server-side PTY viewport change. */}
<MobileSessionCommandDock controller={controller} />
<MobileSessionCommandDock controller={controller} keysInRail={keysInRail} />
</View>
{canDockPanel && activePanel !== null && (
<SessionDockColumn
+14 -6
View File
@@ -10,7 +10,13 @@ import { useHostSidebarReveal } from '../layout/host-sidebar-reveal'
import { useResponsiveLayout } from '../layout/responsive-layout'
import type { MobileSessionController } from './use-mobile-session-controller'
export function MobileSessionHeader({ controller }: { controller: MobileSessionController }) {
export function MobileSessionHeader({
controller,
backInRail
}: {
controller: MobileSessionController
backInRail: boolean
}) {
useMobileColorScheme()
const {
hostId,
@@ -45,11 +51,13 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC
onPress={revealSidebar}
/>
) : null}
<MobileSessionHeaderIconButton
accessibilityLabel="Back to worktrees"
icon={ChevronLeft}
onPress={requestLeaveSession}
/>
{backInRail ? null : (
<MobileSessionHeaderIconButton
accessibilityLabel="Back to worktrees"
icon={ChevronLeft}
onPress={requestLeaveSession}
/>
)}
<View
style={[styles.sessionTitleBlock, tabsInTopBar && styles.sessionTitleBlockBesideTabs]}
+4 -2
View File
@@ -5,16 +5,18 @@ import type { MobileSessionController } from './use-mobile-session-controller'
import { MobileSessionContentRow } from './MobileSessionContentRow'
import { MobileSessionHeader } from './MobileSessionHeader'
import { MobileSessionSheets } from './MobileSessionSheets'
import { useSessionSideRail } from './session-side-rail'
export function MobileSessionSurface({ controller }: { controller: MobileSessionController }) {
useMobileColorScheme()
const { setMobileSessionRootRef } = controller
const rail = useSessionSideRail(controller)
return (
<View ref={setMobileSessionRootRef} style={styles.container}>
<View style={styles.kavInner}>
<MobileSessionHeader controller={controller} />
<MobileSessionHeader controller={controller} backInRail={rail.backInRail} />
{}
<MobileSessionContentRow controller={controller} />
<MobileSessionContentRow controller={controller} keysInRail={rail.keysInRail} />
</View>
<MobileSessionSheets controller={controller} />
</View>
@@ -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,
@@ -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'
+73
View File
@@ -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 ? (
<SessionRail controller={controller} back={backInRail} keys={keysInRail} />
) : null
)
return { backInRail, keysInRail }
}
function SessionRail({
controller,
back,
keys
}: {
controller: MobileSessionController
back: boolean
keys: boolean
}) {
useMobileColorScheme()
return (
<View style={[styles.rail, { paddingBottom: controller.keyboardLift }]}>
{back ? (
<Pressable
style={({ pressed }) => [styles.back, pressed && styles.backPressed]}
onPress={controller.requestLeaveSession}
accessibilityRole="button"
accessibilityLabel="Back to worktrees"
>
<ChevronLeft size={22} color={colors.textPrimary} />
</Pressable>
) : null}
{keys ? <MobileSessionAccessoryKeys controller={controller} vertical /> : null}
</View>
)
}
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
}
}))
+4 -4
View File
@@ -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 () => {
+7 -12
View File
@@ -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<number> {
// Null until the user drags the sidebar; the layout then sizes it from the window.
export async function loadHostSidebarWidth(): Promise<number | null> {
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
}
}