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