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 } }