feat(mobile): iPhone Duo key rail with D-pad, split terminal panes and half-fold layout (#35177)

Carries over #33221 and #33210. Replaces our #34484 Duo support with the community design, on top of main's rebuilt terminal key bar (#36166).

Fixes #33200

Co-authored-by: Takeshi Matsumoto <zytakeshi520@gmail.com>
Co-authored-by: Lars Keggenhoff <lars@kghf.io>
This commit is contained in:
Kelvin Amoaba
2026-10-30 16:35:12 +00:00
committed by GitHub
co-authored by Takeshi Matsumoto Lars Keggenhoff
parent 0eaa1f9049
commit a803cbead8
213 changed files with 10043 additions and 1899 deletions
+4 -4
View File
@@ -26,8 +26,8 @@ permissions:
jobs:
ios-build:
# Use macos-26/Xcode 26 for Expo SDK 55’s Swift 6 syntax/isolation; Xcode 16 runners cannot parse it.
runs-on: macos-26
# Xcode 27.1's iOS SDK enables iPhone Duo resizing; Expo SDK 55 requires Swift 6 syntax.
runs-on: xcode-27
# Archive + upload is ~30–40m when healthy; 90m leaves margin for setup.
timeout-minutes: 90
@@ -50,8 +50,8 @@ jobs:
- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
# Xcode 26.x ships the Swift 6.x toolchain Expo SDK 55 requires.
xcode-version: '26.5'
# iOS 27.1 SDK: iPhone Duo resizing and reserved (fold/camera) regions.
xcode-version: '27.1'
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -450,7 +450,8 @@ export function resolveMobileWebPageRoutes(routeKeys, declared = MOBILE_WEB_PAGE
...(route.optionalGrants === undefined || route.optionalGrants.length === 0
? {}
: { optionalGrants: [...route.optionalGrants] }),
...(route.canOwnHostArea === true ? { canOwnHostArea: true } : {})
...(route.canOwnHostArea === true ? { canOwnHostArea: true } : {}),
...(route.canOwnHostAreaAcrossLayouts === true ? { canOwnHostAreaAcrossLayouts: true } : {})
}))
}
+9 -3
View File
@@ -6,7 +6,8 @@ export const MOBILE_WEB_PAGE_ROUTES = [
{
pathname: '/h/[hostId]',
grants: ['navigate', 'storage', 'externalLink', 'haptics'],
canOwnHostArea: true
canOwnHostArea: true,
canOwnHostAreaAcrossLayouts: true
},
// History needs navigate for session/resume, storage for shared sidebar width and externalLink for layout protocol wall.
{
@@ -75,7 +76,12 @@ export const MOBILE_WEB_PAGE_ROUTES = [
'native.audio.read',
'native.audio.stop'
],
// Optional media.name preserves attachment on older shells by falling back to filename derived from type.
optionalGrants: ['externalNavigation', 'native.media.name', 'native.pdf.view']
// Optional media.name falls back to the file type; terminalKeyRail falls back to the page's row.
optionalGrants: [
'externalNavigation',
'native.media.name',
'native.pdf.view',
'terminalKeyRail'
]
}
]
+43 -6
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
MOBILE_WEB_BUNDLE_MAX_GRANTED_ROUTE_GRANTS,
MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS,
MobileWebBundleRouteSchema
} from '../../src/shared/mobile-web-bundle/manifest-contract.ts'
@@ -58,16 +59,51 @@ describe('the page routes the manifest declares', () => {
])
})
// Wide host-area grants union all served routes to retain in-page hops, within same per-route ceiling.
it('serializes cross-layout ownership only for opted-in routes and declares it only on the host root', () => {
const routes = resolveMobileWebPageRoutes(
['./h/[hostId]/index.tsx', './h/[hostId]/tasks.tsx'],
[
{
pathname: '/h/[hostId]',
grants: ['navigate'],
canOwnHostArea: true,
canOwnHostAreaAcrossLayouts: true
},
{ pathname: '/h/[hostId]/tasks', grants: ['navigate'], canOwnHostArea: true }
]
)
expect(JSON.parse(JSON.stringify(routes))).toEqual([
{
pathname: '/h/[hostId]',
grants: ['navigate'],
canOwnHostArea: true,
canOwnHostAreaAcrossLayouts: true
},
{ pathname: '/h/[hostId]/tasks', grants: ['navigate'], canOwnHostArea: true }
])
expect(
MOBILE_WEB_PAGE_ROUTES.filter((route) => route.canOwnHostAreaAcrossLayouts).map(
(route) => route.pathname
)
).toEqual(['/h/[hostId]'])
for (const route of routes) {
expect(MobileWebBundleRouteSchema.safeParse(route).success).toBe(true)
}
})
/**
* The wide iPad's host-area session is granted every served route's list, so its in-page hops
* stay in the page. Bounded by what one route can be granted, so that grant never outgrows one route's.
*/
it('bounds the aggregate required and optional grants by one route-grant ceiling', () => {
const union = new Set(
MOBILE_WEB_PAGE_ROUTES.flatMap((route) => [...route.grants, ...(route.optionalGrants ?? [])])
)
expect(union.size).toBeLessThanOrEqual(MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS)
expect(union.size).toBeLessThanOrEqual(MOBILE_WEB_BUNDLE_MAX_GRANTED_ROUTE_GRANTS)
})
it('holds the optional lane to the manifest grammar and the ceiling over the union', () => {
// Optional/required tokens share schema grammar and combined bound; independent bounds could exceed manifest ceiling.
it('holds the optional lane to the manifest grammar and its own ceiling', () => {
// Released shells read each list against the ceiling on its own, so the writer bounds each the same way.
const withOptional = (optionalGrants, grants = []) =>
MobileWebBundleRouteSchema.safeParse({ pathname: '/h/[hostId]', grants, optionalGrants })
.success
@@ -75,8 +111,9 @@ describe('the page routes the manifest declares', () => {
expect(withOptional(['native.externalNavigation'])).toBe(false)
const names = (count, prefix) =>
Array.from({ length: count }, (_value, index) => `${prefix}${String(index)}`)
expect(withOptional(names(8, 'opt'), names(8, 'req'))).toBe(true)
expect(withOptional(names(9, 'opt'), names(8, 'req'))).toBe(false)
const cap = MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS
expect(withOptional(names(cap, 'opt'), names(cap, 'req'))).toBe(true)
expect(withOptional(names(cap + 1, 'opt'))).toBe(false)
})
it('fails the build on a declaration the bundle cannot render', () => {
+2
View File
@@ -110,6 +110,8 @@
],
"expo-notifications",
"./plugins/android-respect-rotation-lock.js",
"./plugins/ios-pod-deployment-target-floor.js",
"./plugins/ios-scene-lifecycle.js",
[
"expo-splash-screen",
{
+50 -47
View File
@@ -29,6 +29,7 @@ import { consumeInitialLaunchIntent } from '../src/navigation/initial-launch-int
import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery'
import { appUpdateChecker } from '../src/app-update/app-update-runtime'
import { SideSafeArea } from '../src/layout/side-safe-area'
import { SideRailProvider } from '../src/layout/side-rail'
import { useFollowDeviceUiLocale } from '../src/i18n/use-follow-device-ui-locale'
import { startMobileUiLanguage } from '../src/i18n/ui-language-preference'
import { subscribeMobileUiLanguage } from '../src/i18n/mobile-i18n'
@@ -183,54 +184,56 @@ export default function RootLayout() {
<RpcClientProvider>
<View style={styles.root} onLayout={onNavigatorLayout}>
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
<SideSafeArea>
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.bgPanel },
headerTintColor: colors.textPrimary,
headerTitleStyle: { fontSize: 16, fontWeight: '600' },
contentStyle: { backgroundColor: colors.bgBase },
headerShadowVisible: false
// Leave orientation unset: react-native-screens overrides rotation lock even for default; Android manifest fullUser honors the user’s setting.
}}
>
<Stack.Screen
name="index"
options={{
headerShown: false,
headerTitle: () => <OrcaLogo size={22} />
<SideRailProvider>
<SideSafeArea>
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.bgPanel },
headerTintColor: colors.textPrimary,
headerTitleStyle: { fontSize: 16, fontWeight: '600' },
contentStyle: { backgroundColor: colors.bgBase },
headerShadowVisible: false
// Leave orientation unset: react-native-screens overrides rotation lock even for default; Android manifest fullUser honors the user’s setting.
}}
/>
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
<Stack.Screen name="pair" options={{ headerShown: false }} />
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
<Stack.Screen
name="mobile-onboarding"
options={{ headerShown: false, presentation: 'modal', gestureEnabled: false }}
/>
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="appearance-settings" options={{ headerShown: false }} />
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
<Stack.Screen name="native-chat-settings" options={{ headerShown: false }} />
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
<Stack.Screen name="voice-settings" options={{ headerShown: false }} />
<Stack.Screen name="notifications" options={{ headerShown: false }} />
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
<Stack.Screen name="connection-log" options={{ headerShown: false }} />
<Stack.Screen name="about" options={{ headerShown: false }} />
<Stack.Screen name="h" options={{ headerShown: false }} />
<Stack.Screen
name="new-workspace"
options={{
headerShown: false,
// Draws nothing of its own: the drawer is the page's, over Home.
presentation: 'transparentModal',
animation: 'none',
contentStyle: { backgroundColor: 'transparent' }
}}
/>
</Stack>
</SideSafeArea>
>
<Stack.Screen
name="index"
options={{
headerShown: false,
headerTitle: () => <OrcaLogo size={22} />
}}
/>
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
<Stack.Screen name="pair" options={{ headerShown: false }} />
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
<Stack.Screen
name="mobile-onboarding"
options={{ headerShown: false, presentation: 'modal', gestureEnabled: false }}
/>
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="appearance-settings" options={{ headerShown: false }} />
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
<Stack.Screen name="native-chat-settings" options={{ headerShown: false }} />
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
<Stack.Screen name="voice-settings" options={{ headerShown: false }} />
<Stack.Screen name="notifications" options={{ headerShown: false }} />
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
<Stack.Screen name="connection-log" options={{ headerShown: false }} />
<Stack.Screen name="about" options={{ headerShown: false }} />
<Stack.Screen name="h" options={{ headerShown: false }} />
<Stack.Screen
name="new-workspace"
options={{
headerShown: false,
// Draws nothing of its own: the drawer is the page's, over Home.
presentation: 'transparentModal',
animation: 'none',
contentStyle: { backgroundColor: 'transparent' }
}}
/>
</Stack>
</SideSafeArea>
</SideRailProvider>
</View>
</RpcClientProvider>
)
+1 -11
View File
@@ -20,21 +20,11 @@ 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'
import { translate } from '../../../src/i18n/mobile-i18n'
import { useMobileTranslation } from '../../../src/i18n/use-mobile-translation'
import { TextField } from '../../../src/components/composer-input/TextField'
// 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() {
export default function EditHostScreen() {
useMobileColorScheme()
const t = useMobileTranslation()
const router = useRouter()
+4 -1
View File
@@ -1,9 +1,12 @@
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
import { HostScreen } from '../../../src/host-screen/HostScreen'
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
import { usePageOwnsHostArea } from '../../../src/mobile-web-shell/page-owns-host-area'
// Render screen directly: native sibling imports a shell module that throws in browsers, and manifest imports every route.
export default function HostWorktreeRoute() {
// The placeholder only beside the page's own sidebar: without the host area this is the list.
return usePageOwnsHostArea() ? <WorkspaceDetailPlaceholder /> : <HostScreen />
const ownsHostArea = usePageOwnsHostArea()
const { isWideLayout } = useResponsiveLayout()
return ownsHostArea && isWideLayout ? <WorkspaceDetailPlaceholder /> : <HostScreen />
}
+29 -25
View File
@@ -5,13 +5,9 @@ import { colors } from '../../src/theme/mobile-theme'
import { themedStyleSheet } from '../../src/theme/themed-styles'
import { useMobileColorScheme } from '../../src/theme/use-mobile-color-scheme'
import { useResponsiveLayout } from '../../src/layout/responsive-layout'
import { useFoldSplit } from '../../src/layout/use-fold-split'
import {
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_DEFAULT_WIDTH,
HOST_SIDEBAR_MAX_WIDTH,
HOST_SIDEBAR_MIN_WIDTH,
loadHostSidebarWidth,
@@ -23,16 +19,17 @@ 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 - SPLIT_DETAIL_MIN_WIDTH)
Math.min(HOST_SIDEBAR_MAX_WIDTH, windowWidth - MIN_DETAIL_WIDTH)
)
return Math.min(hardMax, Math.max(HOST_SIDEBAR_MIN_WIDTH, Math.round(width)))
}
@@ -45,12 +42,9 @@ export default function HostGroupLayout() {
const pathname = usePathname()
const overlayRoute = isOverlayPageRoute(pathname)
const [sidebarOpen, setSidebarOpen] = useState(true)
// 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
)
// Keep the chosen width and clamp it per window, so a narrow pose (fold, rotation) never shrinks it for good.
const [chosenSidebarWidth, setSidebarWidth] = useState(HOST_SIDEBAR_DEFAULT_WIDTH)
const sidebarWidth = clampSidebarToWindow(chosenSidebarWidth, windowWidth)
// Refs keep once-created PanResponder handlers reading current widths.
const widthRef = useRef(sidebarWidth)
@@ -71,21 +65,27 @@ export default function HostGroupLayout() {
}
}, [])
// 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}`
// 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
// Book posture: the sidebar takes the half before the fold and the detail the half after it, so
// the terminal never runs across the crease.
const foldSplit = useFoldSplit()
const foldSidebarWidth =
showSidebar &&
foldSplit?.axis === 'vertical' &&
foldSplit.start >= HOST_SIDEBAR_MIN_WIDTH &&
foldSplit.size - foldSplit.end >= MIN_DETAIL_WIDTH
? foldSplit.start
: null
const foldGap = foldSidebarWidth === null || !foldSplit ? 0 : foldSplit.end - foldSplit.start
const canCollapseSidebar = showSidebar && detailHasContent && foldSidebarWidth === null
// Base host detail is a placeholder; hiding its sidebar would leave nothing useful.
useEffect(() => {
@@ -122,22 +122,26 @@ export default function HostGroupLayout() {
return (
<HostProtocolGate hostId={hostId} transparentWhilePending={overlayRoute}>
<View style={[styles.row, overlayRoute && styles.transparent]}>
{showSidebar && sidebarOpen ? (
<View style={[styles.sidebar, { width: sidebarWidth, paddingLeft: railLeft }]}>
{showSidebar && (sidebarOpen || foldSidebarWidth !== null) ? (
<View style={[styles.sidebar, { width: foldSidebarWidth ?? sidebarWidth }]}>
<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} />
{foldSidebarWidth === null ? (
<View style={styles.resizeHandle} {...resizer.panHandlers} />
) : null}
</View>
) : null}
{foldGap > 0 ? <View style={{ width: foldGap }} /> : null}
<View style={styles.detail}>
<HostAreaServingContext.Provider value={setHostAreaServing}>
<HostSidebarRevealContext.Provider
value={showSidebar && !sidebarOpen ? revealSidebar : null}
value={
showSidebar && !sidebarOpen && foldSidebarWidth === null ? revealSidebar : null
}
>
<HostStack animation={showSidebar ? 'none' : 'default'} />
</HostSidebarRevealContext.Provider>
@@ -0,0 +1,6 @@
{
"platforms": ["apple"],
"apple": {
"modules": ["OrcaReservedRegionsModule"]
}
}
@@ -0,0 +1,15 @@
Pod::Spec.new do |s|
s.name = 'OrcaReservedRegions'
s.version = '0.0.1'
s.summary = 'Reports the window regions a fold or camera reserves on iPhone Duo'
s.description = s.summary
s.license = { :type => 'MIT' }
s.author = 'Orca'
s.homepage = 'https://onorca.dev'
s.source = { :git => 'https://github.com/stablyai/orca.git' }
s.platforms = { :ios => '15.1' }
s.swift_version = '5.9'
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.source_files = '**/*.swift'
end
@@ -0,0 +1,192 @@
import ExpoModulesCore
import UIKit
// Why: iPhone Duo reserves part of the window for its fold (when partially folded) and its
// camera, and neither shows up in the safe area. React Native has no view of either, so the
// window's active regions are reported here in window points, the root view's coordinates.
public class OrcaReservedRegionsModule: Module {
private var probe: UIView?
private var keyWindowObserver: NSObjectProtocol?
public func definition() -> ModuleDefinition {
Name("OrcaReservedRegions")
Events("onChange")
OnStartObserving {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.installProbe()
// A reconnected scene brings a new window; the probe follows it.
self.keyWindowObserver = NotificationCenter.default.addObserver(
forName: UIWindow.didBecomeKeyNotification, object: nil, queue: .main
) { [weak self] _ in
self?.installProbe()
}
}
}
OnStopObserving {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
if let keyWindowObserver = self.keyWindowObserver {
NotificationCenter.default.removeObserver(keyWindowObserver)
}
self.keyWindowObserver = nil
self.probe?.removeFromSuperview()
self.probe = nil
}
}
}
private func installProbe() {
guard let window = UIApplication.shared.delegate?.window ?? nil, probe?.window !== window else {
return
}
probe?.removeFromSuperview()
probe = nil
#if canImport(UIKit, _version: 9127.0.85)
// 9127.0.85 is UIKit in the iOS 27.1 SDK, the first with reserved regions; older Xcodes skip this.
if #available(iOS 27.1, *) {
let probe = ReservedRegionsProbe(frame: window.bounds)
probe.onChange = { [weak self] payload in
self?.sendEvent("onChange", payload)
}
window.insertSubview(probe, at: 0)
self.probe = probe
}
#endif
}
}
#if canImport(UIKit, _version: 9127.0.85)
/// Invisible full-window view: it is laid out on every window resize and safe-area change, and
/// its hinge interaction lays it out again when the device folds without changing size.
@available(iOS 27.1, *)
private final class ReservedRegionsProbe: UIView {
var onChange: (([String: Any]) -> Void)?
private var reported: ReservedRegionsSnapshot?
private weak var keyboardView: UIView?
private let keyboardFollower = KeyboardGeometryFollower()
private var keyboardConstraints: [NSLayoutConstraint] = []
override init(frame: CGRect) {
super.init(frame: frame)
autoresizingMask = [.flexibleWidth, .flexibleHeight]
isUserInteractionEnabled = false
isAccessibilityElement = false
keyboardFollower.translatesAutoresizingMaskIntoConstraints = false
keyboardFollower.onLayout = { [weak self] in self?.setNeedsLayout() }
addSubview(keyboardFollower)
addInteraction(
UIHingeInteraction { [weak self] _, _ in
self?.setNeedsLayout()
})
}
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
override func didMoveToWindow() {
super.didMoveToWindow()
NSLayoutConstraint.deactivate(keyboardConstraints)
keyboardConstraints = []
keyboardView = nil
guard let window, let root = window.rootViewController?.view, root.window === window else {
return
}
keyboardView = root
let guide = root.keyboardLayoutGuide
guide.usesBottomSafeArea = false
keyboardConstraints = [
keyboardFollower.topAnchor.constraint(equalTo: guide.topAnchor),
keyboardFollower.bottomAnchor.constraint(equalTo: guide.bottomAnchor),
keyboardFollower.leadingAnchor.constraint(equalTo: guide.leadingAnchor),
keyboardFollower.trailingAnchor.constraint(equalTo: guide.trailingAnchor),
]
NSLayoutConstraint.activate(keyboardConstraints)
setNeedsLayout()
}
override func safeAreaInsetsDidChange() {
super.safeAreaInsetsDidChange()
setNeedsLayout()
}
override func layoutSubviews() {
super.layoutSubviews()
guard let window else { return }
let keyboardInset: CGFloat?
if let keyboardView, keyboardView.window === window {
let frame = keyboardView.convert(keyboardView.keyboardLayoutGuide.layoutFrame, to: window)
let overlap = frame.intersection(window.bounds)
keyboardInset = overlap.isNull ? 0 : overlap.height
} else {
keyboardInset = nil
}
let snapshot = ReservedRegionsSnapshot(
divisions: window.reservedRegions(kind: .division).map(\.frame),
hasFoldDivision: !window.reservedRegions(kind: .division, options: .includeInactive).isEmpty,
occlusions: window.reservedRegions(kind: .occlusion).map(\.frame),
windowBounds: window.bounds,
windowSafeAreaInsets: window.safeAreaInsets,
keyboardInset: keyboardInset
)
// The first layout always reports, so a listener starts from the real state.
guard snapshot != reported else { return }
reported = snapshot
onChange?(snapshot.payload)
}
}
@available(iOS 27.1, *)
private final class KeyboardGeometryFollower: UIView {
var onLayout: (() -> Void)?
private var reportedFrame: CGRect?
override func layoutSubviews() {
super.layoutSubviews()
guard frame != reportedFrame else { return }
reportedFrame = frame
onLayout?()
}
}
private struct ReservedRegionsSnapshot: Equatable {
let divisions: [CGRect]
let hasFoldDivision: Bool
let occlusions: [CGRect]
let windowBounds: CGRect
let windowSafeAreaInsets: UIEdgeInsets
let keyboardInset: CGFloat?
var payload: [String: Any] {
var result: [String: Any] = [
"divisions": divisions.map(Self.rectPayload),
"hasFoldDivision": hasFoldDivision,
"occlusions": occlusions.map(Self.rectPayload),
"windowSafeArea": [
"bounds": Self.rectPayload(windowBounds),
"insets": [
"top": Double(windowSafeAreaInsets.top),
"right": Double(windowSafeAreaInsets.right),
"bottom": Double(windowSafeAreaInsets.bottom),
"left": Double(windowSafeAreaInsets.left),
],
],
]
if let keyboardInset {
result["keyboardInset"] = Double(keyboardInset)
}
return result
}
private static func rectPayload(_ rect: CGRect) -> [String: Double] {
[
"x": Double(rect.minX), "y": Double(rect.minY), "width": Double(rect.width),
"height": Double(rect.height),
]
}
}
#endif
@@ -0,0 +1,5 @@
{
"name": "orca-reserved-regions",
"version": "0.0.1",
"private": true
}
@@ -0,0 +1,32 @@
import { requireOptionalNativeModule, type NativeModule } from 'expo-modules-core'
/** A rect in window points, the root view's coordinates. */
export type ReservedRect = { x: number; y: number; width: number; height: number }
export type WindowSafeArea = {
bounds: ReservedRect
insets: { top: number; right: number; bottom: number; left: number }
}
/** The window's active reserved regions: a partially folded iPhone Duo's fold, and its camera. */
export type ReservedRegions = {
divisions: ReservedRect[]
hasFoldDivision?: boolean
occlusions: ReservedRect[]
keyboardInset?: number
windowSafeArea?: WindowSafeArea
}
type ReservedRegionsEvents = {
/** Sent once the module starts observing, then whenever the active regions change. */
onChange: (regions: ReservedRegions) => void
}
declare class OrcaReservedRegionsModule extends NativeModule<ReservedRegionsEvents> {}
/**
* Null on Android, the web and in a binary built before the module existed. A binary built with
* an SDK older than iOS 27.1, or running on an older iOS, never sends `onChange`.
*/
export const OrcaReservedRegions =
requireOptionalNativeModule<OrcaReservedRegionsModule>('OrcaReservedRegions')
@@ -10,6 +10,16 @@ public class OrcaWindowControlsInsetSubscriber: ExpoAppDelegateSubscriber {
if let window = application.delegate?.window ?? nil {
WindowControlsInsetProbe.install(in: window)
}
// Under the UIScene lifecycle the scene delegate creates the window after launch.
NotificationCenter.default.addObserver(
forName: UIWindow.didBecomeKeyNotification, object: nil, queue: .main
) { notification in
if let window = notification.object as? UIWindow,
window === UIApplication.shared.delegate?.window ?? nil
{
WindowControlsInsetProbe.install(in: window)
}
}
return true
}
}
@@ -19,6 +29,7 @@ private final class WindowControlsInsetProbe: UIView {
private var rootObservation: NSKeyValueObservation?
static func install(in window: UIWindow) {
guard !window.subviews.contains(where: { $0 is WindowControlsInsetProbe }) else { return }
let probe = WindowControlsInsetProbe(frame: window.bounds)
probe.autoresizingMask = [.flexibleWidth, .flexibleHeight]
probe.isUserInteractionEnabled = false
+14
View File
@@ -0,0 +1,14 @@
diff --git a/ios/LinkPreview/LinkPreviewNativeActionView.swift b/ios/LinkPreview/LinkPreviewNativeActionView.swift
index 3884b7844b99484f705632100134a16547e0bf0f..af749936ffc60f54f560fcb84bd6ad4067c746a4 100644
--- a/ios/LinkPreview/LinkPreviewNativeActionView.swift
+++ b/ios/LinkPreview/LinkPreviewNativeActionView.swift
@@ -137,7 +137,8 @@ class LinkPreviewNativeActionView: RouterViewWithLogger, LinkPreviewMenuUpdatabl
baseUiAction.attributes = attributes
baseUiAction.state = isOn == true ? .on : .off
- if let subtitle = subtitle {
+ // Orca: the iOS 27.1 SDK marks UIAction.subtitle iOS 16+, which fails the iOS 15.1 build.
+ if let subtitle = subtitle, #available(iOS 16.0, *) {
baseUiAction.subtitle = subtitle
}
if let label = discoverabilityLabel {
@@ -0,0 +1,33 @@
const { withPodfile } = require('expo/config-plugins')
// Why: Xcode 27 rejects deployment targets below iOS 15.0, and some pods (and their
// resource-bundle targets) still declare 9.0–13.4, which fails the build. The iOS 27 SDK
// is what lets the app resize on iPhone Duo, so raise those targets to the app's own.
const DEPLOYMENT_TARGET_FLOOR = '15.1'
const MARKER = '# orca: pod deployment target floor'
const POST_INSTALL = 'post_install do |installer|'
const FLOOR_SNIPPET = `
${MARKER}
installer.pods_project.targets.each do |target|
target.build_configurations.each do |build_config|
target_version = build_config.build_settings['IPHONEOS_DEPLOYMENT_TARGET']
if target_version && Gem::Version.new(target_version) < Gem::Version.new('${DEPLOYMENT_TARGET_FLOOR}')
build_config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '${DEPLOYMENT_TARGET_FLOOR}'
end
end
end`
module.exports = function withIosPodDeploymentTargetFloor(config) {
return withPodfile(config, (cfg) => {
const podfile = cfg.modResults.contents
if (podfile.includes(MARKER)) {
return cfg
}
if (!podfile.includes(POST_INSTALL)) {
throw new Error('ios-pod-deployment-target-floor: Podfile has no post_install hook')
}
cfg.modResults.contents = podfile.replace(POST_INSTALL, `${POST_INSTALL}${FLOOR_SNIPPET}`)
return cfg
})
}
+181
View File
@@ -0,0 +1,181 @@
const fs = require('node:fs')
const path = require('node:path')
const {
IOSConfig,
withAppDelegate,
withDangerousMod,
withInfoPlist,
withXcodeProject
} = require('expo/config-plugins')
// Why: UIKit stops an app built with the iOS 27 SDK at launch unless it adopts the UIScene
// lifecycle (TN3187), and Expo SDK 55's AppDelegate template still creates the window itself.
// The iOS 27 SDK is what lets the app resize on iPhone Duo, so the window moves into a scene
// delegate here. Drop this plugin once Expo's template adopts scenes (SDK 58).
const MARKER = 'orca: UIScene lifecycle'
const SCENE_MANIFEST = {
UIApplicationSupportsMultipleScenes: false,
UISceneConfigurations: {
UIWindowSceneSessionRoleApplication: [
{
UISceneConfigurationName: 'Default Configuration',
UISceneDelegateClassName: '$(PRODUCT_MODULE_NAME).SceneDelegate'
}
]
}
}
const FACTORY_PROPERTY = ' var reactNativeFactory: RCTReactNativeFactory?\n'
const WINDOW_BLOCK =
/#if os\(iOS\) \|\| os\(tvOS\)\s*\n\s*window = UIWindow\(frame: UIScreen\.main\.bounds\)\s*\n\s*factory\.startReactNative\(\s*withModuleName: "main",\s*in: window,\s*launchOptions: launchOptions\)\s*\n#endif\n/
function patchAppDelegate(contents) {
if (contents.includes(MARKER)) {
return contents
}
// A changed template must fail prebuild rather than ship an app that cannot launch.
if (!contents.includes(FACTORY_PROPERTY) || !WINDOW_BLOCK.test(contents)) {
throw new Error(
"ios-scene-lifecycle: Expo's AppDelegate template changed; update the plugin before building."
)
}
return contents
.replace(
FACTORY_PROPERTY,
`${FACTORY_PROPERTY} // ${MARKER}: SceneDelegate starts React Native with these.\n var launchOptions: [UIApplication.LaunchOptionsKey: Any]?\n`
)
.replace(
WINDOW_BLOCK,
` // ${MARKER}: SceneDelegate creates the window.\n self.launchOptions = launchOptions\n`
)
}
const SCENE_DELEGATE_SWIFT = `import React
import UIKit
// ${MARKER}, generated by plugins/ios-scene-lifecycle.js.
// Under scenes UIKit stops calling the AppDelegate URL callbacks, where RCTLinkingManager and
// Expo's subscribers listen, so every URL and user activity is forwarded to them.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private var appDelegate: AppDelegate? {
UIApplication.shared.delegate as? AppDelegate
}
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene, let appDelegate,
let factory = appDelegate.reactNativeFactory
else { return }
let window = UIWindow(windowScene: windowScene)
self.window = window
// A scene iOS dropped in the background and reconnects keeps the running app, not a fresh one.
let runningRoot = appDelegate.window?.rootViewController
appDelegate.window = window
let launchURL = connectionOptions.urlContexts.first
// Universal links are the only activity the app handles.
let launchActivity = connectionOptions.userActivities.first {
$0.activityType == NSUserActivityTypeBrowsingWeb
}
if let runningRoot {
window.rootViewController = runningRoot
window.makeKeyAndVisible()
} else {
// A cold-start URL arrives here rather than in the launch options getInitialURL reads.
var launchOptions = appDelegate.launchOptions ?? [:]
if let launchURL {
launchOptions[.url] = launchURL.url
if let source = launchURL.options.sourceApplication {
launchOptions[.sourceApplication] = source
}
}
if let launchActivity {
launchOptions[.userActivityDictionary] = [
UIApplication.LaunchOptionsKey.userActivityType.rawValue: launchActivity.activityType,
"UIApplicationLaunchOptionsUserActivityKey": launchActivity,
]
}
factory.startReactNative(withModuleName: "main", in: window, launchOptions: launchOptions)
}
if let launchURL {
forward(launchURL)
}
if let launchActivity {
_ = appDelegate.application(UIApplication.shared, continue: launchActivity) { _ in }
}
}
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
for context in URLContexts {
forward(context)
}
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
_ = appDelegate?.application(UIApplication.shared, continue: userActivity) { _ in }
}
private func forward(_ context: UIOpenURLContext) {
var options: [UIApplication.OpenURLOptionsKey: Any] = [
.openInPlace: context.options.openInPlace
]
if let source = context.options.sourceApplication {
options[.sourceApplication] = source
}
if let annotation = context.options.annotation {
options[.annotation] = annotation
}
_ = appDelegate?.application(UIApplication.shared, open: context.url, options: options)
}
}
`
module.exports = function withIosSceneLifecycle(config) {
config = withInfoPlist(config, (cfg) => {
cfg.modResults.UIApplicationSceneManifest = SCENE_MANIFEST
return cfg
})
config = withAppDelegate(config, (cfg) => {
if (cfg.modResults.language !== 'swift') {
throw new Error(
`ios-scene-lifecycle: expected a Swift AppDelegate, got ${cfg.modResults.language}`
)
}
cfg.modResults.contents = patchAppDelegate(cfg.modResults.contents)
return cfg
})
config = withDangerousMod(config, [
'ios',
(cfg) => {
const { platformProjectRoot, projectName } = cfg.modRequest
fs.writeFileSync(
path.join(platformProjectRoot, projectName, 'SceneDelegate.swift'),
SCENE_DELEGATE_SWIFT
)
return cfg
}
])
return withXcodeProject(config, (cfg) => {
const filepath = `${cfg.modRequest.projectName}/SceneDelegate.swift`
if (!cfg.modResults.hasFile(filepath)) {
IOSConfig.XcodeUtils.addBuildSourceFileToGroup({
filepath,
groupName: cfg.modRequest.projectName,
project: cfg.modResults
})
}
return cfg
})
}
+5 -4
View File
@@ -176,6 +176,7 @@ patchedDependencies:
'@xterm/addon-webgl@0.20.0-beta.300': 5349718f2c19d9319a81552580bf9618ded9b00f3707a5c6cdbbae1910849cd9
braces@3.0.3: c07bb14cec04bc891e1fe529720444cc9729a25117f9aaf24253cfe3132a34fd
expo-notifications@55.0.27: ce20843a3daad4185d7e8571788fa323ba4d11984936188790858650a61749c0
expo-router@55.0.18: 5cdc178be0ec617ebb8dec85eebe4effeca13a6551b87fcb7bcd20ec0d2a313d
node-forge@1.4.0: 2798686e4488058ad81c3b8e32eb763c0feff21f4c717ce4d13b6ef2432cd5b8
query-string@9.5.1: 8624f1beedd0705bfdaf287519ed6363662a0c96f01fd594508b34583bf30d8b
react-native-webview@13.16.2: 55b7bce35c78ee1a50c4b99ad65b52982f3cfa73da6f61d0853c4d03219eec06
@@ -264,7 +265,7 @@ importers:
version: 55.0.27(patch_hash=ce20843a3daad4185d7e8571788fa323ba4d11984936188790858650a61749c0)(expo@55.0.31)(react-native@0.83.10(patch_hash=22bc31f0a057b1f96d9949b38a7ccde81225f617c0f243820dc8a455c91debfb)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.8)(supports-color@8.1.1))(react@19.2.8)(supports-color@8.1.1)(typescript@7.0.2)
expo-router:
specifier: ^55.0.18
version: 55.0.18(589555b6d8c5573172d9947cbca71959)
version: 55.0.18(patch_hash=5cdc178be0ec617ebb8dec85eebe4effeca13a6551b87fcb7bcd20ec0d2a313d)(589555b6d8c5573172d9947cbca71959)
expo-secure-store:
specifier: ^55.0.18
version: 55.0.18(expo@55.0.31)
@@ -8918,7 +8919,7 @@ snapshots:
ws: 8.22.0
zod: 3.25.76(patch_hash=8f57d8e7910df51ec3c40c412b4f8fb3d01fd65e1889c057aae0e4a15483bcec)
optionalDependencies:
expo-router: 55.0.18(589555b6d8c5573172d9947cbca71959)
expo-router: 55.0.18(patch_hash=5cdc178be0ec617ebb8dec85eebe4effeca13a6551b87fcb7bcd20ec0d2a313d)(589555b6d8c5573172d9947cbca71959)
react-native: 0.83.10(patch_hash=22bc31f0a057b1f96d9949b38a7ccde81225f617c0f243820dc8a455c91debfb)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.8)(supports-color@8.1.1)
transitivePeerDependencies:
- '@expo/dom-webview'
@@ -9252,7 +9253,7 @@ snapshots:
react: 19.2.8
optionalDependencies:
'@expo/metro-runtime': 55.0.12(@expo/dom-webview@55.0.6)(expo@55.0.31)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=22bc31f0a057b1f96d9949b38a7ccde81225f617c0f243820dc8a455c91debfb)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.8)(supports-color@8.1.1))(react@19.2.8)
expo-router: 55.0.18(589555b6d8c5573172d9947cbca71959)
expo-router: 55.0.18(patch_hash=5cdc178be0ec617ebb8dec85eebe4effeca13a6551b87fcb7bcd20ec0d2a313d)(589555b6d8c5573172d9947cbca71959)
react-dom: 19.2.8(react@19.2.8)
transitivePeerDependencies:
- supports-color
@@ -12396,7 +12397,7 @@ snapshots:
- supports-color
- typescript
expo-router@55.0.18(589555b6d8c5573172d9947cbca71959):
expo-router@55.0.18(patch_hash=5cdc178be0ec617ebb8dec85eebe4effeca13a6551b87fcb7bcd20ec0d2a313d)(589555b6d8c5573172d9947cbca71959):
dependencies:
'@expo/log-box': 55.0.13(@expo/dom-webview@55.0.6)(expo@55.0.31)(react-native@0.83.10(patch_hash=22bc31f0a057b1f96d9949b38a7ccde81225f617c0f243820dc8a455c91debfb)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.8)(supports-color@8.1.1))(react@19.2.8)
'@expo/metro-runtime': 55.0.12(@expo/dom-webview@55.0.6)(expo@55.0.31)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=22bc31f0a057b1f96d9949b38a7ccde81225f617c0f243820dc8a455c91debfb)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.8)(supports-color@8.1.1))(react@19.2.8)
+1
View File
@@ -29,3 +29,4 @@ patchedDependencies:
braces@3.0.3: patches/braces@3.0.3.patch
node-forge@1.4.0: patches/node-forge@1.4.0.patch
'@expo/cli@55.0.36': patches/@expo__cli@55.0.36.patch
expo-router@55.0.18: patches/expo-router@55.0.18.patch
@@ -1,23 +1,16 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
AppState,
View,
Text,
StyleSheet,
Pressable,
Switch,
type AppStateStatus
} from 'react-native'
import { AppState, View, Text, Pressable, Switch, type AppStateStatus } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { ChevronRight, X } from 'lucide-react-native'
import type Animated from 'react-native-reanimated'
import type { AnimatedRef, SharedValue } from 'react-native-reanimated'
import { useDirectionPadAvailable } from '../layout/use-shell-side-rail'
import { CustomKeyModal } from './CustomKeyModal'
import { loadCustomKeys, saveCustomKeys, type CustomKey } from '../terminal/terminal-custom-keys'
import { DragReorderList } from './DragReorderList'
import { useSerializedStoredState } from '../storage/use-serialized-stored-state'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
import { colors } from '../theme/mobile-theme'
import { styles } from './terminal-shortcut-settings-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import {
@@ -29,8 +22,7 @@ import {
loadTerminalAccessoryLayout,
reorderTerminalAccessoryBuiltInIds,
saveTerminalAccessoryLayout,
setTerminalAccessoryBuiltInVisible,
type TerminalAccessoryLayout
setTerminalAccessoryBuiltInVisible
} from '../terminal/terminal-accessory-layout'
// Absolutely positioned DragReorderList rows require one fixed height per section.
@@ -46,6 +38,7 @@ function ShortcutBarRow({
onToggle: (visible: boolean) => void
}): React.JSX.Element {
useMobileColorScheme()
const t = useMobileTranslation()
return (
<View style={styles.reorderRowContent}>
<View style={styles.keycap}>
@@ -55,6 +48,13 @@ function ShortcutBarRow({
<Text style={styles.rowLabel}>{shortcutKey.accessibilityLabel ?? shortcutKey.label}</Text>
</View>
<Switch
accessibilityLabel={t(
'components.terminalShortcutSettings.showShortcut',
'Show {{label}} shortcut',
{
label: shortcutKey.accessibilityLabel ?? shortcutKey.label
}
)}
value={visible}
onValueChange={onToggle}
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
@@ -64,11 +64,6 @@ function ShortcutBarRow({
)
}
async function loadShortcutLayout(): Promise<TerminalAccessoryLayout> {
const { orderedBuiltInIds, visibleBuiltInIds } = await loadTerminalAccessoryLayout()
return { orderedBuiltInIds, visibleBuiltInIds }
}
type Props = {
scrollRef: AnimatedRef<Animated.ScrollView>
scrollOffsetY: SharedValue<number>
@@ -84,6 +79,9 @@ export function TerminalShortcutSettings({
}: Props): React.JSX.Element {
useMobileColorScheme()
const t = useMobileTranslation()
const hasPad = useDirectionPadAvailable()
const defaultLayout = useCallback(() => getDefaultTerminalAccessoryLayout(hasPad), [hasPad])
const loadShortcutLayout = useCallback(() => loadTerminalAccessoryLayout(hasPad), [hasPad])
const {
value: customKeys,
setValue: setCustomKeys,
@@ -97,11 +95,7 @@ export function TerminalShortcutSettings({
setValue: setShortcutLayout,
persist: persistLayout,
refresh: refreshShortcutLayout
} = useSerializedStoredState(
getDefaultTerminalAccessoryLayout,
loadShortcutLayout,
saveTerminalAccessoryLayout
)
} = useSerializedStoredState(defaultLayout, loadShortcutLayout, saveTerminalAccessoryLayout)
const handleDeleteCustomKey = useCallback(
(key: CustomKey) => {
@@ -154,10 +148,10 @@ export function TerminalShortcutSettings({
)
const resetBuiltInKeys = useCallback(() => {
const next = getDefaultTerminalAccessoryLayout()
const next = defaultLayout()
setShortcutLayout(next)
persistLayout(next)
}, [persistLayout])
}, [defaultLayout, persistLayout])
const reorderCustomKeys = useCallback(
(orderedKeys: string[]) => {
@@ -177,10 +171,6 @@ export function TerminalShortcutSettings({
[persistCustomKeys]
)
const visibleBuiltInSet = useMemo(
() => new Set(shortcutLayout.visibleBuiltInIds),
[shortcutLayout.visibleBuiltInIds]
)
const orderedAccessoryKeys = useMemo(() => {
const byId = new Map(TERMINAL_ACCESSORY_KEYS.map((key) => [key.id, key]))
return shortcutLayout.orderedBuiltInIds.flatMap((id) => {
@@ -191,6 +181,44 @@ export function TerminalShortcutSettings({
return (
<>
{hasPad ? (
<>
<Text style={[styles.groupHeading, styles.groupTopGap]}>
{t('components.terminalShortcutSettings.directionPad', 'DIRECTION PAD')}
</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<View style={styles.row}>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>
{t('components.terminalShortcutSettings.showDirectionPad', 'Show D-pad')}
</Text>
<Text style={styles.rowSublabel}>
{t(
'components.terminalShortcutSettings.showDirectionPadDescription',
'Pin the arrow pad in the side rail. Without it, the rail lists the arrow keys.'
)}
</Text>
</View>
<Switch
accessibilityLabel={t(
'components.terminalShortcutSettings.showDirectionPad',
'Show D-pad'
)}
value={shortcutLayout.directionPadEnabled !== false}
onValueChange={(enabled) =>
setShortcutLayout((current) => {
const next = { ...current, directionPadEnabled: enabled }
persistLayout(next)
return next
})
}
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
</View>
</>
) : null}
<Text style={[styles.groupHeading, styles.groupTopGap]}>
{t('components.terminalShortcutSettings.moreKeys', 'MORE KEYS')}
</Text>
@@ -213,7 +241,7 @@ export function TerminalShortcutSettings({
renderRow={(shortcutKey) => (
<ShortcutBarRow
shortcutKey={shortcutKey}
visible={visibleBuiltInSet.has(shortcutKey.id)}
visible={shortcutLayout.visibleBuiltInIds.includes(shortcutKey.id)}
onToggle={(visible) => toggleBuiltInKey(shortcutKey.id, visible)}
/>
)}
@@ -270,6 +298,27 @@ export function TerminalShortcutSettings({
{key.bytes.replace(/\r/g, ' ↵')}
</Text>
</View>
<Switch
accessibilityLabel={t(
'components.terminalShortcutSettings.showShortcut',
'Show {{label}} shortcut',
{
label: key.label
}
)}
value={key.visible !== false}
onValueChange={(visible) =>
setCustomKeys((current) => {
const next = current.map((item) =>
item.id === key.id ? { ...item, visible } : item
)
persistCustomKeys(next)
return next
})
}
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
<Pressable
style={({ pressed }) => [
styles.deleteButton,
@@ -317,102 +366,3 @@ export function TerminalShortcutSettings({
</>
)
}
const styles = themedStyleSheet(() => ({
groupHeading: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
letterSpacing: 0.5,
marginBottom: spacing.xs,
paddingHorizontal: spacing.xs
},
groupTopGap: {
marginTop: spacing.xl
},
groupDescription: {
fontSize: typography.bodySize - 1,
color: colors.textSecondary,
lineHeight: 20,
paddingHorizontal: spacing.xs
},
section: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden'
},
sectionTopGap: {
marginTop: spacing.sm
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
// Reorder list supplies fixed height/trailing grip; pad content only on left.
reorderRowContent: {
flex: 1,
height: '100%',
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingLeft: spacing.md + 2
},
rowContent: {
flex: 1
},
rowLabel: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
rowSublabel: {
fontSize: typography.bodySize - 2,
color: colors.textSecondary,
marginTop: 2
},
keycap: {
minWidth: 62,
alignItems: 'center',
backgroundColor: colors.bgRaised,
borderRadius: radii.button,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
keycapText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
emptyContainer: {
padding: spacing.md,
alignItems: 'center',
justifyContent: 'center'
},
emptyText: {
fontSize: typography.bodySize,
color: colors.textSecondary,
padding: spacing.md
},
deleteButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(239, 68, 68, 0.1)'
},
deleteButtonPressed: {
backgroundColor: 'rgba(239, 68, 68, 0.2)'
}
}))
@@ -224,6 +224,7 @@ vi.mock('lucide-react-native', () => ({
}))
vi.mock('expo-router', () => ({ useFocusEffect: () => {} }))
vi.mock('./CustomKeyModal', () => ({ CustomKeyModal: () => null }))
vi.mock('../layout/use-shell-side-rail', () => ({ useDirectionPadAvailable: () => false }))
vi.mock('@react-native-async-storage/async-storage', () => ({
default: { getItem: async () => null, setItem: async () => {} }
}))
@@ -1,6 +1,7 @@
import { createElement, useEffect, type ReactNode } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { loadTerminalAccessoryLayout } from '../terminal/terminal-accessory-layout'
import type { CustomKey } from '../terminal/terminal-custom-keys'
const CUSTOM_KEYS = 'orca:custom-accessory-keys'
@@ -34,7 +35,40 @@ const hosts = vi.hoisted(() => {
}
return { View: make('View'), Text: make('Text'), Pressable: make('Pressable') }
})
const geometry = vi.hoisted(() => ({ keyboard: 0, duo: true, hasFoldDivision: false }))
vi.mock('../platform/keyboard-occlusion', () => ({ useKeyboardOcclusion: () => geometry.keyboard }))
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({
top: geometry.hasFoldDivision ? 82 : 0,
right: geometry.duo ? 84 : 0,
bottom: 34,
left: 0
})
}))
vi.mock('../layout/reserved-regions', () => ({
useReservedRegions: () => ({
occlusions: geometry.duo
? [{ x: 867, y: 0, width: 84, height: 124 }]
: geometry.hasFoldDivision
? [{ x: 535, y: 0, width: 134, height: 82 }]
: [],
divisions: [],
hasFoldDivision: geometry.hasFoldDivision,
windowSafeArea: null
})
}))
import { SideRailProvider, useSideRailStrip } from '../layout/side-rail'
import { usePageFoldDivision } from '../mobile-web-shell/page-fold-division'
let drawnHeight: number | null = null
let pagePadAvailable: boolean | undefined
function GeometryProbe() {
drawnHeight = useSideRailStrip()?.height ?? null
pagePadAvailable = usePageFoldDivision().geometry.directionPadAvailable
return null
}
vi.mock('react-native', () => ({
useWindowDimensions: () =>
geometry.hasFoldDivision ? { width: 669, height: 951 } : { width: 951, height: 669 },
AppState: { addEventListener: () => ({ remove: () => {} }) },
View: hosts.View,
Text: hosts.Text,
@@ -53,9 +87,10 @@ vi.mock('lucide-react-native', () => ({
}))
vi.mock('./BottomDrawer', () => ({ BottomDrawer: hosts.View }))
const lists = vi.hoisted(() => {
const held: { custom: { items: CustomKey[]; onReorder: (ids: string[]) => void } | null } = {
custom: null
}
const held: {
custom: { items: CustomKey[]; onReorder: (ids: string[]) => void } | null
builtIn: { items: { id: string }[]; onReorder: (ids: string[]) => void } | null
} = { custom: null, builtIn: null }
return held
})
vi.mock('./DragReorderList', () => ({
@@ -68,6 +103,9 @@ vi.mock('./DragReorderList', () => ({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the custom-key list is the only one whose ids start with `custom-`.
lists.custom = props as unknown as { items: CustomKey[]; onReorder: (ids: string[]) => void }
}
if (props.items.some((item) => item.id === 'escape')) {
lists.builtIn = props
}
return props.items.map((item) => props.renderRow(item))
}
}))
@@ -93,19 +131,26 @@ function shown(): string[] {
let renderer: ReactTestRenderer | null = null
function settingsScreen() {
return createElement(
SideRailProvider,
null,
createElement(GeometryProbe),
createElement(TerminalShortcutSettings, {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked reorder list never reads the scroll bindings.
scrollRef: {} as never,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above.
scrollOffsetY: {} as never,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above.
scrollContentHeight: {} as never,
onDragActiveChange: () => {}
})
)
}
async function mount(): Promise<void> {
await act(async () => {
renderer = create(
createElement(TerminalShortcutSettings, {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked reorder list never reads the scroll bindings.
scrollRef: {} as never,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above.
scrollOffsetY: {} as never,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above.
scrollContentHeight: {} as never,
onDragActiveChange: () => {}
})
)
renderer = create(settingsScreen())
})
}
@@ -125,10 +170,14 @@ async function settle(): Promise<void> {
}
beforeEach(() => {
geometry.keyboard = 0
geometry.duo = true
geometry.hasFoldDivision = false
store.values = new Map([[CUSTOM_KEYS, JSON.stringify(keys)]])
store.holdWrites = null
store.refuseWrites = false
lists.custom = null
lists.builtIn = null
})
afterEach(() => {
@@ -161,3 +210,73 @@ describe('custom shortcuts in terminal settings', () => {
expect(stored()).toEqual(['custom-a', 'custom-b', 'custom-c'])
})
})
it('persists the independent D-pad switch and custom visibility through settings reopen', async () => {
await mount()
const switchFor = (label: string) => renderer!.root.findByProps({ accessibilityLabel: label })
expect(switchFor('Show D-pad').props.value).toBe(true)
expect(switchFor('Show Arrow Up shortcut').props.value).toBe(false)
act(() => switchFor('Show D-pad').props.onValueChange(false))
act(() => switchFor('Show Escape shortcut').props.onValueChange(false))
act(() => switchFor('Show Arrow Up shortcut').props.onValueChange(true))
act(() => lists.builtIn?.onReorder(lists.builtIn.items.map((key) => key.id).toReversed()))
act(() => switchFor('Show a shortcut').props.onValueChange(false))
act(() => lists.custom?.onReorder(['custom-c', 'custom-b', 'custom-a']))
await settle()
expect((await loadTerminalAccessoryLayout()).directionPadEnabled).toBe(false)
act(() => renderer?.unmount())
await mount()
expect(switchFor('Show D-pad').props.value).toBe(false)
expect(switchFor('Show Escape shortcut').props.value).toBe(false)
expect(switchFor('Show Arrow Up shortcut').props.value).toBe(true)
expect((await loadTerminalAccessoryLayout()).visibleBuiltInIds.indexOf('arrowUp')).toBeLessThan(
(await loadTerminalAccessoryLayout()).visibleBuiltInIds.indexOf('tab')
)
expect(switchFor('Show a shortcut').props.value).toBe(false)
expect(shown()).toEqual(['custom-c', 'custom-b', 'custom-a'])
})
it('keeps Duo defaults while the keyboard clips the rail, including when saving another shortcut', async () => {
await mount()
geometry.keyboard = 400
await act(async () => renderer!.update(settingsScreen()))
const switchFor = (label: string) => renderer!.root.findByProps({ accessibilityLabel: label })
expect(drawnHeight).toBe(145)
expect.soft(pagePadAvailable).toBe(true)
expect.soft(switchFor('Show Arrow Up shortcut').props.value).toBe(false)
act(() => switchFor('Show Escape shortcut').props.onValueChange(false))
await settle()
expect((await loadTerminalAccessoryLayout()).visibleBuiltInIds).not.toContain('arrowUp')
act(() => switchFor('Show Arrow Up shortcut').props.onValueChange(true))
await settle()
act(() => renderer!.unmount())
geometry.keyboard = 0
await mount()
expect(switchFor('Show Arrow Up shortcut').props.value).toBe(true)
})
it('retains navigation defaults on a device without a D-pad rail', async () => {
geometry.duo = false
geometry.keyboard = 400
await mount()
expect(pagePadAvailable).toBe(false)
expect(
renderer!.root.findByProps({ accessibilityLabel: 'Show Arrow Up shortcut' }).props.value
).toBe(true)
})
it('uses inactive fold capability for a cold no-rail screen and preserves explicit D-pad disable', async () => {
geometry.duo = false
geometry.hasFoldDivision = true
await mount()
const switchFor = (label: string) => renderer!.root.findByProps({ accessibilityLabel: label })
expect(drawnHeight).toBeNull()
expect.soft(pagePadAvailable).toBe(true)
expect.soft(switchFor('Show Arrow Up shortcut').props.value).toBe(false)
act(() => switchFor('Show D-pad').props.onValueChange(false))
await settle()
act(() => renderer!.unmount())
await mount()
expect(switchFor('Show D-pad').props.value).toBe(false)
expect(switchFor('Show Arrow Up shortcut').props.value).toBe(false)
})
@@ -0,0 +1,102 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
export const styles = themedStyleSheet(() => ({
groupHeading: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
letterSpacing: 0.5,
marginBottom: spacing.xs,
paddingHorizontal: spacing.xs
},
groupTopGap: {
marginTop: spacing.xl
},
groupDescription: {
fontSize: typography.bodySize - 1,
color: colors.textSecondary,
lineHeight: 20,
paddingHorizontal: spacing.xs
},
section: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden'
},
sectionTopGap: {
marginTop: spacing.sm
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
// Reorder list supplies fixed height/trailing grip; pad content only on left.
reorderRowContent: {
flex: 1,
height: '100%',
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingLeft: spacing.md + 2
},
rowContent: {
flex: 1
},
rowLabel: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
rowSublabel: {
fontSize: typography.bodySize - 2,
color: colors.textSecondary,
marginTop: 2
},
keycap: {
minWidth: 62,
alignItems: 'center',
backgroundColor: colors.bgRaised,
borderRadius: radii.button,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
keycapText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
emptyContainer: {
padding: spacing.md,
alignItems: 'center',
justifyContent: 'center'
},
emptyText: {
fontSize: typography.bodySize,
color: colors.textSecondary,
padding: spacing.md
},
deleteButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(239, 68, 68, 0.1)'
},
deleteButtonPressed: {
backgroundColor: 'rgba(239, 68, 68, 0.2)'
}
}))
@@ -27,9 +27,6 @@ vi.mock('react-native', () => ({
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 })
}))
vi.mock('./layout/side-safe-area', () => ({
SideSafeArea: ({ children }: { children: unknown }) => children
}))
vi.mock('expo-router', () => ({
useLocalSearchParams: () => ({ hostId: 'host-1' }),
-3
View File
@@ -27,9 +27,6 @@ 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 }),
@@ -1,194 +0,0 @@
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'
import type { Translate } from '../i18n/mobile-i18n'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
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, t: Translate): HostListAction[] {
const { actions, connState, floatingWorkspaceEnabled, hostId, state } = controller
const offline = connState !== 'connected'
const hostPath = `/h/${encodeURIComponent(hostId)}`
return [
{
id: 'accounts',
label: t('hostScreen.hostRailActions.accounts', 'Accounts'),
icon: UserCircle,
onPress: () => actions.navigateFromHostList(`${hostPath}/accounts`),
disabled: offline
},
{
id: 'tasks',
label: t('hostScreen.hostRailActions.tasks', 'Tasks'),
icon: List,
onPress: () => actions.navigateFromHostList(`${hostPath}/tasks`),
disabled: offline
},
...(floatingWorkspaceEnabled
? [
{
id: 'floating' as const,
label: t('hostScreen.hostRailActions.floatingWorkspace', 'Floating Workspace'),
icon: SquareTerminal,
onPress: actions.openFloatingWorkspace,
disabled: offline
}
]
: []),
{
id: 'new',
label: t('hostScreen.hostRailActions.newWorkspace', 'New workspace'),
icon: Plus,
onPress: actions.openNewWorktreeModal,
disabled: offline
},
{
id: 'search',
label: state.showSearch
? t('hostScreen.hostRailActions.closeSearch', 'Close search')
: t('hostScreen.hostRailActions.searchWorkspaces', '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'
}) {
const t = useMobileTranslation()
useMobileColorScheme()
const all = hostListActions(controller, t)
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
}
}))
+134 -87
View File
@@ -6,6 +6,7 @@ import {
ChevronLeft,
List,
PanelLeftClose,
Plus,
Search,
SquareTerminal,
UserCircle,
@@ -17,8 +18,6 @@ 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 {
@@ -60,14 +59,6 @@ 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}>
@@ -112,7 +103,7 @@ export function HostScreenHeader({ controller }: { controller: HostScreenControl
</Text>
</Pressable>
) : null}
{!embedded && floatingWorkspaceEnabled && !railActions ? (
{!embedded && floatingWorkspaceEnabled ? (
<Pressable
style={[
styles.floatingWorkspaceHeaderButton,
@@ -175,34 +166,95 @@ export function HostScreenHeader({ controller }: { controller: HostScreenControl
</Pressable>
</View>
{railActions ? null : (
<View style={styles.embeddedToolbarRow}>
{hostListActions(controller, t).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 style={styles.embeddedToolbarRow}>
<Pressable
style={[
styles.embeddedToolbarIconButton,
connState !== 'connected' && styles.toolbarIconDisabled
]}
onPress={() =>
actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/accounts`)
}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel={t('hostScreen.hostScreenHeader.accounts', '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={t('hostScreen.hostScreenHeader.tasks', '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={t(
'hostScreen.hostScreenHeader.floatingWorkspace',
'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={t('hostScreen.hostScreenHeader.newWorkspace', '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
? t('hostScreen.hostScreenHeader.closeSearch', 'Close search')
: t('hostScreen.hostScreenHeader.searchWorkspaces', 'Search workspaces')
}
>
{state.showSearch ? (
<X size={16} color={colors.textSecondary} />
) : (
<Search size={16} color={colors.textSecondary} />
)}
</Pressable>
</View>
</View>
) : (
<View style={styles.toolbar}>
@@ -224,56 +276,51 @@ 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={t('hostScreen.hostScreenHeader.accounts', '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={t('hostScreen.hostScreenHeader.tasks', 'Tasks')}
>
<List
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={t('hostScreen.hostScreenHeader.accounts', 'Accounts')}
>
<UserCircle
size={16}
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
/>
</Pressable>
<Pressable
style={styles.searchToggle}
onPress={() => state.setShowSearch((s) => !s)}
accessibilityRole="button"
accessibilityLabel={
state.showSearch
? t('hostScreen.hostScreenHeader.closeSearch', 'Close search')
: t('hostScreen.hostScreenHeader.searchWorkspaces', 'Search workspaces')
}
>
{state.showSearch ? (
<X size={16} color={colors.textSecondary} />
) : (
<Search size={16} color={colors.textSecondary} />
)}
</Pressable>
</>
)}
<Pressable
style={styles.searchToggle}
onPress={() => actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/tasks`)}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel={t('hostScreen.hostScreenHeader.tasks', '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
? t('hostScreen.hostScreenHeader.closeSearch', 'Close search')
: t('hostScreen.hostScreenHeader.searchWorkspaces', 'Search workspaces')
}
>
{state.showSearch ? (
<X size={16} color={colors.textSecondary} />
) : (
<Search size={16} color={colors.textSecondary} />
)}
</Pressable>
</View>
)}
</View>
@@ -37,15 +37,12 @@ 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,
actionsInRail = false
onHideSidebar
}: HostScreenProps = {}) {
useMobileTranslation()
const params = useLocalSearchParams<{ hostId: string; notice?: string }>()
@@ -182,7 +179,6 @@ export function useHostScreenController({
noticeParam,
now,
onHideSidebar,
actionsInRail,
reconnectAttempts,
relayRecovery,
routeNotice,
-11
View File
@@ -1392,14 +1392,6 @@
"hostPendingCreationRow": {
"creatingWorkspace": "Arbeitsbereich wird erstellt…"
},
"hostRailActions": {
"accounts": "Konten",
"closeSearch": "Suche schließen",
"floatingWorkspace": "Schwebender Arbeitsbereich",
"newWorkspace": "Neuer Arbeitsbereich",
"searchWorkspaces": "Arbeitsbereiche durchsuchen",
"tasks": "Aufgaben"
},
"hostScreenHeader": {
"accounts": "Konten",
"activeFilters_one": "Arbeitsbereichsoptionen ({{count}} Filter aktiv)",
@@ -2333,9 +2325,6 @@
"unsavedMarkdown": "Ungespeicherte Markdown-Änderungen",
"urlPrompt": "URL eingeben oder für einen neuen Tab leer lassen."
},
"sideRail": {
"backToWorktrees": "Zurück zu den Worktrees"
},
"sourceControlLaunchAgent": {
"noEnabledAgent": "Auf dem Host dieses Arbeitsbereichs wurde kein aktivierter KI-Agent erkannt.",
"savedAgentUnavailable": "Der für diese Aktion gespeicherte Agent ist auf dem Host dieses Arbeitsbereichs nicht verfügbar."
+49 -15
View File
@@ -550,14 +550,6 @@
"hostPendingCreationRow": {
"creatingWorkspace": "Creating workspace…"
},
"hostRailActions": {
"accounts": "Accounts",
"tasks": "Tasks",
"floatingWorkspace": "Floating Workspace",
"newWorkspace": "New workspace",
"closeSearch": "Close search",
"searchWorkspaces": "Search workspaces"
},
"hostScreenHeader": {
"workspaceOptions": "Workspace options",
"activeFilters_one": "Workspace options ({{count}} filter active)",
@@ -569,7 +561,8 @@
"accounts": "Accounts",
"tasks": "Tasks",
"closeSearch": "Close search",
"searchWorkspaces": "Search workspaces"
"searchWorkspaces": "Search workspaces",
"newWorkspace": "New workspace"
},
"hostScreenOverlays": {
"deleteWorktree": "Delete Worktree",
@@ -1209,7 +1202,11 @@
"addShortcut": "Add Custom Shortcut…",
"addDescription": "Create key combo or text macro",
"moreKeys": "MORE KEYS",
"moreKeysDescription": "These keys sit under ⋯ on the terminal key row. Toggle keys to show or hide them, and hold the grip to drag a key into the order you want. A key already on the row, like Esc or the arrows, is left out of ⋯."
"moreKeysDescription": "These keys sit under ⋯ on the terminal key row. Toggle keys to show or hide them, and hold the grip to drag a key into the order you want. A key already on the row, like Esc or the arrows, is left out of ⋯.",
"directionPad": "DIRECTION PAD",
"showDirectionPad": "Show D-pad",
"showDirectionPadDescription": "Pin the arrow pad in the side rail. Without it, the rail lists the arrow keys.",
"showShortcut": "Show {{label}} shortcut"
},
"voiceModelList": {
"modelSize": "{{size}} MB",
@@ -1816,7 +1813,12 @@
"reconnectToDesktop": "Reconnect to desktop",
"openFileExplorer": "Open file explorer",
"openSourceControl": "Open source control",
"moreActions": "More session actions"
"moreActions": "More session actions",
"terminalLayout": "Terminal layout",
"newTab": "New tab",
"quickCommands": "Quick commands",
"fileExplorer": "File explorer",
"sourceControl": "Source control"
},
"headerMoreActionsSheet": {
"agentHistory": "Agent History",
@@ -1861,7 +1863,8 @@
"tabStatus": "{{title}}, {{status}}",
"newTab": "New tab",
"quickCommandsUpdateRequired": "Desktop update required for quick commands",
"checkingCapabilities": "Checking desktop capabilities — try again in a moment"
"checkingCapabilities": "Checking desktop capabilities — try again in a moment",
"terminalLayout": "Terminal layout"
},
"terminalInputActions": {
"sendingImage": "Sending image",
@@ -2154,9 +2157,6 @@
"agentStartedIn": "Agent started in {{label}}",
"agentStarted": "Agent started"
},
"sideRail": {
"backToWorktrees": "Back to worktrees"
},
"useAttachmentInputLeaseGate": {
"reconnecting": "Attach failed (reconnecting)"
},
@@ -2503,6 +2503,28 @@
"deleteKeyHint": "Long press to delete this key",
"addCustomKey": "Add custom key",
"addKey": "Add key"
},
"terminalLayoutSheet": {
"title": "Terminal layout",
"oneTerminal": "One terminal",
"selected": "Selected",
"auto": "Auto",
"autoHint": "Split to fit the available space",
"sideBySide": "Side by side",
"sideBySideHint": "Two columns, even with the sidebar open",
"stacked": "Stacked",
"stackedHint": "Two rows in any orientation",
"selectedHint": "Selected · {{hint}}"
},
"terminalPaneView": {
"focusedPane": "{{title}}, focused",
"terminal": "Terminal",
"tapToFocusPane": "{{title}}, tap to focus",
"inputPane": "{{title}} · Input"
},
"terminalSplitLayout": {
"saveFailedTitle": "Layout could not be saved",
"saveFailedMessage": "This layout applies for now. Choose it again to retry saving."
}
},
"chat": {
@@ -3975,5 +3997,17 @@
"webviewReadyWatchdog": {
"initializeFailed": "Terminal did not initialize - no ready signal from the terminal view"
}
},
"mobileWebShell": {
"terminalDirectionPad": {
"up": "D-pad up",
"left": "D-pad left",
"right": "D-pad right",
"down": "D-pad down",
"hint": "Sends an arrow key. Hold to repeat."
},
"terminalKeyRail": {
"ctrlC": "Ctrl+C"
}
}
}
-11
View File
@@ -1435,14 +1435,6 @@
"hostPendingCreationRow": {
"creatingWorkspace": "Creando espacio de trabajo…"
},
"hostRailActions": {
"accounts": "Cuentas",
"closeSearch": "Cerrar búsqueda",
"floatingWorkspace": "Espacio de trabajo flotante",
"newWorkspace": "Nuevo espacio de trabajo",
"searchWorkspaces": "Buscar espacios de trabajo",
"tasks": "Tareas"
},
"hostScreenHeader": {
"accounts": "Cuentas",
"activeFilters_many": "Opciones de espacio de trabajo ({{count}} filtros activos)",
@@ -2408,9 +2400,6 @@
"unsavedMarkdown": "Cambios en Markdown sin guardar",
"urlPrompt": "Introduce una URL o deja el campo en blanco para abrir una pestaña nueva."
},
"sideRail": {
"backToWorktrees": "Volver a los worktrees"
},
"sourceControlLaunchAgent": {
"noEnabledAgent": "No se detectó ningún agente de IA habilitado en este host de espacio de trabajo.",
"savedAgentUnavailable": "El agente guardado para esta acción no está disponible en el host de este espacio de trabajo."
-11
View File
@@ -1435,14 +1435,6 @@
"hostPendingCreationRow": {
"creatingWorkspace": "Création de l'espace de travail…"
},
"hostRailActions": {
"accounts": "Comptes",
"closeSearch": "Fermer la recherche",
"floatingWorkspace": "Espace de travail flottant",
"newWorkspace": "Nouvel espace de travail",
"searchWorkspaces": "Rechercher des espaces de travail",
"tasks": "Tâches"
},
"hostScreenHeader": {
"accounts": "Comptes",
"activeFilters_many": "Options des espaces de travail ({{count}} filtres actifs)",
@@ -2408,9 +2400,6 @@
"unsavedMarkdown": "Modifications Markdown non enregistrées",
"urlPrompt": "Saisissez une URL ou laissez le champ vide pour un nouvel onglet."
},
"sideRail": {
"backToWorktrees": "Retour aux worktrees"
},
"sourceControlLaunchAgent": {
"noEnabledAgent": "Aucun agent IA activé n'a été détecté sur l'hôte de cet espace de travail.",
"savedAgentUnavailable": "L'agent enregistré pour cette action n'est pas disponible sur l'hôte de cet espace de travail."
-11
View File
@@ -1388,14 +1388,6 @@
"hostPendingCreationRow": {
"creatingWorkspace": "ワークスペースを作成中…"
},
"hostRailActions": {
"accounts": "アカウント",
"closeSearch": "検索を閉じる",
"floatingWorkspace": "フローティングワークスペース",
"newWorkspace": "新規ワークスペース",
"searchWorkspaces": "ワークスペースの検索",
"tasks": "タスク"
},
"hostScreenHeader": {
"accounts": "アカウント",
"activeFilters_one": "ワークスペースのオプション ({{count}} 件のフィルターが有効)",
@@ -2329,9 +2321,6 @@
"unsavedMarkdown": "Markdown の未保存の変更",
"urlPrompt": "URL を入力してください。空欄のままにすると新規タブが開きます。"
},
"sideRail": {
"backToWorktrees": "ワークツリーに戻る"
},
"sourceControlLaunchAgent": {
"noEnabledAgent": "このワークスペースホストでは有効な AI Agent が検出されませんでした。",
"savedAgentUnavailable": "この操作用に保存された Agent は、このワークスペースホストでは利用できません。"
-11
View File
@@ -1388,14 +1388,6 @@
"hostPendingCreationRow": {
"creatingWorkspace": "워크스페이스 만드는 중…"
},
"hostRailActions": {
"accounts": "계정",
"closeSearch": "검색 닫기",
"floatingWorkspace": "플로팅 워크스페이스",
"newWorkspace": "새로운 워크스페이스",
"searchWorkspaces": "워크스페이스 검색",
"tasks": "작업"
},
"hostScreenHeader": {
"accounts": "계정",
"activeFilters_one": "워크스페이스 옵션 (필터 {{count}}개 활성화됨)",
@@ -2329,9 +2321,6 @@
"unsavedMarkdown": "저장되지 않은 Markdown 변경 사항",
"urlPrompt": "URL을 입력하세요. 비워 두면 새 탭이 열립니다."
},
"sideRail": {
"backToWorktrees": "워크트리로 돌아가기"
},
"sourceControlLaunchAgent": {
"noEnabledAgent": "이 워크스페이스 호스트에서는 활성화된 AI 에이전트가 감지되지 않았습니다.",
"savedAgentUnavailable": "이 작업에 저장된 에이전트는 이 워크스페이스 호스트에서 사용할 수 없습니다."
-11
View File
@@ -1435,14 +1435,6 @@
"hostPendingCreationRow": {
"creatingWorkspace": "Criando espaço de trabalho…"
},
"hostRailActions": {
"accounts": "Contas",
"closeSearch": "Fechar busca",
"floatingWorkspace": "Espaço de Trabalho Flutuante",
"newWorkspace": "Novo espaço de trabalho",
"searchWorkspaces": "Buscar espaços de trabalho",
"tasks": "Tarefas"
},
"hostScreenHeader": {
"accounts": "Contas",
"activeFilters_many": "Opções do espaço de trabalho ({{count}} filtros ativos)",
@@ -2408,9 +2400,6 @@
"unsavedMarkdown": "Alterações em Markdown não salvas",
"urlPrompt": "Digite uma URL ou deixe em branco para abrir uma nova aba."
},
"sideRail": {
"backToWorktrees": "Voltar para as worktrees"
},
"sourceControlLaunchAgent": {
"noEnabledAgent": "Nenhum agente de IA ativado foi detectado neste host do espaço de trabalho.",
"savedAgentUnavailable": "O agente salvo para esta ação não está disponível neste host do espaço de trabalho."
-11
View File
@@ -1478,14 +1478,6 @@
"hostPendingCreationRow": {
"creatingWorkspace": "Создание рабочего пространства…"
},
"hostRailActions": {
"accounts": "Учётные записи",
"closeSearch": "Закрыть поиск",
"floatingWorkspace": "Плавающее рабочее пространство",
"newWorkspace": "Новое рабочее пространство",
"searchWorkspaces": "Поиск рабочих пространств",
"tasks": "Задачи"
},
"hostScreenHeader": {
"accounts": "Учётные записи",
"activeFilters_few": "Параметры рабочих пространств (активны {{count}} фильтра)",
@@ -2483,9 +2475,6 @@
"unsavedMarkdown": "Несохранённые изменения в Markdown",
"urlPrompt": "Введите URL или оставьте поле пустым, чтобы открыть новую вкладку."
},
"sideRail": {
"backToWorktrees": "Вернуться к рабочим деревьям"
},
"sourceControlLaunchAgent": {
"noEnabledAgent": "На сервере этого рабочего пространства не найдено включённых ИИ-агентов.",
"savedAgentUnavailable": "Агент, выбранный для этого действия, недоступен на сервере этого рабочего пространства."
-11
View File
@@ -1392,14 +1392,6 @@
"hostPendingCreationRow": {
"creatingWorkspace": "Çalışma alanı oluşturuluyor…"
},
"hostRailActions": {
"accounts": "Hesaplar",
"closeSearch": "Aramayı kapat",
"floatingWorkspace": "Yüzen Çalışma Alanı",
"newWorkspace": "Yeni çalışma alanı",
"searchWorkspaces": "Çalışma alanlarını ara",
"tasks": "Görevler"
},
"hostScreenHeader": {
"accounts": "Hesaplar",
"activeFilters_one": "Çalışma alanı seçenekleri ({{count}} filtre etkin)",
@@ -2333,9 +2325,6 @@
"unsavedMarkdown": "Kaydedilmemiş Markdown değişiklikleri",
"urlPrompt": "Bir URL girin veya yeni sekme için boş bırakın."
},
"sideRail": {
"backToWorktrees": "Worktree'lere dön"
},
"sourceControlLaunchAgent": {
"noEnabledAgent": "Bu çalışma alanı sunucusunda etkin bir AI ajanı algılanmadı.",
"savedAgentUnavailable": "Bu işlem için kaydedilen ajan, bu çalışma alanı sunucusunda kullanılamıyor."
-11
View File
@@ -1388,14 +1388,6 @@
"hostPendingCreationRow": {
"creatingWorkspace": "正在建立工作區…"
},
"hostRailActions": {
"accounts": "帳戶",
"closeSearch": "關閉搜尋",
"floatingWorkspace": "浮動工作區",
"newWorkspace": "新工作區",
"searchWorkspaces": "搜尋工作區",
"tasks": "任務"
},
"hostScreenHeader": {
"accounts": "帳戶",
"activeFilters_one": "工作區選項(已啟用 {{count}} 個篩選條件)",
@@ -2329,9 +2321,6 @@
"unsavedMarkdown": "尚未儲存的 Markdown 變更",
"urlPrompt": "請輸入網址,或留空以開啟新分頁。"
},
"sideRail": {
"backToWorktrees": "返回工作樹"
},
"sourceControlLaunchAgent": {
"noEnabledAgent": "此工作區主機上未偵測到已啟用的 AI 代理程式。",
"savedAgentUnavailable": "先前為此動作設定的代理程式無法在此工作區主機上使用。"
-11
View File
@@ -1388,14 +1388,6 @@
"hostPendingCreationRow": {
"creatingWorkspace": "正在创建工作区…"
},
"hostRailActions": {
"accounts": "账户",
"closeSearch": "关闭搜索",
"floatingWorkspace": "浮动工作区",
"newWorkspace": "新工作区",
"searchWorkspaces": "搜索工作区",
"tasks": "任务"
},
"hostScreenHeader": {
"accounts": "账户",
"activeFilters_one": "工作区选项(已启用 {{count}} 个筛选条件)",
@@ -2329,9 +2321,6 @@
"unsavedMarkdown": "未保存的 Markdown 更改",
"urlPrompt": "输入 URL,或留空以新建标签页。"
},
"sideRail": {
"backToWorktrees": "返回工作树列表"
},
"sourceControlLaunchAgent": {
"noEnabledAgent": "此工作区主机上未检测到已启用的 AI 智能体。",
"savedAgentUnavailable": "为此操作保存的智能体在此工作区主机上不可用。"
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { getFoldSplit } from './fold-division'
// Measured on the iPhone Duo simulator (iOS 27.1), inner display, half folded; the frames include
// the 20pt margins UIKit reserves on each side of the fold.
const INNER_LANDSCAPE = { x: 0, y: 0, width: 951, height: 669 }
const BOOK_FOLD = { x: 455.5, y: 0, width: 40, height: 669 }
const INNER_PORTRAIT = { x: 0, y: 0, width: 669, height: 951 }
const TABLETOP_FOLD = { x: 0, y: 455.5, width: 669, height: 40 }
describe('getFoldSplit', () => {
it('splits left from right in book posture and top from bottom in tabletop posture', () => {
expect(getFoldSplit([BOOK_FOLD], INNER_LANDSCAPE)).toEqual({
axis: 'vertical',
start: 455.5,
end: 495.5,
size: 951
})
expect(getFoldSplit([TABLETOP_FOLD], INNER_PORTRAIT)).toEqual({
axis: 'horizontal',
start: 455.5,
end: 495.5,
size: 951
})
})
it('answers in the coordinates of a frame inset in the window', () => {
expect(getFoldSplit([BOOK_FOLD], { x: 59, y: 0, width: 808, height: 669 })).toEqual({
axis: 'vertical',
start: 396.5,
end: 436.5,
size: 808
})
})
it('ignores a fold at the edge of a Split View window, which splits nothing', () => {
expect(getFoldSplit([BOOK_FOLD], { x: 0, y: 0, width: 469, height: 669 })).toBeNull()
expect(getFoldSplit([BOOK_FOLD], { x: 482, y: 0, width: 469, height: 669 })).toBeNull()
})
it('ignores regions that do not cross the frame or carry no usable size', () => {
expect(getFoldSplit([{ ...BOOK_FOLD, height: 300 }], INNER_LANDSCAPE)).toBeNull()
expect(getFoldSplit([{ ...BOOK_FOLD, width: 0 }], INNER_LANDSCAPE)).toBeNull()
expect(getFoldSplit([{ ...BOOK_FOLD, x: Number.NaN }], INNER_LANDSCAPE)).toBeNull()
expect(getFoldSplit([BOOK_FOLD], { ...INNER_LANDSCAPE, width: 0 })).toBeNull()
expect(getFoldSplit([], INNER_LANDSCAPE)).toBeNull()
})
})
+52
View File
@@ -0,0 +1,52 @@
import type { ReservedRect } from '../../modules/orca-reserved-regions/src'
/** Where a fold splits a frame: the band from `start` to `end` along the axis it divides. */
export type FoldSplit = {
/** `vertical`: the fold runs top to bottom and splits left from right (book posture). */
axis: 'vertical' | 'horizontal'
start: number
end: number
/** The frame's extent along the split, so `size - end` is the room after the fold. */
size: number
}
type Frame = { x: number; y: number; width: number; height: number }
function isUsable(rect: ReservedRect): boolean {
return (
[rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) &&
rect.width > 0 &&
rect.height > 0
)
}
/**
* The fold that splits `frame` into two usable halves, in the frame's own coordinates; null when
* none does. A fold only counts when it crosses the frame from edge to edge and leaves room on
* both sides; one at the frame's edge (a Split View window beside it) splits nothing.
*/
export function getFoldSplit(divisions: readonly ReservedRect[], frame: Frame): FoldSplit | null {
if (!(frame.width > 0 && frame.height > 0)) {
return null
}
for (const division of divisions) {
if (!isUsable(division)) {
continue
}
const vertical = division.height >= division.width
const crossStart = vertical ? division.y - frame.y : division.x - frame.x
const crossLength = vertical ? division.height : division.width
const crossSize = vertical ? frame.height : frame.width
if (crossStart > 0 || crossStart + crossLength < crossSize) {
continue
}
const size = vertical ? frame.width : frame.height
const start = vertical ? division.x - frame.x : division.y - frame.y
const end = start + (vertical ? division.width : division.height)
if (start <= 0 || end >= size) {
continue
}
return { axis: vertical ? 'vertical' : 'horizontal', start, end, size }
}
return null
}
@@ -20,10 +20,7 @@ vi.mock('react-native', () => ({
PanResponder: { create: () => ({ panHandlers: {} }) },
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
}))
vi.mock('react-native-safe-area-context', () => ({
SafeAreaView: 'SafeAreaView',
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 })
}))
vi.mock('react-native-safe-area-context', () => ({ SafeAreaView: 'SafeAreaView' }))
vi.mock('lucide-react-native', () => ({
ChevronLeft: 'ChevronLeft',
Folder: 'Folder',
@@ -44,6 +41,7 @@ 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,
@@ -60,10 +58,13 @@ vi.mock('../host-screen/HostScreen', () => ({
})
}))
vi.mock('../navigation/host-stack', () => ({
HostStack: () =>
createElement(MobileSessionHeader, { controller: headerController(), backInRail: false })
HostStack: () => createElement(MobileSessionHeader, { controller: headerController() })
}))
vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: () => null }))
vi.mock('./use-top-chrome-space', () => ({ useTopChromeSpace: () => ({}) }))
vi.mock('../session/MobileSessionHeaderMoreActionsSheet', () => ({
MobileSessionHeaderMoreActionsSheet: () => null
}))
vi.mock('../platform/haptics', () => ({ triggerMediumImpact: () => {} }))
vi.mock('../transport/host-client-hooks', () => ({
useDisconnectHostClient: vi.fn(),
+42
View File
@@ -0,0 +1,42 @@
import { useSyncExternalStore } from 'react'
import { OrcaReservedRegions, type ReservedRegions } from '../../modules/orca-reserved-regions/src'
const NO_REGIONS: ReservedRegions = { divisions: [], occlusions: [] }
let current = NO_REGIONS
const listeners = new Set<() => void>()
let observing = false
function subscribe(listener: () => void): () => void {
listeners.add(listener)
// Why: one app-lifetime native subscription; restarting it would report no regions until the
// next layout pass.
if (!observing && OrcaReservedRegions) {
observing = true
OrcaReservedRegions.addListener('onChange', (regions) => {
current = regions
for (const notify of listeners) {
notify()
}
})
}
return () => {
listeners.delete(listener)
}
}
/** Active fold and camera regions in window points; none off iPhone Duo or before iOS 27.1. */
export function useReservedRegions(): ReservedRegions {
return useSyncExternalStore(
subscribe,
() => current,
() => NO_REGIONS
)
}
/** Future native geometry only; cached frames must not overwrite a newer keyboard event. */
export function subscribeReservedRegionChanges(
listener: (regions: ReservedRegions) => void
): () => void {
return subscribe(() => listener(current))
}
+15
View File
@@ -0,0 +1,15 @@
import { useMemo, useSyncExternalStore } from 'react'
import { usePageBridgeClientIfPresent } from '../transport/client-context.web'
import { EMPTY_PAGE_GEOMETRY } from '../mobile-web-shell/bridge/bridge-page-geometry'
export function usePageGeometry() {
const client = usePageBridgeClientIfPresent()
return useSyncExternalStore(
client?.onPageGeometryUpdate ?? (() => () => undefined),
() => client?.getShellSession()?.pageGeometry ?? EMPTY_PAGE_GEOMETRY
)
}
export function useReservedRegions() {
const { occlusions } = usePageGeometry()
return useMemo(() => ({ divisions: [], occlusions }), [occlusions])
}
@@ -21,20 +21,6 @@ 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,
+2 -15
View File
@@ -1,20 +1,7 @@
import { spacing } from '../theme/mobile-theme'
// 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
// Use actual window size so narrow iPad splits keep phone-like layouts.
const WIDE_LAYOUT_MIN_WIDTH = 700
// Require room in both dimensions so rotating a landscape phone cannot switch it to tablet UI.
const TABLET_LAYOUT_MIN_SHORT_SIDE = 600
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest'
import { getSideRailStrip, sideRailStripAboveKeyboard } from './side-rail-strip'
// Measured on the iPhone Duo simulator (iOS 27.1). The status column sits in the right inset.
const INNER_LANDSCAPE = {
windowWidth: 951,
windowHeight: 669,
insets: { top: 0, right: 84, bottom: 34, left: 0 },
occlusions: [{ x: 867, y: 0, width: 84, height: 124 }]
}
const COVER_PORTRAIT = {
windowWidth: 466,
windowHeight: 678,
insets: { top: 0, right: 84, bottom: 34, left: 0 },
occlusions: [
{ x: 399.67, y: 29.33, width: 37, height: 37 },
{ x: 382, y: 0, width: 84, height: 174 }
]
}
describe('getSideRailStrip', () => {
it('offers the strip below the iPhone Duo status column, open or closed', () => {
expect(getSideRailStrip(INNER_LANDSCAPE)).toEqual({
side: 'right',
width: 84,
top: 124,
bottom: 34,
height: 511
})
expect(getSideRailStrip(COVER_PORTRAIT)).toEqual({
side: 'right',
width: 84,
top: 174,
bottom: 34,
height: 470
})
})
it('finds the strip on the left when the device is turned the other way', () => {
expect(
getSideRailStrip({
...INNER_LANDSCAPE,
insets: { top: 0, right: 0, bottom: 34, left: 84 },
occlusions: [{ x: 0, y: 545, width: 84, height: 124 }]
})
).toEqual({ side: 'left', width: 84, top: 0, bottom: 124, height: 545 })
})
it('offers none where nothing occludes the side inset, as on a landscape iPhone', () => {
expect(getSideRailStrip({ ...INNER_LANDSCAPE, occlusions: [] })).toBeNull()
expect(
getSideRailStrip({
windowWidth: 852,
windowHeight: 393,
insets: { top: 0, right: 59, bottom: 21, left: 59 },
occlusions: []
})
).toBeNull()
})
it('offers none beside a camera island or anything that does not reach a screen edge', () => {
expect(
getSideRailStrip({
windowWidth: 956,
windowHeight: 440,
insets: { top: 0, right: 62, bottom: 21, left: 62 },
occlusions: [{ x: 11, y: 157, width: 37, height: 126 }]
})
).toBeNull()
expect(
getSideRailStrip({
...INNER_LANDSCAPE,
occlusions: [{ x: 867, y: 200, width: 84, height: 124 }]
})
).toBeNull()
})
it('offers none when the inset is too narrow or the occlusion leaves too little below it', () => {
expect(
getSideRailStrip({ ...INNER_LANDSCAPE, insets: { ...INNER_LANDSCAPE.insets, right: 40 } })
).toBeNull()
expect(
getSideRailStrip({
...INNER_LANDSCAPE,
occlusions: [{ x: 867, y: 0, width: 84, height: 520 }]
})
).toBeNull()
})
it('ends the strip above the software keyboard, and offers none when it covers it all', () => {
const strip = getSideRailStrip(INNER_LANDSCAPE)
expect(sideRailStripAboveKeyboard(strip, 0)).toBe(strip)
expect(sideRailStripAboveKeyboard(strip, 300)).toEqual({
side: 'right',
width: 84,
top: 124,
bottom: 300,
height: 245
})
expect(sideRailStripAboveKeyboard(strip, 545)).toBeNull()
})
})
+88
View File
@@ -0,0 +1,88 @@
import type { ReservedRect } from '../../modules/orca-reserved-regions/src'
/** The part of a side inset strip below what occludes it, in window points. */
export type SideRailStrip = {
side: 'left' | 'right'
width: number
top: number
bottom: number
height: number
}
export function hasTerminalDirectionPadSpace(strip: SideRailStrip | null): strip is SideRailStrip {
return strip !== null && strip.height >= 224 && strip.width >= 60
}
/** Narrower than a key; the iPhone Duo's status column is 84. */
const MIN_STRIP_WIDTH = 60
/** Room for at least a few keys below the occluded part. */
const MIN_STRIP_HEIGHT = 160
/**
* The side inset strip a rail of controls can use: a whole-height side inset that something as wide
* as it occludes only in part, like the iPhone Duo's status column, less that part. Null for any
* other window, so a landscape iPhone's rounded corners or camera island never get one.
*/
export function getSideRailStrip(args: {
windowWidth: number
windowHeight: number
insets: { top: number; right: number; bottom: number; left: number }
occlusions: readonly ReservedRect[]
}): SideRailStrip | null {
const { windowWidth, windowHeight, insets, occlusions } = args
for (const side of ['right', 'left'] as const) {
const width = insets[side]
if (!(width >= MIN_STRIP_WIDTH)) {
continue
}
const stripStart = side === 'right' ? windowWidth - width : 0
const stripEnd = side === 'right' ? windowWidth : width
const inStrip = occlusions.filter(
(region) => region.x < stripEnd && region.x + region.width > stripStart
)
// The status column spans the strip's width from a screen edge; a camera island does neither.
const statusColumn = inStrip.some(
(region) =>
region.x <= stripStart + 1 &&
region.x + region.width >= stripEnd - 1 &&
(region.y <= 1 || region.y + region.height >= windowHeight - 1)
)
if (!statusColumn) {
continue
}
const occluded = inStrip
.map((region) => ({ from: region.y, to: region.y + region.height }))
.sort((a, b) => a.from - b.from)
// The longest run of the strip nothing occludes, between the top and bottom insets.
let best = { from: 0, to: 0 }
let cursor = insets.top
for (const span of [...occluded, { from: windowHeight - insets.bottom, to: windowHeight }]) {
if (span.from - cursor > best.to - best.from) {
best = { from: cursor, to: span.from }
}
cursor = Math.max(cursor, span.to)
}
if (best.to - best.from >= MIN_STRIP_HEIGHT) {
return {
side,
width,
top: best.from,
bottom: windowHeight - best.to,
height: best.to - best.from
}
}
}
return null
}
/** The strip less what the software keyboard covers of its bottom; null when it covers all of it. */
export function sideRailStripAboveKeyboard(
strip: SideRailStrip | null,
keyboardHeight: number
): SideRailStrip | null {
if (strip === null || keyboardHeight <= strip.bottom) {
return strip
}
const height = strip.height - (keyboardHeight - strip.bottom)
return height > 0 ? { ...strip, bottom: keyboardHeight, height } : null
}
-67
View File
@@ -1,67 +0,0 @@
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([])
})
})
+111 -95
View File
@@ -2,129 +2,145 @@ import {
createContext,
useContext,
useEffect,
useId,
useMemo,
useState,
useSyncExternalStore,
type ReactNode
} from 'react'
import { StyleSheet, View } from 'react-native'
import { StyleSheet, useWindowDimensions, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useReservedRegions } from './reserved-regions'
import { useKeyboardOcclusion } from '../platform/keyboard-occlusion'
import {
getSideRailStrip,
hasTerminalDirectionPadSpace,
sideRailStripAboveKeyboard,
type SideRailStrip
} from './side-rail-strip'
export type SideRailEdge = 'left' | 'right'
/** One screen's claim on the strip; each screen holds its own. */
type SideRailOwner = symbol
// 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
type RailContent = { owner: SideRailOwner; node: ReactNode }
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[] = []
function createRailStore() {
let content: RailContent | null = null
const listeners = new Set<() => void>()
const emit = () => listeners.forEach((listener) => listener())
const notify = () => {
for (const listener of listeners) {
listener()
}
}
return {
read: () => content,
subscribe: (listener: () => void) => {
listeners.add(listener)
return () => listeners.delete(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()
show: (owner: SideRailOwner, node: ReactNode) => {
content = { owner, node }
notify()
},
release: (id: string) => {
claims = claims.filter((claim) => claim.id !== id)
emit()
// Only the screen that showed it takes it down, so a screen leaving never hides its successor's.
hide: (owner: SideRailOwner) => {
if (content?.owner === owner) {
content = null
notify()
}
}
}
}
type SideRailStore = ReturnType<typeof createSideRailStore>
type RailStore = ReturnType<typeof createRailStore>
const SideRailContext = createContext<{ edge: SideRailEdge | null; store: SideRailStore } | null>(
null
)
const SideRailContext = createContext<{
strip: SideRailStrip | null
directionPadAvailable: boolean
store: RailStore
} | 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])
/**
* Lets one screen draw controls in a side inset strip the screens are laid out clear of, such as
* the iPhone Duo's status column. Sits outside `SideSafeArea`, which pads every screen out of it.
*/
export function SideRailProvider({ children }: { children: ReactNode }) {
const [store] = useState(createRailStore)
const { width, height } = useWindowDimensions()
const insets = useSafeAreaInsets()
const { occlusions, hasFoldDivision } = useReservedRegions()
// Landscape, the keyboard covers the strip's bottom; the rail then holds fewer keys, not hidden ones.
const keyboard = Math.max(0, useKeyboardOcclusion())
const unoccludedStrip = useMemo(
() => getSideRailStrip({ windowWidth: width, windowHeight: height, insets, occlusions }),
[width, height, insets, occlusions]
)
const strip = useMemo(
() => sideRailStripAboveKeyboard(unoccludedStrip, keyboard),
[unoccludedStrip, keyboard]
)
// Keyboard clipping changes what fits now, not this device's shortcut defaults.
const directionPadAvailable =
hasFoldDivision === true || hasTerminalDirectionPadSpace(unoccludedStrip)
const value = useMemo(
() => ({ strip, directionPadAvailable, store }),
[strip, directionPadAvailable, 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}
<SideRailOutlet />
</SideRailContext.Provider>
)
}
function SideRailSlot({ store }: { store: SideRailStore }) {
const node = useSyncExternalStore(store.subscribe, store.top, store.top)
return node
function SideRailOutlet() {
const context = useContext(SideRailContext)
const content = useSyncExternalStore(
context?.store.subscribe ?? (() => () => undefined),
() => context?.store.read() ?? null
)
const strip = context?.strip
if (!strip || !content) {
return null
}
return (
<View
style={[
styles.outlet,
{ width: strip.width, top: strip.top, bottom: strip.bottom },
strip.side === 'right' ? { right: 0 } : { left: 0 }
]}
>
{content.node}
</View>
)
}
/** The side strip this window has to draw in; null for a window without one. */
export function useSideRailStrip(): SideRailStrip | null {
return useContext(SideRailContext)?.strip ?? null
}
export function useSideRailDirectionPadAvailable(): boolean {
return useContext(SideRailContext)?.directionPadAvailable ?? false
}
/** Draws `node` in the side strip while this screen is mounted; null draws nothing. */
export function useSideRailContent(node: ReactNode | null): void {
// The store outlives strip changes, which replace the context value; re-registering on them would flash the rail.
const store = useContext(SideRailContext)?.store
const [owner] = useState(() => Symbol('side-rail-owner'))
useEffect(() => {
if (!store || node === null) {
return
}
store.show(owner, node)
return () => store.hide(owner)
}, [store, owner, node])
}
const styles = StyleSheet.create({
rail: {
position: 'absolute',
top: SIDE_RAIL_TOP_CLEARANCE,
bottom: 0
}
outlet: { position: 'absolute' }
})
+113 -7
View File
@@ -2,6 +2,8 @@ import { act, create as createRenderer, type ReactTestRenderer } from 'react-tes
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
import { getFoldSplit, type FoldSplit } from './fold-division'
import type { ReservedRegions } from '../../modules/orca-reserved-regions/src'
import type { EdgeInsets, Rect } from 'react-native-safe-area-context'
const renderers = new Set<ReturnType<typeof createRenderer>>()
@@ -19,8 +21,13 @@ function unmountRenderers(): void {
renderers.clear()
}
const geometry = vi.hoisted((): { regions: ReservedRegions; platform: { OS: string } } => ({
regions: { divisions: [], occlusions: [] },
platform: { OS: 'ios' }
}))
vi.mock('./reserved-regions', () => ({ useReservedRegions: () => geometry.regions }))
vi.mock('react-native', () => ({
Platform: { OS: 'ios' },
Platform: geometry.platform,
View: 'View',
StyleSheet: { create: <T,>(styles: T) => styles }
}))
@@ -45,21 +52,29 @@ const { SideSafeArea, useWindowSafeAreaInsets } = await import('./side-safe-area
const WINDOW = { top: 0, right: 48, bottom: 21, left: 59 }
const FRAME = { x: 0, y: 0, width: 852, height: 393 }
function renderInLandscape() {
const read: { screen?: EdgeInsets; frame?: Rect; modal?: EdgeInsets } = {}
function renderInLandscape(window = WINDOW, frame = FRAME, nested = false) {
const read: { screen?: EdgeInsets; frame?: Rect; modal?: EdgeInsets; fold?: FoldSplit | null } =
{}
function Probe() {
read.screen = useSafeAreaInsets()
read.frame = useSafeAreaFrame()
read.fold = getFoldSplit(geometry.regions.divisions, read.frame)
read.modal = useWindowSafeAreaInsets()
return null
}
let tree: ReactTestRenderer | undefined
act(() => {
tree = create(
<SafeAreaFrameContext.Provider value={FRAME}>
<SafeAreaInsetsContext.Provider value={WINDOW}>
<SafeAreaFrameContext.Provider value={frame}>
<SafeAreaInsetsContext.Provider value={window}>
<SideSafeArea>
<Probe />
{nested ? (
<SideSafeArea>
<Probe />
</SideSafeArea>
) : (
<Probe />
)}
</SideSafeArea>
</SafeAreaInsetsContext.Provider>
</SafeAreaFrameContext.Provider>
@@ -85,4 +100,95 @@ describe('the app laid out clear of the side insets', () => {
})
})
afterEach(unmountRenderers)
afterEach(() => {
unmountRenderers()
geometry.regions = { divisions: [], occlusions: [] }
geometry.platform.OS = 'ios'
})
const DUO_FRAME = { x: 0, y: 0, width: 951, height: 669 }
const DUO_INSETS = { top: 0, right: 84, bottom: 34, left: 0 }
function duoGeometry() {
geometry.regions = {
divisions: [],
occlusions: [{ x: 867, y: 0, width: 84, height: 120 }],
windowSafeArea: { bounds: DUO_FRAME, insets: DUO_INSETS }
}
}
function topPadding(tree: ReactTestRenderer) {
return tree.root
.findAll((node) => String(node.type) === 'View')
.map((node) => Object.assign({}, ...[node.props.style].flat()).paddingTop ?? 0)
}
it('keeps the full Duo height without adding a top band across nested areas', () => {
duoGeometry()
const { tree, read } = renderInLandscape(DUO_INSETS, DUO_FRAME, true)
expect(topPadding(tree)).toEqual([0, 0])
expect(read.frame).toEqual({ x: 0, y: 0, width: 867, height: 669 })
expect(read.screen).toEqual({ top: 0, right: 0, bottom: 34, left: 0 })
})
it('adds no Duo edge spacing when the keyboard changes or a banner offsets the frame', () => {
duoGeometry()
geometry.regions.keyboardInset = 600
expect(topPadding(renderInLandscape(DUO_INSETS, DUO_FRAME).tree)).toEqual([0])
expect(topPadding(renderInLandscape(DUO_INSETS, { ...DUO_FRAME, y: 12 }).tree)).toEqual([0])
expect(topPadding(renderInLandscape(DUO_INSETS, { ...DUO_FRAME, y: 30 }).tree)).toEqual([0])
})
it.each(['web', 'android'])('does not apply the native Duo workaround on %s', (platform) => {
duoGeometry()
geometry.platform.OS = platform
expect(topPadding(renderInLandscape(DUO_INSETS, DUO_FRAME).tree)).toEqual([0])
})
it('leaves portrait top-safe-area donation and ordinary iPad/phone geometry unchanged', () => {
geometry.regions = {
divisions: [],
occlusions: [{ x: 470, y: 0, width: 199, height: 84 }],
windowSafeArea: {
bounds: { x: 0, y: 0, width: 669, height: 951 },
insets: { top: 84, right: 0, bottom: 34, left: 0 }
}
}
expect(
topPadding(
renderInLandscape(
{ top: 84, right: 0, bottom: 34, left: 0 },
{ x: 0, y: 0, width: 669, height: 951 }
).tree
)
).toEqual([0])
geometry.regions = { divisions: [], occlusions: [] }
expect(topPadding(renderInLandscape().tree)).toEqual([0])
})
it.each([
{
division: { x: 0, y: 320, width: 951, height: 20 },
expected: { axis: 'horizontal', start: 320, end: 340, size: 669 }
},
{
division: { x: 430, y: 0, width: 10, height: 669 },
expected: { axis: 'vertical', start: 430, end: 440, size: 867 }
}
])(
'keeps the window hinge aligned without a synthetic top offset: $expected.axis',
({ division, expected }) => {
duoGeometry()
geometry.regions.divisions = [division]
const { read, tree } = renderInLandscape(DUO_INSETS, DUO_FRAME, true)
expect(topPadding(tree)).toEqual([0, 0])
expect(read.frame).toEqual({ x: 0, y: 0, width: 867, height: 669 })
expect(read.fold).toEqual(expected)
}
)
it('preserves genuine top insets for the screen and modal without a synthetic guard', () => {
duoGeometry()
const { tree, read } = renderInLandscape({ ...DUO_INSETS, top: 18 }, DUO_FRAME)
expect(topPadding(tree)).toEqual([0])
expect(read.screen?.top).toBe(18)
expect(read.modal?.top).toBe(18)
})
+11 -48
View File
@@ -1,13 +1,4 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type Dispatch,
type ReactNode,
type SetStateAction
} from 'react'
import { createContext, useContext, useMemo, type ReactNode } from 'react'
import { Platform, StyleSheet, View } from 'react-native'
import {
SafeAreaFrameContext,
@@ -17,67 +8,39 @@ 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 [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 areaInsets = useMemo(() => ({ ...insets, left: 0, right: 0 }), [insets])
const areaFrame = useMemo(
() => ({ ...frame, x: frame.x + left, width: Math.max(0, frame.width - left - right) }),
[frame, left, right]
() => ({
...frame,
x: frame.x + insets.left,
width: Math.max(0, frame.width - insets.left - insets.right)
}),
[frame, insets.left, insets.right]
)
const area = (
<WindowSafeAreaInsetsContext.Provider value={insets}>
<SafeAreaFrameContext.Provider value={areaFrame}>
<SafeAreaInsetsContext.Provider value={areaInsets}>
<SideRailReleaseContext.Provider value={setReleases}>
{children}
</SideRailReleaseContext.Provider>
{children}
</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: 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 style={[styles.area, { paddingLeft: insets.left, paddingRight: insets.right }]}>
{Platform.OS === 'web' ? area : <SafeAreaProvider>{area}</SafeAreaProvider>}
</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()
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { topChromeSpace } from './top-chrome-space'
const frame = { x: 0, y: 0, width: 669, height: 88 }
describe('header sharing the Duo top status strip', () => {
it('uses the space beside the reported top-right system region', () => {
expect(topChromeSpace(frame, 84, [{ x: 470, y: 0, width: 199, height: 84 }])).toEqual({
left: 0,
right: 199,
minHeight: 84
})
})
it('handles a left region and a header offset by a sidebar independently', () => {
expect(topChromeSpace(frame, 84, [{ x: 0, y: 0, width: 199, height: 84 }])).toEqual({
left: 199,
right: 0,
minHeight: 84
})
expect(
topChromeSpace({ ...frame, x: 200, width: 469 }, 84, [
{ x: 570, y: 0, width: 99, height: 84 }
])
).toEqual({ left: 0, right: 99, minHeight: 84 })
})
it('keeps normal safe areas for unknown, centered, full-width or crowded obstructions', () => {
expect(topChromeSpace(frame, 84, [])).toBeNull()
expect(topChromeSpace(frame, 84, [{ x: 300, y: 0, width: 69, height: 84 }])).toBeNull()
expect(topChromeSpace(frame, 84, [{ x: 0, y: 0, width: 669, height: 84 }])).toBeNull()
expect(topChromeSpace(frame, 84, [{ x: 200, y: 0, width: 469, height: 84 }])).toBeNull()
expect(topChromeSpace(frame, 0, [{ x: 470, y: 0, width: 199, height: 84 }])).toBeNull()
})
it('does not collide with an additional camera region or assume an inset was not already consumed', () => {
expect(
topChromeSpace(frame, 84, [
{ x: 470, y: 0, width: 199, height: 84 },
{ x: 0, y: 20, width: 30, height: 30 }
])
).toEqual({ left: 30, right: 199, minHeight: 84 })
expect(
topChromeSpace({ ...frame, y: 84 }, 84, [{ x: 470, y: 0, width: 199, height: 84 }])
).toBeNull()
})
})
+51
View File
@@ -0,0 +1,51 @@
import type { ReservedRect } from '../../modules/orca-reserved-regions/src'
/** A header can share a top strip only when the system reports its full edge obstruction. */
export function topChromeSpace(
frame: ReservedRect | null,
topInset: number,
occlusions: readonly ReservedRect[]
): { left: number; right: number; minHeight: number } | null {
if (!frame || topInset <= 0 || frame.width <= 0) {
return null
}
const end = frame.x + frame.width
const obstacles = occlusions.filter(
(rect) =>
rect.width > 0 &&
rect.height > 0 &&
rect.x < end &&
rect.x + rect.width > frame.x &&
rect.y < frame.y + topInset &&
rect.y + rect.height > frame.y
)
if (
!obstacles.some(
(rect) =>
rect.y <= frame.y + 1 &&
rect.y + rect.height >= frame.y + topInset - 1 &&
(rect.x <= frame.x + 1 || rect.x + rect.width >= end - 1)
)
) {
return null
}
const spans = obstacles
.map((rect) => ({
start: Math.max(frame.x, rect.x),
end: Math.min(end, rect.x + rect.width)
}))
.sort((a, b) => a.start - b.start)
let cursor = frame.x
let best = { start: frame.x, end: frame.x }
for (const span of [...spans, { start: end, end }]) {
if (span.start - cursor > best.end - best.start) {
best = { start: cursor, end: span.start }
}
cursor = Math.max(cursor, span.end)
}
// Keep a usable tab strip; a camera island or full-width status bar cannot donate a header row.
if (best.end - best.start < 240) {
return null
}
return { left: best.start - frame.x, right: end - best.end, minHeight: topInset }
}
+20
View File
@@ -0,0 +1,20 @@
import { useMemo } from 'react'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import { OrcaReservedRegions } from '../../modules/orca-reserved-regions/src'
import { getFoldSplit, type FoldSplit } from './fold-division'
import { useReservedRegions } from './reserved-regions'
function useMeasuredFoldSplit(): FoldSplit | null {
const { divisions } = useReservedRegions()
// Below `SideSafeArea` the frame is the padded area, in window points like the regions.
const frame = useSafeAreaFrame()
return useMemo(() => getFoldSplit(divisions, frame), [divisions, frame])
}
const noFoldSplit = (): FoldSplit | null => null
/**
* The fold splitting the area `SideSafeArea` lays screens out in, in that area's coordinates.
* Chosen once: a platform without the module never reports a fold, so it measures nothing.
*/
export const useFoldSplit = OrcaReservedRegions ? useMeasuredFoldSplit : noFoldSplit
+18
View File
@@ -0,0 +1,18 @@
import { useSyncExternalStore } from 'react'
import { useWindowDimensions } from 'react-native'
import { usePageBridgeClient } from '../transport/client-context.web'
import type { FoldSplit } from './fold-division'
/** The fold the shell measured over this page, in the page's px; null when none splits it. */
export function useFoldSplit(): FoldSplit | null {
const client = usePageBridgeClient()
const division = useSyncExternalStore(
client.onFoldDivisionUpdate,
() => client.getShellSession()?.foldDivision ?? null
)
const { width, height } = useWindowDimensions()
if (division === null) {
return null
}
return { ...division, size: division.axis === 'vertical' ? width : height }
}
+8
View File
@@ -0,0 +1,8 @@
import { useSideRailStrip, useSideRailDirectionPadAvailable } from './side-rail'
export function useShellSideRail(): boolean {
return useSideRailStrip() !== null
}
export function useDirectionPadAvailable(): boolean {
return useSideRailDirectionPadAvailable()
}
@@ -0,0 +1,8 @@
import { usePageGeometry } from './reserved-regions.web'
export function useShellSideRail(): boolean {
return usePageGeometry().sideRail
}
export function useDirectionPadAvailable(): boolean {
return usePageGeometry().directionPadAvailable === true
}
+30
View File
@@ -0,0 +1,30 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useWindowDimensions, type View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import type { ReservedRect } from '../../modules/orca-reserved-regions/src'
import { useReservedRegions } from './reserved-regions'
import { topChromeSpace } from './top-chrome-space'
export function useTopChromeSpace() {
const ref = useRef<View>(null)
const [frame, setFrame] = useState<ReservedRect | null>(null)
const dimensions = useWindowDimensions()
const { top } = useSafeAreaInsets()
const { occlusions } = useReservedRegions()
const onLayout = useCallback(
() =>
ref.current?.measureInWindow((x, y, width, height) => {
setFrame((current) =>
current?.x === x &&
current.y === y &&
current.width === width &&
current.height === height
? current
: { x, y, width, height }
)
}),
[]
)
useEffect(onLayout, [onLayout, top, occlusions, dimensions.width, dimensions.height])
return { ref, onLayout, space: topChromeSpace(frame, top, occlusions) }
}
@@ -306,12 +306,14 @@ describe('the hybrid shell screen', () => {
dependencies.client = createFakeRpcClient()
const tree = await renderScreen(readyState('session-one'))
const view = byName(tree, 'ShellViewProbe')[0]
const refreshesBeforeReady = dependencies.storageRefreshes
await act(async () => {
view.props.onBridgeMessage({ nativeEvent: { json: clientFrame({ type: 'ready' }) } })
view.props.onBridgeMessage({ nativeEvent: { json: clientFrame({ type: 'ready' }) } })
})
// A reload within this mount must refresh storage again so intervening app writes reach init.
expect(dependencies.storageRefreshes).toBe(2)
// A document that reloads inside one mount asks again; a refresh per ask is what lets a key
// the app changed meanwhile reach the `init` after it.
expect(dependencies.storageRefreshes - refreshesBeforeReady).toBe(2)
})
// Observe wire frames only; ruling 34 leaves delivery untracked and one-shot consumption to the page.
@@ -1,7 +1,7 @@
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 { useNavigation, useNavigationContainerRef, useRouter } from 'expo-router'
import {
OrcaMobileWebShellView,
parseMobileWebShellLoadState
@@ -25,18 +25,21 @@ import { useNativeDeviceVerbs } from '../platform/use-native-device-verbs'
import { useNativePdfViewer } from '../platform/native-pdf-view'
import { useShellPageBack } from './use-shell-page-back'
import { useShellStackPop } from './use-shell-stack-pop'
import { usePageLocationOnFold } from './page-location-on-fold'
import { useMobileWebShellSession } from './use-mobile-web-shell-session'
import { useShellPageStorageRefresh } from './use-shell-page-storage-refresh'
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 { 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 } from './ShellWaitingFrame'
import { ShellStateScreen } from './ShellStateScreen'
import { useReportedShellOverlayLoad, type ShellOverlayLoad } from './shell-overlay-load'
import { pageSafeAreaInsets, usePublishedSafeAreaInsets } from './page-safe-area-insets'
import { ShellStateScreen } from './ShellStateScreen'
import { usePageFoldDivision, usePublishedFoldDivision } from './page-fold-division'
import { usePublishedShellKeyRail, useShellKeyRail } from './use-shell-key-rail'
import { hostProfileUnreadableLine, pageFaultLine } from './shell-lifecycle-log'
import { recordShellLifecycleLine } from './shell-lifecycle-line-recorder'
@@ -112,7 +115,13 @@ export function MobileWebShellScreen({
updateNotice !== null && state.kind === 'ready' && noticeDismissedFor !== state.sessionId
// Edge-to-edge disables adjustResize and zeroes WebView IME insets, so visualViewport never shrinks; publish native keyboard height to the page.
const keyboardInset = Math.max(0, useKeyboardOcclusion())
const pageInsets = pageSafeAreaInsets({ insets, topCovered: noticeShown })
const pageFold = usePageFoldDivision()
const pageInsets = pageSafeAreaInsets({
insets: pageFold.safeAreaInsets,
topCovered: noticeShown
})
const readySessionId = state.kind === 'ready' ? state.sessionId : null
const keyRail = useShellKeyRail({ sessionId: readySessionId, overlay, routeGrants })
const {
snapshot,
unreadable,
@@ -122,9 +131,10 @@ export function MobileWebShellScreen({
writeStorage,
readWorkspaceKey
} = usePageHostSnapshot(hostId, route.pathname, ownsHostArea)
// Create the media registry before the bridge; its staged files must belong to this page session.
const serveDeviceVerb = useNativeDeviceVerbs(state.kind === 'ready' ? state.sessionId : null)
const servePdfView = useNativePdfViewer(hostId, state.kind === 'ready' ? state.sessionId : null)
// Create the media registry before the bridge; staged files belong to this page session.
const serveDeviceVerb = useNativeDeviceVerbs(readySessionId)
const servePdfView = useNativePdfViewer(hostId, readySessionId)
// Read the shell's storage for this host.
const serveNativeVerb = (verb: BridgeNativeVerb, params: unknown): Promise<unknown> =>
verb === 'native.storage.read'
? readWorkspaceKey(storageReadParamsSchema.parse(params).key).then((value) => ({ value }))
@@ -137,6 +147,7 @@ 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) {
@@ -157,8 +168,8 @@ export function MobileWebShellScreen({
})
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)) {
// Host exits preserve notice params and replace cold deep links at either width.
if (isHostRouteOf(href, hostId)) {
router.dismissTo(href)
return
}
@@ -175,6 +186,9 @@ export function MobileWebShellScreen({
route,
safeAreaInsets: pageInsets,
keyboardInset,
pageGeometry: pageFold.geometry,
foldDivision: pageFold.division,
terminalKeyRail: keyRail.keys,
colorScheme,
uiLocale,
pageRoutes,
@@ -202,6 +216,7 @@ export function MobileWebShellScreen({
onPagePainted: reportPagePainted,
onPageBackClaim: reportPageBackClaim,
onPageLocation: (href) => holdPageLocation(href, ownsHostArea),
onTerminalKeyRailClaim: keyRail.reportClaim,
onRouteParamClear: (param, value) => {
onRouteParamClear?.(param, value)
},
@@ -221,17 +236,17 @@ export function MobileWebShellScreen({
onBinaryFramesDropped: reportDroppedBinaryFrames
})
// paneKey changes keep the session mounted; publish the new route without reloading. The longer-lived host deduplicates routes and preserves any in-flight frame.
const publishRoute = bridge.publishRoute
useEffect(() => {
publishRoute(route)
}, [publishRoute, route])
// paneKey changes keep the session mounted; the host deduplicates routes and preserves in-flight frames.
useShellPageStorageRefresh(refreshStorage, bridge.publishStorage)
useEffect(() => bridge.publishRoute(route), [bridge.publishRoute, route])
usePublishedSafeAreaInsets(bridge.publishSafeAreaInsets, pageInsets)
const publishKeyboardInset = bridge.publishKeyboardInset
useEffect(() => {
publishKeyboardInset(keyboardInset)
}, [publishKeyboardInset, keyboardInset])
usePublishedFoldDivision(bridge.publishFoldDivision, pageFold.division)
usePublishedShellKeyRail(keyRail.keys, bridge, keyRail.inputOwner, keyRail.toolbar)
// Use navigation screen options to disable this stack entry’s gesture; the router has no such option.
useShellPageBack({
@@ -276,6 +291,8 @@ export function MobileWebShellScreen({
}
return (
<View
ref={pageFold.rootRef}
onLayout={pageFold.onRootLayout}
style={[
styles.shellRoot,
// The page pads system bars from init insets; a visible banner owns the status-bar strip.
@@ -297,6 +314,7 @@ export function MobileWebShellScreen({
ref={bridge.viewRef}
// Overlays cannot cover the screen beneath; hide their opaque WebView until first paint.
style={[styles.shellView, overlay && pageFrame !== 'painted' && styles.unpaintedOverlay]}
onLayout={pageFold.onShellLayout}
generationDirectory={state.generationDirectory}
sessionId={state.sessionId}
bridgeEnabled={bridge.bridgeEnabled}
@@ -339,10 +357,6 @@ const styles = themedStyleSheet(() => ({
flex: 1,
backgroundColor: colors.bgBase
},
shellView: {
flex: 1
},
unpaintedOverlay: {
opacity: 0
}
shellView: { flex: 1 },
unpaintedOverlay: { opacity: 0 }
}))
@@ -1,7 +1,6 @@
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'
@@ -86,7 +85,8 @@ function Failed({
)
}
function StateView({
// What the shell shows natively until its page is ready.
export function ShellStateScreen({
hostId,
state,
onRetry,
@@ -140,15 +140,6 @@ function StateView({
}
}
// 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,
@@ -4,6 +4,9 @@ import { BRIDGE_BACK_FRAME } from './bridge/bridge-page-back'
// Carry Back state across client-driven host rebuilds: the page remains mounted and is never told to claim again.
export type BridgeSessionBack = {
readonly claimed: boolean
/** The negotiated storage keys the page's `ready` named; absent for a page that named none. */
readonly storageKeys?: readonly string[]
readonly terminalKeyRailClaim?: { readonly inputOwner?: string }
}
// Drop claims when their document ends to avoid swallowing Back on an unrelated page. Host disposal alone transfers the session’s read state without reporting a dropped claim.
@@ -1,3 +1,9 @@
import type {
BridgeTerminalKeyId,
BridgeTerminalToolbar,
BridgeTerminalKeyRail
} from './bridge/bridge-terminal-key'
import type { BridgePageGeometry } from './bridge/bridge-page-geometry'
import type { TerminalBacklogEnd, TerminalBacklogTimers } from './bridge-terminal-output-backlog'
import type { RpcClient } from '../transport/rpc-client'
import type { BridgeSessionBack } from './bridge-host-back'
@@ -10,6 +16,7 @@ import type {
} from './bridge/bridge-envelope'
import type { BridgeClearableRouteParam } from './bridge/bridge-route-update'
import type { BridgeSafeAreaInsets } from './bridge/bridge-safe-area-insets'
import type { BridgeFoldDivision } from './bridge/bridge-fold-division'
import type { BridgeHapticsKind } from './bridge/bridge-haptics-notify'
import type { BridgeErrorCapture } from './bridge/bridge-error-capture'
import type { BridgeNativeVerb } from './bridge/bridge-native-verbs'
@@ -76,6 +83,13 @@ export type BridgeHostOptions = {
keyboardInset?: number
// Default to dark; publishColorScheme moves the app's resolved scheme later.
colorScheme?: BridgeColorScheme
/** The fold splitting the page on a partially folded device. None when absent; moved later with
* `publishFoldDivision`. */
pageGeometry?: BridgePageGeometry
foldDivision?: BridgeFoldDivision | null
/** The keys the shell's rail shows beside the page. None when absent; moved later with
* `publishTerminalKeyRail`. */
terminalKeyRail?: BridgeTerminalKeyRail
// Absent until the shell knows its language; publishUiLocale moves it later.
uiLocale?: BridgeUiLocale
// Read identity at forward time because this host outlives renders; replace the page placeholder as init promised or the desktop refuses spoofed sends.
@@ -100,9 +114,20 @@ 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.
// Where a host-area page from an older desktop 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).
/** The page shows a terminal with its key row, or stopped; false also arrives each time a
* document starts or ends, and the page claims again on the `init` that follows. */
onTerminalKeyRailClaim?: (
claimed: boolean,
inputOwner?: string,
toolbar?: BridgeTerminalToolbar
) => void
/**
* The page applied a one-shot route param and is asking for it to be erased (ruling 34), naming
* the value it applied. The holder of that param compares before it clears: a tap that has moved
* on since leaves a newer value here, and a clear naming the older one is not for it.
*/
onRouteParamClear: (param: BridgeClearableRouteParam, value: string) => void
// Escalate invalid shell routes visibly; a page-only console warning would leave a blank document retrying forever.
onRouteRefused: (issue: string) => void
@@ -112,3 +137,32 @@ export type BridgeHostOptions = {
// Inject timers for tests so they exercise the shipped silence bound without waiting twenty seconds per case.
terminalTimers?: TerminalBacklogTimers
}
export type BridgeHost = {
publishStorage: () => void
receive: (json: string) => void
// Hold same-screen param updates even when posting fails; repair from the next ready without delivery tracking. Different pathnames require remount; the page clears consumed params (rulings 33.1/34).
publishRoute: (next: BridgeInitRoute) => void
publishSafeAreaInsets: (next: BridgeSafeAreaInsets) => void
publishKeyboardInset: (next: number) => void
/** Hands this session a moved fold over the same re-sent `init`. */
publishPageGeometry: (next: BridgePageGeometry) => void
publishFoldDivision: (next: BridgeFoldDivision | null) => void
/** Tells this session the shell's key rail shows or hides, over the same re-sent `init`. */
publishTerminalKeyRail: (next: BridgeTerminalKeyRail) => void
publishColorScheme: (next: BridgeColorScheme) => void
publishUiLocale: (next: BridgeUiLocale) => void
/**
* Hands the page one press on the key rail. False when this session was not granted the rail,
* which is every page older than it, or when no document holds the view.
*/
sendTerminalKey: (key: BridgeTerminalKeyId, inputOwner?: string) => boolean
/**
* Hands the page one Back press. False when this document never said it takes one, which is
* every page older than the frame; the caller then leaves the key to the navigator.
*/
sendBack: () => boolean
// Transfer the session’s Back state on client-driven host rebuild; the mounted page never reclaims it. Ending sessions simply discard it.
readSessionBack: () => BridgeSessionBack
dispose: () => void
}
@@ -1,3 +1,4 @@
import type { BridgeTerminalToolbar } from './bridge/bridge-terminal-key'
import { readBridgeExternalLinkUrl } from './bridge/bridge-caps'
import {
BRIDGE_EXTERNAL_LINK_GRANT,
@@ -13,6 +14,7 @@ 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 { BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY } from './bridge/bridge-terminal-key'
import type { BridgeHostOptions } from './bridge-host-contract'
import { pageMayWriteStorageKey } from './page-storage-keys'
@@ -25,6 +27,11 @@ export function createBridgeNotifyForwarder(args: {
initSent: () => boolean
route: () => BridgeInitRoute | null
onBackClaim: (claimed: boolean) => void
onTerminalKeyRailClaim: (
claimed: boolean,
inputOwner?: string,
toolbar?: BridgeTerminalToolbar
) => void
}): (message: NotifyMessage) => void {
const { options, granted } = args
const { client, host } = options
@@ -48,6 +55,10 @@ export function createBridgeNotifyForwarder(args: {
args.onBackClaim(message.claimed)
return
}
if (message.name === BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY) {
args.onTerminalKeyRailClaim(message.claimed, message.inputOwner, message.toolbar)
return
}
if (message.name === 'foreground') {
if (message.reason === undefined) {
client.notifyForeground()
@@ -1,3 +1,8 @@
import {
EMPTY_PAGE_GEOMETRY,
samePageGeometry,
type BridgePageGeometry
} from './bridge/bridge-page-geometry'
import {
BridgeInitRouteSchema,
type BridgeColorScheme,
@@ -10,6 +15,8 @@ import {
ZERO_SAFE_AREA_INSETS,
type BridgeSafeAreaInsets
} from './bridge/bridge-safe-area-insets'
import { sameFoldDivision, type BridgeFoldDivision } from './bridge/bridge-fold-division'
import { sameTerminalKeyRail, type BridgeTerminalKeyRail } from './bridge/bridge-terminal-key'
export type BridgeHostRoute = {
readonly current: () => BridgeInitRoute | null
@@ -21,6 +28,16 @@ export type BridgeHostRoute = {
readonly publishSafeAreaInsets: (next: BridgeSafeAreaInsets, deliverable: boolean) => void
readonly keyboardInset: () => number
readonly publishKeyboardInset: (next: number, deliverable: boolean) => void
/** The fold the next `init` carries; null when none splits the page. */
readonly pageGeometry: () => BridgePageGeometry
readonly publishPageGeometry: (next: BridgePageGeometry, deliverable: boolean) => void
readonly foldDivision: () => BridgeFoldDivision | null
/** Moves the held fold on the same lane as the insets. */
readonly publishFoldDivision: (next: BridgeFoldDivision | null, deliverable: boolean) => void
/** The keys the next `init` tells the page the shell's rail shows beside it. */
readonly terminalKeyRail: () => BridgeTerminalKeyRail
/** Moves the rail's keys on the same lane as the insets. */
readonly publishTerminalKeyRail: (next: BridgeTerminalKeyRail, deliverable: boolean) => void
readonly colorScheme: () => BridgeColorScheme
readonly publishColorScheme: (next: BridgeColorScheme, deliverable: boolean) => void
readonly uiLocale: () => BridgeUiLocale | undefined
@@ -36,6 +53,9 @@ export function createBridgeHostRoute(args: {
onRefused: (issue: string) => void
safeAreaInsets?: BridgeSafeAreaInsets
keyboardInset?: number
pageGeometry?: BridgePageGeometry
foldDivision?: BridgeFoldDivision | null
terminalKeyRail?: BridgeTerminalKeyRail
colorScheme?: BridgeColorScheme
uiLocale?: BridgeUiLocale
}): BridgeHostRoute {
@@ -43,6 +63,9 @@ export function createBridgeHostRoute(args: {
let route = parsed.success && !args.refused ? parsed.data : null
let insets = args.safeAreaInsets ?? ZERO_SAFE_AREA_INSETS
let keyboard = args.keyboardInset ?? 0
let geometry = args.pageGeometry ?? EMPTY_PAGE_GEOMETRY
let fold = args.foldDivision ?? null
let rail = args.terminalKeyRail ?? []
let scheme = args.colorScheme ?? 'dark'
let uiLocale = args.uiLocale
return {
@@ -79,6 +102,36 @@ export function createBridgeHostRoute(args: {
args.sendInit()
}
},
pageGeometry: () => geometry,
publishPageGeometry: (next, deliverable) => {
if (samePageGeometry(geometry, next)) {
return
}
geometry = next
if (deliverable) {
args.sendInit()
}
},
foldDivision: () => fold,
publishFoldDivision: (next, deliverable) => {
if (sameFoldDivision(fold, next)) {
return
}
fold = next
if (deliverable) {
args.sendInit()
}
},
terminalKeyRail: () => rail,
publishTerminalKeyRail: (next, deliverable) => {
if (sameTerminalKeyRail(rail, next)) {
return
}
rail = next
if (deliverable) {
args.sendInit()
}
},
colorScheme: () => scheme,
publishColorScheme: (next, deliverable) => {
if (scheme === next) {
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import { clientFrame } from './bridge-host-test-fakes'
import { harness } from './bridge-host-test-harness'
import {
BRIDGE_TERMINAL_KEY_FRAME,
BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY
} from './bridge/bridge-terminal-key'
function claim(claimed: boolean, inputOwner?: string): string {
return clientFrame({
type: 'notify',
name: BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY,
claimed,
inputOwner
})
}
describe('a terminal key rail claim across host rebuilds', () => {
it('delivers the first key for the established owner without another ready or storage refresh', () => {
const first = harness({ ready: true })
first.host.receive(claim(true, 'pane-a'))
expect(first.host.sendTerminalKey('arrowUp', 'pane-a')).toBe(true)
first.host.dispose()
const rebuilt = harness({
sessionEstablished: true,
sessionBack: first.host.readSessionBack()
})
expect(rebuilt.host.sendTerminalKey('arrowUp', 'pane-a')).toBe(true)
expect(rebuilt.last()).toEqual({
v: 1,
type: BRIDGE_TERMINAL_KEY_FRAME,
key: 'arrowUp',
inputOwner: 'pane-a'
})
expect(rebuilt.host.sendTerminalKey('arrowUp', 'pane-b')).toBe(false)
})
it.each(['ready', 'close'] as const)(
'drops the carried claim on %s before a new owner takes it',
(type) => {
const first = harness({ ready: true })
first.host.receive(claim(true, 'pane-a'))
const rebuilt = harness({
sessionEstablished: true,
sessionBack: first.host.readSessionBack()
})
rebuilt.host.receive(clientFrame({ type }))
expect(rebuilt.host.sendTerminalKey('arrowUp', 'pane-a')).toBe(false)
expect(rebuilt.host.readSessionBack()).not.toHaveProperty('terminalKeyRailClaim')
if (type === 'close') {
rebuilt.host.receive(clientFrame({ type: 'ready' }))
}
rebuilt.host.receive(claim(true, 'pane-b'))
expect(rebuilt.host.sendTerminalKey('enter', 'pane-a')).toBe(false)
expect(rebuilt.host.sendTerminalKey('enter', 'pane-b')).toBe(true)
}
)
it('does not carry a rail the page has explicitly released', () => {
const first = harness({ ready: true })
first.host.receive(claim(true, 'pane-a'))
first.host.receive(claim(false))
const snapshot = first.host.readSessionBack()
expect(snapshot).not.toHaveProperty('terminalKeyRailClaim')
const rebuilt = harness({ sessionEstablished: true, sessionBack: snapshot })
expect(rebuilt.host.sendTerminalKey('enter', 'pane-a')).toBe(false)
})
})
+74 -36
View File
@@ -10,37 +10,20 @@ import {
BRIDGE_RPC_ENVELOPE_GRANT,
readBridgeClientMessage,
type BridgeClientMessage,
type BridgeColorScheme,
type BridgeUiLocale,
type BridgeConnectionSnapshot,
type BridgeInitRoute
type BridgeConnectionSnapshot
} from './bridge/bridge-envelope'
import { BridgePageRouteGrantsSchema } from './bridge/bridge-page-route-grants'
import { createBridgeInitFrame } from './bridge/bridge-init-frame'
import { splitBridgeReply } from './bridge/bridge-reply-chunking'
import { createBridgeHostFrames } from './bridge-host-frames'
import { createBridgeHostBack, type BridgeSessionBack } from './bridge-host-back'
import { createBridgeHostBack } from './bridge-host-back'
import { createBridgeNotifyForwarder } from './bridge-host-notify'
import { createBridgeHostRoute } from './bridge-host-route'
import type { BridgeSafeAreaInsets } from './bridge/bridge-safe-area-insets'
import type { BridgeHostOptions } from './bridge-host-contract'
import { pageStorageForReader } from './page-storage-keys'
import { BRIDGE_TERMINAL_KEY_FRAME } from './bridge/bridge-terminal-key'
import type { BridgeHost, BridgeHostOptions } from './bridge-host-contract'
export type { BridgeHostDiagnostic, BridgeHostOptions } from './bridge-host-contract'
export type BridgeHost = {
receive: (json: string) => void
// Hold same-screen param updates even when posting fails; repair from the next ready without delivery tracking. Different pathnames require remount; the page clears consumed params (rulings 33.1/34).
publishRoute: (next: BridgeInitRoute) => void
publishSafeAreaInsets: (next: BridgeSafeAreaInsets) => void
publishKeyboardInset: (next: number) => void
publishColorScheme: (next: BridgeColorScheme) => void
publishUiLocale: (next: BridgeUiLocale) => void
// False leaves Back to native navigation, including pages predating this frame.
sendBack: () => boolean
// Transfer the session’s Back state on client-driven host rebuild; the mounted page never reclaims it. Ending sessions simply discard it.
readSessionBack: () => BridgeSessionBack
dispose: () => void
}
export type { BridgeHost, BridgeHostDiagnostic, BridgeHostOptions } from './bridge-host-contract'
// Bind the host structurally to one RPC client; the protocol lets pages name no other host. Native origin checks reject expired sessions, and init caps are enforced here.
export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
@@ -68,6 +51,9 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
onRefused: (issue) => options.onDiagnostic?.({ kind: 'route-update-refused', issue }),
...(options.safeAreaInsets === undefined ? {} : { safeAreaInsets: options.safeAreaInsets }),
...(options.keyboardInset === undefined ? {} : { keyboardInset: options.keyboardInset }),
...(options.pageGeometry === undefined ? {} : { pageGeometry: options.pageGeometry }),
...(options.foldDivision === undefined ? {} : { foldDivision: options.foldDivision }),
...(options.terminalKeyRail === undefined ? {} : { terminalKeyRail: options.terminalKeyRail }),
...(options.colorScheme === undefined ? {} : { colorScheme: options.colorScheme }),
...(options.uiLocale === undefined ? {} : { uiLocale: options.uiLocale })
})
@@ -76,6 +62,9 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
let serving = true
// serving initially admits the first ready batch, but only an established session holds grants. Inherit establishment when rebuilding beneath a live page.
let initSent = options.sessionEstablished
// What the page's init reader accepts, carried across a rebuild because the page does not
// re-handshake. Null is a page older than `storageKeys`, which gets the legacy keys only.
let pageStorageKeys: readonly string[] | null = options.sessionBack?.storageKeys ?? null
const frames = createBridgeHostFrames({
post: options.post,
@@ -108,11 +97,25 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
}
}
// Build init synchronously for first-render/golden timing; reread route, client state and storage on every ready, including reloads after writes.
// Refused routes send nothing; failed posts produce one diagnostic with no held frame or retry. The page’s next ready repairs state (ruling 34).
function sendInit(): void {
/**
* Posts `init`, and answers nothing (ruling 34).
*
* Sent every time it is asked for, with the keys read every time it is sent. A page that saw a
* `state` older than the one it holds recovers by asking again rather than by living with a
* cache it knows is wrong, and the same is true of its storage: a document that reloads inside
* one mount — which the fault path produces — would otherwise be primed from before its own
* writes, and `publishPageStorage` clears the page's cache to match.
*
* The frame is built synchronously, because the page refuses every member until `init` lands and
* the golden recorder mounts its screen in the same turn it drains one; a frame whose contents
* waited on a promise would change what the first render of every replay sees.
*
* A refused route sends nothing at all, and a post the view would not take is one diagnostic and
* no further attempt: nothing here holds a frame, and nothing retries one.
*/
function sendInit(refreshTerminalShortcuts = false): void {
const route = routes.current()
if (route === null) {
if (route === null || (refreshTerminalShortcuts && !deliverable())) {
return
}
initSent = true
@@ -125,6 +128,9 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
route,
safeAreaInsets: routes.safeAreaInsets(),
keyboardInset: routes.keyboardInset(),
pageGeometry: routes.pageGeometry(),
foldDivision: routes.foldDivision(),
terminalKeyRail: routes.terminalKeyRail(),
colorScheme: routes.colorScheme(),
uiLocale: routes.uiLocale(),
pageRoutes,
@@ -134,7 +140,8 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
granted,
host,
ownsHostArea: options.ownsHostArea === true,
...options.readStorage()
...pageStorageForReader(options.readStorage(), pageStorageKeys),
refreshTerminalShortcuts
})
)
)
@@ -183,12 +190,21 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
...(options.sessionBack === undefined ? {} : { established: options.sessionBack })
})
let railOwner = options.sessionBack?.terminalKeyRailClaim?.inputOwner
let railClaimed = options.sessionBack?.terminalKeyRailClaim !== undefined
type RailClaim = NonNullable<BridgeHostOptions['onTerminalKeyRailClaim']>
const reportRailClaim: RailClaim = (claimed, inputOwner, toolbar) => {
railClaimed = claimed
railOwner = claimed ? inputOwner : undefined
options.onTerminalKeyRailClaim?.(claimed, inputOwner, toolbar)
}
const forwardNotify = createBridgeNotifyForwarder({
options,
granted,
initSent: () => initSent,
route: () => routes.current(),
onBackClaim: back.readClaim
onBackClaim: back.readClaim,
onTerminalKeyRailClaim: reportRailClaim
})
function settleAll(notify: boolean): void {
@@ -209,8 +225,13 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
// Every ready claims the view, whether a new document or a repeated ask.
if (message.type === 'ready') {
serving = true
pageStorageKeys = message.storageKeys ?? null
back.readReady()
// Answer every ready with current state; this repairs missed frames without host retries (ruling 34).
// Dropped like the Back claim: a page still showing a terminal claims again on this `init`.
reportRailClaim(false)
// Every time it is asked, not once: the page re-asks on a backoff, and each ask is answered
// with the route the shell holds now. That is the whole repair path for a frame that never
// arrived (ruling 34) — nothing here waits on one, and nothing retries one.
sendInit()
options.onPageReady()
return
@@ -251,6 +272,7 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
settleAll(false)
serving = false
back.drop()
reportRailClaim(false)
return
}
}
@@ -279,14 +301,26 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
}
dispatch(read.message)
},
publishRoute: (next) => {
routes.publish(next, serving && initSent)
},
publishRoute: (next) => routes.publish(next, serving && initSent),
publishSafeAreaInsets: (next) => {
routes.publishSafeAreaInsets(next, deliverable())
},
publishKeyboardInset: (next) => {
routes.publishKeyboardInset(next, deliverable())
publishKeyboardInset: (next) => routes.publishKeyboardInset(next, deliverable()),
publishStorage: () => sendInit(true),
publishPageGeometry: (next) => routes.publishPageGeometry(next, deliverable()),
publishFoldDivision: (next) => routes.publishFoldDivision(next, deliverable()),
publishTerminalKeyRail: (next) => routes.publishTerminalKeyRail(next, deliverable()),
sendTerminalKey: (key, inputOwner) => {
if (!railClaimed || inputOwner !== railOwner || !deliverable()) {
return false
}
send({
v: BRIDGE_PROTOCOL_VERSION,
type: BRIDGE_TERMINAL_KEY_FRAME,
key,
inputOwner
})
return true
},
publishColorScheme: (next) => {
routes.publishColorScheme(next, deliverable())
@@ -295,7 +329,11 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
routes.publishUiLocale(next, deliverable())
},
sendBack: back.send,
readSessionBack: back.read,
readSessionBack: () => ({
...back.read(),
...(pageStorageKeys === null ? {} : { storageKeys: pageStorageKeys }),
...(railClaimed ? { terminalKeyRailClaim: { inputOwner: railOwner } } : {})
}),
dispose
}
}
@@ -47,7 +47,7 @@ describe('a Back press crossing from the shell to the page', () => {
expect(pair.host.sendBack()).toBe(true)
await pair.flush()
expect(took).toHaveBeenCalledTimes(1)
expect(pair.readToShell().find((frame) => frame.type === 'ready')).toEqual({
expect(pair.readToShell().find((frame) => frame.type === 'ready')).toMatchObject({
v: 1,
type: 'ready'
})
@@ -1,4 +1,5 @@
import { utf8ByteLength } from './bridge-caps'
import type { BridgeTerminalKeyId } from './bridge-terminal-key'
import { BridgeReplyRefusedError } from './bridge-client-errors'
import type { BridgeRpcClientDiagnostic } from './bridge-client-diagnostics'
import type { BridgeClientRequests } from './bridge-client-requests'
@@ -18,6 +19,7 @@ export type BridgeInboundFramePort = {
acceptInit: (message: Extract<BridgeHostMessage, { type: 'init' }>) => void
acceptState: (connection: BridgeConnectionSnapshot) => void
acceptBack: () => void
acceptTerminalKey: (key: BridgeTerminalKeyId, inputOwner?: string) => void
}
function describeStreamFailure(error: unknown): string {
@@ -60,6 +62,9 @@ export function createBridgeInboundFrameReader(
case 'back':
port.acceptBack()
return
case 'terminalKey':
port.acceptTerminalKey(message.key, message.inputOwner)
return
case 'reply':
if (!requests.has(message.id)) {
report({ kind: 'unknown-id' })
@@ -1,4 +1,5 @@
import { BRIDGE_PROTOCOL_VERSION, type BridgeClientMessage } from './bridge-envelope'
import { NEGOTIATED_PAGE_STORAGE_KEYS } from '../page-storage-keys'
export const BRIDGE_READY_RETRY_MIN_MS = 50
export const BRIDGE_READY_RETRY_MAX_MS = 2000
@@ -41,5 +42,9 @@ export function createBridgeInitHandshake(ask: () => void): BridgeInitHandshake
}
export function createPageReadyFrame(): Extract<BridgeClientMessage, { type: 'ready' }> {
return { v: BRIDGE_PROTOCOL_VERSION, type: 'ready' }
return {
v: BRIDGE_PROTOCOL_VERSION,
type: 'ready',
storageKeys: [...NEGOTIATED_PAGE_STORAGE_KEYS]
}
}
@@ -1,3 +1,4 @@
import type { BridgeTerminalToolbar } from './bridge-terminal-key'
import type { ForegroundNudgeReason } from '../../transport/types'
import {
BRIDGE_EXTERNAL_LINK_GRANT,
@@ -15,8 +16,11 @@ import {
import { captureBridgeError } from './bridge-error-capture'
import { BRIDGE_LIVENESS_PROBE_NOTIFY } from './bridge-liveness-probe-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_TERMINAL_KEY_RAIL_CLAIM_NOTIFY,
BRIDGE_TERMINAL_KEY_RAIL_GRANT
} from './bridge-terminal-key'
// 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.
// Fault reporting must never throw from its error-boundary caller; it reads the session without requiring one.
@@ -42,7 +46,11 @@ export type BridgeClientNotifications = {
notifyPageFault: (error: unknown) => boolean
notifyPagePainted: () => void
notifyBackClaim: (claimed: boolean) => void
notifyPageLocation: (href: string) => void
notifyTerminalKeyRailClaim: (
claimed: boolean,
inputOwner?: string,
toolbar?: BridgeTerminalToolbar
) => void
}
export function createBridgeClientNotifications(
@@ -131,8 +139,18 @@ 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 })
// Only to a shell that granted the rail: any other refuses the name, and has no rail to draw.
notifyTerminalKeyRailClaim: (claimed, inputOwner, toolbar) => {
if (deps.hasGrant(BRIDGE_TERMINAL_KEY_RAIL_GRANT)) {
post({
v: BRIDGE_PROTOCOL_VERSION,
type: 'notify',
name: BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY,
...(inputOwner === undefined ? {} : { inputOwner }),
...(toolbar === undefined ? {} : { toolbar }),
claimed
})
}
}
}
}
@@ -7,6 +7,9 @@ import type {
BridgeUiLocale
} from './bridge-envelope'
import { ZERO_SAFE_AREA_INSETS, type BridgeSafeAreaInsets } from './bridge-safe-area-insets'
import { EMPTY_PAGE_GEOMETRY, type BridgePageGeometry } from './bridge-page-geometry'
import type { BridgeFoldDivision } from './bridge-fold-division'
import type { BridgeTerminalKeyRail } from './bridge-terminal-key'
export type BridgeShellSession = {
sessionId: string
@@ -20,8 +23,15 @@ export type BridgeShellSession = {
keyboardInset: number
// Older shells omit this and paint dark native chrome, so absent means dark.
colorScheme: BridgeColorScheme
/** The fold splitting the page on a partially folded device; null when none does, which is
* every device that does not fold and every shell before the field. */
pageGeometry: BridgePageGeometry
foldDivision: BridgeFoldDivision | null
/** The keys the shell's rail shows beside this page; empty for every shell before the field. */
terminalKeyRail: BridgeTerminalKeyRail
// Null means an older shell sent no language; the page then follows the WebView's languages.
uiLocale: BridgeUiLocale | null
/** Null for a shell too old to name it; the page's own `loadHosts()` then answers with nothing. */
host: BridgeInitHost | null
storage: Readonly<Record<string, string>>
// Oversize keys are withheld from storage and reject writes locally; older shells omit this additive field (ruling 33.6).
@@ -42,6 +52,9 @@ export function readShellSession(
pageRouteGrants: message.pageRouteGrants ?? null,
safeAreaInsets: message.safeAreaInsets ?? ZERO_SAFE_AREA_INSETS,
keyboardInset: message.keyboardInset ?? 0,
pageGeometry: message.pageGeometry ?? EMPTY_PAGE_GEOMETRY,
foldDivision: message.foldDivision ?? null,
terminalKeyRail: message.terminalKeyRail ?? [],
colorScheme: message.colorScheme ?? 'dark',
uiLocale: message.uiLocale ?? null,
host: message.host ?? null,
@@ -1,3 +1,4 @@
import { TERMINAL_SHORTCUT_STORAGE_KEYS } from '../page-storage-keys'
import { readShellSession, type BridgeShellSession } from './bridge-client-session'
import { bridgeRouteMoved } from './bridge-route-update'
import {
@@ -5,6 +6,13 @@ import {
ZERO_SAFE_AREA_INSETS,
type BridgeSafeAreaInsets
} from './bridge-safe-area-insets'
import {
EMPTY_PAGE_GEOMETRY,
samePageGeometry,
type BridgePageGeometry
} from './bridge-page-geometry'
import { sameFoldDivision, type BridgeFoldDivision } from './bridge-fold-division'
import { sameTerminalKeyRail, type BridgeTerminalKeyRail } from './bridge-terminal-key'
import type {
BridgeColorScheme,
BridgeConnectionSnapshot,
@@ -20,23 +28,77 @@ export type BridgeClientShellSession = {
current: () => BridgeShellSession | null
accept: (message: InitMessage) => void
onReady: (listener: () => void) => () => void
/**
* Fires each time the shell rewrites a param of the screen this page is already on, which is a
* second `init` for the session it already holds. Never for the first one, and never for a
* re-sent `init` whose route is the one the page already holds — a re-asked `ready` is answered
* with that route and is not a request.
*
* One delivery per tap rather than one per distinct value: the shell clears the param after each
* delivery, so a notification tap for the pane already showing arrives as a real move and a
* listener that deduplicated by value would lose exactly that one.
*/
onStorageUpdate: (listener: () => void) => () => void
onRouteUpdate: (listener: (route: BridgeInitRoute | null) => void) => () => void
onSafeAreaInsetsUpdate: (listener: (insets: BridgeSafeAreaInsets) => void) => () => void
onKeyboardInsetUpdate: (listener: (height: number) => void) => () => void
/** Fires when an `init` moved the fold the page holds, including a replacement's. */
onPageGeometryUpdate: (listener: (geometry: BridgePageGeometry) => void) => () => void
onFoldDivisionUpdate: (listener: (division: BridgeFoldDivision | null) => void) => () => void
/** Fires when an `init` moved the keys the shell's rail shows, including a replacement's. */
onTerminalKeyRailUpdate: (listener: (keys: BridgeTerminalKeyRail) => void) => () => void
onColorSchemeUpdate: (listener: (scheme: BridgeColorScheme) => void) => () => void
onUiLocaleUpdate: (listener: (locale: BridgeUiLocale | null) => void) => () => void
/** Drops the session and every listener waiting on one, which `close` is the only caller of. */
close: () => void
}
/** The session's listeners, which the page client hands on as its own members. */
export type BridgeShellSessionListeners = Pick<
BridgeClientShellSession,
| 'onReady'
| 'onRouteUpdate'
| 'onStorageUpdate'
| 'onSafeAreaInsetsUpdate'
| 'onKeyboardInsetUpdate'
| 'onColorSchemeUpdate'
| 'onUiLocaleUpdate'
| 'onPageGeometryUpdate'
| 'onFoldDivisionUpdate'
| 'onTerminalKeyRailUpdate'
>
/** Picked rather than spread: the session's `accept` and `close` are the client's alone. */
export function shellSessionListeners(
session: BridgeClientShellSession
): BridgeShellSessionListeners {
return {
onReady: session.onReady,
onRouteUpdate: session.onRouteUpdate,
onStorageUpdate: session.onStorageUpdate,
onSafeAreaInsetsUpdate: session.onSafeAreaInsetsUpdate,
onKeyboardInsetUpdate: session.onKeyboardInsetUpdate,
onColorSchemeUpdate: session.onColorSchemeUpdate,
onUiLocaleUpdate: session.onUiLocaleUpdate,
onPageGeometryUpdate: session.onPageGeometryUpdate,
onFoldDivisionUpdate: session.onFoldDivisionUpdate,
onTerminalKeyRailUpdate: session.onTerminalKeyRailUpdate
}
}
export function createBridgeClientShellSession(args: {
onReplaced: () => void
prime: (connection: BridgeConnectionSnapshot) => void
}): BridgeClientShellSession {
let session: BridgeShellSession | null = null
const storageListeners = new Set<() => void>()
const readyListeners = new Set<() => void>()
const routeUpdateListeners = new Set<(route: BridgeInitRoute | null) => void>()
const insetsListeners = new Set<(insets: BridgeSafeAreaInsets) => void>()
const keyboardListeners = new Set<(height: number) => void>()
const geometryListeners = new Set<(geometry: BridgePageGeometry) => void>()
const foldListeners = new Set<(division: BridgeFoldDivision | null) => void>()
const railListeners = new Set<(keys: BridgeTerminalKeyRail) => void>()
const schemeListeners = new Set<(scheme: BridgeColorScheme) => void>()
const uiLocaleListeners = new Set<(locale: BridgeUiLocale | null) => void>()
@@ -48,34 +110,82 @@ export function createBridgeClientShellSession(args: {
if (held !== null && update === null) {
args.onReplaced()
}
// Preserve the original storage snapshot across same-session init while rebuilding the session value with new route/insets.
const refreshShortcuts = update !== null && message.refreshTerminalShortcuts === true
const sentStorage = message.storage ?? {}
const sentOversize = message.storageOversize ?? []
const storage = refreshShortcuts ? { ...update.storage } : undefined
const storageOversize = refreshShortcuts
? [
...update.storageOversize.filter(
(key) => !TERMINAL_SHORTCUT_STORAGE_KEYS.some((item) => item === key)
),
...TERMINAL_SHORTCUT_STORAGE_KEYS.filter((key) => sentOversize.includes(key))
]
: update?.storageOversize
if (refreshShortcuts && storage) {
for (const key of TERMINAL_SHORTCUT_STORAGE_KEYS) {
if (sentStorage[key] === undefined) {
delete storage[key]
} else {
storage[key] = sentStorage[key]
}
}
}
const sent = message.safeAreaInsets ?? ZERO_SAFE_AREA_INSETS
const movedInsets = held !== null && !sameSafeAreaInsets(held.safeAreaInsets, sent)
// Reuse equal values’ identity so repeated ready cannot rerender keyed readers.
const safeAreaInsets = held === null || movedInsets ? sent : held.safeAreaInsets
const keyboardInset = message.keyboardInset ?? 0
const movedKeyboard = held !== null && held.keyboardInset !== keyboardInset
const sentGeometry = message.pageGeometry ?? EMPTY_PAGE_GEOMETRY
const movedGeometry = held !== null && !samePageGeometry(held.pageGeometry, sentGeometry)
const pageGeometry = held === null || movedGeometry ? sentGeometry : held.pageGeometry
const sentFold = message.foldDivision ?? null
const movedFold = held !== null && !sameFoldDivision(held.foldDivision, sentFold)
const foldDivision = held === null || movedFold ? sentFold : held.foldDivision
const sentRail = message.terminalKeyRail ?? []
const movedRail = held !== null && !sameTerminalKeyRail(held.terminalKeyRail, sentRail)
const terminalKeyRail = held === null || movedRail ? sentRail : held.terminalKeyRail
const colorScheme = message.colorScheme ?? 'dark'
const movedScheme = held !== null && held.colorScheme !== colorScheme
const uiLocale = message.uiLocale ?? null
const movedUiLocale = held !== null && held.uiLocale !== uiLocale
session =
update === null
? { ...readShellSession(message), safeAreaInsets }
? {
...readShellSession(message),
safeAreaInsets,
pageGeometry,
foldDivision,
terminalKeyRail
}
: {
...update,
storage: storage ?? update.storage,
storageOversize: storageOversize ?? update.storageOversize,
route: message.route ?? null,
safeAreaInsets,
keyboardInset,
pageGeometry,
foldDivision,
terminalKeyRail,
colorScheme,
uiLocale
}
// Reprime on every init to repair refused stale generations after client rebuild; identical pane-update snapshots emit no transition.
// Re-primed either way, because a second `init` is also how the page recovers a cache it has
// refused a `state` frame into: the shell rebuilt under it publishes a generation the page's
// own is newer than, and this frame is what puts the two back in step. A pane update carries
// the same snapshot it already holds, which `prime` answers with no transition.
args.prime(message.connection)
for (const listener of readyListeners) {
listener()
}
readyListeners.clear()
if (refreshShortcuts) {
for (const listener of storageListeners) {
listener()
}
}
if (movedInsets) {
for (const listener of insetsListeners) {
listener(safeAreaInsets)
@@ -86,6 +196,21 @@ export function createBridgeClientShellSession(args: {
listener(keyboardInset)
}
}
if (movedGeometry) {
for (const listener of geometryListeners) {
listener(pageGeometry)
}
}
if (movedFold) {
for (const listener of foldListeners) {
listener(foldDivision)
}
}
if (movedRail) {
for (const listener of railListeners) {
listener(terminalKeyRail)
}
}
if (movedScheme) {
for (const listener of schemeListeners) {
listener(colorScheme)
@@ -96,7 +221,9 @@ export function createBridgeClientShellSession(args: {
listener(uiLocale)
}
}
// Publish only changed routes; repeated ready used for state repair must not retrigger the current pane.
// Only a route that moved is an update. The shell answers every `ready` with the route it
// holds, and the page re-asks after a refused `state` frame, so publishing each of those
// would hand the pane hook the route it is already on.
if (update === null || !bridgeRouteMoved(update.route, session.route)) {
return
}
@@ -114,7 +241,14 @@ export function createBridgeClientShellSession(args: {
readyListeners.delete(listener)
}
},
// Late listeners read current; updates report changes without replaying the held route.
// Not fired on subscribe, and no replay: a listener that arrives late reads the route it wants
// off `current`, and what this publishes is the fact that one moved.
onStorageUpdate: (listener) => {
storageListeners.add(listener)
return () => {
storageListeners.delete(listener)
}
},
onRouteUpdate: (listener) => {
routeUpdateListeners.add(listener)
return () => {
@@ -133,6 +267,24 @@ export function createBridgeClientShellSession(args: {
keyboardListeners.delete(listener)
}
},
onPageGeometryUpdate: (listener) => {
geometryListeners.add(listener)
return () => {
geometryListeners.delete(listener)
}
},
onFoldDivisionUpdate: (listener) => {
foldListeners.add(listener)
return () => {
foldListeners.delete(listener)
}
},
onTerminalKeyRailUpdate: (listener) => {
railListeners.add(listener)
return () => {
railListeners.delete(listener)
}
},
onColorSchemeUpdate: (listener) => {
schemeListeners.add(listener)
return () => {
@@ -0,0 +1,52 @@
import type { BridgeTerminalKeyId, BridgeTerminalToolbar } from './bridge-terminal-key'
export function createBridgeClientTerminalKeys(
publishClaim: (claimed: boolean, inputOwner?: string, toolbar?: BridgeTerminalToolbar) => void
) {
const listeners = new Map<
(key: BridgeTerminalKeyId, inputOwner?: string) => void,
{ inputOwner?: string; toolbar?: BridgeTerminalToolbar }
>()
const publish = () => {
const owner = [...listeners.values()].at(-1)
publishClaim(listeners.size > 0, owner?.inputOwner, owner?.toolbar)
}
return {
accept: (key: BridgeTerminalKeyId, inputOwner?: string) => {
for (const [listener, owner] of listeners) {
if (owner.inputOwner === inputOwner) {
listener(key, inputOwner)
}
}
},
claim: (
listener: (key: BridgeTerminalKeyId, inputOwner?: string) => void,
inputOwner?: string,
toolbar?: BridgeTerminalToolbar
) => {
const entry = (key: BridgeTerminalKeyId, owner?: string) => listener(key, owner)
listeners.set(entry, { inputOwner, toolbar })
publish()
const release = () => {
if (listeners.delete(entry)) {
publish()
}
}
return Object.assign(release, {
update: (nextInputOwner?: string, nextToolbar?: BridgeTerminalToolbar) => {
const owner = listeners.get(entry)
if (owner && (owner.inputOwner !== nextInputOwner || owner.toolbar !== nextToolbar)) {
owner.inputOwner = nextInputOwner
owner.toolbar = nextToolbar
publish()
}
}
})
},
reassert: () => {
if (listeners.size > 0) {
publish()
}
}
}
}
@@ -10,8 +10,15 @@ import { BridgeInitRouteSchema, type BridgeInitRoute } from './bridge-init-route
import { BridgePageRouteGrantsSchema } from './bridge-page-route-grants'
import { BridgeSafeAreaInsetsSchema } from './bridge-safe-area-insets'
import { BridgeKeyboardInsetSchema } from './bridge-keyboard-inset'
import { BridgePageGeometrySchema } from './bridge-page-geometry'
import { BridgeFoldDivisionSchema } from './bridge-fold-division'
import { BridgeNotifySchema } from './bridge-notify-envelope'
import { BRIDGE_BACK_FRAME } from './bridge-page-back'
import {
BRIDGE_TERMINAL_KEY_FRAME,
BridgeTerminalKeyIdSchema,
BridgeTerminalKeyRailSchema
} from './bridge-terminal-key'
import { BRIDGE_ID_PATTERN, idSchema, methodSchema, versionSchema } from './bridge-frame-fields'
export {
@@ -148,7 +155,14 @@ const replyPartSchema = z.object({
const BridgeClientMessageSchema = z.discriminatedUnion('type', [
z.object({
v: versionSchema,
type: z.literal('ready')
type: z.literal('ready'),
// Negotiated keys this page's `init` reader accepts. A malformed list reads as none, so the
// page gets the legacy keys rather than no session.
storageKeys: z
.array(z.string().min(1).max(PAGE_STORAGE_MAX_KEY_CHARS))
.max(PAGE_STORAGE_MAX_ENTRIES)
.optional()
.catch(undefined)
}),
z.object({
v: versionSchema,
@@ -228,6 +242,13 @@ const BridgeHostMessageSchema = z.union([
}),
// Back names no destination; only the native stack knows what screen lies beneath.
z.object({ v: versionSchema, type: z.literal(BRIDGE_BACK_FRAME) }),
// One press on the shell's key rail. Sent only to a page granted the rail, which knows the frame.
z.object({
v: versionSchema,
type: z.literal(BRIDGE_TERMINAL_KEY_FRAME),
key: BridgeTerminalKeyIdSchema,
inputOwner: z.string().min(1).max(128).optional()
}),
z.object({
v: versionSchema,
type: z.literal('end'),
@@ -252,11 +273,30 @@ const BridgeHostMessageSchema = z.union([
keyboardInset: BridgeKeyboardInsetSchema.optional(),
// Older pages strip this key; salvage an unknown future scheme so it cannot invalidate the whole init.
colorScheme: salvagedOptional('colorScheme', BridgeColorSchemeSchema).optional(),
/** The fold splitting the page on a partially folded device; absent reads as none. Salvaged so a
* value this page cannot read drops the field, not `init`. */
pageGeometry: salvagedOptional('pageGeometry', BridgePageGeometrySchema).optional(),
foldDivision: salvagedOptional('foldDivision', BridgeFoldDivisionSchema).optional(),
/** The keys the shell's rail shows beside this page, which then drops them from its own row.
* Absent reads as no rail. */
terminalKeyRail: salvagedOptional('terminalKeyRail', BridgeTerminalKeyRailSchema).optional(),
// Older pages strip this key; a locale this page does not ship is salvaged rather than refusing init.
uiLocale: salvagedOptional('uiLocale', BridgeUiLocaleSchema).optional(),
host: BridgeInitHostSchema.optional(),
storage: BridgeInitStorageSchema.optional(),
// Advisory page fast path for oversized keys; the shell independently refuses writes because older pages ignore this additive field (ruling 33.6).
refreshTerminalShortcuts: salvagedOptional('refreshTerminalShortcuts', z.boolean()).optional(),
/**
* The allowlisted keys the shell holds a value for that `storage` could not carry, because the
* app's value is over the page's own cap (ruling 33.6).
*
* Advisory, not the enforcement. The shell refuses a write to one of these on its own side
* too, because a page served from an older desktop bundle ignores this field entirely and
* would still replace what the device holds; this is the page's fast path, so a write it can
* refuse locally rejects without a round trip and reaches its caller as `too-large`.
*
* Optional and additive: an older shell sends none and an older page ignores it. Bounded by
* the same count as the storage record, since it names a subset of the same keys.
*/
storageOversize: z
.array(z.string().min(1).max(PAGE_STORAGE_MAX_KEY_CHARS).refine(isPageStorageKey))
.max(PAGE_STORAGE_MAX_ENTRIES)
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import { createFakeBridgePortPair } from './bridge-port-pair-test-harness'
import { readBridgeHostMessage } from './bridge-envelope'
import { createBridgeInitFrame } from './bridge-init-frame'
import { readShellSession } from './bridge-client-session'
import type { BridgeFoldDivision } from './bridge-fold-division'
// iPhone Duo, inner display, half folded: the fold band with UIKit's 20pt margins either side.
const BOOK: BridgeFoldDivision = { axis: 'vertical', start: 455.5, end: 495.5 }
const TABLETOP: BridgeFoldDivision = { axis: 'horizontal', start: 455.5, end: 495.5 }
function initFrame(foldDivision?: BridgeFoldDivision | null) {
return createBridgeInitFrame({
sessionId: 'session-a',
buildId: 'build-a',
connection: {
state: 'connected',
reconnectAttempt: 0,
lastConnectedAt: null,
lastInboundAt: null,
generation: null
},
route: { pathname: '/h/host-a' },
pageRoutes: ['/h/[hostId]'],
granted: [],
host: { id: 'host-a', name: 'Host A', endpoint: 'ws://host-a', lastConnected: 0 },
storage: {},
...(foldDivision === undefined ? {} : { foldDivision })
})
}
function readInit(json: string) {
const read = readBridgeHostMessage(json)
if (!read.ok || read.message.type !== 'init') {
throw new Error(
`not an init the page would read: ${read.ok ? read.message.type : read.refusal}`
)
}
return read.message
}
describe('foldDivision on init', () => {
it('omits no fold, and the page reads the absent field as none', () => {
expect('foldDivision' in initFrame(null)).toBe(false)
expect(readShellSession(readInit(JSON.stringify(initFrame()))).foldDivision).toBeNull()
})
it('passes the fold through to the session the page holds', () => {
expect(readShellSession(readInit(JSON.stringify(initFrame(BOOK)))).foldDivision).toEqual(BOOK)
})
it('drops a fold the page cannot read without refusing the init around it', () => {
for (const bad of [
{ ...BOOK, end: 400 },
{ ...BOOK, axis: 'diagonal' },
{ ...BOOK, start: -1 }
]) {
const init = readInit(JSON.stringify({ ...initFrame(), foldDivision: bad }))
expect(readShellSession(init).foldDivision, JSON.stringify(bad)).toBeNull()
}
})
})
describe('a fold that moves under a live page', () => {
it('re-sends init as the device folds, turns and opens, and the page reads each in place', async () => {
const pair = createFakeBridgePortPair()
await pair.flush()
const sessionId = pair.client.getShellSession()?.sessionId
const seen: (BridgeFoldDivision | null)[] = []
pair.client.onFoldDivisionUpdate((division) => seen.push(division))
for (const division of [BOOK, TABLETOP, null]) {
pair.host.publishFoldDivision(division)
await pair.flush()
expect(pair.client.getShellSession()?.foldDivision).toEqual(division)
}
expect(seen).toEqual([BOOK, TABLETOP, null])
expect(pair.client.getShellSession()?.sessionId).toBe(sessionId)
})
it('sends nothing for a fold that did not move', async () => {
const pair = createFakeBridgePortPair({ foldDivision: BOOK })
await pair.flush()
const inits = () => pair.readToPage().filter((frame) => frame.type === 'init').length
const before = inits()
pair.host.publishFoldDivision({ ...BOOK })
await pair.flush()
expect(inits()).toBe(before)
})
})
@@ -0,0 +1,34 @@
import { z } from 'zod'
import { BRIDGE_MAX_SAFE_AREA_INSET } from './bridge-safe-area-insets'
const offsetSchema = z.number().finite().min(0).max(BRIDGE_MAX_SAFE_AREA_INSET)
/**
* The band a partially folded device's fold takes out of the page, in the page's own px: from
* `start` to `end` across the width (`vertical`, book posture) or the height (`horizontal`,
* tabletop posture). The page lays its panes out either side of it. Optional and additive: absent
* reads as no fold, which is every device that does not fold and every shell older than the field.
*/
export const BridgeFoldDivisionSchema = z
.object({
axis: z.enum(['vertical', 'horizontal']),
start: offsetSchema,
end: offsetSchema
})
.refine((division) => division.end > division.start, 'Expected end after start')
export type BridgeFoldDivision = z.infer<typeof BridgeFoldDivisionSchema>
export function sameFoldDivision(
left: BridgeFoldDivision | null,
right: BridgeFoldDivision | null
): boolean {
return (
left === right ||
(left !== null &&
right !== null &&
left.axis === right.axis &&
left.start === right.start &&
left.end === right.end)
)
}
@@ -1,3 +1,8 @@
import {
EMPTY_PAGE_GEOMETRY,
samePageGeometry,
type BridgePageGeometry
} from './bridge-page-geometry'
import { BRIDGE_MAX_PENDING_REQUESTS, BRIDGE_MAX_SUBSCRIPTIONS } from './bridge-caps'
import { MOBILE_WEB_SHELL_GRANTS } from '../page-route-policy'
import {
@@ -16,6 +21,8 @@ import {
ZERO_SAFE_AREA_INSETS,
type BridgeSafeAreaInsets
} from './bridge-safe-area-insets'
import type { BridgeFoldDivision } from './bridge-fold-division'
import type { BridgeTerminalKeyRail } from './bridge-terminal-key'
// Grant the intersection of implemented capabilities and route declarations, plus protocol fault. Use the same resolved list for init and enforcement; new names need no version bump.
export const BRIDGE_NATIVE_GRANTS: readonly string[] = [
@@ -32,6 +39,11 @@ export function createBridgeInitFrame(args: {
pageRoutes: readonly string[]
safeAreaInsets?: BridgeSafeAreaInsets
keyboardInset?: number
/** The fold splitting the page, in its px; null or absent when none does. */
pageGeometry?: BridgePageGeometry
foldDivision?: BridgeFoldDivision | null
/** The keys the shell's rail shows beside the page; omitted while none does. */
terminalKeyRail?: BridgeTerminalKeyRail
colorScheme?: BridgeColorScheme
uiLocale?: BridgeUiLocale
// Omit absent manifest pairs so older routing behavior remains available.
@@ -39,7 +51,10 @@ export function createBridgeInitFrame(args: {
granted: readonly string[]
host: BridgeInitHost
storage: Readonly<Record<string, string>>
// Withheld oversized storage keys reject page writes; absent/empty both mean no app value was excluded (ruling 33.6).
refreshTerminalShortcuts?: boolean
/** The allowlisted keys whose app-side value is over the page's cap, so `storage` has none
* (ruling 33.6). The page refuses its own writes to these rather than replacing the device's.
* Absent and empty are the same answer: nothing of the app's was left out. */
storageOversize?: readonly string[]
ownsHostArea?: boolean
}): Extract<BridgeHostMessage, { type: 'init' }> {
@@ -65,6 +80,21 @@ export function createBridgeInitFrame(args: {
...(args.keyboardInset === undefined || args.keyboardInset === 0
? {}
: { keyboardInset: args.keyboardInset }),
...(args.pageGeometry === undefined || samePageGeometry(args.pageGeometry, EMPTY_PAGE_GEOMETRY)
? {}
: {
pageGeometry: {
...args.pageGeometry,
occlusions: args.pageGeometry.occlusions.map((rect) => ({ ...rect }))
}
}),
...(args.foldDivision === undefined || args.foldDivision === null
? {}
: { foldDivision: { ...args.foldDivision } }),
...(args.refreshTerminalShortcuts ? { refreshTerminalShortcuts: true } : {}),
...(args.terminalKeyRail === undefined || args.terminalKeyRail.length === 0
? {}
: { terminalKeyRail: [...args.terminalKeyRail] }),
// Omit dark: it is what pages assume when an older shell sends nothing.
...(args.colorScheme === 'light' ? { colorScheme: 'light' as const } : {}),
// Sent even for English: an absent field lets the page fall back to the WebView's language.
@@ -1,21 +0,0 @@
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
})
}
@@ -5,6 +5,10 @@ import { BRIDGE_LIVENESS_PROBE_NOTIFY_FIELDS } from './bridge-liveness-probe-not
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_TERMINAL_KEY_RAIL_CLAIM_NOTIFY,
BridgeTerminalToolbarSchema
} from './bridge-terminal-key'
import { BRIDGE_CLEARABLE_ROUTE_PARAMS, BRIDGE_ROUTE_PARAM_CLEAR } from './bridge-route-update'
import {
isPageStorageKey,
@@ -102,12 +106,22 @@ export const BridgeNotifySchema = z.discriminatedUnion('name', [
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.
// Pages from desktops released with #34484 navigate inside their own document and 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)
}),
// Behind the `terminalKeyRail` grant, which is what keeps it off a shell that has no rail. Whole
// state rather than a toggle, for the reason the Back claim is.
z.object({
v: versionSchema,
type: z.literal('notify'),
name: z.literal(BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY),
claimed: z.boolean(),
inputOwner: z.string().min(1).max(128).optional(),
toolbar: BridgeTerminalToolbarSchema.optional()
})
])
@@ -10,6 +10,10 @@ 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'
import {
BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY,
BRIDGE_TERMINAL_KEY_RAIL_GRANT
} from './bridge-terminal-key'
export type BridgeNotifyName = Extract<BridgeClientMessage, { type: 'notify' }>['name']
@@ -28,6 +32,7 @@ const BRIDGE_NOTIFY_GRANTS: Readonly<Record<BridgeNotifyName, string | null>> =
[BRIDGE_EXTERNAL_LINK_GRANT]: BRIDGE_EXTERNAL_LINK_GRANT,
[BRIDGE_FAULT_GRANT]: BRIDGE_FAULT_GRANT,
[BRIDGE_HAPTICS_NOTIFY]: BRIDGE_HAPTICS_GRANT,
[BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY]: BRIDGE_TERMINAL_KEY_RAIL_GRANT,
[BRIDGE_LIVENESS_PROBE_NOTIFY]: null
}
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import { createFakeBridgePortPair } from './bridge-port-pair-test-harness'
import {
EMPTY_PAGE_GEOMETRY,
pageOcclusions,
type BridgePageGeometry
} from './bridge-page-geometry'
const GEOMETRY: BridgePageGeometry = {
sideRail: true,
occlusions: [{ x: 0, y: 0, width: 124, height: 84 }]
}
describe('page geometry over the bridge', () => {
it('translates and clips native occlusions into the WebView frame', () => {
expect(
pageOcclusions(
[
{ x: 20, y: 30, width: 100, height: 90 },
{ x: 0, y: 0, width: 20, height: 20 },
{ x: 300, y: 300, width: 100, height: 100 }
],
{ x: 40, y: 50, width: 320, height: 320 }
)
).toEqual([
{ x: 0, y: 0, width: 80, height: 70 },
{ x: 260, y: 250, width: 60, height: 70 }
])
expect(pageOcclusions(GEOMETRY.occlusions, null)).toEqual([])
})
it('updates geometry in place, deduplicates it and clears it on rotation', async () => {
const pair = createFakeBridgePortPair()
await pair.flush()
expect(pair.client.getShellSession()?.pageGeometry).toEqual(EMPTY_PAGE_GEOMETRY)
const seen: BridgePageGeometry[] = []
pair.client.onPageGeometryUpdate((geometry) => seen.push(geometry))
pair.host.publishPageGeometry(GEOMETRY)
await pair.flush()
expect(pair.client.getShellSession()?.pageGeometry).toEqual(GEOMETRY)
pair.host.publishPageGeometry({ ...GEOMETRY, occlusions: [...GEOMETRY.occlusions] })
await pair.flush()
expect(seen).toEqual([GEOMETRY])
pair.host.publishPageGeometry(EMPTY_PAGE_GEOMETRY)
await pair.flush()
expect(seen).toEqual([GEOMETRY, EMPTY_PAGE_GEOMETRY])
})
it('salvages an init with invalid geometry without trusting its rectangles', async () => {
for (const bad of [
{ ...GEOMETRY, occlusions: [{ x: -1, y: 0, width: 12, height: 84 }] },
{ ...GEOMETRY, occlusions: Array(17).fill(GEOMETRY.occlusions[0]) },
{ ...GEOMETRY, sideRail: 'yes' }
]) {
const pair = createFakeBridgePortPair({
rewriteToPage: (json) => {
const frame = JSON.parse(json)
return JSON.stringify(frame.type === 'init' ? { ...frame, pageGeometry: bad } : frame)
}
})
await pair.flush()
expect(pair.client.getShellSession()?.pageGeometry).toEqual(EMPTY_PAGE_GEOMETRY)
}
})
})
it('delivers D-pad space changes even when the side rail and occlusions are unchanged', async () => {
const pair = createFakeBridgePortPair()
await pair.flush()
const seen: BridgePageGeometry[] = []
pair.client.onPageGeometryUpdate((geometry) => seen.push(geometry))
pair.host.publishPageGeometry({ ...GEOMETRY, directionPadAvailable: true })
await pair.flush()
pair.host.publishPageGeometry({ ...GEOMETRY, directionPadAvailable: false })
await pair.flush()
expect(seen).toEqual([
{ ...GEOMETRY, directionPadAvailable: true },
{ ...GEOMETRY, directionPadAvailable: false }
])
})
@@ -0,0 +1,47 @@
import { z } from 'zod'
import { BRIDGE_MAX_SAFE_AREA_INSET } from './bridge-safe-area-insets'
const coordinate = z.number().finite().min(0).max(BRIDGE_MAX_SAFE_AREA_INSET)
export const BridgePageGeometrySchema = z.object({
occlusions: z
.array(z.object({ x: coordinate, y: coordinate, width: coordinate, height: coordinate }))
.max(16),
sideRail: z.boolean(),
directionPadAvailable: z.boolean().optional()
})
export type BridgePageGeometry = z.infer<typeof BridgePageGeometrySchema>
export const EMPTY_PAGE_GEOMETRY: BridgePageGeometry = { occlusions: [], sideRail: false }
export function samePageGeometry(a: BridgePageGeometry, b: BridgePageGeometry): boolean {
return (
a.sideRail === b.sideRail &&
a.directionPadAvailable === b.directionPadAvailable &&
a.occlusions.length === b.occlusions.length &&
a.occlusions.every((rect, i) => {
const other = b.occlusions[i]
return (
other !== undefined &&
rect.x === other.x &&
rect.y === other.y &&
rect.width === other.width &&
rect.height === other.height
)
})
)
}
/** Native regions are window points; the web header measures in the WebView's local points. */
export function pageOcclusions(
regions: readonly { x: number; y: number; width: number; height: number }[],
frame: { x: number; y: number; width: number; height: number } | null
): BridgePageGeometry['occlusions'] {
if (frame === null) {
return []
}
return regions.flatMap((rect) => {
const x = Math.max(0, rect.x - frame.x)
const y = Math.max(0, rect.y - frame.y)
const right = Math.min(frame.width, rect.x + rect.width - frame.x)
const bottom = Math.min(frame.height, rect.y + rect.height - frame.y)
return right > x && bottom > y ? [{ x, y, width: right - x, height: bottom - y }] : []
})
}
@@ -1,6 +1,6 @@
import { z } from 'zod'
import {
MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS,
MOBILE_WEB_BUNDLE_MAX_GRANTED_ROUTE_GRANTS,
MobileWebBundleGrantNameSchema
} from '../../../../src/shared/mobile-web-bundle/manifest-contract'
import { BRIDGE_MAX_PAGE_ROUTES, BRIDGE_MAX_ROUTE_PATHNAME_CHARS } from './bridge-caps'
@@ -11,7 +11,9 @@ export const BridgePageRouteGrantsSchema = z
z
.object({
pathname: z.string().min(1).max(BRIDGE_MAX_ROUTE_PATHNAME_CHARS),
grants: z.array(MobileWebBundleGrantNameSchema).max(MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS)
grants: z
.array(MobileWebBundleGrantNameSchema)
.max(MOBILE_WEB_BUNDLE_MAX_GRANTED_ROUTE_GRANTS)
})
.strict()
)
@@ -14,6 +14,8 @@ import {
} from './bridge-envelope'
import type { BridgeErrorCapture } from './bridge-error-capture'
import type { BridgeSafeAreaInsets } from './bridge-safe-area-insets'
import type { BridgeFoldDivision } from './bridge-fold-division'
import type { BridgeTerminalKeyRail } from './bridge-terminal-key'
import {
createBridgeRpcClient,
type BridgeRpcClient,
@@ -39,6 +41,10 @@ export type BridgePortPair<TRpc extends RpcClient = FakeRpcClient> = {
readonly pageReadyCount: () => number
readonly pagePaintCount: () => number
readonly backClaims: boolean[]
/** Every claim on the key rail the host reported, drops on `ready` and `close` included. */
readonly terminalKeyRailClaims: boolean[]
/** What each answered `ready` declared it reports, in order. */
/** Every clear the page asked the shell for, in order. */
readonly routeParamClears: () => readonly { param: string; value: string }[]
readonly routeRefusals: string[]
flush: () => Promise<void>
@@ -64,6 +70,9 @@ export type BridgePortPairOptions<TRpc extends RpcClient> = {
serveNativeVerb?: (verb: BridgeNativeVerb, params: unknown) => Promise<unknown>
safeAreaInsets?: BridgeSafeAreaInsets
keyboardInset?: number
foldDivision?: BridgeFoldDivision | null
terminalKeyRail?: BridgeTerminalKeyRail
/** Stands for the wide host-area session, the one shell mount that tells the page it owns it. */
ownsHostArea?: boolean
onStorageWrite?: (key: string, value: string | null) => void
}
@@ -168,6 +177,7 @@ export function createBridgePortPair<TRpc extends RpcClient>(
const storageWrites: { key: string; value: string | null }[] = []
const pageFaults: BridgeErrorCapture[] = []
const backClaims: boolean[] = []
const terminalKeyRailClaims: boolean[] = []
let pageReadies = 0
let pagePaints = 0
const routeParamClears: { param: string; value: string }[] = []
@@ -189,6 +199,8 @@ export function createBridgePortPair<TRpc extends RpcClient>(
route: options.route ?? { pathname: '/h/host-a' },
...(options.safeAreaInsets === undefined ? {} : { safeAreaInsets: options.safeAreaInsets }),
...(options.keyboardInset === undefined ? {} : { keyboardInset: options.keyboardInset }),
...(options.foldDivision === undefined ? {} : { foldDivision: options.foldDivision }),
...(options.terminalKeyRail === undefined ? {} : { terminalKeyRail: options.terminalKeyRail }),
readClientIdentity: () =>
options.clientIdentity === undefined ? PORT_PAIR_CLIENT_IDENTITY : options.clientIdentity,
pageRoutes: options.pageRoutes ?? ['/h/[hostId]'],
@@ -223,6 +235,7 @@ export function createBridgePortPair<TRpc extends RpcClient>(
},
onPageBackClaim: (claimed) => backClaims.push(claimed),
onPageLocation: () => {},
onTerminalKeyRailClaim: (claimed) => terminalKeyRailClaims.push(claimed),
onRouteParamClear: (param, value) => routeParamClears.push({ param, value }),
onRouteRefused: (issue) => routeRefusals.push(issue),
onDiagnostic: (diagnostic) => hostDiagnostics.push(diagnostic)
@@ -260,6 +273,7 @@ export function createBridgePortPair<TRpc extends RpcClient>(
pageReadyCount: () => pageReadies,
pagePaintCount: () => pagePaints,
backClaims,
terminalKeyRailClaims,
routeParamClears: () => routeParamClears,
routeRefusals,
async flush(): Promise<void> {
@@ -36,9 +36,11 @@ afterEach(() => {
})
describe('bridge client handshake', () => {
it('asks for a session as soon as it exists, declaring nothing', () => {
it('asks for a session immediately and declares the storage keys it can read', () => {
const page = createPageClient()
expect(page.frames()).toEqual([{ v: BRIDGE_PROTOCOL_VERSION, type: 'ready' }])
expect(page.frames()).toEqual([
{ v: BRIDGE_PROTOCOL_VERSION, type: 'ready', storageKeys: ['orca:terminalSplitLayout'] }
])
})
it('keeps asking on a widening backoff until init answers', () => {
@@ -123,7 +125,11 @@ describe('bridge client handshake', () => {
safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 },
keyboardInset: 0,
colorScheme: 'dark',
foldDivision: null,
pageGeometry: { occlusions: [], sideRail: false },
terminalKeyRail: [],
uiLocale: null,
// And no host and no stored keys, which is what `host-store.web.ts` then answers with.
host: null,
storage: {},
storageOversize: [],
@@ -8,12 +8,17 @@ import {
} from './bridge-caps'
import { BridgeConnectionCache } from './bridge-client-connection-cache'
import type { BridgeRpcClientDiagnostic } from './bridge-client-diagnostics'
import { createBridgeClientShellSession } from './bridge-client-shell-session'
import {
createBridgeClientShellSession,
shellSessionListeners,
type BridgeShellSessionListeners
} from './bridge-client-shell-session'
import type { BridgeShellSession } from './bridge-client-session'
import { createBridgeInitHandshake, createPageReadyFrame } from './bridge-client-init-handshake'
import {
BridgeClientCapExceededError,
BridgeClientClosedError,
BridgeClientNotNativeVerbError,
BridgeClientNotReadyError,
BridgeRequestOversizedError,
BridgeSendFailedError,
@@ -22,20 +27,18 @@ import {
import type { BridgeHapticsKind } from './bridge-haptics-notify'
import { createBridgeInboundFrameReader } from './bridge-client-inbound-frames'
import { createBridgeClientNotifications } from './bridge-client-notifications'
import type { BridgeSafeAreaInsets } from './bridge-safe-area-insets'
import { createBridgeClientTerminalKeys } from './bridge-client-terminal-keys'
import { createPageBackConsumers, type PageBackConsumer } from './page-back-consumers'
import { BridgeClientRequests } from './bridge-client-requests'
import { readBridgeRequestFrameOptions, readBridgeRpcEnvelope } from './bridge-client-rpc-envelope'
import { BridgeClientSubscriptions } from './bridge-client-subscriptions'
import type { BridgeNativeVerb } from './bridge-native-verbs'
import { callBridgeNativeVerb } from './bridge-native-verb-call'
import { isBridgeNativeMethod, type BridgeNativeVerb } from './bridge-native-verbs'
import { BRIDGE_ROUTE_PARAM_CLEAR, type BridgeClearableRouteParam } from './bridge-route-update'
import {
BRIDGE_PROTOCOL_VERSION,
BRIDGE_RPC_ENVELOPE_GRANT,
type BridgeClientMessage,
type BridgeConnectionSnapshot,
type BridgeInitRoute
type BridgeConnectionSnapshot
} from './bridge-envelope'
export type { BridgeShellSession } from './bridge-client-session'
@@ -62,36 +65,33 @@ export type BridgeRpcClientOptions = {
onDiagnostic?: (diagnostic: BridgeRpcClientDiagnostic) => void
}
export type BridgeRpcClient = RpcClient & {
onReady: (listener: () => void) => () => void
// Publish changed params only, never initial/repeated init. Clear consumed one-shot params so repeat taps to the same pane still appear as real changes.
onRouteUpdate: (listener: (route: BridgeInitRoute | null) => void) => () => void
onSafeAreaInsetsUpdate: (listener: (insets: BridgeSafeAreaInsets) => void) => () => void
onKeyboardInsetUpdate: (listener: (height: number) => void) => () => void
onColorSchemeUpdate: (listener: (scheme: BridgeShellSession['colorScheme']) => void) => () => void
onUiLocaleUpdate: (listener: (locale: BridgeShellSession['uiLocale']) => void) => () => void
getShellSession: () => BridgeShellSession | null
// False without navigate lets tap handlers fall back safely instead of throwing into uncaught UI callbacks.
notifyNavigate: (href: string, replace?: boolean) => boolean
// The page owns no native history; ask the shell to pop. Missing grant returns false; older shells may also drop the new notify, so callers retain their router fallback.
notifyNavigateBack: () => boolean
// False means missing externalLink grant or refused URL; callers must offer another outcome for the tap.
notifyExternalLink: (url: string) => boolean
// Use the existing request/id/cap lane but keep native methods inside the bridge’s typed surface; they are device verbs, not catalogued desktop RPCs.
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
notifyHaptics: (kind: BridgeHapticsKind) => boolean
// Never throw from error-boundary reporting. False means no frame left; shell load recovery covers reports that cannot be sent, without retrying this fault.
notifyPageFault: (error: unknown) => boolean
// After applying a one-shot param, ask its holder to clear that value (ruling 34). A failed clear is repaired when init delivers the still-held request again.
clearRouteParam: (param: BridgeClearableRouteParam, value: string) => boolean
}
export type BridgeRpcClient = RpcClient &
BridgeShellSessionListeners & {
/**
* Asks the shell to draw its key rail, while a terminal and its key row are on screen, until the
* dispose is called; `listener` takes each press. Nothing is sent to a shell without the grant.
*/
claimTerminalKeyRail: ReturnType<typeof createBridgeClientTerminalKeys>['claim']
getShellSession: () => BridgeShellSession | null
// False without navigate lets tap handlers fall back safely instead of throwing into uncaught UI callbacks.
notifyNavigate: (href: string, replace?: boolean) => boolean
// The page owns no native history; ask the shell to pop. Missing grant returns false; older shells may also drop the new notify, so callers retain their router fallback.
notifyNavigateBack: () => boolean
// False means missing externalLink grant or refused URL; callers must offer another outcome for the tap.
notifyExternalLink: (url: string) => boolean
// Use the existing request/id/cap lane but keep native methods inside the bridge’s typed surface; they are device verbs, not catalogued desktop RPCs.
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
// 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
notifyHaptics: (kind: BridgeHapticsKind) => boolean
// Never throw from error-boundary reporting. False means no frame left; shell load recovery covers reports that cannot be sent, without retrying this fault.
notifyPageFault: (error: unknown) => boolean
// After applying a one-shot param, ask its holder to clear that value (ruling 34). A failed clear is repaired when init delivers the still-held request again.
clearRouteParam: (param: BridgeClearableRouteParam, value: string) => boolean
}
// Expose synchronous RpcClient getters from init-primed state so first renders match native clients. Page close settles its own work, never the native client shared by other screens.
// Ungated calls before init throw for mount-order bugs; after close, teardown calls are inert and getters retain a disconnected snapshot. Stream failures notify listeners.
@@ -135,9 +135,7 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp
const subscriptions = new BridgeClientSubscriptions({
send: (frame) => posted(frame),
onDroppedBinaryFrame: () => {
report({ kind: 'binary-frame-dropped' })
}
onDroppedBinaryFrame: () => report({ kind: 'binary-frame-dropped' })
})
const handshake = createBridgeInitHandshake(() => {
@@ -196,9 +194,11 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp
handshake.stop()
shellSession.accept(message)
back.reassert()
terminalKeys.reassert()
},
acceptState,
acceptBack: back.press
acceptBack: back.press,
acceptTerminalKey: (key, inputOwner) => terminalKeys.accept(key, inputOwner)
})
function receive(json: string): void {
@@ -301,6 +301,7 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp
isClosed: () => closed,
hasGrant
})
const terminalKeys = createBridgeClientTerminalKeys(notifications.notifyTerminalKeyRailClaim)
const unsubscribeFromMessages = options.onMessage(receive)
handshake.start()
@@ -323,20 +324,27 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp
notifyNavigate: notifications.notifyNavigate,
notifyNavigateBack: notifications.notifyNavigateBack,
notifyExternalLink: notifications.notifyExternalLink,
callNativeVerb: (verb, params) => callBridgeNativeVerb(sendRequest, verb, params),
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
})
},
notifyStorageWrite: notifications.notifyStorageWrite,
notifyHaptics: notifications.notifyHaptics,
notifyPageFault: notifications.notifyPageFault,
notifyPagePainted: notifications.notifyPagePainted,
notifyPageLocation: notifications.notifyPageLocation,
claimBack: back.claim,
close,
onReady: shellSession.onReady,
onRouteUpdate: shellSession.onRouteUpdate,
onSafeAreaInsetsUpdate: shellSession.onSafeAreaInsetsUpdate,
onKeyboardInsetUpdate: shellSession.onKeyboardInsetUpdate,
onColorSchemeUpdate: shellSession.onColorSchemeUpdate,
onUiLocaleUpdate: shellSession.onUiLocaleUpdate,
...shellSessionListeners(shellSession),
claimTerminalKeyRail: terminalKeys.claim,
getShellSession: shellSession.current,
clearRouteParam: (param, value) =>
posted({
@@ -0,0 +1,194 @@
import { describe, expect, it } from 'vitest'
import { MOBILE_WEB_SHELL_GRANTS } from '../page-route-policy'
import { createFakeBridgePortPair } from './bridge-port-pair-test-harness'
import { BRIDGE_PROTOCOL_VERSION } from './bridge-envelope'
import {
BridgeTerminalKeyIdSchema,
BridgeTerminalToolbarSchema,
BRIDGE_TERMINAL_KEY_IDS,
BRIDGE_TERMINAL_MODIFIER_IDS,
BRIDGE_TERMINAL_TOOLBAR_KEY_IDS,
BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY,
BRIDGE_TERMINAL_KEY_RAIL_GRANT,
type BridgeTerminalKeyId,
type BridgeTerminalKeyRail
} from './bridge-terminal-key'
const ALL_RAIL_KEYS: BridgeTerminalKeyRail = [
...BRIDGE_TERMINAL_TOOLBAR_KEY_IDS,
...BRIDGE_TERMINAL_KEY_IDS,
...BRIDGE_TERMINAL_MODIFIER_IDS,
'char:/',
'char:@'
]
describe('the iPhone Duo key rail over the bridge', () => {
it('publishes and delivers every rail key through both bridge ends', async () => {
const pair = createFakeBridgePortPair({ terminalKeyRail: ALL_RAIL_KEYS })
await pair.flush()
expect(pair.client.getShellSession()?.terminalKeyRail).toEqual(ALL_RAIL_KEYS)
const pressed: BridgeTerminalKeyId[] = []
const release = pair.client.claimTerminalKeyRail((key) => pressed.push(key), 'all-keys')
await pair.flush()
for (const key of ALL_RAIL_KEYS) {
expect(pair.host.sendTerminalKey(key, 'all-keys')).toBe(true)
}
await pair.flush()
expect(pressed).toEqual(ALL_RAIL_KEYS)
release()
await pair.flush()
expect(pair.host.sendTerminalKey('ctrlC', 'all-keys')).toBe(false)
})
it('tells the page which keys the rail holds, and moves that live as the window turns', async () => {
const pair = createFakeBridgePortPair({ terminalKeyRail: ['escape', 'tab', 'enter'] })
await pair.flush()
expect(pair.client.getShellSession()?.terminalKeyRail).toEqual(['escape', 'tab', 'enter'])
const seen: BridgeTerminalKeyRail[] = []
pair.client.onTerminalKeyRailUpdate((keys) => seen.push(keys))
pair.host.publishTerminalKeyRail(['escape'])
await pair.flush()
pair.host.publishTerminalKeyRail(['escape'])
await pair.flush()
pair.host.publishTerminalKeyRail([])
await pair.flush()
expect(pair.client.getShellSession()?.terminalKeyRail).toEqual([])
expect(seen).toEqual([['escape'], []])
})
it('hears the page claim the rail, and drops the claim when a document starts or ends', async () => {
const pair = createFakeBridgePortPair({ terminalKeyRail: ['escape', 'arrowUp'] })
await pair.flush()
const pressed: BridgeTerminalKeyId[] = []
const release = pair.client.claimTerminalKeyRail((key) => pressed.push(key))
await pair.flush()
expect(pair.terminalKeyRailClaims.at(-1)).toBe(true)
expect(pair.host.sendTerminalKey('escape')).toBe(true)
expect(pair.host.sendTerminalKey('arrowUp')).toBe(true)
await pair.flush()
expect(pressed).toEqual(['escape', 'arrowUp'])
// A re-asked `ready` drops the claim, and the page still holding one claims again on its `init`.
pair.host.receive(JSON.stringify({ v: BRIDGE_PROTOCOL_VERSION, type: 'ready' }))
await pair.flush()
expect(pair.terminalKeyRailClaims.slice(-2)).toEqual([false, true])
release()
await pair.flush()
expect(pair.terminalKeyRailClaims.at(-1)).toBe(false)
})
it('sends nothing either way with a shell that did not grant it, which is every older one', async () => {
const pair = createFakeBridgePortPair({
routeGrants: MOBILE_WEB_SHELL_GRANTS.filter(
(grant) => grant !== BRIDGE_TERMINAL_KEY_RAIL_GRANT
)
})
await pair.flush()
const release = pair.client.claimTerminalKeyRail(() => {})
await pair.flush()
release()
await pair.flush()
expect(pair.toShell.some((json) => json.includes(BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY))).toBe(
false
)
const before = pair.readToPage().length
expect(pair.host.sendTerminalKey('enter')).toBe(false)
await pair.flush()
expect(pair.readToPage()).toHaveLength(before)
})
it('drops queued old-owner presses and rejects held repeats after focus moves', async () => {
const pair = createFakeBridgePortPair()
await pair.flush()
const oldKeys: BridgeTerminalKeyId[] = []
const newKeys: BridgeTerminalKeyId[] = []
const release = pair.client.claimTerminalKeyRail((key) => oldKeys.push(key), 'pane-a-1')
await pair.flush()
expect(pair.host.sendTerminalKey('arrowUp', 'pane-a-1')).toBe(true)
release()
pair.client.claimTerminalKeyRail((key) => newKeys.push(key), 'pane-b-2')
await pair.flush()
expect(oldKeys).toEqual([])
expect(newKeys).toEqual([])
expect(pair.host.sendTerminalKey('arrowUp', 'pane-a-1')).toBe(false)
expect(pair.host.sendTerminalKey('arrowUp', 'pane-b-2')).toBe(true)
await pair.flush()
expect(newKeys).toEqual(['arrowUp'])
})
})
it('carries toolbar state across both bridge ends and delivers toolbar actions only to the current owner', async () => {
const pair = createFakeBridgePortPair()
await pair.flush()
const pressed: string[] = []
const toolbar = {
canSend: true,
canCompose: true,
keyboardVisible: false,
liveInputEnabled: true,
phoneDisplayMode: false,
runsAgent: false,
modifiers: { ctrl: 'once', alt: 'off', shift: 'locked' } as const
}
const release = pair.client.claimTerminalKeyRail(
(key) => pressed.push(key),
'toolbar-owner',
toolbar
)
await pair.flush()
expect(
pair.toShell
.map((json) => JSON.parse(json))
.findLast((frame) => frame.name === BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY)?.toolbar
).toEqual(toolbar)
expect(pair.host.sendTerminalKey('toggleKeyboard', 'toolbar-owner')).toBe(true)
expect(pair.host.sendTerminalKey('moreKeys', 'toolbar-owner')).toBe(true)
await pair.flush()
expect(pressed).toEqual(['toggleKeyboard', 'moreKeys'])
const updateStart = pair.toShell.length
release.update('toolbar-owner', { ...toolbar, keyboardVisible: true })
await pair.flush()
expect(
pair.toShell
.slice(updateStart)
.map((json) => JSON.parse(json))
.filter((frame) => frame.name === BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY)
).toEqual([
expect.objectContaining({
claimed: true,
inputOwner: 'toolbar-owner',
toolbar: { ...toolbar, keyboardVisible: true }
})
])
release()
await pair.flush()
expect(pair.host.sendTerminalKey('toggleKeyboard', 'toolbar-owner')).toBe(false)
const releaseEnd = pair.toShell.length
release.update('toolbar-owner', toolbar)
await pair.flush()
expect(pair.toShell).toHaveLength(releaseEnd)
})
it('accepts only single printable characters and known latches', () => {
expect(BridgeTerminalKeyIdSchema.safeParse('char:~').success).toBe(true)
expect(BridgeTerminalKeyIdSchema.safeParse('char:ab').success).toBe(false)
expect(BridgeTerminalKeyIdSchema.safeParse('char: ').success).toBe(false)
const toolbar = {
canSend: true,
canCompose: true,
keyboardVisible: false,
liveInputEnabled: true,
phoneDisplayMode: false,
runsAgent: true,
modifiers: { ctrl: 'off', alt: 'off', shift: 'off' }
}
expect(BridgeTerminalToolbarSchema.parse(toolbar)).toEqual(toolbar)
expect(
BridgeTerminalToolbarSchema.safeParse({
...toolbar,
modifiers: { ...toolbar.modifiers, ctrl: 'on' }
}).success
).toBe(false)
})
@@ -0,0 +1,105 @@
import { z } from 'zod'
/** The iPhone Duo key rail: terminal keys the shell draws in a side inset strip for the page. */
/**
* The grant a session page declares, optionally, when it takes key presses from the shell's rail
* and drops its own row of those keys while the rail shows. An older page declares none, so the
* shell never draws a rail for it.
*/
export const BRIDGE_TERMINAL_KEY_RAIL_GRANT = 'terminalKeyRail'
/**
* Page to shell, a notify on that grant: a terminal and its key row are on screen, or no longer.
* The shell draws the rail only while this holds, so it never sends keys into a hidden terminal.
*/
export const BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY = 'terminal-key-rail-claim'
/** Shell to page, a frame: one press of a key on the rail. */
export const BRIDGE_TERMINAL_KEY_FRAME = 'terminalKey'
/** Terminal keys the rail can send; the page applies its held modifiers, as its own key row does. */
export const BRIDGE_TERMINAL_KEY_IDS = [
'escape',
'tab',
'shiftTab',
'arrowUp',
'arrowDown',
'arrowLeft',
'arrowRight',
'enter',
'ctrlC'
] as const
/** Sticky modifiers: a tap applies to the next key, a double tap keeps it on. */
export const BRIDGE_TERMINAL_MODIFIER_IDS = ['ctrl', 'alt', 'shift'] as const
export const BRIDGE_TERMINAL_TOOLBAR_KEY_IDS = [
'toggleKeyboard',
'toggleDisplayMode',
'toggleInputMode',
'moreKeys'
] as const
const StickyModifierLatchSchema = z.enum(['off', 'once', 'locked'])
export const BridgeTerminalToolbarSchema = z.object({
canSend: z.boolean(),
canCompose: z.boolean(),
keyboardVisible: z.boolean(),
liveInputEnabled: z.boolean(),
phoneDisplayMode: z.boolean(),
/** Agent tabs get the agent row (⇧tab, ⏎, ^C), shells the shell row, as the page's own row does. */
runsAgent: z.boolean(),
modifiers: z.object({
ctrl: StickyModifierLatchSchema,
alt: StickyModifierLatchSchema,
shift: StickyModifierLatchSchema
}),
directionPadEnabled: z.boolean().optional()
})
export type BridgeTerminalToolbar = z.infer<typeof BridgeTerminalToolbarSchema>
export function sameTerminalKeyRailToolbar(
a?: BridgeTerminalToolbar,
b?: BridgeTerminalToolbar
): boolean {
if (!a || !b) {
return a === b
}
return (
a.canSend === b.canSend &&
a.canCompose === b.canCompose &&
a.keyboardVisible === b.keyboardVisible &&
a.liveInputEnabled === b.liveInputEnabled &&
a.phoneDisplayMode === b.phoneDisplayMode &&
a.runsAgent === b.runsAgent &&
a.modifiers.ctrl === b.modifiers.ctrl &&
a.modifiers.alt === b.modifiers.alt &&
a.modifiers.shift === b.modifiers.shift &&
a.directionPadEnabled === b.directionPadEnabled
)
}
export const BridgeTerminalKeyIdSchema = z.union([
z.enum(BRIDGE_TERMINAL_KEY_IDS),
z.enum(BRIDGE_TERMINAL_MODIFIER_IDS),
z.enum(BRIDGE_TERMINAL_TOOLBAR_KEY_IDS),
// One printable character, typed with the held modifiers.
z.custom<`char:${string}`>(
(value) => typeof value === 'string' && /^char:[\x21-\x7e]$/.test(value)
)
])
export type BridgeTerminalKeyId = z.infer<typeof BridgeTerminalKeyIdSchema>
/**
* The keys the scrollable rail shows beside the page, in its order. Empty or absent while
* no rail shows, including every moment the page holds no claim.
*/
export const BridgeTerminalKeyRailSchema = z.array(BridgeTerminalKeyIdSchema)
export type BridgeTerminalKeyRail = readonly BridgeTerminalKeyId[]
export function sameTerminalKeyRail(a: BridgeTerminalKeyRail, b: BridgeTerminalKeyRail): boolean {
return a.length === b.length && a.every((key, index) => key === b[index])
}
@@ -1,4 +1,5 @@
import {
TERMINAL_SHORTCUT_STORAGE_KEYS,
isHostWorkspaceStorageKey,
isPageStorageKeyForRoute,
PAGE_STORAGE_MAX_VALUE_CHARS
@@ -58,6 +59,28 @@ export function publishPageStorage(
readWorkspaceKey = forWorkspaceKeyReader
}
/** Apply native shortcut settings without replacing page-owned drafts or pending journals. */
export function refreshPageTerminalShortcuts(
entries: Readonly<Record<string, string>>,
oversize: readonly string[]
): void {
const nextOversize = new Set(oversizeKeys)
for (const key of TERMINAL_SHORTCUT_STORAGE_KEYS) {
const value = entries[key]
if (value === undefined) {
values.delete(key)
} else {
values.set(key, value)
}
if (oversize.includes(key)) {
nextOversize.add(key)
} else {
nextOversize.delete(key)
}
}
oversizeKeys = nextOversize
}
function getItem(key: string): Promise<string | null> {
return readWorkspaceKey !== null && isHostWorkspaceStorageKey(key, hostId)
? readWorkspaceKey(key)
@@ -78,16 +78,9 @@ 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', 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('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ bottom: 8, left: 0, right: 0, top: 44 })
}))
vi.mock('expo-router', () => ({
router: { replace: vi.fn() },
useLocalSearchParams: () => dependencies.params,
@@ -1,22 +0,0 @@
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')
})
})
@@ -1,19 +0,0 @@
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('/')}`
}
@@ -25,9 +25,6 @@ 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-router', async () => {
const { useEffect } = await import('react')
return { useFocusEffect: (effect: () => () => void) => useEffect(effect, [effect]) }
@@ -87,12 +87,13 @@ const store: GenerationStore = {
forgetHostUpdateFailures: async () => undefined
}
function routes(declares: boolean): MobileWebBundleManifestRead['routes'] {
function routes(declares: boolean, acrossLayouts = true): MobileWebBundleManifestRead['routes'] {
return [
{
pathname: '/h/[hostId]',
grants: ['navigate'],
...(declares ? { canOwnHostArea: true } : {})
...(declares ? { canOwnHostArea: true } : {}),
...(declares && acrossLayouts ? { canOwnHostAreaAcrossLayouts: true } : {})
},
{ pathname: '/h/[hostId]/session/[worktreeId]', grants: ['navigate'] }
]
@@ -101,9 +102,10 @@ function routes(declares: boolean): MobileWebBundleManifestRead['routes'] {
async function settledKinds(
declares: boolean,
widths: readonly boolean[],
routePathname = '/h/host-1/session/wt-1'
routePathname = '/h/host-1/session/wt-1',
acrossLayouts = true
) {
doubles.routes = routes(declares)
doubles.routes = routes(declares, acrossLayouts)
doubles.manifestReads = 0
doubles.gates.hostCapabilities = [MOBILE_WEB_BUNDLE_CAPABILITY]
const seen: MobileWebShellSessionState[] = []
@@ -142,9 +144,16 @@ async function settledKinds(
}
describe('a detail session across a layout-class change', () => {
// Each layout flip must reconsider which host-route hops the page may keep.
it('restarts an open page once per flip under a declaring bundle', async () => {
// Ownership and route grants belong to the document, not its changing viewport.
it('preserves the open page across rotation under a declaring bundle', async () => {
expect(await settledKinds(true, [false, true, false])).toEqual({
kinds: ['ready session-1', 'ready session-1', 'ready session-1'],
manifestReads: 1
})
})
it('keeps legacy declaring pages usable under their original layout-specific sessions', async () => {
expect(await settledKinds(true, [false, true, false], '/h/host-1', false)).toEqual({
kinds: ['ready session-1', 'ready session-2', 'ready session-3'],
manifestReads: 3
})
@@ -157,9 +166,9 @@ describe('a detail session across a layout-class change', () => {
})
})
it('rebuilds the host route, whose wide session owns the area under other grants', async () => {
const { kinds } = await settledKinds(true, [false, true], '/h/host-1')
expect(kinds).toEqual(['ready session-1', 'ready session-2'])
it('preserves the host-area document and its in-page workspace across rotation', async () => {
const { kinds } = await settledKinds(true, [false, true, false], '/h/host-1')
expect(kinds).toEqual(['ready session-1', 'ready session-1', 'ready session-1'])
})
})
@@ -145,6 +145,17 @@ describe('leaving a wide detail session for its host route', () => {
})
describe('the same hop on a phone', () => {
it.each([HOST, BOUNCE])('replaces a cold detail session with %s', async (href) => {
await hopFromSession(PHONE, [DETAIL], href)
expect(applied([DETAIL])).toEqual([href])
expect(dependencies.push).not.toHaveBeenCalled()
})
it('updates the existing host notice without retaining the exited detail session', async () => {
await hopFromSession(PHONE, [HOST, DETAIL], BOUNCE)
expect(applied([HOST, DETAIL])).toEqual([BOUNCE])
})
it('returns to the host route below rather than stacking a second one', async () => {
await hopFromSession(PHONE, [HOST, DETAIL], HOST)
expect(device.dismissTo).not.toHaveBeenCalled()
@@ -100,6 +100,49 @@ describe('the page declaring it can own the host area', () => {
const unserved = [{ ...desktopRoutes(true)[0]!, grants: ['native.teleport.start'] }]
expect(routeViewOf(unserved, HOST_ROUTE, true).ownsHostArea).toBe(false)
})
it('keeps the area at either width only for a page that declares it across layouts', () => {
const [host, ...rest] = desktopRoutes(true)
const routes = [{ ...host!, canOwnHostAreaAcrossLayouts: true }, ...rest]
expect(routeViewOf(routes, HOST_ROUTE, true).ownsHostArea).toBe(true)
expect(routeViewOf(routes, HOST_ROUTE, false).ownsHostArea).toBe(true)
// A narrow detail session hands a hop to the host route back to the shell, like a wide one.
expect(
routeViewOf(routes, '/h/host-1/session/wt-1', false).pageRouteGrants.map((p) => p.pathname)
).not.toContain('/h/[hostId]')
// Without `canOwnHostArea` the across-layouts flag claims nothing.
const flagOnly = [{ ...desktopRoutes(false)[0]!, canOwnHostAreaAcrossLayouts: true }]
expect(routeViewOf(flagOnly, HOST_ROUTE, false).ownsHostArea).toBe(false)
expect(routeViewOf(flagOnly, HOST_ROUTE, true).ownsHostArea).toBe(false)
})
it.each(['/h/[hostId]', '/h/[...page]'])(
'does not combine ownership declarations from a separate matching %s route',
(acrossLayoutsPattern) => {
const [host, ...details] = desktopRoutes(true)
const routes = [
host!,
{
pathname: acrossLayoutsPattern,
grants: ['navigate'],
canOwnHostAreaAcrossLayouts: true
},
...details
]
const parsed = MobileWebBundleManifestReadSchema.parse({
...MANIFEST_WIRE,
buildId: computeMobileWebBundleId(MANIFEST_WIRE.assets),
routes
})
expect(routeViewOf(parsed.routes, HOST_ROUTE, false).ownsHostArea).toBe(false)
expect(routeViewOf(parsed.routes, HOST_ROUTE, true).ownsHostArea).toBe(true)
expect(
routeViewOf(parsed.routes, '/h/host-1/session/wt-1', false).pageRouteGrants.map(
(pair) => pair.pathname
)
).toContain('/h/[hostId]')
}
)
})
describe('the wide host-area session', () => {
@@ -81,6 +81,7 @@ 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,
@@ -173,7 +174,7 @@ function pageRoutes(page: Page): MobileWebPageRoute[] | null {
{
pathname: '/h/[hostId]',
grants: ['navigate'],
...(page === 'declared' ? { canOwnHostArea: true } : {})
...(page === 'declared' ? { canOwnHostArea: true, canOwnHostAreaAcrossLayouts: true } : {})
},
{ pathname: '/h/[hostId]/session/[worktreeId]', grants: ['navigate'] }
]
@@ -346,11 +347,11 @@ describe('the page sidebar rule', () => {
})
describe('a phone', () => {
it('reports nothing to the layout from a served host route', async () => {
it('reports host ownership without drawing a sidebar on a narrow viewport', async () => {
const hostSession = sessionFor('declared', HOST, false)
expect(hostSession.state.kind).toBe('activating')
env.reports.length = 0
expect(await sidebarsIn('native', PHONE, { hostSession })).toBe(0)
expect(env.reports).toEqual([])
expect(env.reports).toEqual([true, false])
})
})

Some files were not shown because too many files have changed in this diff Show More