diff --git a/.github/workflows/mobile-ios-release.yml b/.github/workflows/mobile-ios-release.yml
index 2d2ab4d4037..b4ce73b00ab 100644
--- a/.github/workflows/mobile-ios-release.yml
+++ b/.github/workflows/mobile-ios-release.yml
@@ -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
diff --git a/config/scripts/build-mobile-web-app-bundle.mjs b/config/scripts/build-mobile-web-app-bundle.mjs
index 4dbf484cc90..d7120f50096 100644
--- a/config/scripts/build-mobile-web-app-bundle.mjs
+++ b/config/scripts/build-mobile-web-app-bundle.mjs
@@ -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 } : {})
}))
}
diff --git a/config/scripts/mobile-web-page-routes.mjs b/config/scripts/mobile-web-page-routes.mjs
index 1db04838000..33ec3571eb1 100644
--- a/config/scripts/mobile-web-page-routes.mjs
+++ b/config/scripts/mobile-web-page-routes.mjs
@@ -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'
+ ]
}
]
diff --git a/config/scripts/mobile-web-page-routes.test.mjs b/config/scripts/mobile-web-page-routes.test.mjs
index 1395233eddb..5ef45bb3a0d 100644
--- a/config/scripts/mobile-web-page-routes.test.mjs
+++ b/config/scripts/mobile-web-page-routes.test.mjs
@@ -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', () => {
diff --git a/mobile/app.json b/mobile/app.json
index e35ef0d9040..f9c25484379 100644
--- a/mobile/app.json
+++ b/mobile/app.json
@@ -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",
{
diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx
index bceaf710cf9..76e382eee72 100644
--- a/mobile/app/_layout.tsx
+++ b/mobile/app/_layout.tsx
@@ -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() {
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ >
+
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
diff --git a/mobile/app/h/[hostId]/edit.tsx b/mobile/app/h/[hostId]/edit.tsx
index df42c15ad32..d70aefeeb7a 100644
--- a/mobile/app/h/[hostId]/edit.tsx
+++ b/mobile/app/h/[hostId]/edit.tsx
@@ -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 (
-
-
-
- )
-}
-
-function EditHostScreen() {
+export default function EditHostScreen() {
useMobileColorScheme()
const t = useMobileTranslation()
const router = useRouter()
diff --git a/mobile/app/h/[hostId]/index.web.tsx b/mobile/app/h/[hostId]/index.web.tsx
index 27844170977..7ab7dbb1ca5 100644
--- a/mobile/app/h/[hostId]/index.web.tsx
+++ b/mobile/app/h/[hostId]/index.web.tsx
@@ -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() ? :
+ const ownsHostArea = usePageOwnsHostArea()
+ const { isWideLayout } = useResponsiveLayout()
+ return ownsHostArea && isWideLayout ? :
}
diff --git a/mobile/app/h/_layout.tsx b/mobile/app/h/_layout.tsx
index 75a6a6a44c5..85791eb3eb3 100644
--- a/mobile/app/h/_layout.tsx
+++ b/mobile/app/h/_layout.tsx
@@ -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(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 (
- {showSidebar && sidebarOpen ? (
-
+ {showSidebar && (sidebarOpen || foldSidebarWidth !== null) ? (
+
{/* Edge drag handle captures Android gestures above the list. */}
-
+ {foldSidebarWidth === null ? (
+
+ ) : null}
) : null}
+ {foldGap > 0 ? : null}
diff --git a/mobile/modules/orca-reserved-regions/expo-module.config.json b/mobile/modules/orca-reserved-regions/expo-module.config.json
new file mode 100644
index 00000000000..0d6b3d8ff93
--- /dev/null
+++ b/mobile/modules/orca-reserved-regions/expo-module.config.json
@@ -0,0 +1,6 @@
+{
+ "platforms": ["apple"],
+ "apple": {
+ "modules": ["OrcaReservedRegionsModule"]
+ }
+}
diff --git a/mobile/modules/orca-reserved-regions/ios/OrcaReservedRegions.podspec b/mobile/modules/orca-reserved-regions/ios/OrcaReservedRegions.podspec
new file mode 100644
index 00000000000..6e6df64e122
--- /dev/null
+++ b/mobile/modules/orca-reserved-regions/ios/OrcaReservedRegions.podspec
@@ -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
diff --git a/mobile/modules/orca-reserved-regions/ios/OrcaReservedRegionsModule.swift b/mobile/modules/orca-reserved-regions/ios/OrcaReservedRegionsModule.swift
new file mode 100644
index 00000000000..2a4fe1dc16b
--- /dev/null
+++ b/mobile/modules/orca-reserved-regions/ios/OrcaReservedRegionsModule.swift
@@ -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
diff --git a/mobile/modules/orca-reserved-regions/package.json b/mobile/modules/orca-reserved-regions/package.json
new file mode 100644
index 00000000000..681c3481d36
--- /dev/null
+++ b/mobile/modules/orca-reserved-regions/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "orca-reserved-regions",
+ "version": "0.0.1",
+ "private": true
+}
diff --git a/mobile/modules/orca-reserved-regions/src/index.ts b/mobile/modules/orca-reserved-regions/src/index.ts
new file mode 100644
index 00000000000..f9d2b735f2c
--- /dev/null
+++ b/mobile/modules/orca-reserved-regions/src/index.ts
@@ -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 {}
+
+/**
+ * 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('OrcaReservedRegions')
diff --git a/mobile/modules/orca-window-controls-inset/ios/OrcaWindowControlsInsetSubscriber.swift b/mobile/modules/orca-window-controls-inset/ios/OrcaWindowControlsInsetSubscriber.swift
index 8256a26b320..097952c2fc9 100644
--- a/mobile/modules/orca-window-controls-inset/ios/OrcaWindowControlsInsetSubscriber.swift
+++ b/mobile/modules/orca-window-controls-inset/ios/OrcaWindowControlsInsetSubscriber.swift
@@ -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
diff --git a/mobile/patches/expo-router@55.0.18.patch b/mobile/patches/expo-router@55.0.18.patch
new file mode 100644
index 00000000000..9454f7cf2fa
--- /dev/null
+++ b/mobile/patches/expo-router@55.0.18.patch
@@ -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 {
diff --git a/mobile/plugins/ios-pod-deployment-target-floor.js b/mobile/plugins/ios-pod-deployment-target-floor.js
new file mode 100644
index 00000000000..188d1e4236e
--- /dev/null
+++ b/mobile/plugins/ios-pod-deployment-target-floor.js
@@ -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
+ })
+}
diff --git a/mobile/plugins/ios-scene-lifecycle.js b/mobile/plugins/ios-scene-lifecycle.js
new file mode 100644
index 00000000000..cf316126d9e
--- /dev/null
+++ b/mobile/plugins/ios-scene-lifecycle.js
@@ -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) {
+ 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
+ })
+}
diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml
index 3a6ab1174ef..bbdc3be5330 100644
--- a/mobile/pnpm-lock.yaml
+++ b/mobile/pnpm-lock.yaml
@@ -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)
diff --git a/mobile/pnpm-workspace.yaml b/mobile/pnpm-workspace.yaml
index ba675dcb4cc..cd918cbcadd 100644
--- a/mobile/pnpm-workspace.yaml
+++ b/mobile/pnpm-workspace.yaml
@@ -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
diff --git a/mobile/src/components/TerminalShortcutSettings.tsx b/mobile/src/components/TerminalShortcutSettings.tsx
index 2a1dc095e6d..e91525723d2 100644
--- a/mobile/src/components/TerminalShortcutSettings.tsx
+++ b/mobile/src/components/TerminalShortcutSettings.tsx
@@ -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 (
@@ -55,6 +48,13 @@ function ShortcutBarRow({
{shortcutKey.accessibilityLabel ?? shortcutKey.label}
{
- const { orderedBuiltInIds, visibleBuiltInIds } = await loadTerminalAccessoryLayout()
- return { orderedBuiltInIds, visibleBuiltInIds }
-}
-
type Props = {
scrollRef: AnimatedRef
scrollOffsetY: SharedValue
@@ -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 ? (
+ <>
+
+ {t('components.terminalShortcutSettings.directionPad', 'DIRECTION PAD')}
+
+
+
+
+
+ {t('components.terminalShortcutSettings.showDirectionPad', 'Show D-pad')}
+
+
+ {t(
+ 'components.terminalShortcutSettings.showDirectionPadDescription',
+ 'Pin the arrow pad in the side rail. Without it, the rail lists the arrow keys.'
+ )}
+
+
+
+ setShortcutLayout((current) => {
+ const next = { ...current, directionPadEnabled: enabled }
+ persistLayout(next)
+ return next
+ })
+ }
+ trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
+ thumbColor={colors.textPrimary}
+ />
+
+
+ >
+ ) : null}
{t('components.terminalShortcutSettings.moreKeys', 'MORE KEYS')}
@@ -213,7 +241,7 @@ export function TerminalShortcutSettings({
renderRow={(shortcutKey) => (
toggleBuiltInKey(shortcutKey.id, visible)}
/>
)}
@@ -270,6 +298,27 @@ export function TerminalShortcutSettings({
{key.bytes.replace(/\r/g, ' ↵')}
+
+ 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}
+ />
[
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)'
- }
-}))
diff --git a/mobile/src/components/drag-reorder-test-harness.test-fixture.tsx b/mobile/src/components/drag-reorder-test-harness.test-fixture.tsx
index ec707cd52cd..eb77c88c079 100644
--- a/mobile/src/components/drag-reorder-test-harness.test-fixture.tsx
+++ b/mobile/src/components/drag-reorder-test-harness.test-fixture.tsx
@@ -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 () => {} }
}))
diff --git a/mobile/src/components/terminal-shortcut-settings-custom-keys.test.tsx b/mobile/src/components/terminal-shortcut-settings-custom-keys.test.tsx
index 72c32c98f7f..1f7ed8a6aa2 100644
--- a/mobile/src/components/terminal-shortcut-settings-custom-keys.test.tsx
+++ b/mobile/src/components/terminal-shortcut-settings-custom-keys.test.tsx
@@ -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 {
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 {
}
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)
+})
diff --git a/mobile/src/components/terminal-shortcut-settings-styles.ts b/mobile/src/components/terminal-shortcut-settings-styles.ts
new file mode 100644
index 00000000000..bdbc0938aad
--- /dev/null
+++ b/mobile/src/components/terminal-shortcut-settings-styles.ts
@@ -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)'
+ }
+}))
diff --git a/mobile/src/host-edit-route-accessibility.test.ts b/mobile/src/host-edit-route-accessibility.test.ts
index 3590e30df21..568ef5ff523 100644
--- a/mobile/src/host-edit-route-accessibility.test.ts
+++ b/mobile/src/host-edit-route-accessibility.test.ts
@@ -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' }),
diff --git a/mobile/src/host-edit-save-flow.test.ts b/mobile/src/host-edit-save-flow.test.ts
index 68202f0b7b6..9f32d686dbd 100644
--- a/mobile/src/host-edit-save-flow.test.ts
+++ b/mobile/src/host-edit-save-flow.test.ts
@@ -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 }),
diff --git a/mobile/src/host-screen/host-rail-actions.tsx b/mobile/src/host-screen/host-rail-actions.tsx
deleted file mode 100644
index 68d8b733242..00000000000
--- a/mobile/src/host-screen/host-rail-actions.tsx
+++ /dev/null
@@ -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 (
- [
- styles.icon,
- pressed && styles.pressed,
- action.disabled && styles.disabled
- ]}
- onPress={action.onPress}
- disabled={action.disabled}
- accessibilityRole="button"
- accessibilityLabel={action.label}
- >
-
-
- )
-}
-
-function pick(all: HostListAction[], ids: HostListAction['id'][]): HostListAction[] {
- return ids.flatMap((id) => all.filter((action) => action.id === id))
-}
-
-// Beside the sidebar: navigation, then the workspace tools, then New as the one filled button.
-// On the cover the list keeps its own New button, and search sits at the bottom within thumb reach.
-export function HostRailActions({
- controller,
- placement
-}: {
- controller: HostScreenController
- placement: 'split' | 'cover'
-}) {
- const t = useMobileTranslation()
- useMobileColorScheme()
- const all = hostListActions(controller, t)
- if (placement === 'cover') {
- return (
-
-
- {pick(all, ['accounts', 'tasks', 'floating']).map((action) => (
-
- ))}
-
- {pick(all, ['search']).map((action) => (
-
- ))}
-
- )
- }
- const create = all.find((action) => action.id === 'new')
- return (
-
- {pick(all, ['accounts', 'tasks', 'search', 'floating']).map((action) => (
-
- {action.id === 'floating' ? : null}
-
-
- ))}
- {create ? (
- [
- styles.create,
- pressed && styles.createPressed,
- create.disabled && styles.disabled
- ]}
- onPress={create.onPress}
- disabled={create.disabled}
- accessibilityRole="button"
- accessibilityLabel={create.label}
- >
-
-
- ) : null}
-
- )
-}
-
-const styles = themedStyleSheet(() => ({
- rail: {
- alignItems: 'center',
- gap: spacing.sm
- },
- coverRail: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'space-between',
- paddingBottom: spacing.sm
- },
- icon: {
- width: 44,
- height: 44,
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: 22
- },
- pressed: {
- backgroundColor: colors.bgRaised
- },
- disabled: {
- opacity: 0.4
- },
- divider: {
- width: 32,
- height: StyleSheet.hairlineWidth,
- backgroundColor: colors.borderSubtle
- },
- create: {
- marginTop: spacing.xs,
- width: 52,
- height: 52,
- borderRadius: 26,
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: colors.surfaceBright
- },
- createPressed: {
- opacity: 0.85
- }
-}))
diff --git a/mobile/src/host-screen/host-screen-header.tsx b/mobile/src/host-screen/host-screen-header.tsx
index caff1cb1ee4..f2ac5b1d49c 100644
--- a/mobile/src/host-screen/host-screen-header.tsx
+++ b/mobile/src/host-screen/host-screen-header.tsx
@@ -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 ? : null
- )
return (
@@ -112,7 +103,7 @@ export function HostScreenHeader({ controller }: { controller: HostScreenControl
) : null}
- {!embedded && floatingWorkspaceEnabled && !railActions ? (
+ {!embedded && floatingWorkspaceEnabled ? (
- {railActions ? null : (
-
- {hostListActions(controller, t).map((action) => (
-
-
-
- ))}
-
- )}
+
+
+ actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/accounts`)
+ }
+ disabled={connState !== 'connected'}
+ accessibilityRole="button"
+ accessibilityLabel={t('hostScreen.hostScreenHeader.accounts', 'Accounts')}
+ >
+
+
+
+ actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/tasks`)}
+ disabled={connState !== 'connected'}
+ accessibilityRole="button"
+ accessibilityLabel={t('hostScreen.hostScreenHeader.tasks', 'Tasks')}
+ >
+
+
+
+ {floatingWorkspaceEnabled ? (
+
+
+
+ ) : null}
+
+
+
+
+
+ state.setShowSearch((s) => !s)}
+ accessibilityRole="button"
+ accessibilityLabel={
+ state.showSearch
+ ? t('hostScreen.hostScreenHeader.closeSearch', 'Close search')
+ : t('hostScreen.hostScreenHeader.searchWorkspaces', 'Search workspaces')
+ }
+ >
+ {state.showSearch ? (
+
+ ) : (
+
+ )}
+
+
) : (
@@ -224,56 +276,51 @@ export function HostScreenHeader({ controller }: { controller: HostScreenControl
- {railActions ? null : (
- <>
-
- actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/accounts`)
- }
- disabled={connState !== 'connected'}
- accessibilityRole="button"
- accessibilityLabel={t('hostScreen.hostScreenHeader.accounts', 'Accounts')}
- >
-
-
-
- actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/tasks`)
- }
- disabled={connState !== 'connected'}
- accessibilityRole="button"
- accessibilityLabel={t('hostScreen.hostScreenHeader.tasks', 'Tasks')}
- >
-
-
+
+ actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/accounts`)
+ }
+ disabled={connState !== 'connected'}
+ accessibilityRole="button"
+ accessibilityLabel={t('hostScreen.hostScreenHeader.accounts', 'Accounts')}
+ >
+
+
- state.setShowSearch((s) => !s)}
- accessibilityRole="button"
- accessibilityLabel={
- state.showSearch
- ? t('hostScreen.hostScreenHeader.closeSearch', 'Close search')
- : t('hostScreen.hostScreenHeader.searchWorkspaces', 'Search workspaces')
- }
- >
- {state.showSearch ? (
-
- ) : (
-
- )}
-
- >
- )}
+ actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/tasks`)}
+ disabled={connState !== 'connected'}
+ accessibilityRole="button"
+ accessibilityLabel={t('hostScreen.hostScreenHeader.tasks', 'Tasks')}
+ >
+
+
+
+ state.setShowSearch((s) => !s)}
+ accessibilityRole="button"
+ accessibilityLabel={
+ state.showSearch
+ ? t('hostScreen.hostScreenHeader.closeSearch', 'Close search')
+ : t('hostScreen.hostScreenHeader.searchWorkspaces', 'Search workspaces')
+ }
+ >
+ {state.showSearch ? (
+
+ ) : (
+
+ )}
+
)}
diff --git a/mobile/src/host-screen/use-host-screen-controller.ts b/mobile/src/host-screen/use-host-screen-controller.ts
index 080688c277a..92d3d2e89e9 100644
--- a/mobile/src/host-screen/use-host-screen-controller.ts
+++ b/mobile/src/host-screen/use-host-screen-controller.ts
@@ -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,
diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json
index 0e973934c6a..5f25a0ede37 100644
--- a/mobile/src/i18n/locales/de.json
+++ b/mobile/src/i18n/locales/de.json
@@ -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."
diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json
index 27e44fec81c..01307a84218 100644
--- a/mobile/src/i18n/locales/en.json
+++ b/mobile/src/i18n/locales/en.json
@@ -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"
+ }
}
}
diff --git a/mobile/src/i18n/locales/es.json b/mobile/src/i18n/locales/es.json
index f4d076ec3f3..d2a3d16bbba 100644
--- a/mobile/src/i18n/locales/es.json
+++ b/mobile/src/i18n/locales/es.json
@@ -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."
diff --git a/mobile/src/i18n/locales/fr.json b/mobile/src/i18n/locales/fr.json
index 9dd8501705b..c0c9131c3e4 100644
--- a/mobile/src/i18n/locales/fr.json
+++ b/mobile/src/i18n/locales/fr.json
@@ -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."
diff --git a/mobile/src/i18n/locales/ja.json b/mobile/src/i18n/locales/ja.json
index 3bc83b3e724..5c027dfa2bf 100644
--- a/mobile/src/i18n/locales/ja.json
+++ b/mobile/src/i18n/locales/ja.json
@@ -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 は、このワークスペースホストでは利用できません。"
diff --git a/mobile/src/i18n/locales/ko.json b/mobile/src/i18n/locales/ko.json
index fe198726d5d..d15086aa7cd 100644
--- a/mobile/src/i18n/locales/ko.json
+++ b/mobile/src/i18n/locales/ko.json
@@ -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": "이 작업에 저장된 에이전트는 이 워크스페이스 호스트에서 사용할 수 없습니다."
diff --git a/mobile/src/i18n/locales/pt.json b/mobile/src/i18n/locales/pt.json
index 4e57c1d2b32..96671508d56 100644
--- a/mobile/src/i18n/locales/pt.json
+++ b/mobile/src/i18n/locales/pt.json
@@ -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."
diff --git a/mobile/src/i18n/locales/ru.json b/mobile/src/i18n/locales/ru.json
index 33906837470..7b68e54c8bf 100644
--- a/mobile/src/i18n/locales/ru.json
+++ b/mobile/src/i18n/locales/ru.json
@@ -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": "Агент, выбранный для этого действия, недоступен на сервере этого рабочего пространства."
diff --git a/mobile/src/i18n/locales/tr.json b/mobile/src/i18n/locales/tr.json
index f045ee7fd62..e2e0b1fd146 100644
--- a/mobile/src/i18n/locales/tr.json
+++ b/mobile/src/i18n/locales/tr.json
@@ -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."
diff --git a/mobile/src/i18n/locales/zh-Hant.json b/mobile/src/i18n/locales/zh-Hant.json
index da88c25bd6c..3c17cdd6584 100644
--- a/mobile/src/i18n/locales/zh-Hant.json
+++ b/mobile/src/i18n/locales/zh-Hant.json
@@ -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": "先前為此動作設定的代理程式無法在此工作區主機上使用。"
diff --git a/mobile/src/i18n/locales/zh.json b/mobile/src/i18n/locales/zh.json
index c36e3076774..e54cdbb2350 100644
--- a/mobile/src/i18n/locales/zh.json
+++ b/mobile/src/i18n/locales/zh.json
@@ -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": "为此操作保存的智能体在此工作区主机上不可用。"
diff --git a/mobile/src/layout/fold-division.test.ts b/mobile/src/layout/fold-division.test.ts
new file mode 100644
index 00000000000..7a96c8dc8ab
--- /dev/null
+++ b/mobile/src/layout/fold-division.test.ts
@@ -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()
+ })
+})
diff --git a/mobile/src/layout/fold-division.ts b/mobile/src/layout/fold-division.ts
new file mode 100644
index 00000000000..7e2f492ba15
--- /dev/null
+++ b/mobile/src/layout/fold-division.ts
@@ -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
+}
diff --git a/mobile/src/layout/host-sidebar-reveal.test.tsx b/mobile/src/layout/host-sidebar-reveal.test.tsx
index 2e52daaaadf..2e32c4e9d41 100644
--- a/mobile/src/layout/host-sidebar-reveal.test.tsx
+++ b/mobile/src/layout/host-sidebar-reveal.test.tsx
@@ -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(),
diff --git a/mobile/src/layout/reserved-regions.ts b/mobile/src/layout/reserved-regions.ts
new file mode 100644
index 00000000000..673fe5f5386
--- /dev/null
+++ b/mobile/src/layout/reserved-regions.ts
@@ -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))
+}
diff --git a/mobile/src/layout/reserved-regions.web.ts b/mobile/src/layout/reserved-regions.web.ts
new file mode 100644
index 00000000000..2f3717f003b
--- /dev/null
+++ b/mobile/src/layout/reserved-regions.web.ts
@@ -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])
+}
diff --git a/mobile/src/layout/responsive-layout-metrics.test.ts b/mobile/src/layout/responsive-layout-metrics.test.ts
index 81b64c255d8..235b5339ed8 100644
--- a/mobile/src/layout/responsive-layout-metrics.test.ts
+++ b/mobile/src/layout/responsive-layout-metrics.test.ts
@@ -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,
diff --git a/mobile/src/layout/responsive-layout-metrics.ts b/mobile/src/layout/responsive-layout-metrics.ts
index 6a7cb0b74c7..c6520c47a37 100644
--- a/mobile/src/layout/responsive-layout-metrics.ts
+++ b/mobile/src/layout/responsive-layout-metrics.ts
@@ -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
diff --git a/mobile/src/layout/side-rail-strip.test.ts b/mobile/src/layout/side-rail-strip.test.ts
new file mode 100644
index 00000000000..df70c27285d
--- /dev/null
+++ b/mobile/src/layout/side-rail-strip.test.ts
@@ -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()
+ })
+})
diff --git a/mobile/src/layout/side-rail-strip.ts b/mobile/src/layout/side-rail-strip.ts
new file mode 100644
index 00000000000..c8df4b95e4c
--- /dev/null
+++ b/mobile/src/layout/side-rail-strip.ts
@@ -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
+}
diff --git a/mobile/src/layout/side-rail.test.tsx b/mobile/src/layout/side-rail.test.tsx
deleted file mode 100644
index 2e5bd231a2b..00000000000
--- a/mobile/src/layout/side-rail.test.tsx
+++ /dev/null
@@ -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[]) {
- const tree = (
-
- {screens}
-
- )
- act(() => {
- if (renderer) {
- renderer.update(tree)
- } else {
- renderer = create(tree)
- }
- })
- }
-
- function railText(): string[] {
- return (renderer?.root.findAllByType(RailLabel) ?? []).map((node) => node.props.text)
- }
-
- it('shows the top screen, never one a stack keeps beneath it', () => {
- render('right', screen('host actions', true), screen('keys', false))
- expect(railText()).toEqual(['host actions'])
- })
-
- it('gives the rail back when a claim ends, and draws nothing without a rail', () => {
- render('right', screen('keys', true))
- expect(railText()).toEqual(['keys'])
- render('right', screen(null, true))
- expect(railText()).toEqual([])
- render(null, screen('keys', true))
- expect(railText()).toEqual([])
- })
-})
diff --git a/mobile/src/layout/side-rail.tsx b/mobile/src/layout/side-rail.tsx
index 2cee230c8af..4b2ea69a040 100644
--- a/mobile/src/layout/side-rail.tsx
+++ b/mobile/src/layout/side-rail.tsx
@@ -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
+type RailStore = ReturnType
-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 (
{children}
- {edge ? (
-
-
-
- ) : null}
+
)
}
-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 (
+
+ {content.node}
+
+ )
+}
+
+/** 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' }
})
diff --git a/mobile/src/layout/side-safe-area.test.tsx b/mobile/src/layout/side-safe-area.test.tsx
index 11cdc509e38..0069b3f6738 100644
--- a/mobile/src/layout/side-safe-area.test.tsx
+++ b/mobile/src/layout/side-safe-area.test.tsx
@@ -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>()
@@ -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: (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(
-
-
+
+
-
+ {nested ? (
+
+
+
+ ) : (
+
+ )}
@@ -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)
+})
diff --git a/mobile/src/layout/side-safe-area.tsx b/mobile/src/layout/side-safe-area.tsx
index c7c1c48f861..fa7e6c4f36d 100644
--- a/mobile/src/layout/side-safe-area.tsx
+++ b/mobile/src/layout/side-safe-area.tsx
@@ -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(null)
-const SideRailReleaseContext = createContext> | null>(null)
// Pad side cutouts/navigation bars once, then expose the padded frame and insets so descendants neither pad twice nor size past it.
-// Nesting one is safe: it pads only what an outer one left unpadded.
export function SideSafeArea({ children }: { children: ReactNode }) {
const insets = useSafeAreaInsets()
const frame = useSafeAreaFrame()
- const [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 = (
-
- {children}
-
+ {children}
)
// Native SafeAreaView measures its nearest native provider, so nest one inside the padding; web reads the adjusted context.
return (
-
-
- {Platform.OS === 'web' ? area : {area}}
-
+
+ {Platform.OS === 'web' ? area : {area}}
)
}
-// A layout whose screens reach the Duo's status-bar rail themselves keeps it unpadded while mounted.
-// Pages then get the rail's width as an inset and draw in it; native views beneath wrap themselves in SideSafeArea.
-export function useReleasedSideRail(release: boolean): void {
- const setReleases = useContext(SideRailReleaseContext)
- useEffect(() => {
- if (!release || setReleases === null) {
- return
- }
- setReleases((count) => count + 1)
- return () => setReleases((count) => count - 1)
- }, [release, setReleases])
-}
-
// Modals own a separate window outside SideSafeArea and need the original window insets.
export function useWindowSafeAreaInsets(): EdgeInsets {
const insets = useSafeAreaInsets()
diff --git a/mobile/src/layout/top-chrome-space.test.ts b/mobile/src/layout/top-chrome-space.test.ts
new file mode 100644
index 00000000000..c52c3ff9619
--- /dev/null
+++ b/mobile/src/layout/top-chrome-space.test.ts
@@ -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()
+ })
+})
diff --git a/mobile/src/layout/top-chrome-space.ts b/mobile/src/layout/top-chrome-space.ts
new file mode 100644
index 00000000000..e3c8b8e6c07
--- /dev/null
+++ b/mobile/src/layout/top-chrome-space.ts
@@ -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 }
+}
diff --git a/mobile/src/layout/use-fold-split.ts b/mobile/src/layout/use-fold-split.ts
new file mode 100644
index 00000000000..7e68aeb7c50
--- /dev/null
+++ b/mobile/src/layout/use-fold-split.ts
@@ -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
diff --git a/mobile/src/layout/use-fold-split.web.ts b/mobile/src/layout/use-fold-split.web.ts
new file mode 100644
index 00000000000..3c9f5df91b0
--- /dev/null
+++ b/mobile/src/layout/use-fold-split.web.ts
@@ -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 }
+}
diff --git a/mobile/src/layout/use-shell-side-rail.ts b/mobile/src/layout/use-shell-side-rail.ts
new file mode 100644
index 00000000000..6110b2ee21b
--- /dev/null
+++ b/mobile/src/layout/use-shell-side-rail.ts
@@ -0,0 +1,8 @@
+import { useSideRailStrip, useSideRailDirectionPadAvailable } from './side-rail'
+export function useShellSideRail(): boolean {
+ return useSideRailStrip() !== null
+}
+
+export function useDirectionPadAvailable(): boolean {
+ return useSideRailDirectionPadAvailable()
+}
diff --git a/mobile/src/layout/use-shell-side-rail.web.ts b/mobile/src/layout/use-shell-side-rail.web.ts
new file mode 100644
index 00000000000..0df1612a504
--- /dev/null
+++ b/mobile/src/layout/use-shell-side-rail.web.ts
@@ -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
+}
diff --git a/mobile/src/layout/use-top-chrome-space.ts b/mobile/src/layout/use-top-chrome-space.ts
new file mode 100644
index 00000000000..d0be9797754
--- /dev/null
+++ b/mobile/src/layout/use-top-chrome-space.ts
@@ -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(null)
+ const [frame, setFrame] = useState(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) }
+}
diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx
index f7d06a8cf49..395f1d83921 100644
--- a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx
+++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx
@@ -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.
diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx
index e2fbdb10599..bca43369807 100644
--- a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx
+++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx
@@ -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 =>
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 (
({
flex: 1,
backgroundColor: colors.bgBase
},
- shellView: {
- flex: 1
- },
- unpaintedOverlay: {
- opacity: 0
- }
+ shellView: { flex: 1 },
+ unpaintedOverlay: { opacity: 0 }
}))
diff --git a/mobile/src/mobile-web-shell/ShellStateScreen.tsx b/mobile/src/mobile-web-shell/ShellStateScreen.tsx
index 2c56d8b4d02..6e085b310da 100644
--- a/mobile/src/mobile-web-shell/ShellStateScreen.tsx
+++ b/mobile/src/mobile-web-shell/ShellStateScreen.tsx
@@ -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[0]) {
- return (
-
-
-
- )
-}
-
const styles = themedStyleSheet(() => ({
centered: {
flex: 1,
diff --git a/mobile/src/mobile-web-shell/bridge-host-back.ts b/mobile/src/mobile-web-shell/bridge-host-back.ts
index 29f151bc4bc..3480fa699d8 100644
--- a/mobile/src/mobile-web-shell/bridge-host-back.ts
+++ b/mobile/src/mobile-web-shell/bridge-host-back.ts
@@ -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.
diff --git a/mobile/src/mobile-web-shell/bridge-host-contract.ts b/mobile/src/mobile-web-shell/bridge-host-contract.ts
index 325b8e83363..88d5d98071a 100644
--- a/mobile/src/mobile-web-shell/bridge-host-contract.ts
+++ b/mobile/src/mobile-web-shell/bridge-host-contract.ts
@@ -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
+}
diff --git a/mobile/src/mobile-web-shell/bridge-host-notify.ts b/mobile/src/mobile-web-shell/bridge-host-notify.ts
index 5fa6f049da2..3b8d5564eae 100644
--- a/mobile/src/mobile-web-shell/bridge-host-notify.ts
+++ b/mobile/src/mobile-web-shell/bridge-host-notify.ts
@@ -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()
diff --git a/mobile/src/mobile-web-shell/bridge-host-route.ts b/mobile/src/mobile-web-shell/bridge-host-route.ts
index 0f42155ee39..735ffa90e46 100644
--- a/mobile/src/mobile-web-shell/bridge-host-route.ts
+++ b/mobile/src/mobile-web-shell/bridge-host-route.ts
@@ -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) {
diff --git a/mobile/src/mobile-web-shell/bridge-host-terminal-key-rail.test.ts b/mobile/src/mobile-web-shell/bridge-host-terminal-key-rail.test.ts
new file mode 100644
index 00000000000..8444d07155e
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge-host-terminal-key-rail.test.ts
@@ -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)
+ })
+})
diff --git a/mobile/src/mobile-web-shell/bridge-host.ts b/mobile/src/mobile-web-shell/bridge-host.ts
index a76de618a66..786e9f50633 100644
--- a/mobile/src/mobile-web-shell/bridge-host.ts
+++ b/mobile/src/mobile-web-shell/bridge-host.ts
@@ -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
+ 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
}
}
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-back-round-trip.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-back-round-trip.test.ts
index 87d58a1e82a..c311fca7f99 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-back-round-trip.test.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-back-round-trip.test.ts
@@ -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'
})
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-inbound-frames.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-inbound-frames.ts
index 84385d90f51..b4e63736bc8 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-client-inbound-frames.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-client-inbound-frames.ts
@@ -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) => 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' })
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-init-handshake.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-init-handshake.ts
index f6a01b001ee..83ba60c43c8 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-client-init-handshake.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-client-init-handshake.ts
@@ -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 {
- return { v: BRIDGE_PROTOCOL_VERSION, type: 'ready' }
+ return {
+ v: BRIDGE_PROTOCOL_VERSION,
+ type: 'ready',
+ storageKeys: [...NEGOTIATED_PAGE_STORAGE_KEYS]
+ }
}
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts
index 128fdbb0281..d411b6f862e 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts
@@ -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
+ })
+ }
}
}
}
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-session.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-session.ts
index 09c74aeb976..1d3bf9c5f75 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-client-session.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-client-session.ts
@@ -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>
// 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,
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-shell-session.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-shell-session.ts
index 77d2146e83a..6c4eeff1bb5 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-client-shell-session.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-client-shell-session.ts
@@ -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 () => {
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-terminal-keys.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-terminal-keys.ts
new file mode 100644
index 00000000000..86174ebfac4
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge/bridge-client-terminal-keys.ts
@@ -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()
+ }
+ }
+ }
+}
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts b/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts
index c396e2de8e1..6e96fac47fc 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts
@@ -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)
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-fold-division.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-fold-division.test.ts
new file mode 100644
index 00000000000..994f6b39b21
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge/bridge-fold-division.test.ts
@@ -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)
+ })
+})
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-fold-division.ts b/mobile/src/mobile-web-shell/bridge/bridge-fold-division.ts
new file mode 100644
index 00000000000..aff88f55de6
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge/bridge-fold-division.ts
@@ -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
+
+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)
+ )
+}
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-init-frame.ts b/mobile/src/mobile-web-shell/bridge/bridge-init-frame.ts
index a8f2600e71f..dc0b2297f1c 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-init-frame.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-init-frame.ts
@@ -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>
- // 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 {
@@ -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.
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-native-verb-call.ts b/mobile/src/mobile-web-shell/bridge/bridge-native-verb-call.ts
deleted file mode 100644
index 2a8f2aa7df4..00000000000
--- a/mobile/src/mobile-web-shell/bridge/bridge-native-verb-call.ts
+++ /dev/null
@@ -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,
- verb: BridgeNativeVerb,
- params: unknown
-): Promise {
- // Validate the typed verb at runtime too; widened callers must not turn this into an untracked raw desktop RPC port.
- if (!isBridgeNativeMethod(verb)) {
- return Promise.reject(new BridgeClientNotNativeVerbError(verb))
- }
- return sendRequest(verb, params).then((reply) => {
- // Native refusals reject as error frames and never forward to desktop, so the return has no RpcFailure arm.
- if (!reply.ok) {
- throw new Error(reply.error.message)
- }
- return reply
- })
-}
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-notify-envelope.ts b/mobile/src/mobile-web-shell/bridge/bridge-notify-envelope.ts
index a55f604260d..3f2f1fee6ee 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-notify-envelope.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-notify-envelope.ts
@@ -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()
})
])
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts b/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts
index 20feb271bdd..0a9469e5c55 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts
@@ -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['name']
@@ -28,6 +32,7 @@ const BRIDGE_NOTIFY_GRANTS: Readonly> =
[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
}
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-page-geometry.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-page-geometry.test.ts
new file mode 100644
index 00000000000..d15d6be684e
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge/bridge-page-geometry.test.ts
@@ -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 }
+ ])
+})
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-page-geometry.ts b/mobile/src/mobile-web-shell/bridge/bridge-page-geometry.ts
new file mode 100644
index 00000000000..e73da86ceeb
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge/bridge-page-geometry.ts
@@ -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
+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 }] : []
+ })
+}
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-page-route-grants.ts b/mobile/src/mobile-web-shell/bridge/bridge-page-route-grants.ts
index 3470ee1d5ad..8039f0c5517 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-page-route-grants.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-page-route-grants.ts
@@ -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()
)
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts b/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts
index 188080bb861..447a17b78f3 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts
@@ -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 = {
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
@@ -64,6 +70,9 @@ export type BridgePortPairOptions = {
serveNativeVerb?: (verb: BridgeNativeVerb, params: unknown) => Promise
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(
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(
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(
},
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(
pageReadyCount: () => pageReadies,
pagePaintCount: () => pagePaints,
backClaims,
+ terminalKeyRailClaims,
routeParamClears: () => routeParamClears,
routeRefusals,
async flush(): Promise {
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts
index beae1b64455..947beef2d4d 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts
@@ -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: [],
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts
index 32b54110d66..fa8a339d005 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts
@@ -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
- // 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['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
+ // 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({
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-terminal-key.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-terminal-key.test.ts
new file mode 100644
index 00000000000..ba77ca16619
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge/bridge-terminal-key.test.ts
@@ -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)
+})
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-terminal-key.ts b/mobile/src/mobile-web-shell/bridge/bridge-terminal-key.ts
new file mode 100644
index 00000000000..8366e96878f
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge/bridge-terminal-key.ts
@@ -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
+
+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
+
+/**
+ * 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])
+}
diff --git a/mobile/src/mobile-web-shell/bridge/page-async-storage.ts b/mobile/src/mobile-web-shell/bridge/page-async-storage.ts
index f797ac1c170..d80e846f6de 100644
--- a/mobile/src/mobile-web-shell/bridge/page-async-storage.ts
+++ b/mobile/src/mobile-web-shell/bridge/page-async-storage.ts
@@ -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>,
+ 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 {
return readWorkspaceKey !== null && isHostWorkspaceStorageKey(key, hostId)
? readWorkspaceKey(key)
diff --git a/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx b/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx
index cb93df8c6c3..116d0f24b99 100644
--- a/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx
+++ b/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx
@@ -78,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,
diff --git a/mobile/src/mobile-web-shell/encoded-route-href.test.ts b/mobile/src/mobile-web-shell/encoded-route-href.test.ts
deleted file mode 100644
index a8b287fa924..00000000000
--- a/mobile/src/mobile-web-shell/encoded-route-href.test.ts
+++ /dev/null
@@ -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')
- })
-})
diff --git a/mobile/src/mobile-web-shell/encoded-route-href.ts b/mobile/src/mobile-web-shell/encoded-route-href.ts
deleted file mode 100644
index 547920d94ea..00000000000
--- a/mobile/src/mobile-web-shell/encoded-route-href.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-type RouteParams = Readonly>
-
-// expo-router's pathname joins raw param values; a worktree id holds slashes, so encode each dynamic segment or the native router matches another screen.
-export function encodedRouteHref(segments: readonly string[], params: RouteParams): string {
- const parts = segments.flatMap((segment) => {
- if (segment.startsWith('(') && segment.endsWith(')')) {
- return []
- }
- const rest = /^\[\.\.\.(.+)\]$/.exec(segment)?.[1]
- const name = rest ?? /^\[(.+)\]$/.exec(segment)?.[1]
- if (name === undefined) {
- return [segment]
- }
- const value = params[name]
- const values = value === undefined ? [] : Array.isArray(value) ? value : [value]
- return values.map(encodeURIComponent)
- })
- return `/${parts.join('/')}`
-}
diff --git a/mobile/src/mobile-web-shell/host-area-in-page-cleanup.test.tsx b/mobile/src/mobile-web-shell/host-area-in-page-cleanup.test.tsx
index 901107fdda1..9729856525d 100644
--- a/mobile/src/mobile-web-shell/host-area-in-page-cleanup.test.tsx
+++ b/mobile/src/mobile-web-shell/host-area-in-page-cleanup.test.tsx
@@ -25,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]) }
diff --git a/mobile/src/mobile-web-shell/host-area-layout-class-change.test.tsx b/mobile/src/mobile-web-shell/host-area-layout-class-change.test.tsx
index 9f2a53fb85a..d6be51f2065 100644
--- a/mobile/src/mobile-web-shell/host-area-layout-class-change.test.tsx
+++ b/mobile/src/mobile-web-shell/host-area-layout-class-change.test.tsx
@@ -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'])
})
})
diff --git a/mobile/src/mobile-web-shell/host-area-leave-session.test.tsx b/mobile/src/mobile-web-shell/host-area-leave-session.test.tsx
index 8931729a753..b2bded1c410 100644
--- a/mobile/src/mobile-web-shell/host-area-leave-session.test.tsx
+++ b/mobile/src/mobile-web-shell/host-area-leave-session.test.tsx
@@ -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()
diff --git a/mobile/src/mobile-web-shell/host-area-session.test.ts b/mobile/src/mobile-web-shell/host-area-session.test.ts
index 7b6bc8e3443..b70f1d59992 100644
--- a/mobile/src/mobile-web-shell/host-area-session.test.ts
+++ b/mobile/src/mobile-web-shell/host-area-session.test.ts
@@ -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', () => {
diff --git a/mobile/src/mobile-web-shell/host-area-sidebar-matrix.test.tsx b/mobile/src/mobile-web-shell/host-area-sidebar-matrix.test.tsx
index 42f740798c7..88ba85220a7 100644
--- a/mobile/src/mobile-web-shell/host-area-sidebar-matrix.test.tsx
+++ b/mobile/src/mobile-web-shell/host-area-sidebar-matrix.test.tsx
@@ -81,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])
})
})
diff --git a/mobile/src/mobile-web-shell/host-area-two-hosts.test.tsx b/mobile/src/mobile-web-shell/host-area-two-hosts.test.tsx
index cdd86a729c9..e01a9bc607f 100644
--- a/mobile/src/mobile-web-shell/host-area-two-hosts.test.tsx
+++ b/mobile/src/mobile-web-shell/host-area-two-hosts.test.tsx
@@ -60,6 +60,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,
diff --git a/mobile/src/mobile-web-shell/host-root-responsive-content.test.tsx b/mobile/src/mobile-web-shell/host-root-responsive-content.test.tsx
new file mode 100644
index 00000000000..ccbfd1b96c6
--- /dev/null
+++ b/mobile/src/mobile-web-shell/host-root-responsive-content.test.tsx
@@ -0,0 +1,43 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { describe, expect, it, vi } from 'vitest'
+
+const viewport = vi.hoisted(() => ({ width: 669, height: 951, ownsHostArea: true }))
+vi.mock('react-native', () => ({ useWindowDimensions: () => viewport }))
+vi.mock('./page-owns-host-area', () => ({ usePageOwnsHostArea: () => viewport.ownsHostArea }))
+vi.mock('../host-screen/HostScreen', () => ({ HostScreen: () => createElement('WorkspacePicker') }))
+vi.mock('../components/WorkspaceDetailPlaceholder', () => ({
+ WorkspaceDetailPlaceholder: () => createElement('WorkspacePlaceholder')
+}))
+
+import HostWorktreeRoute from '../../app/h/[hostId]/index.web'
+
+describe('host root content across layouts', () => {
+ it.each([
+ { width: 390, height: 844, ownsHostArea: true, expected: 'WorkspacePicker' },
+ { width: 669, height: 951, ownsHostArea: true, expected: 'WorkspacePicker' },
+ { width: 951, height: 669, ownsHostArea: true, expected: 'WorkspacePlaceholder' },
+ { width: 951, height: 669, ownsHostArea: false, expected: 'WorkspacePicker' }
+ ])('renders $expected at $width with ownership $ownsHostArea', (state) => {
+ Object.assign(viewport, state)
+ let tree: ReactTestRenderer | undefined
+ act(() => {
+ tree = create(createElement(HostWorktreeRoute))
+ })
+ expect(tree?.toJSON()).toMatchObject({ type: state.expected })
+ act(() => tree?.unmount())
+ })
+
+ it('returns the picker after rotating a host root from landscape to portrait', () => {
+ Object.assign(viewport, { width: 951, height: 669, ownsHostArea: true })
+ let tree: ReactTestRenderer | undefined
+ act(() => {
+ tree = create(createElement(HostWorktreeRoute))
+ })
+ expect(tree?.toJSON()).toMatchObject({ type: 'WorkspacePlaceholder' })
+ Object.assign(viewport, { width: 669, height: 951 })
+ act(() => tree?.update(createElement(HostWorktreeRoute)))
+ expect(tree?.toJSON()).toMatchObject({ type: 'WorkspacePicker' })
+ act(() => tree?.unmount())
+ })
+})
diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-keyboard-overlay-screen.test.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-keyboard-overlay-screen.test.tsx
index 06954ecd598..215fab58b6c 100644
--- a/mobile/src/mobile-web-shell/mobile-web-shell-keyboard-overlay-screen.test.tsx
+++ b/mobile/src/mobile-web-shell/mobile-web-shell-keyboard-overlay-screen.test.tsx
@@ -32,17 +32,23 @@ import {
renderScreen as mountScreen
} from './mobile-web-shell-screen-test-harness'
import { readBridgeHostMessage } from './bridge/bridge-envelope'
-import { MobileWebShellScreen } from './MobileWebShellScreen'
import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract'
import type { ReactTestRenderer } from 'react-test-renderer'
+import type * as ScreenModule from './MobileWebShellScreen'
+
+let MobileWebShellScreen: typeof ScreenModule.MobileWebShellScreen
const renderScreen = (state: MobileWebShellSessionState): Promise =>
mountScreen(MobileWebShellScreen, dependencies, state)
afterEach(harness.unmountRenderedScreens)
-beforeEach(() => {
+beforeEach(async () => {
+ // Each OS case starts a fresh app-lifetime keyboard observer.
+ vi.resetModules()
harness.resetScreenDependencies(dependencies)
+ dependencies.keyboardListeners.clear()
+ MobileWebShellScreen = (await import('./MobileWebShellScreen')).MobileWebShellScreen
})
const WINDOW = { top: 44, right: 0, bottom: 8, left: 0 }
diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-harness.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-harness.tsx
index 79c319fa791..cdb69864590 100644
--- a/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-harness.tsx
+++ b/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-harness.tsx
@@ -24,6 +24,7 @@ export type ScreenDependencies = {
reportPagePainted: Mock
snapshotUnreadable: boolean
storageRefreshes: number
+ appStateListeners: Set<(state: string) => void>
openUrl: Mock
push: Mock
back: Mock
@@ -82,6 +83,7 @@ export function createScreenDependencies(): ScreenDependencies {
reportPagePainted: vi.fn(),
snapshotUnreadable: false,
storageRefreshes: 0,
+ appStateListeners: new Set(),
openUrl: vi.fn(),
push: vi.fn(),
back: vi.fn(),
@@ -124,6 +126,7 @@ export function resetScreenDependencies(dependencies: ScreenDependencies): void
dependencies.reportPagePainted.mockReset()
dependencies.snapshotUnreadable = false
dependencies.storageRefreshes = 0
+ dependencies.appStateListeners.clear()
dependencies.downloadProgress = createDownloadProgressFeed()
dependencies.lifecycle.length = 0
dependencies.viewRenders = 0
diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx
index e66d2d72bd4..c88ae1821ea 100644
--- a/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx
+++ b/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx
@@ -8,6 +8,17 @@ export function screenModuleMocks(dependencies: ScreenDependencies) {
return {
'react-native': () => ({
ActivityIndicator: 'ActivityIndicator',
+ AppState: {
+ currentState: 'active',
+ addEventListener: (_name: string, listener: (state: string) => void) => {
+ dependencies.appStateListeners.add(listener)
+ return {
+ remove: () => {
+ dependencies.appStateListeners.delete(listener)
+ }
+ }
+ }
+ },
AccessibilityInfo: {
isReduceMotionEnabled: () => Promise.resolve(false),
addEventListener: () => ({ remove: () => {} })
@@ -98,16 +109,9 @@ export function screenModuleMocks(dependencies: ScreenDependencies) {
Paths: { cache: 'file:///cache' }
}),
'lucide-react-native': () => ({ X: 'Icon' }),
- '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
- }
- },
+ 'react-native-safe-area-context': () => ({
+ useSafeAreaInsets: () => ({ bottom: 8, left: 0, right: 0, top: 44 })
+ }),
'expo-router': () => ({
router: { replace: vi.fn() },
useRouter: () => ({
@@ -163,20 +167,27 @@ export function screenModuleMocks(dependencies: ScreenDependencies) {
'../transport/host-client-hooks': () => ({
useHostClient: () => ({ client: dependencies.client })
}),
- // Stub host-store input to avoid expo-secure-store’s unavailable global.
- './use-page-host-snapshot': () => ({
- usePageHostSnapshot: () => ({
- // Keep preference identity stable like useState; fresh objects would rebuild the bridge on every render and settle pending requests.
- snapshot: SCREEN_SNAPSHOT,
- unreadable: dependencies.snapshotUnreadable,
- retry: () => {},
- readStorage: () => ({ storage: {}, storageOversize: [] }),
- refreshStorage: () => {
- dependencies.storageRefreshes += 1
- },
- writeStorage: () => {}
- })
- }),
+ // Reaching the real one imports the host store and expo-secure-store, whose module touches an Expo
+ // global this test does not have. What it answers is the screen's input, not its behaviour.
+ './use-page-host-snapshot': () => {
+ const readStorage = () => ({ storage: {}, storageOversize: [] })
+ const refreshStorage = async () => {
+ dependencies.storageRefreshes += 1
+ }
+ const writeStorage = () => {}
+ const retry = () => {}
+ return {
+ usePageHostSnapshot: () => ({
+ // Stable values match the real hook's state and callbacks across screen renders.
+ snapshot: SCREEN_SNAPSHOT,
+ unreadable: dependencies.snapshotUnreadable,
+ retry,
+ readStorage,
+ refreshStorage,
+ writeStorage
+ })
+ }
+ },
'./use-mobile-web-shell-session': () => ({
useMobileWebShellSession: () => ({
state: dependencies.state,
diff --git a/mobile/src/mobile-web-shell/page-fold-division.test.tsx b/mobile/src/mobile-web-shell/page-fold-division.test.tsx
new file mode 100644
index 00000000000..0665b7ca68a
--- /dev/null
+++ b/mobile/src/mobile-web-shell/page-fold-division.test.tsx
@@ -0,0 +1,131 @@
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import type * as ReservedRegions from '../layout/reserved-regions'
+import type * as SideRail from '../layout/side-rail'
+
+const harness = await vi.hoisted(async () => await import('./mobile-web-shell-screen-test-harness'))
+const dependencies = vi.hoisted(() => harness.createScreenDependencies())
+const geometry = vi.hoisted(() => ({
+ root: { x: 669, y: 0, width: 669, height: 951 },
+ insets: { top: 84, right: 0, bottom: 34, left: 0 },
+ regions: { divisions: [], occlusions: [{ x: 470, y: 0, width: 199, height: 84 }] }
+}))
+vi.mock('../layout/reserved-regions', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useReservedRegions: () => geometry.regions
+}))
+vi.mock('../layout/side-rail', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useSideRailStrip: () => null,
+ useSideRailDirectionPadAvailable: () => true
+}))
+
+const { screenModuleMocks } = await vi.hoisted(
+ async () => await import('./mobile-web-shell-screen-test-mocks')
+)
+const mocks = vi.hoisted(() => screenModuleMocks(dependencies))
+vi.mock('react-native', () => ({
+ ...mocks['react-native'](),
+ useWindowDimensions: () => ({ width: 669, height: 951 })
+}))
+vi.mock('expo-clipboard', mocks['expo-clipboard'])
+vi.mock('expo-haptics', mocks['expo-haptics'])
+vi.mock('expo-document-picker', mocks['expo-document-picker'])
+vi.mock('@orca/expo-two-way-audio', mocks['@orca/expo-two-way-audio'])
+vi.mock('expo-keep-awake', mocks['expo-keep-awake'])
+vi.mock('expo-image-picker', mocks['expo-image-picker'])
+vi.mock('expo-file-system', mocks['expo-file-system'])
+vi.mock('lucide-react-native', mocks['lucide-react-native'])
+vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => geometry.insets }))
+vi.mock('expo-router', mocks['expo-router'])
+vi.mock('../../modules/orca-mobile-web-shell/src', mocks['../../modules/orca-mobile-web-shell/src'])
+vi.mock('../app-update/use-wall-app-update', mocks['../app-update/use-wall-app-update'])
+vi.mock('../transport/host-client-hooks', mocks['../transport/host-client-hooks'])
+vi.mock('./use-page-host-snapshot', mocks['./use-page-host-snapshot'])
+vi.mock('./use-mobile-web-shell-session', mocks['./use-mobile-web-shell-session'])
+
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { clientFrame, createFakeRpcClient } from './bridge-host-test-fakes'
+import { byName, readyState } from './mobile-web-shell-screen-test-harness'
+import { readBridgeHostMessage } from './bridge/bridge-envelope'
+import { MobileWebShellScreen } from './MobileWebShellScreen'
+import { topChromeSpace } from '../layout/top-chrome-space'
+
+let tree: ReactTestRenderer | undefined
+beforeEach(() => {
+ harness.resetScreenDependencies(dependencies)
+ dependencies.client = createFakeRpcClient()
+ dependencies.state = readyState('root-layout')
+ geometry.root.x = 669
+})
+afterEach(() => {
+ act(() => tree?.unmount())
+ tree = undefined
+})
+async function mount() {
+ await act(async () => {
+ tree = create(
+ createElement(MobileWebShellScreen, { hostId: 'host-1', route: { pathname: '/h/host-1' } }),
+ {
+ createNodeMock: () => ({
+ measureInWindow: (
+ callback: (x: number, y: number, width: number, height: number) => void
+ ) => {
+ const { x, y, width, height } = geometry.root
+ callback(x, y, width, height)
+ }
+ })
+ }
+ )
+ })
+ await act(async () => {
+ byName(tree!, 'ShellViewProbe')[0].props.onBridgeMessage({
+ nativeEvent: { json: clientFrame({ type: 'ready' }) }
+ })
+ })
+}
+async function layoutRoot() {
+ await act(async () => {
+ tree!.root.find((node) => node.props.testID === 'mobile-web-shell-ready').props.onLayout?.()
+ })
+}
+async function layoutWebView() {
+ await act(async () => {
+ byName(tree!, 'ShellViewProbe')[0].props.onLayout({
+ nativeEvent: { layout: { x: 0, y: 0, width: 669, height: 951 } }
+ })
+ })
+}
+function pageChromeSpace() {
+ const init = dependencies.posted
+ .flatMap((json) => {
+ const read = readBridgeHostMessage(json)
+ return read.ok && read.message.type === 'init' ? [read.message] : []
+ })
+ .at(-1)
+ return topChromeSpace(
+ { x: 0, y: 0, width: 669, height: 88 },
+ init?.safeAreaInsets?.top ?? 0,
+ init?.pageGeometry?.occlusions ?? []
+ )
+}
+
+it('restores the header beside the status area when only the shell root moves', async () => {
+ await mount()
+ await layoutWebView()
+ expect(pageChromeSpace()).toBeNull()
+
+ geometry.root.x = 0
+ await layoutRoot()
+ expect(pageChromeSpace()).toEqual({ left: 0, right: 199, minHeight: 84 })
+ expect(dependencies.lifecycle).toEqual(['mount:root-layout'])
+})
+
+it('waits for the child frame when the root lays out first', async () => {
+ geometry.root.x = 0
+ await mount()
+ await layoutRoot()
+ expect(pageChromeSpace()).toBeNull()
+ await layoutWebView()
+ expect(pageChromeSpace()).toEqual({ left: 0, right: 199, minHeight: 84 })
+})
diff --git a/mobile/src/mobile-web-shell/page-fold-division.ts b/mobile/src/mobile-web-shell/page-fold-division.ts
new file mode 100644
index 00000000000..1410aaed804
--- /dev/null
+++ b/mobile/src/mobile-web-shell/page-fold-division.ts
@@ -0,0 +1,104 @@
+import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'
+import { useWindowDimensions, type LayoutChangeEvent, type View } from 'react-native'
+import { useSafeAreaInsets } from 'react-native-safe-area-context'
+import { useSideRailStrip, useSideRailDirectionPadAvailable } from '../layout/side-rail'
+import { pageOcclusions, type BridgePageGeometry } from './bridge/bridge-page-geometry'
+import { getFoldSplit } from '../layout/fold-division'
+import { useReservedRegions } from '../layout/reserved-regions'
+import type { BridgeFoldDivision } from './bridge/bridge-fold-division'
+import type { BridgeSafeAreaInsets } from './bridge/bridge-safe-area-insets'
+import { measuredPageSafeAreaInsets } from './page-safe-area-insets'
+
+type WindowFrame = { x: number; y: number; width: number; height: number }
+
+/**
+ * The fold splitting the page's WebView, in the page's px. The regions come in window points, so
+ * the view is measured in the window: `rootRef` and `onRootLayout` go on its container,
+ * and `onShellLayout` on the WebView itself.
+ */
+export function usePageFoldDivision(): {
+ division: BridgeFoldDivision | null
+ geometry: BridgePageGeometry
+ safeAreaInsets: BridgeSafeAreaInsets
+ rootRef: RefObject
+ onRootLayout: () => void
+ onShellLayout: (event: LayoutChangeEvent) => void
+} {
+ const { divisions, occlusions, windowSafeArea } = useReservedRegions()
+ const strip = useSideRailStrip()
+ const sideRail = strip !== null
+ const directionPadAvailable = useSideRailDirectionPadAvailable()
+ const rootRef = useRef(null)
+ const layoutRef = useRef(null)
+ const [frame, setFrame] = useState(null)
+ const measure = useCallback(() => {
+ const layout = layoutRef.current
+ if (layout === null) {
+ return
+ }
+ const { x, y, width, height } = layout
+ rootRef.current?.measureInWindow((rootX, rootY) => {
+ const next = { x: rootX + x, y: rootY + y, width, height }
+ setFrame((held) =>
+ held !== null &&
+ held.x === next.x &&
+ held.y === next.y &&
+ held.width === next.width &&
+ held.height === next.height
+ ? held
+ : next
+ )
+ })
+ }, [])
+ const onShellLayout = useCallback(
+ (event: LayoutChangeEvent) => {
+ layoutRef.current = event.nativeEvent.layout
+ measure()
+ },
+ [measure]
+ )
+ // A 180° turn moves the status column across and the root with it, without a new local layout.
+ const windowSize = useWindowDimensions()
+ const insets = useSafeAreaInsets()
+ useEffect(measure, [
+ measure,
+ windowSize.width,
+ windowSize.height,
+ insets.left,
+ insets.right,
+ insets.top,
+ windowSafeArea
+ ])
+ const split = frame === null ? null : getFoldSplit(divisions, frame)
+ const axis = split?.axis
+ const start = split?.start
+ const end = split?.end
+ const division = useMemo(
+ () =>
+ axis === undefined || start === undefined || end === undefined ? null : { axis, start, end },
+ [axis, start, end]
+ )
+ const geometry = useMemo(
+ () => ({
+ sideRail,
+ directionPadAvailable,
+ occlusions: pageOcclusions(occlusions, frame)
+ }),
+ [frame, occlusions, sideRail, directionPadAvailable]
+ )
+ const safeAreaInsets = useMemo(
+ () => measuredPageSafeAreaInsets(windowSafeArea, frame, insets),
+ [windowSafeArea, frame, insets]
+ )
+ return { division, geometry, safeAreaInsets, rootRef, onRootLayout: measure, onShellLayout }
+}
+
+/** Hands a moved fold to the page over the re-sent `init`, as the insets are. */
+export function usePublishedFoldDivision(
+ publish: (division: BridgeFoldDivision | null) => void,
+ division: BridgeFoldDivision | null
+): void {
+ useEffect(() => {
+ publish(division)
+ }, [publish, division])
+}
diff --git a/mobile/src/mobile-web-shell/page-location-report.ts b/mobile/src/mobile-web-shell/page-location-report.ts
deleted file mode 100644
index 5a2e94d9ee3..00000000000
--- a/mobile/src/mobile-web-shell/page-location-report.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// Native routes are the location already; only a page reports its own hops.
-export function useReportedPageLocation(): void {}
diff --git a/mobile/src/mobile-web-shell/page-location-report.web.ts b/mobile/src/mobile-web-shell/page-location-report.web.ts
deleted file mode 100644
index 38f2881950a..00000000000
--- a/mobile/src/mobile-web-shell/page-location-report.web.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { useEffect } from 'react'
-import { useGlobalSearchParams, useSegments } from 'expo-router'
-import { normalizeExecutionHostId } from '../../../src/shared/execution-host'
-import { firstParam } from '../navigation/route-param-reader'
-import { workspaceRouteHref } from '../navigation/workspace-execution-host'
-import { usePageBridgeClientIfPresent } from '../transport/client-context.web'
-import { encodedRouteHref } from './encoded-route-href'
-
-// Report in-document hops while this page owns the host area, so a fold can reopen the same screen natively.
-// Keep executionHost so the reopened workspace stays on its server; one-shot params like paneKey are not replayed.
-export function useReportedPageLocation(): void {
- const client = usePageBridgeClientIfPresent()
- const ownsHostArea = client?.getShellSession()?.ownsHostArea === true
- const params = useGlobalSearchParams()
- const executionHost = normalizeExecutionHostId(firstParam(params.executionHost)) ?? undefined
- const href = workspaceRouteHref(encodedRouteHref(useSegments(), params), executionHost)
- useEffect(() => {
- if (client && ownsHostArea) {
- client.notifyPageLocation(href)
- }
- }, [client, ownsHostArea, href])
-}
diff --git a/mobile/src/mobile-web-shell/page-route-policy.ts b/mobile/src/mobile-web-shell/page-route-policy.ts
index 95e0992442f..78db755504f 100644
--- a/mobile/src/mobile-web-shell/page-route-policy.ts
+++ b/mobile/src/mobile-web-shell/page-route-policy.ts
@@ -1,4 +1,5 @@
import type { MobileWebBundleManifestRead } from '../transport/mobile-web-bundle-reply-schemas'
+import { BRIDGE_TERMINAL_KEY_RAIL_GRANT } from './bridge/bridge-terminal-key'
import { BRIDGE_EXTERNAL_NAVIGATION_GRANT } from './cancelled-navigation-target'
import { BRIDGE_HAPTICS_GRANT } from './bridge/bridge-haptics-notify'
import { BRIDGE_NATIVE_VERB_NAMES } from './bridge/bridge-native-verbs'
@@ -15,7 +16,11 @@ export const MOBILE_WEB_SHELL_GRANTS = [
BRIDGE_HAPTICS_GRANT,
// No page notify exists for cancelled browser navigation; this capability tells the page whether native can open those links externally.
BRIDGE_EXTERNAL_NAVIGATION_GRANT,
- // Derive advertised verbs from the handler table’s tuple so neither side can grow alone.
+ // The iPhone Duo key rail: the shell draws terminal keys in a side inset strip and forwards each
+ // press as a frame, which only a page declaring this knows.
+ BRIDGE_TERMINAL_KEY_RAIL_GRANT,
+ // Spread rather than restated: the verb table is keyed on this same tuple, so a verb cannot be
+ // advertised without a row and a row cannot exist without being advertised.
...BRIDGE_NATIVE_VERB_NAMES
] as const
@@ -108,10 +113,20 @@ export function routeViewOf(
if (!servesWideLayout(entries, pathname, wide)) {
return { pageRoutes: [], pageRouteGrants: [], routeGrants: [], ownsHostArea: false }
}
- const ownsHostArea = wide && pathname === root
- // Omit the host-route pair in wide detail panes so returning to the host opens its owning session instead of another list beside it.
+ const canOwnHostArea =
+ root !== null &&
+ entries.some(
+ (route) =>
+ route.canOwnHostArea === true &&
+ matchesRoutePattern(root, route.pathname) &&
+ (wide || route.canOwnHostAreaAcrossLayouts === true)
+ )
+ // New pages keep ownership across rotation; legacy pages require the wide-only contract.
+ const ownsHostArea = canOwnHostArea && pathname === root
+ // No pair for the host route in an owned detail session, so a hop there goes to the shell, which
+ // opens it owning the area rather than as a second host list in this pane.
const paired =
- wide && !ownsHostArea && root !== null
+ canOwnHostArea && !ownsHostArea && root !== null
? entries.filter((route) => !matchesRoutePattern(root, route.pathname))
: entries
return {
diff --git a/mobile/src/mobile-web-shell/page-safe-area-insets.test.ts b/mobile/src/mobile-web-shell/page-safe-area-insets.test.ts
index e0bd1305405..88b4b84979a 100644
--- a/mobile/src/mobile-web-shell/page-safe-area-insets.test.ts
+++ b/mobile/src/mobile-web-shell/page-safe-area-insets.test.ts
@@ -1,5 +1,5 @@
-import { describe, expect, it } from 'vitest'
-import { pageSafeAreaInsets } from './page-safe-area-insets'
+import { describe, expect, it, vi } from 'vitest'
+import { measuredPageSafeAreaInsets, pageSafeAreaInsets } from './page-safe-area-insets'
const WINDOW = { top: 52, right: 12, bottom: 24, left: 12 }
@@ -12,3 +12,121 @@ describe('the insets the page pads for', () => {
expect(pageSafeAreaInsets({ insets: WINDOW, topCovered: true })).toEqual({ ...WINDOW, top: 0 })
})
})
+
+describe('window-authoritative page insets', () => {
+ const windowSafeArea = {
+ bounds: { x: 0, y: 0, width: 951, height: 669 },
+ insets: { top: 0, right: 84, bottom: 34, left: 0 }
+ }
+ const pageFrame = { x: 0, y: 0, width: 867, height: 669 }
+ const nestedInsets = { top: 18, right: 0, bottom: 34, left: 0 }
+
+ it.each([
+ { width: 466, height: 678, nestedTop: 52 / 3 },
+ { width: 951, height: 669, nestedTop: 18 }
+ ])(
+ 'removes the nested top gap for a $width pt window while keeping bottom protection',
+ ({ width, height, nestedTop }) => {
+ expect(
+ measuredPageSafeAreaInsets(
+ { ...windowSafeArea, bounds: { x: 0, y: 0, width, height } },
+ { x: 0, y: 0, width: width - 84, height },
+ { ...nestedInsets, top: nestedTop }
+ )
+ ).toEqual({
+ top: 0,
+ right: 0,
+ bottom: 34,
+ left: 0
+ })
+ }
+ )
+
+ it('subtracts sidebar and banner offsets already consumed outside the WebView', () => {
+ expect(
+ measuredPageSafeAreaInsets(
+ {
+ bounds: { x: 100, y: 200, width: 951, height: 669 },
+ insets: { top: 84, right: 84, bottom: 34, left: 20 }
+ },
+ { x: 110, y: 240, width: 857, height: 629 },
+ nestedInsets
+ )
+ ).toEqual({
+ top: 44,
+ right: 0,
+ bottom: 34,
+ left: 10
+ })
+ expect(
+ measuredPageSafeAreaInsets(
+ windowSafeArea,
+ {
+ x: 200,
+ y: 20,
+ width: 667,
+ height: 600
+ },
+ nestedInsets
+ )
+ ).toEqual({ top: 0, right: 0, bottom: 0, left: 0 })
+ })
+
+ it('preserves real top exclusions and the banner override without changing bottom protection', () => {
+ const insets = measuredPageSafeAreaInsets(
+ {
+ ...windowSafeArea,
+ insets: { top: 84, right: 0, bottom: 34, left: 0 }
+ },
+ pageFrame,
+ nestedInsets
+ )
+ expect(insets.top).toBe(84)
+ expect(pageSafeAreaInsets({ insets, topCovered: true })).toEqual({
+ top: 0,
+ right: 0,
+ bottom: 34,
+ left: 0
+ })
+ })
+
+ it('uses the existing behavior until geometry arrives, including old native modules', () => {
+ expect(measuredPageSafeAreaInsets(undefined, pageFrame, nestedInsets)).toBe(nestedInsets)
+ expect(measuredPageSafeAreaInsets(windowSafeArea, null, nestedInsets)).toBe(nestedInsets)
+ })
+
+ it('bounds untrusted geometry and logs a malformed producer before falling back', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ try {
+ for (const bounds of [
+ { ...windowSafeArea.bounds, width: 0 },
+ { ...windowSafeArea.bounds, width: 5000 },
+ { ...windowSafeArea.bounds, x: Number.NaN }
+ ]) {
+ expect(
+ measuredPageSafeAreaInsets({ ...windowSafeArea, bounds }, pageFrame, nestedInsets)
+ ).toBe(nestedInsets)
+ }
+ expect(
+ measuredPageSafeAreaInsets(
+ {
+ ...windowSafeArea,
+ insets: { ...windowSafeArea.insets, bottom: 700 }
+ },
+ pageFrame,
+ nestedInsets
+ )
+ ).toBe(nestedInsets)
+ expect(
+ measuredPageSafeAreaInsets(
+ windowSafeArea,
+ { ...pageFrame, height: Number.POSITIVE_INFINITY },
+ nestedInsets
+ )
+ ).toBe(nestedInsets)
+ expect(warn).toHaveBeenCalledTimes(5)
+ } finally {
+ warn.mockRestore()
+ }
+ })
+})
diff --git a/mobile/src/mobile-web-shell/page-safe-area-insets.ts b/mobile/src/mobile-web-shell/page-safe-area-insets.ts
index 66f9243b177..38504620155 100644
--- a/mobile/src/mobile-web-shell/page-safe-area-insets.ts
+++ b/mobile/src/mobile-web-shell/page-safe-area-insets.ts
@@ -1,5 +1,54 @@
import { useEffect } from 'react'
-import type { BridgeSafeAreaInsets } from './bridge/bridge-safe-area-insets'
+import { z } from 'zod'
+import type { ReservedRect, WindowSafeArea } from '../../modules/orca-reserved-regions/src'
+import {
+ BRIDGE_MAX_SAFE_AREA_INSET,
+ BridgeSafeAreaInsetsSchema,
+ type BridgeSafeAreaInsets
+} from './bridge/bridge-safe-area-insets'
+
+const coordinate = z
+ .number()
+ .finite()
+ .min(-BRIDGE_MAX_SAFE_AREA_INSET)
+ .max(BRIDGE_MAX_SAFE_AREA_INSET)
+const extent = z.number().finite().positive().max(BRIDGE_MAX_SAFE_AREA_INSET)
+const frameSchema = z.object({ x: coordinate, y: coordinate, width: extent, height: extent })
+const windowSafeAreaSchema = z
+ .object({ bounds: frameSchema, insets: BridgeSafeAreaInsetsSchema })
+ .refine(
+ ({ bounds, insets }) =>
+ insets.left + insets.right <= bounds.width && insets.top + insets.bottom <= bounds.height,
+ 'Safe area exceeds window bounds'
+ )
+
+/** Insets from the window's safe rectangle, translated into the actual WebView frame. */
+export function measuredPageSafeAreaInsets(
+ windowSafeArea: WindowSafeArea | undefined,
+ frame: ReservedRect | null,
+ fallback: BridgeSafeAreaInsets
+): BridgeSafeAreaInsets {
+ if (windowSafeArea === undefined || frame === null) {
+ return fallback
+ }
+ const window = windowSafeAreaSchema.safeParse(windowSafeArea)
+ const page = frameSchema.safeParse(frame)
+ if (!window.success || !page.success) {
+ console.warn('[web-shell] invalid measured safe-area geometry', {
+ windowIssue: window.success ? undefined : window.error.message,
+ pageIssue: page.success ? undefined : page.error.message
+ })
+ return fallback
+ }
+ const { bounds, insets } = window.data
+ const { x, y, width, height } = page.data
+ return {
+ top: Math.min(height, Math.max(0, bounds.y + insets.top - y)),
+ right: Math.min(width, Math.max(0, x + width - (bounds.x + bounds.width - insets.right))),
+ bottom: Math.min(height, Math.max(0, y + height - (bounds.y + bounds.height - insets.bottom))),
+ left: Math.min(width, Math.max(0, bounds.x + insets.left - x))
+ }
+}
// Give pages window insets like native screens, except status-bar space already consumed by the shell banner.
export function pageSafeAreaInsets(input: {
diff --git a/mobile/src/mobile-web-shell/page-storage-keys-negotiation.test.ts b/mobile/src/mobile-web-shell/page-storage-keys-negotiation.test.ts
new file mode 100644
index 00000000000..88ce4572b4d
--- /dev/null
+++ b/mobile/src/mobile-web-shell/page-storage-keys-negotiation.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it } from 'vitest'
+import { z } from 'zod'
+import { harness } from './bridge-host-test-harness'
+import { clientFrame } from './bridge-host-test-fakes'
+import { createPageReadyFrame } from './bridge/bridge-client-init-handshake'
+import { versionSchema } from './bridge/bridge-frame-fields'
+import { NEGOTIATED_PAGE_STORAGE_KEYS, PAGE_STORAGE_EXACT_KEYS } from './page-storage-keys'
+
+/** The exact keys every page shipped before `storageKeys` refines `init` storage against. */
+const LEGACY_EXACT_KEYS = [
+ 'orca:last-visited-worktree',
+ 'orca:hostSidebarWidth',
+ 'orca:hostDockWidth',
+ 'orca:terminal-accessory-layout',
+ 'orca:custom-accessory-keys',
+ 'orca:defaultSessionView',
+ 'orca:mobileStructuredSendOperations:v1',
+ 'orca:terminalTextScale',
+ 'orca:terminalAutocompleteEnabled',
+ 'orca:terminalLinkOpenMode'
+]
+const LEGACY_PREFIXES = [
+ 'orca:pins:',
+ 'orca:nativeChatTabs:',
+ 'orca:terminalLiveInputDisabled:',
+ 'orca:terminalDrafts:'
+]
+const legacyIsPageStorageKey = (key: string): boolean =>
+ key.length <= 256 &&
+ (LEGACY_EXACT_KEYS.includes(key) ||
+ LEGACY_PREFIXES.some((prefix) => key.startsWith(prefix) && key.length > prefix.length))
+// The older page's `init` storage members, as `BridgeInitStorageSchema` and `storageOversize` read them.
+const LegacyInitStorageSchema = z.object({
+ storage: z
+ .record(z.string().min(1).max(256).refine(legacyIsPageStorageKey), z.string().max(16 * 1024))
+ .refine((entries) => Object.keys(entries).length <= 32)
+ .optional(),
+ storageOversize: z.array(z.string().refine(legacyIsPageStorageKey)).max(32).optional()
+})
+// The `ready` reader every shell shipped before `storageKeys`.
+const LegacyReadySchema = z.object({ v: versionSchema, type: z.literal('ready') })
+
+const SPLIT_KEY = 'orca:terminalSplitLayout'
+const STORAGE = { [SPLIT_KEY]: 'stacked', 'orca:pins:host-a': '["wt-1"]' }
+
+function lastInit(posted: readonly string[]): Record {
+ const init = posted
+ .map((json): unknown => JSON.parse(json))
+ .findLast(
+ (frame): frame is Record =>
+ z.object({ type: z.literal('init') }).safeParse(frame).success
+ )
+ if (init === undefined) {
+ throw new Error('no init was posted')
+ }
+ return init
+}
+
+describe('negotiated page storage keys across page and shell versions', () => {
+ it('names every key added after the legacy allowlist as negotiated', () => {
+ expect(PAGE_STORAGE_EXACT_KEYS.filter((key) => !LEGACY_EXACT_KEYS.includes(key))).toEqual([
+ ...NEGOTIATED_PAGE_STORAGE_KEYS
+ ])
+ })
+
+ it('keeps the split layout out of an older page init, which that page then accepts', () => {
+ const bridge = harness({ storage: STORAGE })
+ bridge.host.receive(clientFrame({ type: 'ready' }))
+
+ const init = lastInit(bridge.posted)
+ expect(LegacyInitStorageSchema.safeParse(init).success).toBe(true)
+ expect(init.storage).toEqual({ 'orca:pins:host-a': '["wt-1"]' })
+ })
+
+ it('keeps an oversize split layout out of an older page init too', () => {
+ const bridge = harness({
+ readStorage: () => ({ storage: {}, storageOversize: [SPLIT_KEY] })
+ })
+ bridge.host.receive(clientFrame({ type: 'ready' }))
+
+ expect(LegacyInitStorageSchema.safeParse(lastInit(bridge.posted)).success).toBe(true)
+ })
+
+ it('hands the split layout to a page whose ready names it', () => {
+ const bridge = harness({ storage: STORAGE })
+ bridge.host.receive(JSON.stringify(createPageReadyFrame()))
+
+ expect(lastInit(bridge.posted).storage).toEqual(STORAGE)
+ expect(bridge.frames().some((frame) => frame.type === 'init')).toBe(true)
+ })
+
+ it('sends a ready an older shell still reads', () => {
+ const ready: unknown = JSON.parse(JSON.stringify(createPageReadyFrame()))
+ expect(LegacyReadySchema.safeParse(ready).success).toBe(true)
+ })
+
+ it('reads a malformed storageKeys list as none rather than refusing the ready', () => {
+ const bridge = harness({ storage: STORAGE })
+ bridge.host.receive(clientFrame({ type: 'ready', storageKeys: [SPLIT_KEY, 7] }))
+
+ expect(bridge.pageReadyCount()).toBe(1)
+ expect(lastInit(bridge.posted).storage).toEqual({ 'orca:pins:host-a': '["wt-1"]' })
+ })
+
+ it('keeps what the page declared across a host rebuild under the same document', () => {
+ const first = harness({ storage: STORAGE })
+ first.host.receive(JSON.stringify(createPageReadyFrame()))
+ const carried = first.host.readSessionBack()
+
+ const rebuilt = harness({ storage: STORAGE, sessionEstablished: true, sessionBack: carried })
+ rebuilt.host.publishKeyboardInset(120)
+ expect(lastInit(rebuilt.posted).storage).toEqual(STORAGE)
+ })
+
+ it('drops the declaration when the next document says ready without one', () => {
+ const bridge = harness({ storage: STORAGE })
+ bridge.host.receive(JSON.stringify(createPageReadyFrame()))
+ bridge.host.receive(clientFrame({ type: 'close' }))
+ bridge.host.receive(clientFrame({ type: 'ready' }))
+
+ expect(lastInit(bridge.posted).storage).toEqual({ 'orca:pins:host-a': '["wt-1"]' })
+ expect(bridge.host.readSessionBack()).toEqual({ claimed: false })
+ })
+})
diff --git a/mobile/src/mobile-web-shell/page-storage-keys.ts b/mobile/src/mobile-web-shell/page-storage-keys.ts
index 1a2364d3807..23f50ea46e3 100644
--- a/mobile/src/mobile-web-shell/page-storage-keys.ts
+++ b/mobile/src/mobile-web-shell/page-storage-keys.ts
@@ -1,4 +1,26 @@
-// Bridge into native storage: Android disables localStorage and iOS origins change per session. Explicit keys prevent pages rewriting pairings/push registrations in the same orca: namespace.
+/**
+ * Which of the app's stored keys the page may read and write, and how much of each.
+ *
+ * AsyncStorage's web build is `window.localStorage`, which the page does not have: Android turns
+ * DOM storage off and on iOS the origin host is the session id, so anything written there is empty
+ * again on the next remount. A pin that silently forgets itself is worse than one that cannot be
+ * set, so the page's store is the app's, reached over the `storage` grant.
+ *
+ * An allowlist and not a passthrough. Everything the app keeps under `orca:` is in one namespace —
+ * paired hosts, push registrations — and a page that could write any of them could rewrite state
+ * the native app owns. A prefix is listed only where the key carries an id the desktop chooses; the
+ * rest are exact.
+ *
+ * Every key here was read off a page route's own closure rather than taken from a list: a key a
+ * screen reads and this file does not name is a preference that silently falls back to its default
+ * inside the page and keeps working outside it, which is the failure the grant exists to end
+ * (rulings-ota-c7.md ruling 7).
+ */
+export const TERMINAL_SHORTCUT_STORAGE_KEYS = [
+ 'orca:terminal-accessory-layout',
+ 'orca:custom-accessory-keys'
+] as const
+
export const PAGE_STORAGE_EXACT_KEYS = [
'orca:last-visited-worktree',
'orca:hostSidebarWidth',
@@ -10,9 +32,40 @@ export const PAGE_STORAGE_EXACT_KEYS = [
'orca:mobileStructuredSendOperations:v1',
'orca:terminalTextScale',
'orca:terminalAutocompleteEnabled',
- 'orca:terminalLinkOpenMode'
+ /** Whether a terminal link opens in Orca's browser or the phone's. */
+ 'orca:terminalLinkOpenMode',
+ /** The split terminal's chosen direction. */
+ 'orca:terminalSplitLayout'
] as const
+/**
+ * Keys added after pages stopped sharing one allowlist with every host. An older page's `init`
+ * reader refines each key against its own list and refuses the whole frame on one it lacks, so
+ * the host sends these only to a page whose `ready` named them in `storageKeys`.
+ */
+export const NEGOTIATED_PAGE_STORAGE_KEYS = ['orca:terminalSplitLayout'] as const
+
+/** Whether a page that declared `storageKeys` (null: declared nothing) reads this key in `init`. */
+export function pageReadsStorageKey(key: string, declared: readonly string[] | null): boolean {
+ return (
+ !NEGOTIATED_PAGE_STORAGE_KEYS.some((negotiated) => negotiated === key) ||
+ (declared?.includes(key) ?? false)
+ )
+}
+
+/** The part of `init` storage the page on the other side can read. */
+export function pageStorageForReader(
+ held: PageStorageForInit,
+ declared: readonly string[] | null
+): PageStorageForInit {
+ return {
+ storage: Object.fromEntries(
+ Object.entries(held.storage).filter(([key]) => pageReadsStorageKey(key, declared))
+ ),
+ storageOversize: held.storageOversize.filter((key) => pageReadsStorageKey(key, declared))
+ }
+}
+
export const PAGE_STORAGE_KEY_PREFIXES = [
'orca:pins:',
'orca:nativeChatTabs:',
diff --git a/mobile/src/mobile-web-shell/terminal-direction-pad.test.tsx b/mobile/src/mobile-web-shell/terminal-direction-pad.test.tsx
new file mode 100644
index 00000000000..32f0eeca363
--- /dev/null
+++ b/mobile/src/mobile-web-shell/terminal-direction-pad.test.tsx
@@ -0,0 +1,197 @@
+import { createElement, createRef, startTransition, Suspense } from 'react'
+import { act, create } from 'react-test-renderer'
+import { afterEach, expect, it, vi } from 'vitest'
+import { TerminalDirectionPad, type TerminalDirectionPadHandle } from './terminal-direction-pad'
+
+const appState = vi.hoisted(() => ({ listener: (_state: string) => {} }))
+vi.mock('react-native', () => ({
+ View: 'View',
+ Text: 'Text',
+ Pressable: 'Pressable',
+ AppState: {
+ addEventListener: (_name: string, listener: (state: string) => void) => {
+ appState.listener = listener
+ return { remove() {} }
+ }
+ },
+ StyleSheet: { create: (styles: T) => styles }
+}))
+
+let renderer: ReturnType | undefined
+afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = undefined
+ vi.useRealTimers()
+})
+const touch = (pageX = 20, pageY = 20) => ({ nativeEvent: { pageX, pageY } })
+function mount() {
+ const send = vi.fn((_key: string) => true)
+ const ref = createRef()
+ const render = (enabled = true) =>
+ act(() => {
+ const element = createElement(TerminalDirectionPad, {
+ ref,
+ width: 68,
+ enabled,
+ onKey: send,
+ onInteractionStart: () => {}
+ })
+ if (renderer) {
+ renderer.update(element)
+ } else {
+ renderer = create(element)
+ }
+ })
+ render()
+ const button = (direction: string) =>
+ renderer!.root.findByProps({ accessibilityLabel: `D-pad ${direction}` })
+ return { send, ref, render, button }
+}
+
+it.each(['up', 'down', 'left', 'right'])(
+ 'sends one %s tap, and repeats a hold without an extra release',
+ async (direction) => {
+ vi.useFakeTimers()
+ const { send, button } = mount()
+ const key = button(direction)
+ act(() => {
+ key.props.onPressIn(touch())
+ key.props.onPressOut()
+ key.props.onPress()
+ })
+ expect(send).toHaveBeenCalledExactlyOnceWith(
+ `arrow${direction[0].toUpperCase()}${direction.slice(1)}`
+ )
+ send.mockClear()
+ act(() => {
+ key.props.onPressIn(touch())
+ key.props.onLongPress()
+ })
+ expect(send).toHaveBeenCalledTimes(1)
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(90)
+ })
+ expect(send).toHaveBeenCalledTimes(3)
+ act(() => {
+ key.props.onPressOut()
+ key.props.onPress()
+ })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1000)
+ })
+ expect(send).toHaveBeenCalledTimes(3)
+ }
+)
+
+it.each(['drag', 'touch cancel', 'background', 'scroll cancel', 'disabled', 'unmount'])(
+ 'stops held input immediately on %s',
+ async (reason) => {
+ vi.useFakeTimers()
+ const { send, ref, render, button } = mount()
+ const key = button('up')
+ act(() => {
+ key.props.onPressIn(touch())
+ key.props.onLongPress()
+ })
+ expect(send).toHaveBeenCalledTimes(1)
+ act(() => {
+ const pad = renderer!.root.findAll(
+ (node) => String(node.type) === 'View' && node.props.onTouchCancel
+ )[0]
+ if (reason === 'drag') {
+ pad.props.onTouchMove(touch(40, 20))
+ } else if (reason === 'touch cancel') {
+ pad.props.onTouchCancel()
+ } else if (reason === 'background') {
+ appState.listener('background')
+ } else if (reason === 'scroll cancel') {
+ ref.current?.cancel()
+ } else if (reason === 'disabled') {
+ render(false)
+ } else {
+ renderer!.unmount()
+ renderer = undefined
+ }
+ })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1000)
+ })
+ expect(send).toHaveBeenCalledTimes(1)
+ if (renderer) {
+ act(() => button('up').props.onPress())
+ expect(send).toHaveBeenCalledTimes(1)
+ }
+ }
+)
+
+it('provides four explicit accessible directions and sends their actions once', () => {
+ const { send, render, button } = mount()
+ for (const direction of ['up', 'down', 'left', 'right']) {
+ const key = button(direction)
+ expect(key.props.accessibilityHint).toContain('Hold to repeat')
+ act(() => {
+ key.props.onAccessibilityTap()
+ key.props.onPress()
+ })
+ }
+ expect(send.mock.calls.map(([key]) => key)).toEqual([
+ 'arrowUp',
+ 'arrowDown',
+ 'arrowLeft',
+ 'arrowRight'
+ ])
+ render(false)
+ expect(button('up').props.accessibilityState.disabled).toBe(true)
+ act(() => button('up').props.onAccessibilityTap())
+ expect(send).toHaveBeenCalledTimes(4)
+})
+
+it.each([true, false])(
+ 'keeps committed D-pad delivery while a transition to enabled=%s is suspended',
+ (nextEnabled) => {
+ const committed = vi.fn((_key: string) => true)
+ const next = vi.fn((_key: string) => true)
+ const pending = new Promise(() => {})
+ function Pause({ suspend }: { suspend: boolean }) {
+ if (suspend) {
+ throw pending
+ }
+ return null
+ }
+ const frame = (onKey: typeof committed, enabled: boolean, suspend: boolean) => (
+
+ {}}
+ />
+
+
+ )
+ act(() => {
+ renderer = create(frame(committed, true, false))
+ })
+ const up = renderer!.root.findByProps({ accessibilityLabel: 'D-pad up' })
+ act(() => {
+ startTransition(() => renderer!.update(frame(next, nextEnabled, true)))
+ })
+ act(() => {
+ up.props.onPressIn(touch())
+ up.props.onPressOut()
+ up.props.onPress()
+ })
+ expect({ committed: committed.mock.calls, uncommitted: next.mock.calls }).toEqual({
+ committed: [['arrowUp']],
+ uncommitted: []
+ })
+ act(() => renderer!.update(frame(next, nextEnabled, false)))
+ act(() => up.props.onAccessibilityTap())
+ expect(committed).toHaveBeenCalledTimes(1)
+ if (nextEnabled) {
+ expect(next).toHaveBeenCalledExactlyOnceWith('arrowUp')
+ } else {
+ expect(next).not.toHaveBeenCalled()
+ }
+ }
+)
diff --git a/mobile/src/mobile-web-shell/terminal-direction-pad.tsx b/mobile/src/mobile-web-shell/terminal-direction-pad.tsx
new file mode 100644
index 00000000000..b0819a12a86
--- /dev/null
+++ b/mobile/src/mobile-web-shell/terminal-direction-pad.tsx
@@ -0,0 +1,172 @@
+import {
+ forwardRef,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useLayoutEffect,
+ useRef,
+ useState
+} from 'react'
+import { AppState, Pressable, Text, View } from 'react-native'
+import {
+ createTerminalAccessoryRepeatController,
+ TERMINAL_ACCESSORY_REPEAT_DELAY_MS,
+ TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS
+} from '../terminal/terminal-accessory-repeat'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+import { themedStyleSheet } from '../theme/themed-styles'
+import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
+import { translate } from '../i18n/mobile-i18n'
+
+export const TERMINAL_DIRECTION_KEYS = ['arrowUp', 'arrowLeft', 'arrowRight', 'arrowDown'] as const
+type DirectionKey = (typeof TERMINAL_DIRECTION_KEYS)[number]
+export type TerminalDirectionPadHandle = { cancel: () => void }
+
+const DIRECTIONS = [
+ { key: 'arrowUp', glyph: '↑' },
+ { key: 'arrowLeft', glyph: '←' },
+ { key: 'arrowRight', glyph: '→' },
+ { key: 'arrowDown', glyph: '↓' }
+] as const
+
+function directionLabel(key: DirectionKey): string {
+ return key === 'arrowUp'
+ ? translate('mobileWebShell.terminalDirectionPad.up', 'D-pad up')
+ : key === 'arrowLeft'
+ ? translate('mobileWebShell.terminalDirectionPad.left', 'D-pad left')
+ : key === 'arrowRight'
+ ? translate('mobileWebShell.terminalDirectionPad.right', 'D-pad right')
+ : translate('mobileWebShell.terminalDirectionPad.down', 'D-pad down')
+}
+
+export const TerminalDirectionPad = forwardRef<
+ TerminalDirectionPadHandle,
+ {
+ enabled: boolean
+ width: number
+ onKey: (key: DirectionKey) => boolean
+ onInteractionStart: () => void
+ }
+>(function TerminalDirectionPad({ enabled, width, onKey, onInteractionStart }, ref) {
+ useMobileColorScheme()
+ const [repeat] = useState(() => createTerminalAccessoryRepeatController())
+ const delivery = useRef({ enabled, onKey })
+ useLayoutEffect(() => {
+ delivery.current = { enabled, onKey }
+ }, [enabled, onKey])
+ const gesture = useRef({ x: 0, y: 0, cancelled: true, repeated: false })
+ const cancel = useCallback(() => {
+ gesture.current.cancelled = true
+ repeat.cancel()
+ }, [repeat])
+ useImperativeHandle(ref, () => ({ cancel }), [cancel])
+ useLayoutEffect(() => {
+ delivery.current.enabled = enabled
+ if (!enabled) {
+ cancel()
+ }
+ return () => {
+ delivery.current.enabled = false
+ cancel()
+ }
+ }, [enabled, cancel])
+ useEffect(() => {
+ const subscription = AppState.addEventListener('change', (state) => {
+ if (state !== 'active') {
+ cancel()
+ }
+ })
+ return () => subscription.remove()
+ }, [cancel])
+ const send = (key: DirectionKey) =>
+ Promise.resolve(delivery.current.enabled && delivery.current.onKey(key))
+ const button = (direction: (typeof DIRECTIONS)[number]) => (
+ [styles.key, pressed && styles.pressed, !enabled && styles.disabled]}
+ onPressIn={(event) => {
+ onInteractionStart()
+ repeat.cancel()
+ gesture.current = {
+ x: event.nativeEvent.pageX,
+ y: event.nativeEvent.pageY,
+ cancelled: !delivery.current.enabled,
+ repeated: false
+ }
+ }}
+ delayLongPress={TERMINAL_ACCESSORY_REPEAT_DELAY_MS}
+ onLongPress={() => {
+ if (!gesture.current.cancelled && delivery.current.enabled) {
+ gesture.current.repeated = true
+ repeat.start(direction.key, send, TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS)
+ }
+ }}
+ onPressOut={() => repeat.stop()}
+ onPress={() => {
+ if (!gesture.current.cancelled && !gesture.current.repeated && delivery.current.enabled) {
+ delivery.current.onKey(direction.key)
+ }
+ }}
+ onAccessibilityTap={() => {
+ onInteractionStart()
+ cancel()
+ if (delivery.current.enabled) {
+ delivery.current.onKey(direction.key)
+ }
+ }}
+ >
+ {direction.glyph}
+
+ )
+ return (
+ {
+ if (
+ Math.hypot(
+ event.nativeEvent.pageX - gesture.current.x,
+ event.nativeEvent.pageY - gesture.current.y
+ ) > 8
+ ) {
+ cancel()
+ }
+ }}
+ >
+ {button(DIRECTIONS[0])}
+
+ {button(DIRECTIONS[1])}
+ {button(DIRECTIONS[2])}
+
+ {button(DIRECTIONS[3])}
+
+ )
+})
+
+const styles = themedStyleSheet(() => ({
+ pad: { alignSelf: 'center', gap: spacing.xs, paddingVertical: spacing.xs },
+ middle: { flexDirection: 'row', gap: spacing.xs },
+ key: {
+ flexGrow: 1,
+ height: 44,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: colors.bgRaised,
+ borderRadius: radii.button
+ },
+ pressed: { backgroundColor: colors.borderSubtle },
+ disabled: { opacity: 0.45 },
+ glyph: {
+ color: colors.textSecondary,
+ fontSize: typography.bodySize,
+ fontFamily: typography.monoFamily
+ }
+}))
diff --git a/mobile/src/mobile-web-shell/terminal-key-rail.test.tsx b/mobile/src/mobile-web-shell/terminal-key-rail.test.tsx
new file mode 100644
index 00000000000..6cd0fce3932
--- /dev/null
+++ b/mobile/src/mobile-web-shell/terminal-key-rail.test.tsx
@@ -0,0 +1,616 @@
+import { createElement, startTransition, Suspense, type ReactNode } from 'react'
+import { ScrollView } from 'react-native'
+import { act, create, type ReactTestInstance } from 'react-test-renderer'
+import { afterEach, expect, it, vi } from 'vitest'
+import type { SideRailStrip } from '../layout/side-rail-strip'
+
+const outlet = vi.hoisted((): { node: ReactNode } => ({ node: null }))
+const appState = vi.hoisted(() => ({ listener: (_state: string) => {}, remove: vi.fn() }))
+const railState = vi.hoisted((): { strip: SideRailStrip | null; capable: boolean } => ({
+ strip: null,
+ capable: true
+}))
+vi.mock('react-native', () => ({
+ View: 'View',
+ Text: 'Text',
+ Pressable: 'Pressable',
+ ScrollView: 'ScrollView',
+ AppState: {
+ addEventListener: (_event: string, listener: (state: string) => void) => {
+ appState.listener = listener
+ return { remove: appState.remove }
+ }
+ },
+ StyleSheet: { create: (styles: T) => styles }
+}))
+vi.mock('lucide-react-native', () => ({
+ Keyboard: 'KeyboardIcon',
+ ChevronDown: 'ChevronDown',
+ Monitor: 'Monitor',
+ Smartphone: 'Smartphone',
+ ChevronsRight: 'ChevronsRight',
+ Ellipsis: 'Ellipsis'
+}))
+vi.mock('expo-router', async () => ({ useFocusEffect: (await import('react')).useEffect }))
+vi.mock('../layout/side-rail', () => ({
+ useSideRailContent: (node: ReactNode) => {
+ outlet.node = node
+ },
+ useSideRailDirectionPadAvailable: () => railState.capable,
+ useSideRailStrip: () => railState.strip
+}))
+import { TerminalKeyRail, terminalKeyRailKeys } from './terminal-key-rail'
+import { useShellKeyRail, usePublishedShellKeyRail } from './use-shell-key-rail'
+import type { BridgeTerminalToolbar } from './bridge/bridge-terminal-key'
+
+const SHELL_TOOLBAR: BridgeTerminalToolbar = {
+ canSend: true,
+ canCompose: true,
+ keyboardVisible: false,
+ liveInputEnabled: true,
+ phoneDisplayMode: false,
+ runsAgent: false,
+ modifiers: { ctrl: 'off', alt: 'off', shift: 'off' },
+ directionPadEnabled: false
+}
+
+let renderer: ReturnType | undefined
+afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = undefined
+ railState.strip = null
+ railState.capable = true
+ vi.useRealTimers()
+})
+
+it.each(['left', 'right'] as const)(
+ '%s rail pins the pad when it fits and scrolls it above the keyboard when short',
+ async (side) => {
+ vi.useFakeTimers()
+ railState.strip = { side, width: 84, height: 500, top: 0, bottom: 0 }
+ const toolbar = { ...SHELL_TOOLBAR, directionPadEnabled: true }
+ const send = vi.fn(() => true)
+ let keys = terminalKeyRailKeys(500, toolbar)
+ const render = () =>
+ act(() => {
+ const element = createElement(TerminalKeyRail, {
+ keys,
+ toolbar,
+ onKey: send
+ })
+ if (renderer) {
+ renderer.update(element)
+ } else {
+ renderer = create(element)
+ }
+ })
+ render()
+ const scroll = renderer!.root.findByType(ScrollView)
+ expect(renderer!.root.findAllByProps({ accessibilityLabel: 'D-pad up' })).toHaveLength(1)
+ expect(scroll.findAllByProps({ accessibilityLabel: 'D-pad up' })).toHaveLength(0)
+ expect(scroll.findAllByProps({ accessibilityLabel: 'Arrow Up' })).toHaveLength(0)
+ const up = renderer!.root.findByProps({ accessibilityLabel: 'D-pad up' })
+ act(() => {
+ up.props.onPressIn(touch(20))
+ up.props.onLongPress()
+ scroll.props.onScrollBeginDrag()
+ up.props.onPressOut()
+ up.props.onPress()
+ })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1000)
+ })
+ expect(send).toHaveBeenCalledExactlyOnceWith('arrowUp')
+ railState.strip = { ...railState.strip, height: 145 }
+ render()
+ const shortScroll = renderer!.root.findByType(ScrollView)
+ const shortUp = shortScroll.findByProps({ accessibilityLabel: 'D-pad up' })
+ const sentBefore = send.mock.calls.length
+ act(() => {
+ shortUp.props.onPressIn(touch(20))
+ shortScroll.props.onTouchMove(touch(22))
+ shortUp.props.onPressOut()
+ shortUp.props.onPress()
+ })
+ expect(send).toHaveBeenCalledTimes(sentBefore + 1)
+ expect(send).toHaveBeenLastCalledWith('arrowUp')
+ act(() => {
+ shortUp.props.onPressIn(touch(20))
+ shortScroll.props.onScrollBeginDrag()
+ shortUp.props.onPressOut()
+ shortUp.props.onPress()
+ })
+ expect(send).toHaveBeenCalledTimes(sentBefore + 1)
+ expect(shortScroll.findAllByProps({ accessibilityLabel: 'Arrow Up' })).toHaveLength(0)
+ toolbar.directionPadEnabled = false
+ render()
+ expect(renderer!.root.findAllByProps({ accessibilityLabel: 'D-pad up' })).toHaveLength(0)
+ toolbar.directionPadEnabled = true
+ railState.capable = false
+ railState.strip = { ...railState.strip, height: 500, side: 'left' }
+ render()
+ expect(renderer!.root.findAllByProps({ accessibilityLabel: 'D-pad up' })).toHaveLength(0)
+ }
+)
+function mount() {
+ const send = vi.fn((_key: string) => true)
+ act(() => {
+ renderer = create(
+ createElement(TerminalKeyRail, {
+ keys: terminalKeyRailKeys(100, SHELL_TOOLBAR),
+ toolbar: SHELL_TOOLBAR,
+ onKey: send
+ })
+ )
+ })
+ const root = renderer!.root
+ const key = (label: string): ReactTestInstance => root.findByProps({ accessibilityLabel: label })
+ return { send, root, key }
+}
+const touch = (pageY: number) => ({ nativeEvent: { pageX: 20, pageY } })
+it("lists the page key bar's shell and agent rows, whatever the strip's height", () => {
+ const controls = ['toggleKeyboard', 'toggleDisplayMode', 'toggleInputMode']
+ const arrows = ['arrowUp', 'arrowDown', 'arrowLeft', 'arrowRight']
+ const shellRow = [
+ 'escape',
+ 'ctrl',
+ 'tab',
+ 'alt',
+ 'shift',
+ 'char:/',
+ 'char:|',
+ 'char:~',
+ 'char:-',
+ 'char::'
+ ]
+ const agentRow = ['escape', 'shiftTab', 'enter', 'ctrlC', 'ctrl', 'char:/', 'char:@', 'char:!']
+ expect(terminalKeyRailKeys(100, SHELL_TOOLBAR)).toEqual([
+ ...controls,
+ ...shellRow,
+ ...arrows,
+ 'moreKeys'
+ ])
+ expect(terminalKeyRailKeys(100, { ...SHELL_TOOLBAR, runsAgent: true })).toEqual([
+ ...controls,
+ ...agentRow,
+ ...arrows,
+ 'moreKeys'
+ ])
+ expect(terminalKeyRailKeys(0, SHELL_TOOLBAR)).toEqual([])
+ expect(terminalKeyRailKeys(100)).toEqual([])
+})
+
+it('uses a vertical scroll view and sends a tap only on release', () => {
+ const { send, root, key } = mount()
+ expect(root.findByType(ScrollView).props.horizontal).not.toBe(true)
+ const arrow = key('Arrow Up')
+ act(() => arrow.props.onPressIn(touch(20)))
+ expect(send).not.toHaveBeenCalled()
+ act(() => {
+ arrow.props.onPressOut()
+ arrow.props.onPress()
+ })
+ expect(send).toHaveBeenCalledExactlyOnceWith('arrowUp')
+})
+it('cancels a swipe starting on a key before it can send or repeat', async () => {
+ vi.useFakeTimers()
+ const { send, root, key } = mount()
+ const arrow = key('Arrow Up')
+ act(() => arrow.props.onPressIn(touch(20)))
+ act(() => root.findByType(ScrollView).props.onTouchMove(touch(40)))
+ act(() => {
+ arrow.props.onLongPress()
+ arrow.props.onPressOut()
+ arrow.props.onPress()
+ })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1000)
+ })
+ expect(send).not.toHaveBeenCalled()
+})
+it('repeats a held key and cancels on drag, release, and unmount without an extra tap', async () => {
+ vi.useFakeTimers()
+ const { send, root, key } = mount()
+ const arrow = key('Arrow Down')
+ act(() => {
+ arrow.props.onPressIn(touch(20))
+ arrow.props.onLongPress()
+ })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(445)
+ })
+ expect(send.mock.calls.length).toBeGreaterThan(1)
+ act(() => root.findByType(ScrollView).props.onScrollBeginDrag())
+ const count = send.mock.calls.length
+ act(() => {
+ arrow.props.onPressOut()
+ arrow.props.onPress()
+ })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1000)
+ })
+ expect(send).toHaveBeenCalledTimes(count)
+ act(() => {
+ arrow.props.onPressIn(touch(20))
+ arrow.props.onLongPress()
+ })
+ act(() => renderer?.unmount())
+ renderer = undefined
+ const afterUnmount = send.mock.calls.length
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1000)
+ })
+ expect(send).toHaveBeenCalledTimes(afterUnmount)
+})
+
+it.each(['release', 'background', 'touch cancel'])('stops a held repeat on %s', async (reason) => {
+ vi.useFakeTimers()
+ const { send, root, key } = mount()
+ const arrow = key('Arrow Up')
+ act(() => {
+ arrow.props.onPressIn(touch(20))
+ arrow.props.onLongPress()
+ })
+ expect(send).toHaveBeenCalledTimes(1)
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(45)
+ })
+ expect(send).toHaveBeenCalledTimes(2)
+ act(() => {
+ if (reason === 'release') {
+ arrow.props.onPressOut()
+ } else if (reason === 'background') {
+ appState.listener('background')
+ } else {
+ root.findByType(ScrollView).props.onTouchCancel()
+ }
+ arrow.props.onPress()
+ })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1000)
+ })
+ expect(send).toHaveBeenCalledTimes(2)
+})
+it('does not send a non-repeatable key during a swipe', () => {
+ const { send, root, key } = mount()
+ const escape = key('Escape')
+ act(() => {
+ escape.props.onPressIn(touch(20))
+ root.findByType(ScrollView).props.onTouchMove(touch(40))
+ escape.props.onPressOut()
+ escape.props.onPress()
+ })
+ expect(send).not.toHaveBeenCalled()
+})
+it.each([
+ ['Escape', 'escape'],
+ ['Tab', 'tab'],
+ ['Control', 'ctrl'],
+ ['Type ~', 'char:~']
+])('sends held non-repeatable %s on release under native long-press suppression', (label, id) => {
+ const { send, key } = mount()
+ const button = key(label)
+ act(() => {
+ button.props.onPressIn(touch(20))
+ button.props.onLongPress?.()
+ button.props.onPressOut()
+ // RN Pressability suppresses release onPress after a long press when a handler exists.
+ if (button.props.onLongPress == null) {
+ button.props.onPress()
+ }
+ })
+ expect(send).toHaveBeenCalledExactlyOnceWith(id)
+})
+
+it('renders the bar controls, ⋯ and sticky modifiers in the same scroll view', () => {
+ const send = vi.fn((_key: string) => true)
+ const render = (next: BridgeTerminalToolbar) =>
+ act(() => {
+ const element = createElement(TerminalKeyRail, {
+ keys: terminalKeyRailKeys(100, next),
+ toolbar: next,
+ onKey: send
+ })
+ if (renderer) {
+ renderer.update(element)
+ } else {
+ renderer = create(element)
+ }
+ })
+ render(SHELL_TOOLBAR)
+ const key = (label: string) => renderer!.root.findByProps({ accessibilityLabel: label })
+ const keysMode = 'Keys mode: typing goes straight to the terminal. Switch to Message mode'
+ const press = (label: string) =>
+ act(() => {
+ const button = key(label)
+ button.props.onPressIn(touch(20))
+ button.props.onPressOut()
+ button.props.onPress()
+ })
+ press('Show keyboard')
+ press('Phone width')
+ press(keysMode)
+ press('Control')
+ press('More keys and actions')
+ expect(send.mock.calls.map(([key]) => key)).toEqual([
+ 'toggleKeyboard',
+ 'toggleDisplayMode',
+ 'toggleInputMode',
+ 'ctrl',
+ 'moreKeys'
+ ])
+ const latchStyle = (label: string) =>
+ renderer!.root
+ .findByProps({ accessibilityLabel: label })
+ .props.style({ pressed: false })
+ .filter(Boolean)
+ render({ ...SHELL_TOOLBAR, modifiers: { ctrl: 'once', alt: 'locked', shift: 'off' } })
+ expect(key('Control').props.accessibilityState).toEqual({ selected: true })
+ expect(key('Alt').props.accessibilityState).toEqual({ selected: true })
+ expect(key('Shift').props.accessibilityState).toEqual({ selected: false })
+ expect(latchStyle('Control')).not.toEqual(latchStyle('Alt'))
+ render({ ...SHELL_TOOLBAR, keyboardVisible: true, canSend: false })
+ expect(key('Dismiss keyboard').props.disabled).toBe(false)
+ expect(key('Phone width').props.disabled).toBe(true)
+ expect(key('Control').props.disabled).toBe(true)
+ expect(key('More keys and actions').props.disabled).toBeFalsy()
+})
+
+it('lists the arrows only while the D-pad is off', () => {
+ railState.strip = { side: 'right', width: 84, height: 500, top: 0, bottom: 0 }
+ let toolbar: BridgeTerminalToolbar = {
+ ...SHELL_TOOLBAR,
+ runsAgent: true,
+ directionPadEnabled: true
+ }
+ const send = vi.fn(() => true)
+ const render = () =>
+ act(() => {
+ const element = createElement(TerminalKeyRail, {
+ keys: terminalKeyRailKeys(500, toolbar),
+ toolbar,
+ onKey: send
+ })
+ if (renderer) {
+ renderer.update(element)
+ } else {
+ renderer = create(element)
+ }
+ })
+ render()
+ const labels = () =>
+ renderer!.root
+ .findByType(ScrollView)
+ .findAll((node) => String(node.type) === 'Pressable')
+ .map((node) => node.props.accessibilityLabel)
+ expect(labels()).not.toContain('Arrow Up')
+ expect(labels()).toContain('Interrupt terminal')
+ act(() => {
+ const up = renderer!.root.findByProps({ accessibilityLabel: 'D-pad up' })
+ up.props.onPressIn(touch(20))
+ up.props.onPressOut()
+ up.props.onPress()
+ })
+ expect(send).toHaveBeenCalledWith('arrowUp')
+ toolbar = { ...toolbar, directionPadEnabled: false }
+ render()
+ expect(renderer!.root.findAllByProps({ accessibilityLabel: 'D-pad up' })).toHaveLength(0)
+ expect(labels()).toContain('Arrow Up')
+})
+
+function mountRailHost() {
+ railState.strip = { side: 'right', width: 84, height: 500, top: 0, bottom: 0 }
+ const bridge = { publishTerminalKeyRail: vi.fn(), sendTerminalKey: vi.fn(() => true) }
+ let reportClaim: ReturnType['reportClaim'] = () => {}
+ function TerminalKeyRailHost({ overlay = false }: { overlay?: boolean }) {
+ const rail = useShellKeyRail({
+ sessionId: 'session',
+ overlay,
+ routeGrants: ['terminalKeyRail']
+ })
+ reportClaim = rail.reportClaim
+ usePublishedShellKeyRail(rail.keys, bridge, rail.inputOwner, rail.toolbar)
+ return outlet.node
+ }
+ act(() => {
+ renderer = create(createElement(TerminalKeyRailHost))
+ })
+ const toolbar = SHELL_TOOLBAR
+ const claim = (owner = 'pane-a', next = toolbar) =>
+ act(() => {
+ reportClaim(true, owner, structuredClone(next))
+ })
+ claim()
+ const arrow = () => renderer!.root.findByProps({ accessibilityLabel: 'Arrow Up' })
+ return {
+ bridge,
+ claim,
+ arrow,
+ toolbar,
+ reportClaim,
+ refresh: () => act(() => renderer!.update(createElement(TerminalKeyRailHost))),
+ cover: () => act(() => renderer!.update(createElement(TerminalKeyRailHost, { overlay: true })))
+ }
+}
+
+it('keeps the scroll view mounted when the focused pane changes', () => {
+ const h = mountRailHost()
+ const scroll = renderer!.root.findByType(ScrollView)
+ h.claim('pane-b')
+ expect(renderer!.root.findByType(ScrollView)).toBe(scroll)
+ const arrow = h.arrow()
+ act(() => {
+ arrow.props.onPressIn(touch(20))
+ arrow.props.onPressOut()
+ arrow.props.onPress()
+ })
+ expect(h.bridge.sendTerminalKey).toHaveBeenCalledExactlyOnceWith('arrowUp', 'pane-b')
+})
+
+it('delivers a release tap across an equivalent serialized rail claim', () => {
+ const h = mountRailHost()
+ const arrow = h.arrow()
+ act(() => arrow.props.onPressIn(touch(20)))
+ h.claim()
+ act(() => {
+ arrow.props.onPressOut()
+ arrow.props.onPress()
+ })
+ expect(h.bridge.sendTerminalKey).toHaveBeenCalledExactlyOnceWith('arrowUp', 'pane-a')
+})
+
+it('continues held repeats across an equivalent serialized rail claim', async () => {
+ vi.useFakeTimers()
+ const h = mountRailHost()
+ act(() => {
+ h.arrow().props.onPressIn(touch(20))
+ h.arrow().props.onLongPress()
+ })
+ h.claim()
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(90)
+ })
+ expect(h.bridge.sendTerminalKey).toHaveBeenCalledTimes(3)
+ expect(h.bridge.sendTerminalKey).toHaveBeenLastCalledWith('arrowUp', 'pane-a')
+ act(() => {
+ h.arrow().props.onPressOut()
+ h.arrow().props.onPress()
+ })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(90)
+ })
+ expect(h.bridge.sendTerminalKey).toHaveBeenCalledTimes(3)
+})
+
+it.each(['owner', 'toolbar', 'keys', 'release', 'overlay', 'layout'])(
+ 'cancels held rail input on a real %s change',
+ async (change) => {
+ vi.useFakeTimers()
+ const h = mountRailHost()
+ act(() => {
+ h.arrow().props.onPressIn(touch(20))
+ h.arrow().props.onLongPress()
+ })
+ if (change === 'owner') {
+ h.claim('pane-b')
+ }
+ if (change === 'toolbar') {
+ h.claim('pane-a', { ...h.toolbar, canSend: false })
+ }
+ if (change === 'keys') {
+ h.claim('pane-a', { ...h.toolbar, runsAgent: true })
+ }
+ if (change === 'release') {
+ act(() => h.reportClaim(false))
+ }
+ if (change === 'overlay') {
+ h.cover()
+ }
+ if (change === 'layout') {
+ railState.strip = { side: 'right', width: 84, height: 140, top: 0, bottom: 0 }
+ h.refresh()
+ }
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(90)
+ })
+ expect(h.bridge.sendTerminalKey).toHaveBeenCalledExactlyOnceWith('arrowUp', 'pane-a')
+ }
+)
+
+it('cancels held input when only the owner changes and keys stay identical', async () => {
+ vi.useFakeTimers()
+ railState.strip = { side: 'right', width: 84, height: 500, top: 0, bottom: 0 }
+ const toolbar = SHELL_TOOLBAR
+ const keys = terminalKeyRailKeys(500, toolbar)
+ const send = vi.fn(() => true)
+ const render = (inputOwner: string) =>
+ act(() => {
+ const node = createElement(TerminalKeyRail, { keys, toolbar, onKey: send, inputOwner })
+ if (renderer) {
+ renderer.update(node)
+ } else {
+ renderer = create(node)
+ }
+ })
+ render('pane-a')
+ const arrow = renderer!.root.findByProps({ accessibilityLabel: 'Arrow Up' })
+ act(() => {
+ arrow.props.onPressIn(touch(20))
+ arrow.props.onLongPress()
+ })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(90)
+ })
+ expect(send).toHaveBeenCalledTimes(3)
+ const sent = send.mock.calls.length
+ render('pane-b')
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(180)
+ })
+ act(() => {
+ arrow.props.onPressOut()
+ arrow.props.onPress()
+ })
+ expect(send).toHaveBeenCalledTimes(sent)
+})
+
+it('keeps the committed shell sender while a bridge transition is suspended', () => {
+ railState.strip = { side: 'right', width: 84, height: 500, top: 0, bottom: 0 }
+ const committed = vi.fn((_key: string, _owner?: string) => true)
+ const next = vi.fn((_key: string, _owner?: string) => false)
+ const publish = vi.fn()
+ const keys = terminalKeyRailKeys(500, SHELL_TOOLBAR)
+ const pending = new Promise(() => {})
+ function Host({ send, suspend = false }: { send: typeof committed; suspend?: boolean }) {
+ usePublishedShellKeyRail(
+ keys,
+ { publishTerminalKeyRail: publish, sendTerminalKey: send },
+ 'pane-a',
+ SHELL_TOOLBAR
+ )
+ if (suspend) {
+ throw pending
+ }
+ return outlet.node
+ }
+ act(() => {
+ renderer = create(
+
+
+
+ )
+ })
+ const arrow = renderer!.root.findByProps({ accessibilityLabel: 'Arrow Up' })
+ act(() => {
+ startTransition(() =>
+ renderer!.update(
+
+
+
+ )
+ )
+ })
+ act(() => {
+ arrow.props.onPressIn(touch(20))
+ arrow.props.onPressOut()
+ arrow.props.onPress()
+ })
+ expect({ committed: committed.mock.calls, uncommitted: next.mock.calls }).toEqual({
+ committed: [['arrowUp', 'pane-a']],
+ uncommitted: []
+ })
+ act(() =>
+ renderer!.update(
+
+
+
+ )
+ )
+ act(() => {
+ arrow.props.onPressIn(touch(20))
+ arrow.props.onPressOut()
+ arrow.props.onPress()
+ })
+ expect(committed).toHaveBeenCalledTimes(1)
+ expect(next).toHaveBeenCalledExactlyOnceWith('arrowUp', 'pane-a')
+})
diff --git a/mobile/src/mobile-web-shell/terminal-key-rail.tsx b/mobile/src/mobile-web-shell/terminal-key-rail.tsx
new file mode 100644
index 00000000000..049916cbceb
--- /dev/null
+++ b/mobile/src/mobile-web-shell/terminal-key-rail.tsx
@@ -0,0 +1,368 @@
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
+import { AppState, Pressable, ScrollView, Text, View } from 'react-native'
+import {
+ Keyboard,
+ ChevronDown,
+ Ellipsis,
+ Monitor,
+ Smartphone,
+ ChevronsRight
+} from 'lucide-react-native'
+import { hasTerminalDirectionPadSpace } from '../layout/side-rail-strip'
+import { useSideRailStrip, useSideRailDirectionPadAvailable } from '../layout/side-rail'
+import { TERMINAL_ACCESSORY_KEY_DEFINITIONS } from '../terminal/terminal-key-definitions'
+import {
+ createTerminalAccessoryRepeatController,
+ TERMINAL_ACCESSORY_REPEAT_DELAY_MS,
+ TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS
+} from '../terminal/terminal-accessory-repeat'
+import { useMobileTranslation } from '../i18n/use-mobile-translation'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+import { themedStyleSheet } from '../theme/themed-styles'
+import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
+import { TerminalDirectionPad, type TerminalDirectionPadHandle } from './terminal-direction-pad'
+import type {
+ BridgeTerminalToolbar,
+ BridgeTerminalKeyId,
+ BridgeTerminalKeyRail
+} from './bridge/bridge-terminal-key'
+
+const KEY_HEIGHT = 40
+
+const ARROW_IDS = ['arrowUp', 'arrowDown', 'arrowLeft', 'arrowRight'] as const
+// The same rows the page's key bar shows, so the rail is that bar stood on end.
+const SHELL_ROW: readonly BridgeTerminalKeyId[] = [
+ 'escape',
+ 'ctrl',
+ 'tab',
+ 'alt',
+ 'shift',
+ ...['/', '|', '~', '-', ':'].map((character) => `char:${character}` as const)
+]
+const AGENT_ROW: readonly BridgeTerminalKeyId[] = [
+ 'escape',
+ 'shiftTab',
+ 'enter',
+ 'ctrlC',
+ 'ctrl',
+ ...['/', '@', '!'].map((character) => `char:${character}` as const)
+]
+
+/** Every key remains reachable by scrolling, regardless of the strip's height. */
+export function terminalKeyRailKeys(
+ height: number,
+ toolbar?: BridgeTerminalToolbar
+): BridgeTerminalKeyRail {
+ if (height <= 0 || !toolbar) {
+ return []
+ }
+ return [
+ 'toggleKeyboard',
+ 'toggleDisplayMode',
+ 'toggleInputMode',
+ ...(toolbar.runsAgent ? AGENT_ROW : SHELL_ROW),
+ ...ARROW_IDS,
+ 'moreKeys'
+ ]
+}
+
+type RailKey = {
+ id: BridgeTerminalKeyId
+ label: string
+ accessibilityLabel: string
+ repeatable?: boolean
+ disabled?: boolean
+ active?: boolean
+ held?: boolean
+}
+
+/**
+ * The page's terminal keys stood on end in a side inset strip, for a window that has one: the
+ * iPhone Duo's status column leaves the rest of its strip empty. Each press goes to the page, which
+ * sends it as its own key bar would; arrows repeat while held, as there.
+ */
+export function TerminalKeyRail({
+ keys,
+ onKey,
+ toolbar,
+ inputOwner
+}: {
+ keys: BridgeTerminalKeyRail
+ onKey: (key: BridgeTerminalKeyId) => boolean
+ toolbar?: BridgeTerminalToolbar
+ inputOwner?: string | null
+}) {
+ useMobileColorScheme()
+ const t = useMobileTranslation()
+ const [repeat] = useState(() => createTerminalAccessoryRepeatController())
+ const padRef = useRef(null)
+ const strip = useSideRailStrip()
+ const padAvailable = useSideRailDirectionPadAvailable()
+ const pinPad = hasTerminalDirectionPadSpace(strip)
+ const showPad =
+ strip !== null && padAvailable && toolbar !== undefined && toolbar.directionPadEnabled !== false
+ const gesture = useRef({ x: 0, y: 0, cancelled: false, repeated: false })
+ const cancelGesture = useCallback(() => {
+ gesture.current.cancelled = true
+ repeat.cancel()
+ padRef.current?.cancel()
+ }, [repeat])
+ useLayoutEffect(cancelGesture, [cancelGesture, inputOwner])
+ useEffect(() => cancelGesture, [cancelGesture, keys, showPad, pinPad])
+ useEffect(() => {
+ const subscription = AppState.addEventListener('change', (state) => {
+ if (state !== 'active') {
+ cancelGesture()
+ }
+ })
+ return () => subscription.remove()
+ }, [cancelGesture])
+ const send = (key: BridgeTerminalKeyId) => Promise.resolve(onKey(key))
+ const canSend = toolbar?.canSend ?? false
+ const sendKeyLabel = (label: string) =>
+ t('session.accessoryKeys.sendKey', 'Send {{label}}', { label })
+ const railKey = (id: BridgeTerminalKeyId): RailKey | null => {
+ if (id === 'toggleKeyboard') {
+ return {
+ id,
+ label: '',
+ accessibilityLabel: toolbar?.keyboardVisible
+ ? t('session.accessoryKeys.dismissKeyboard', 'Dismiss keyboard')
+ : t('session.accessoryKeys.showKeyboard', 'Show keyboard'),
+ disabled:
+ !toolbar?.keyboardVisible &&
+ !(toolbar?.liveInputEnabled ? toolbar.canSend : toolbar?.canCompose)
+ }
+ }
+ if (id === 'toggleDisplayMode') {
+ return {
+ id,
+ label: '',
+ accessibilityLabel: toolbar?.phoneDisplayMode
+ ? t('session.terminalMoreKeys.desktopWidth', 'Desktop width')
+ : t('session.terminalMoreKeys.phoneWidth', 'Phone width'),
+ disabled: !canSend
+ }
+ }
+ if (id === 'toggleInputMode') {
+ return {
+ id,
+ label: '',
+ accessibilityLabel: toolbar?.liveInputEnabled
+ ? t(
+ 'session.accessoryKeys.keysModeLabel',
+ 'Keys mode: typing goes straight to the terminal. Switch to Message mode'
+ )
+ : t(
+ 'session.accessoryKeys.messageModeLabel',
+ 'Message mode: write, then send. Switch to Keys mode'
+ ),
+ disabled: !toolbar?.canCompose,
+ active: toolbar?.liveInputEnabled
+ }
+ }
+ if (id === 'moreKeys') {
+ return {
+ id,
+ label: '',
+ accessibilityLabel: t('session.accessoryKeys.moreKeys', 'More keys and actions')
+ }
+ }
+ if (id === 'ctrl' || id === 'alt' || id === 'shift') {
+ const latch = toolbar?.modifiers[id] ?? 'off'
+ return {
+ id,
+ label: id === 'ctrl' ? 'Ctrl' : id === 'alt' ? 'Alt' : 'Shift',
+ accessibilityLabel:
+ id === 'ctrl'
+ ? t('session.accessoryKeys.control', 'Control')
+ : id === 'alt'
+ ? t('session.accessoryKeys.alt', 'Alt')
+ : t('session.accessoryKeys.shift', 'Shift'),
+ disabled: !canSend,
+ held: latch === 'once',
+ active: latch === 'locked'
+ }
+ }
+ if (id.startsWith('char:')) {
+ const character = id.slice('char:'.length)
+ return {
+ id,
+ label: character,
+ accessibilityLabel: t('session.accessoryKeys.typeCharacter', 'Type {{character}}', {
+ character
+ }),
+ disabled: !canSend
+ }
+ }
+ if (id === 'ctrlC') {
+ return {
+ id,
+ label: t('mobileWebShell.terminalKeyRail.ctrlC', 'Ctrl+C'),
+ accessibilityLabel: t('session.accessoryKeys.interrupt', 'Interrupt terminal'),
+ disabled: !canSend
+ }
+ }
+ const definition = TERMINAL_ACCESSORY_KEY_DEFINITIONS.find((key) => key.id === id)
+ return definition
+ ? {
+ id,
+ label: definition.label,
+ accessibilityLabel: definition.accessibilityLabel ?? sendKeyLabel(definition.label),
+ repeatable: definition.repeatable,
+ disabled: !canSend
+ }
+ : null
+ }
+ // The pad sends the arrows while it shows, so the column lists them only without it.
+ const renderedKeys = keys.flatMap((id) => {
+ if (showPad && ARROW_IDS.some((arrow) => arrow === id)) {
+ return []
+ }
+ const key = railKey(id)
+ return key ? [key] : []
+ })
+ const pad =
+ showPad && strip && toolbar ? (
+
+ ) : null
+ return (
+
+ {
+ const { pageX, pageY } = event.nativeEvent
+ if (
+ !gesture.current.cancelled &&
+ Math.hypot(pageX - gesture.current.x, pageY - gesture.current.y) > 8
+ ) {
+ cancelGesture()
+ }
+ }}
+ >
+ {!pinPad && pad}
+ {renderedKeys.map((key) => (
+ [
+ styles.key,
+ key.held && styles.keyHeld,
+ key.active && styles.keyActive,
+ pressed && styles.keyPressed,
+ key.disabled && styles.keyDisabled
+ ]}
+ disabled={key.disabled}
+ onPressIn={(event) => {
+ repeat.cancel()
+ padRef.current?.cancel()
+ gesture.current = {
+ x: event.nativeEvent.pageX,
+ y: event.nativeEvent.pageY,
+ cancelled: false,
+ repeated: false
+ }
+ }}
+ delayLongPress={TERMINAL_ACCESSORY_REPEAT_DELAY_MS}
+ onLongPress={
+ key.repeatable
+ ? () => {
+ if (!gesture.current.cancelled) {
+ gesture.current.repeated = true
+ repeat.start(key.id, send, TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS)
+ }
+ }
+ : undefined
+ }
+ onPressOut={() => {
+ if (key.repeatable) {
+ repeat.stop()
+ }
+ }}
+ onPress={() => {
+ if (!gesture.current.cancelled && !gesture.current.repeated) {
+ onKey(key.id)
+ }
+ }}
+ accessibilityRole="button"
+ accessibilityLabel={key.accessibilityLabel}
+ accessibilityState={
+ key.id === 'ctrl' || key.id === 'alt' || key.id === 'shift'
+ ? { selected: Boolean(key.held || key.active) }
+ : undefined
+ }
+ >
+ {key.id === 'toggleKeyboard' ? (
+
+
+ {toolbar?.keyboardVisible && }
+
+ ) : key.id === 'toggleDisplayMode' ? (
+ toolbar?.phoneDisplayMode ? (
+
+ ) : (
+
+ )
+ ) : key.id === 'toggleInputMode' ? (
+
+ ) : key.id === 'moreKeys' ? (
+
+ ) : (
+
+ {key.label}
+
+ )}
+
+ ))}
+
+ {pinPad && pad}
+
+ )
+}
+
+const styles = themedStyleSheet(() => ({
+ viewport: {
+ flex: 1
+ },
+ rail: {
+ alignItems: 'stretch',
+ gap: spacing.xs,
+ paddingHorizontal: spacing.sm,
+ paddingVertical: spacing.xs
+ },
+ key: {
+ height: KEY_HEIGHT,
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingHorizontal: spacing.xs,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgRaised
+ },
+ keyActive: { backgroundColor: colors.textPrimary },
+ keyHeld: { borderWidth: 1, borderColor: colors.textPrimary },
+ keyDisabled: { opacity: 0.45 },
+ keyboardGlyph: { alignItems: 'center' },
+ keyPressed: {
+ backgroundColor: colors.borderSubtle
+ },
+ keyText: {
+ color: colors.textSecondary,
+ fontSize: typography.metaSize,
+ fontFamily: typography.monoFamily
+ },
+ keyTextActive: { color: colors.bgBase }
+}))
diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts
index a152fec4b55..11bb81496e3 100644
--- a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts
+++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts
@@ -136,6 +136,9 @@ function Harness(props: {
route: { pathname: '/h/host-1' },
safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 },
keyboardInset: 0,
+ foldDivision: null,
+ pageGeometry: { occlusions: [], sideRail: false },
+ terminalKeyRail: [],
colorScheme: 'dark',
uiLocale: 'en',
pageRoutes: ['/h/[hostId]'],
@@ -172,7 +175,8 @@ function Harness(props: {
props.probe.paints += 1
},
onPageBackClaim: (claimed) => props.probe.backClaims.push(claimed),
- onPageLocation: () => {}
+ onPageLocation: () => {},
+ onTerminalKeyRailClaim: () => {}
})
props.probe.view = view
return props.session.kind === 'ready'
diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts
index 2a8fb6a1d46..ad36702cba9 100644
--- a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts
+++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts
@@ -1,3 +1,9 @@
+import type {
+ BridgeTerminalToolbar,
+ BridgeTerminalKeyId,
+ BridgeTerminalKeyRail
+} from './bridge/bridge-terminal-key'
+import type { BridgePageGeometry } from './bridge/bridge-page-geometry'
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'
import type {
MobileWebShellBridgeMessagePayload,
@@ -10,6 +16,7 @@ import { recordShellLifecycleLine } from './shell-lifecycle-line-recorder'
import type { BridgeColorScheme, BridgeInitRoute, BridgeUiLocale } 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 { createBridgeHost, type BridgeHost } from './bridge-host'
import type { BridgeSessionBack } from './bridge-host-back'
@@ -41,11 +48,24 @@ export type MobileWebShellBridgeView = {
readonly bridgeEnabled: boolean
readonly viewRef: (handle: OrcaMobileWebShellViewHandle | null) => void
readonly onBridgeMessage: (event: MobileWebShellBridgeMessageEvent) => void
- // Without a host, drop updates; host construction reads the latest route.
+ /**
+ * Hands the mounted host a rewritten route for the screen it is already serving. Dropped when
+ * there is no host yet; the route the host is built from carries it instead.
+ */
+ readonly publishStorage: () => void
readonly publishRoute: (route: BridgeInitRoute) => void
readonly publishSafeAreaInsets: (insets: BridgeSafeAreaInsets) => void
readonly publishKeyboardInset: (height: number) => void
- // Return false if no host/document can take Back so the navigator keeps the press.
+ /** Hands the mounted host a moved fold; dropped with no host, as the insets are. */
+ readonly publishFoldDivision: (division: BridgeFoldDivision | null) => void
+ /** Tells the mounted host the key rail shows or hides; dropped with no host. */
+ readonly publishTerminalKeyRail: (keys: BridgeTerminalKeyRail) => void
+ /** Hands the page one rail press. False with no host, or a page not granted the rail. */
+ readonly sendTerminalKey: (key: BridgeTerminalKeyId, inputOwner?: string) => boolean
+ /**
+ * Hands the mounted host one Back press. False when there is no host, or when the document it
+ * serves never said it takes one — the caller then leaves the key to the navigator.
+ */
readonly sendBack: () => boolean
}
@@ -57,8 +77,14 @@ export type MobileWebShellBridgeArgs = {
route: BridgeInitRoute
safeAreaInsets: BridgeSafeAreaInsets
keyboardInset: number
+ /** What the first `init` of a new host carries; later moves go through `publishFoldDivision`. */
+ pageGeometry?: BridgePageGeometry
+ foldDivision: BridgeFoldDivision | null
+ /** What the first `init` of a new host carries; later moves go through `publishTerminalKeyRail`. */
+ terminalKeyRail: BridgeTerminalKeyRail
colorScheme: BridgeColorScheme
uiLocale: BridgeUiLocale
+ /** The route patterns the page keeps for itself; everything else comes back as `navigate`. */
pageRoutes: readonly string[]
pageRouteGrants: readonly { pathname: string; grants: readonly string[] }[]
// Effective session grants include the served-route union when this page owns the host area.
@@ -79,6 +105,13 @@ export type MobileWebShellBridgeArgs = {
// Document resets explicitly publish false so retired pages cannot retain Back.
onPageBackClaim: (claimed: boolean) => void
onPageLocation: (href: string) => void
+ /** The page shows a terminal with its key row, or stopped; false again on each new document. */
+ onTerminalKeyRailClaim: (
+ claimed: boolean,
+ inputOwner?: string,
+ toolbar?: BridgeTerminalToolbar
+ ) => void
+ /** The page applied a one-shot route param and asks for it to be erased (ruling 34). */
onRouteParamClear: (param: BridgeClearableRouteParam, value: string) => void
onRouteRefused: (issue: string) => void
onBinaryFramesDropped: (total: number) => void
@@ -122,6 +155,9 @@ export function useMobileWebShellBridge(args: MobileWebShellBridgeArgs): MobileW
route: latest.route,
safeAreaInsets: latest.safeAreaInsets,
keyboardInset: latest.keyboardInset,
+ pageGeometry: latest.pageGeometry,
+ foldDivision: latest.foldDivision,
+ terminalKeyRail: latest.terminalKeyRail,
colorScheme: latest.colorScheme,
uiLocale: latest.uiLocale,
pageRoutes: latest.pageRoutes,
@@ -160,7 +196,9 @@ export function useMobileWebShellBridge(args: MobileWebShellBridgeArgs): MobileW
onPageReady: () => argsRef.current.onPageReady(),
onPagePainted: () => argsRef.current.onPagePainted(),
onPageBackClaim: (claimed) => argsRef.current.onPageBackClaim(claimed),
- onPageLocation: (href) => argsRef.current.onPageLocation(href)
+ onPageLocation: (href) => argsRef.current.onPageLocation(href),
+ onTerminalKeyRailClaim: (claimed, inputOwner, toolbar) =>
+ argsRef.current.onTerminalKeyRailClaim(claimed, inputOwner, toolbar)
})
hostRef.current = { sessionId, host }
// Reset host-owned drop totals on rebuild so a new lower count cannot look like frames being recovered.
@@ -173,6 +211,13 @@ export function useMobileWebShellBridge(args: MobileWebShellBridgeArgs): MobileW
}
}, [buildId, client, sessionId, snapshot])
+ useEffect(() => {
+ const mounted = hostRef.current
+ if (mounted !== null && mounted.sessionId === sessionId && args.pageGeometry !== undefined) {
+ mounted.host.publishPageGeometry(args.pageGeometry)
+ }
+ }, [buildId, client, sessionId, snapshot, args.pageGeometry])
+
// A rebuilt host already starts from the latest scheme; this re-sends init when it changes under a live page.
const colorScheme = args.colorScheme
useEffect(() => {
@@ -222,7 +267,19 @@ export function useMobileWebShellBridge(args: MobileWebShellBridgeArgs): MobileW
},
[sessionId]
),
- // Fence updates by session and change callback identity with all host inputs; callers must retry after late host construction, whose layout effect runs first.
+ // Fenced on the session the same way inbound frames are: a host left over from a session this
+ // render has moved past must not be handed this one's route.
+ //
+ // Keyed on everything the host is built from, not on the session alone: a caller that holds a
+ // route the host was not there to take retries when this identity changes, and the host's own
+ // effect is a layout effect, so by the time a passive effect sees the new identity the host
+ // behind it exists.
+ publishStorage: useCallback(() => {
+ const mounted = hostRef.current
+ if (mounted !== null && mounted.sessionId === sessionId) {
+ mounted.host.publishStorage()
+ }
+ }, [buildId, client, sessionId, snapshot]),
publishRoute: useCallback(
(route: BridgeInitRoute) => {
const mounted = hostRef.current
@@ -250,7 +307,38 @@ export function useMobileWebShellBridge(args: MobileWebShellBridgeArgs): MobileW
},
[buildId, client, sessionId, snapshot]
),
- // Keep the session fence and return false when forwarding is unavailable so hardware Back falls through to navigation.
+ publishFoldDivision: useCallback(
+ (division: BridgeFoldDivision | null) => {
+ const mounted = hostRef.current
+ if (mounted !== null && mounted.sessionId === sessionId) {
+ mounted.host.publishFoldDivision(division)
+ }
+ },
+ [buildId, client, sessionId, snapshot]
+ ),
+ publishTerminalKeyRail: useCallback(
+ (keys: BridgeTerminalKeyRail) => {
+ const mounted = hostRef.current
+ if (mounted !== null && mounted.sessionId === sessionId) {
+ mounted.host.publishTerminalKeyRail(keys)
+ }
+ },
+ [buildId, client, sessionId, snapshot]
+ ),
+ sendTerminalKey: useCallback(
+ (key: BridgeTerminalKeyId, inputOwner?: string) => {
+ const mounted = hostRef.current
+ return (
+ mounted !== null &&
+ mounted.sessionId === sessionId &&
+ mounted.host.sendTerminalKey(key, inputOwner)
+ )
+ },
+ [buildId, client, sessionId, snapshot]
+ ),
+ // Fenced on the session the way inbound frames are, and answering false rather than throwing
+ // when there is no host: the caller is a hardware key handler, and a press it cannot forward
+ // is one the navigator has to be left to answer.
sendBack: useCallback(() => {
const mounted = hostRef.current
return mounted !== null && mounted.sessionId === sessionId && mounted.host.sendBack()
diff --git a/mobile/src/mobile-web-shell/use-shell-key-rail.tsx b/mobile/src/mobile-web-shell/use-shell-key-rail.tsx
new file mode 100644
index 00000000000..b7f2c290ab2
--- /dev/null
+++ b/mobile/src/mobile-web-shell/use-shell-key-rail.tsx
@@ -0,0 +1,106 @@
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { useFocusEffect } from 'expo-router'
+import { useSideRailContent, useSideRailStrip } from '../layout/side-rail'
+import {
+ sameTerminalKeyRailToolbar,
+ BRIDGE_TERMINAL_KEY_RAIL_GRANT,
+ type BridgeTerminalToolbar,
+ type BridgeTerminalKeyId,
+ type BridgeTerminalKeyRail
+} from './bridge/bridge-terminal-key'
+import { TerminalKeyRail, terminalKeyRailKeys } from './terminal-key-rail'
+
+const NO_RAIL_KEYS: BridgeTerminalKeyRail = []
+
+/**
+ * The keys this shell screen's scrollable rail shows: when the window has one, the
+ * page is open on the focused screen, was granted the rail, which only a session route is, and
+ * claims it, which it does only while a terminal and its key row are on screen. Empty otherwise.
+ */
+export function useShellKeyRail(args: {
+ /** The session on screen, or null while none is ready. A claim belongs to the one that made it. */
+ sessionId: string | null
+ overlay: boolean
+ routeGrants: readonly string[]
+}): {
+ keys: BridgeTerminalKeyRail
+ inputOwner: string | undefined
+ toolbar: BridgeTerminalToolbar | undefined
+ reportClaim: (claimed: boolean, inputOwner?: string, toolbar?: BridgeTerminalToolbar) => void
+} {
+ const { sessionId } = args
+ const height = useSideRailStrip()?.height ?? null
+ const [claim, setClaim] = useState<{
+ sessionId: string | null
+ inputOwner?: string
+ toolbar?: BridgeTerminalToolbar
+ } | null>(null)
+ // Focus rather than mount: the stack keeps this screen mounted under whatever is pushed over it.
+ const [focused, setFocused] = useState(false)
+ useFocusEffect(
+ useCallback(() => {
+ setFocused(true)
+ return () => setFocused(false)
+ }, [])
+ )
+ const took =
+ sessionId !== null &&
+ claim?.sessionId === sessionId &&
+ focused &&
+ !args.overlay &&
+ args.routeGrants.includes(BRIDGE_TERMINAL_KEY_RAIL_GRANT)
+ const keys = useMemo(
+ () => (took && height !== null ? terminalKeyRailKeys(height, claim?.toolbar) : NO_RAIL_KEYS),
+ [took, height, claim?.toolbar]
+ )
+ const reportClaim = useCallback(
+ (claimed: boolean, inputOwner?: string, toolbar?: BridgeTerminalToolbar) =>
+ setClaim((previous) => {
+ if (!claimed) {
+ return null
+ }
+ // Equivalent init reassertions must not cancel a key already held on the rail.
+ return previous?.sessionId === sessionId &&
+ previous.inputOwner === inputOwner &&
+ sameTerminalKeyRailToolbar(previous.toolbar, toolbar)
+ ? previous
+ : { sessionId, inputOwner, toolbar }
+ }),
+ [sessionId]
+ )
+ return { keys, inputOwner: claim?.inputOwner, toolbar: claim?.toolbar, reportClaim }
+}
+
+/** Draws the rail and tells the page which keys it holds, so the page drops just those. */
+export function usePublishedShellKeyRail(
+ keys: BridgeTerminalKeyRail,
+ bridge: {
+ publishTerminalKeyRail: (keys: BridgeTerminalKeyRail) => void
+ sendTerminalKey: (key: BridgeTerminalKeyId, inputOwner?: string) => boolean
+ },
+ inputOwner?: string,
+ toolbar?: BridgeTerminalToolbar
+): void {
+ const { publishTerminalKeyRail } = bridge
+ useEffect(() => {
+ publishTerminalKeyRail(keys)
+ }, [publishTerminalKeyRail, keys])
+ // Held, so a new bridge identity does not rebuild the node, which would show the rail again.
+ const sendRef = useRef(bridge.sendTerminalKey)
+ useLayoutEffect(() => {
+ sendRef.current = bridge.sendTerminalKey
+ }, [bridge.sendTerminalKey])
+ const node = useMemo(
+ () =>
+ keys.length > 0 ? (
+ sendRef.current(key, inputOwner)}
+ />
+ ) : null,
+ [keys, inputOwner, toolbar]
+ )
+ useSideRailContent(node)
+}
diff --git a/mobile/src/mobile-web-shell/use-shell-page-storage-refresh.test.tsx b/mobile/src/mobile-web-shell/use-shell-page-storage-refresh.test.tsx
new file mode 100644
index 00000000000..8ad4c8d9c9e
--- /dev/null
+++ b/mobile/src/mobile-web-shell/use-shell-page-storage-refresh.test.tsx
@@ -0,0 +1,156 @@
+import { createElement, useEffect } from 'react'
+import { act, create } from 'react-test-renderer'
+import { beforeEach, expect, it, vi } from 'vitest'
+import { useShellPageStorageRefresh } from './use-shell-page-storage-refresh'
+import { useSerializedStoredState } from '../storage/use-serialized-stored-state'
+import {
+ persistMirrored,
+ hydrateMirroredStorage,
+ readMirroredStorage,
+ retainMirroredStorage,
+ writeMirroredStorage
+} from '../storage/mirrored-storage-keys'
+import { TERMINAL_SHORTCUT_STORAGE_KEYS } from './page-storage-keys'
+
+const store = vi.hoisted(() => ({
+ values: new Map(),
+ pending: new Array<() => void>()
+}))
+vi.mock('@react-native-async-storage/async-storage', () => ({
+ default: {
+ setItem: (key: string, value: string) =>
+ new Promise((resolve) =>
+ store.pending.push(() => {
+ store.values.set(key, value)
+ resolve()
+ })
+ ),
+ getItem: async (key: string) => store.values.get(key) ?? null,
+ multiGet: async (keys: string[]) => keys.map((key) => [key, store.values.get(key) ?? null])
+ }
+}))
+const focus = vi.hoisted(() => ({ active: true }))
+const app = vi.hoisted(() => ({ listener: (_state: string) => {}, remove: vi.fn() }))
+vi.mock('expo-router', () => ({
+ useFocusEffect: (effect: () => void | (() => void)) =>
+ useEffect(() => (focus.active ? effect() : undefined), [effect, focus.active])
+}))
+vi.mock('react-native', () => ({
+ AppState: {
+ addEventListener: (_event: string, listener: (state: string) => void) => {
+ app.listener = listener
+ return { remove: app.remove }
+ }
+ }
+}))
+
+beforeEach(() => {
+ focus.active = true
+ app.remove.mockClear()
+ store.values.clear()
+ store.pending.length = 0
+})
+
+it('refreshes before publishing on native return and foreground, and drops a blurred completion', async () => {
+ let finish!: () => void
+ const refresh = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ finish = resolve
+ })
+ )
+ const publish = vi.fn()
+ function Harness() {
+ useShellPageStorageRefresh(refresh, publish)
+ return null
+ }
+ let renderer!: ReturnType
+ act(() => {
+ renderer = create(createElement(Harness))
+ })
+ expect(publish).not.toHaveBeenCalled()
+ focus.active = false
+ act(() => renderer.update(createElement(Harness)))
+ await act(async () => finish())
+ expect(publish).not.toHaveBeenCalled()
+ focus.active = true
+ act(() => renderer.update(createElement(Harness)))
+ await act(async () => finish())
+ expect(publish).toHaveBeenCalledTimes(1)
+ act(() => app.listener('active'))
+ await act(async () => finish())
+ expect(publish).toHaveBeenCalledTimes(2)
+ act(() => renderer.unmount())
+ expect(app.remove).toHaveBeenCalledTimes(2)
+})
+
+it.each(TERMINAL_SHORTCUT_STORAGE_KEYS)(
+ 'publishes the final queued native save for %s, only while focused',
+ async (key) => {
+ focus.active = false
+ store.values.set(key, 'initial')
+ // The shell screen holds its route's keys while mounted, as use-page-host-snapshot does.
+ const releaseKeys = retainMirroredStorage([key])
+ let page = 'initial'
+ const save = vi.fn((value: string) => persistMirrored(key, value))
+ const load = async () => store.values.get(key) ?? 'initial'
+ const refresh = () => hydrateMirroredStorage([key])
+ const publish = vi.fn(() => {
+ page = readMirroredStorage([key])[key]!
+ })
+ let settings!: ReturnType>
+ function Harness() {
+ settings = useSerializedStoredState('initial', load, save)
+ useShellPageStorageRefresh(refresh, publish)
+ return null
+ }
+ let renderer!: ReturnType
+ await act(async () => {
+ renderer = create(createElement(Harness))
+ })
+ await act(async () => {
+ settings.persist('first-toggle')
+ settings.persist('second-toggle')
+ })
+ expect(save).toHaveBeenCalledTimes(1)
+ focus.active = true
+ await act(async () => {
+ store.pending.shift()!()
+ renderer.update(createElement(Harness))
+ })
+ expect(page).toBe('first-toggle')
+ expect(save).toHaveBeenCalledTimes(2)
+ await act(async () => store.pending.shift()!())
+ expect(store.values.get(key)).toBe('second-toggle')
+ expect(page).toBe('second-toggle')
+
+ const completed = publish.mock.calls.length
+ writeMirroredStorage(key, 'page-local-edit')
+ await act(async () => store.pending.shift()!())
+ expect(publish).toHaveBeenCalledTimes(completed)
+ const unrelated = persistMirrored('orca:hostDockWidth', '450')
+ await act(async () => {
+ store.pending.shift()!()
+ await unrelated
+ })
+ expect(publish).toHaveBeenCalledTimes(completed)
+
+ focus.active = false
+ act(() => renderer.update(createElement(Harness)))
+ await act(async () => settings.persist('saved-while-covered'))
+ await act(async () => store.pending.shift()!())
+ expect(publish).toHaveBeenCalledTimes(completed)
+ focus.active = true
+ await act(async () => renderer.update(createElement(Harness)))
+ expect(page).toBe('saved-while-covered')
+ act(() => renderer.unmount())
+ const afterUnmount = publish.mock.calls.length
+ const later = persistMirrored(key, 'later-session')
+ await act(async () => {
+ store.pending.shift()!()
+ await later
+ })
+ expect(publish).toHaveBeenCalledTimes(afterUnmount)
+ releaseKeys()
+ }
+)
diff --git a/mobile/src/mobile-web-shell/use-shell-page-storage-refresh.ts b/mobile/src/mobile-web-shell/use-shell-page-storage-refresh.ts
new file mode 100644
index 00000000000..dbb10c8256d
--- /dev/null
+++ b/mobile/src/mobile-web-shell/use-shell-page-storage-refresh.ts
@@ -0,0 +1,43 @@
+import { useCallback } from 'react'
+import { AppState } from 'react-native'
+import { useFocusEffect } from 'expo-router'
+import { subscribeMirroredKey } from '../storage/mirrored-storage-keys'
+import { TERMINAL_SHORTCUT_STORAGE_KEYS } from './page-storage-keys'
+
+export function useShellPageStorageRefresh(refresh: () => Promise, publish: () => void) {
+ useFocusEffect(
+ useCallback(() => {
+ let focused = true
+ const refreshAndPublish = () => {
+ void refresh().then(
+ () => {
+ if (focused) {
+ publish()
+ }
+ },
+ (error: unknown) => console.warn('[web-shell] could not refresh page settings', error)
+ )
+ }
+ const stopWatching = TERMINAL_SHORTCUT_STORAGE_KEYS.map((key) =>
+ subscribeMirroredKey(key, (_value, source) => {
+ if (focused && source === 'persisted') {
+ publish()
+ }
+ })
+ )
+ refreshAndPublish()
+ const sub = AppState.addEventListener('change', (state) => {
+ if (state === 'active') {
+ refreshAndPublish()
+ }
+ })
+ return () => {
+ focused = false
+ for (const stop of stopWatching) {
+ stop()
+ }
+ sub.remove()
+ }
+ }, [refresh, publish])
+ )
+}
diff --git a/mobile/src/navigation/host-stack.web.tsx b/mobile/src/navigation/host-stack.web.tsx
index 2446b4e7db7..59dd3f4fd13 100644
--- a/mobile/src/navigation/host-stack.web.tsx
+++ b/mobile/src/navigation/host-stack.web.tsx
@@ -7,7 +7,6 @@ import { themedStyleSheet } from '../theme/themed-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { HOST_STACK_SCREENS, type HostStackAnimation } from './host-stack-screens'
import { isOverlayPageRoute } from '../new-workspace/overlay-page-route'
-import { SideRailOnTopContext } from '../layout/side-rail'
// Expo native-stack’s web view ignores animation and switches display; reuse StackRouter with a view that retains popped screens through their exit slide.
export function HostStack({ animation }: { animation: HostStackAnimation }) {
@@ -101,9 +100,7 @@ function HostStackView({ animation }: { animation: HostStackAnimation }) {
motion={leaving ? 'exit' : entering ? 'enter' : null}
onSettled={settle}
>
-
- {descriptor?.render()}
-
+ {descriptor?.render()}
)
})}
diff --git a/mobile/src/navigation/native-root-recovery.test.tsx b/mobile/src/navigation/native-root-recovery.test.tsx
index eb0570a58d4..b0dc1714043 100644
--- a/mobile/src/navigation/native-root-recovery.test.tsx
+++ b/mobile/src/navigation/native-root-recovery.test.tsx
@@ -88,6 +88,9 @@ vi.mock('../components/OrcaLogo', () => ({ OrcaLogo: () => null }))
vi.mock('../layout/side-safe-area', () => ({
SideSafeArea: ({ children }: { children: ReactNode }) => children
}))
+vi.mock('../layout/side-rail', () => ({
+ SideRailProvider: ({ children }: { children: ReactNode }) => children
+}))
vi.mock('../theme/mobile-appearance', () => ({ startMobileAppearance: async () => {} }))
vi.mock('../i18n/use-follow-device-ui-locale', () => ({ useFollowDeviceUiLocale: () => {} }))
vi.mock('../i18n/ui-language-preference', () => ({ startMobileUiLanguage: async () => {} }))
diff --git a/mobile/src/navigation/route-handoff-host-area.web.test.tsx b/mobile/src/navigation/route-handoff-host-area.web.test.tsx
index 4456ee8a9fd..5509a4b8c8e 100644
--- a/mobile/src/navigation/route-handoff-host-area.web.test.tsx
+++ b/mobile/src/navigation/route-handoff-host-area.web.test.tsx
@@ -36,7 +36,8 @@ const ROUTES: MobileWebPageRoute[] = [
{
pathname: '/h/[hostId]',
grants: ['navigate', 'storage', 'externalLink', 'haptics'],
- canOwnHostArea: true
+ canOwnHostArea: true,
+ canOwnHostAreaAcrossLayouts: true
},
{
pathname: '/h/[hostId]/tasks',
@@ -160,18 +161,11 @@ describe('a worktree row in the host-area page', () => {
expect(page.navigations()).toEqual([])
})
- it('is still handed to the shell from a phone host page, whose grants do not cover it', () => {
+ it('keeps a workspace in the same declaring host document on a narrow viewport too', () => {
const page = mountPage('/h/host-1', false)
page.handoff.push('/h/host-1/session/wt-1')
- expect(router.push).not.toHaveBeenCalled()
- expect(page.navigations()).toEqual([
- {
- v: BRIDGE_PROTOCOL_VERSION,
- type: 'notify',
- name: 'navigate',
- href: '/h/host-1/session/wt-1'
- }
- ])
+ expect(router.push).toHaveBeenCalledWith('/h/host-1/session/wt-1', undefined)
+ expect(page.navigations()).toEqual([])
})
})
@@ -188,10 +182,12 @@ describe('a session page opened on its own, leaving for the host route', () => {
expect(routeViewOf(ROUTES, '/h/host-1', true).ownsHostArea).toBe(true)
})
- it('keeps it in this document on a phone, as before', () => {
+ it('returns a narrow detail document to the same shell-owned host area', () => {
const page = mountPage('/h/host-1/session/wt-1', false)
page.handoff.replace(hop)
- expect(router.replace).toHaveBeenCalledWith(hop, undefined)
- expect(page.navigations()).toEqual([])
+ expect(router.replace).not.toHaveBeenCalled()
+ expect(page.navigations()).toEqual([
+ { v: BRIDGE_PROTOCOL_VERSION, type: 'notify', name: 'navigate', href: hop, replace: true }
+ ])
})
})
diff --git a/mobile/src/platform/keyboard-occlusion.test.tsx b/mobile/src/platform/keyboard-occlusion.test.tsx
index 8b1b4b3cf2c..bc76e26b14f 100644
--- a/mobile/src/platform/keyboard-occlusion.test.tsx
+++ b/mobile/src/platform/keyboard-occlusion.test.tsx
@@ -2,7 +2,10 @@ import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-type Listener = (event: { endCoordinates: { height: number }; duration: number }) => void
+type Listener = (event: {
+ endCoordinates: { height: number; screenY?: number }
+ duration: number
+}) => void
type KeyboardHarness = {
listeners: Map
@@ -21,6 +24,22 @@ const keyboard = vi.hoisted((): KeyboardHarness => ({
metrics: undefined
}))
+const regions = vi.hoisted(
+ (): { listener: ((value: { keyboardInset?: number }) => void) | null; removed: boolean } => ({
+ listener: null,
+ removed: false
+ })
+)
+vi.mock('../layout/reserved-regions', () => ({
+ subscribeReservedRegionChanges: (listener: (value: { keyboardInset?: number }) => void) => {
+ regions.listener = listener
+ return () => {
+ regions.listener = null
+ regions.removed = true
+ }
+ }
+}))
+
vi.mock('react-native', () => ({
Keyboard: {
metrics: () => keyboard.metrics,
@@ -35,6 +54,7 @@ vi.mock('react-native', () => ({
}
}
},
+ Dimensions: { get: () => ({ height: 951 }) },
Platform: {
get OS() {
return keyboard.platform
@@ -42,13 +62,14 @@ vi.mock('react-native', () => ({
}
}))
-import {
- currentSoftKeyboardHeight,
- subscribeSoftKeyboard,
- useKeyboardOcclusion,
- useSoftKeyboard,
- type SoftKeyboardState
-} from './keyboard-occlusion'
+import type { SoftKeyboardState } from './keyboard-occlusion'
+import type * as KeyboardOcclusion from './keyboard-occlusion'
+let keyboardModule: typeof KeyboardOcclusion
+const currentSoftKeyboardHeight = () => keyboardModule.currentSoftKeyboardHeight()
+const subscribeSoftKeyboard: typeof KeyboardOcclusion.subscribeSoftKeyboard = (...args) =>
+ keyboardModule.subscribeSoftKeyboard(...args)
+const useKeyboardOcclusion = () => keyboardModule.useKeyboardOcclusion()
+const useSoftKeyboard = () => keyboardModule.useSoftKeyboard()
let lift = 0
let keyboardState: SoftKeyboardState = { height: 0, visible: false }
@@ -77,21 +98,31 @@ async function mountComponent(component: () => null): Promise> => mountComponent(Harness)
describe('the keyboard the phone reports', () => {
- beforeEach(() => {
+ beforeEach(async () => {
+ vi.resetModules()
+ keyboardModule = await import('./keyboard-occlusion')
keyboard.listeners.clear()
keyboard.removed.length = 0
keyboard.platform = 'ios'
keyboard.addListenerCalls = 0
keyboard.metrics = undefined
+ regions.listener = null
+ regions.removed = false
lift = 0
keyboardState = { height: 0, visible: false }
})
it('subscribes to will events on iOS and did events on Android', async () => {
await mount()
- expect([...keyboard.listeners.keys()].sort()).toEqual(['keyboardWillHide', 'keyboardWillShow'])
+ expect([...keyboard.listeners.keys()].sort()).toEqual([
+ 'keyboardWillChangeFrame',
+ 'keyboardWillHide',
+ 'keyboardWillShow'
+ ])
keyboard.platform = 'android'
+ vi.resetModules()
+ keyboardModule = await import('./keyboard-occlusion')
keyboard.listeners.clear()
await mount()
expect([...keyboard.listeners.keys()].sort()).toEqual(['keyboardDidHide', 'keyboardDidShow'])
@@ -120,15 +151,19 @@ describe('the keyboard the phone reports', () => {
expect(lift).toBe(0)
})
- it('removes both listeners on unmount', async () => {
+ it('removes consumer callbacks on unmount while preserving visibility observation', async () => {
const tree = await mount()
await act(async () => tree.unmount())
- expect(keyboard.removed.sort()).toEqual(['keyboardWillHide', 'keyboardWillShow'])
+ await act(async () => {
+ keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 999 }, duration: 0 })
+ })
+ expect(lift).toBe(0)
+ expect(keyboard.removed).toEqual([])
})
it('answers both facts from one subscription, so a screen wanting each pays for one', async () => {
await mountComponent(StateHarness)
- expect(keyboard.addListenerCalls).toBe(2)
+ expect(keyboard.addListenerCalls).toBe(3)
await act(async () => {
keyboard.listeners.get('keyboardWillShow')?.({
endCoordinates: { height: 336 },
@@ -150,7 +185,7 @@ describe('the keyboard the phone reports', () => {
expect(keyboardState).toEqual({ height: 0, visible: true })
})
- it('hands a sheet each event with its duration, and removes both listeners', () => {
+ it('hands a sheet each event with its duration, and removes its callbacks', () => {
const calls: string[] = []
const unsubscribe = subscribeSoftKeyboard(
(height, duration) => calls.push(`show ${height} ${duration}`),
@@ -160,7 +195,121 @@ describe('the keyboard the phone reports', () => {
keyboard.listeners.get('keyboardWillHide')?.({ endCoordinates: { height: 0 }, duration: 180 })
expect(calls).toEqual(['show 336 250', 'hide 180'])
unsubscribe()
- expect(keyboard.removed.sort()).toEqual(['keyboardWillHide', 'keyboardWillShow'])
+ keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 999 }, duration: 0 })
+ expect(calls).toEqual(['show 336 250', 'hide 180'])
+ expect(keyboard.removed).toEqual([])
+ })
+
+ it('updates an open keyboard when unfolding changes its frame without another show', async () => {
+ await mountComponent(StateHarness)
+ await act(async () => {
+ keyboard.listeners.get('keyboardWillShow')?.({
+ endCoordinates: { height: 391, screenY: 560 },
+ duration: 250
+ })
+ keyboard.listeners.get('keyboardWillChangeFrame')?.({
+ endCoordinates: { height: 306, screenY: 645 },
+ duration: 180
+ })
+ })
+ expect(keyboardState).toEqual({ height: 306, visible: true })
+ })
+
+ it.each(['before', 'after'])('ignores an offscreen frame %s hide without reopening', (order) => {
+ const calls: string[] = []
+ const unsubscribe = subscribeSoftKeyboard(
+ (height, duration) => calls.push(`show ${height} ${duration}`),
+ (duration) => calls.push(`hide ${duration}`)
+ )
+ keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 306 }, duration: 250 })
+ const frame = () =>
+ keyboard.listeners.get('keyboardWillChangeFrame')?.({
+ endCoordinates: { height: 253, screenY: 951 },
+ duration: 180
+ })
+ if (order === 'before') {
+ frame()
+ }
+ keyboard.listeners.get('keyboardWillHide')?.({ endCoordinates: { height: 253 }, duration: 180 })
+ if (order === 'after') {
+ frame()
+ }
+ // An overlapping transition's in-window frame must not override the explicit hide either.
+ keyboard.listeners.get('keyboardWillChangeFrame')?.({
+ endCoordinates: { height: 253, screenY: 698 },
+ duration: 180
+ })
+ expect(calls).toEqual(['show 306 250', 'hide 180'])
+ keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 306 }, duration: 250 })
+ keyboard.listeners.get('keyboardWillChangeFrame')?.({
+ endCoordinates: { height: 391, screenY: 560 },
+ duration: 120
+ })
+ expect(calls.slice(-2)).toEqual(['show 306 250', 'show 391 120'])
+ unsubscribe()
+ expect(keyboard.listeners.size).toBe(3)
+ })
+
+ it('follows native geometry without keyboard events and preserves explicit hide ordering', () => {
+ const calls: string[] = []
+ const unsubscribe = subscribeSoftKeyboard(
+ (height) => calls.push(`show ${height}`),
+ () => calls.push('hide')
+ )
+ regions.listener?.({ keyboardInset: 495.5 })
+ expect(calls).toEqual([])
+ keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 495.5 }, duration: 0 })
+ regions.listener?.({})
+ regions.listener?.({ keyboardInset: 0 })
+ expect(calls).toEqual(['show 495.5'])
+ regions.listener?.({ keyboardInset: 311 })
+ expect(calls).toEqual(['show 495.5', 'show 311'])
+ keyboard.listeners.get('keyboardWillHide')?.({ endCoordinates: { height: 311 }, duration: 180 })
+ regions.listener?.({ keyboardInset: 495.5 })
+ regions.listener?.({ keyboardInset: 0 })
+ expect(calls).toEqual(['show 495.5', 'show 311', 'hide'])
+ keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 0 }, duration: 0 })
+ regions.listener?.({ keyboardInset: 311 })
+ expect(calls.slice(-2)).toEqual(['show 0', 'show 311'])
+ unsubscribe()
+ expect(regions.removed).toBe(false)
+ })
+
+ it('retains explicit visibility across resubscriptions and observes hide while detached', async () => {
+ const first = subscribeSoftKeyboard(
+ () => undefined,
+ () => undefined
+ )
+ keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 495.5 }, duration: 0 })
+ first()
+ const calls: number[] = []
+ const second = subscribeSoftKeyboard(
+ (height) => calls.push(height),
+ () => undefined
+ )
+ regions.listener?.({ keyboardInset: 311 })
+ expect(calls).toEqual([311])
+ expect(keyboard.addListenerCalls).toBe(3)
+ second()
+ keyboard.listeners.get('keyboardWillHide')?.({ endCoordinates: { height: 311 }, duration: 180 })
+ keyboard.metrics = { height: 311 }
+ const third = subscribeSoftKeyboard(
+ (height) => calls.push(height),
+ () => undefined
+ )
+ regions.listener?.({ keyboardInset: 495.5 })
+ expect(calls).toEqual([311])
+ third()
+ })
+
+ it('does not subscribe Android to native iOS geometry', () => {
+ keyboard.platform = 'android'
+ const unsubscribe = subscribeSoftKeyboard(
+ () => undefined,
+ () => undefined
+ )
+ expect(regions.listener).toBeNull()
+ unsubscribe()
})
it('reads a keyboard already up from metrics(), and 0 when there is none', () => {
diff --git a/mobile/src/platform/keyboard-occlusion.ts b/mobile/src/platform/keyboard-occlusion.ts
index 6cecdd77ac6..fcca29cad05 100644
--- a/mobile/src/platform/keyboard-occlusion.ts
+++ b/mobile/src/platform/keyboard-occlusion.ts
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
-import { Keyboard } from 'react-native'
+import { Dimensions, Keyboard } from 'react-native'
+import { subscribeReservedRegionChanges } from '../layout/reserved-regions'
import { hostOs } from './host-os'
export type SoftKeyboardState = { readonly height: number; readonly visible: boolean }
@@ -23,19 +24,70 @@ export function useSoftKeyboard(): SoftKeyboardState {
return keyboard
}
+/**
+ * The keyboard as events, for a consumer that animates with each one (the event's `duration`) rather
+ * than rendering from state. iOS is told `will`, Android `did`.
+ */
+type KeyboardSubscriber = {
+ onShow: (height: number, duration: number) => void
+ onHide: (duration: number) => void
+}
+const subscribers = new Set()
+let observing = false
+
+function observeSoftKeyboard(): void {
+ if (observing) {
+ return
+ }
+ observing = true
+ const showEvent = hostOs() === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
+ const hideEvent = hostOs() === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
+ let hiding = false
+ let visible = false
+ const reportHeight = (height: number, duration: number) => {
+ for (const subscriber of subscribers) {
+ subscriber.onShow(height, duration)
+ }
+ }
+ // App-lifetime observation preserves explicit hide even between subscriber mounts.
+ Keyboard.addListener(showEvent, (event) => {
+ hiding = false
+ visible = true
+ reportHeight(event.endCoordinates.height, event.duration)
+ })
+ Keyboard.addListener(hideEvent, (event) => {
+ hiding = true
+ visible = false
+ for (const subscriber of subscribers) {
+ subscriber.onHide(event.duration)
+ }
+ })
+ if (hostOs() === 'ios') {
+ Keyboard.addListener('keyboardWillChangeFrame', (event) => {
+ const { screenY, height } = event.endCoordinates
+ // Hide frames keep a height; only show can reopen after an explicit hide.
+ if (hiding || screenY >= Dimensions.get('window').height || screenY + height <= 0) {
+ return
+ }
+ reportHeight(height, event.duration)
+ })
+ subscribeReservedRegionChanges(({ keyboardInset }) => {
+ if (visible && keyboardInset !== undefined && keyboardInset > 0) {
+ reportHeight(keyboardInset, 0)
+ }
+ })
+ }
+}
+
export function subscribeSoftKeyboard(
onShow: (height: number, duration: number) => void,
onHide: (duration: number) => void
): () => void {
- const showEvent = hostOs() === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
- const hideEvent = hostOs() === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
- const show = Keyboard.addListener(showEvent, (event) =>
- onShow(event.endCoordinates.height, event.duration)
- )
- const hide = Keyboard.addListener(hideEvent, (event) => onHide(event.duration))
+ const subscriber = { onShow, onHide }
+ subscribers.add(subscriber)
+ observeSoftKeyboard()
return () => {
- show.remove()
- hide.remove()
+ subscribers.delete(subscriber)
}
}
diff --git a/mobile/src/platform/shell-storage.ts b/mobile/src/platform/shell-storage.ts
new file mode 100644
index 00000000000..defdc45a7fd
--- /dev/null
+++ b/mobile/src/platform/shell-storage.ts
@@ -0,0 +1,3 @@
+export function useShellStorage(): Readonly> | null {
+ return null
+}
diff --git a/mobile/src/platform/shell-storage.web.ts b/mobile/src/platform/shell-storage.web.ts
new file mode 100644
index 00000000000..5dc2f2d882b
--- /dev/null
+++ b/mobile/src/platform/shell-storage.web.ts
@@ -0,0 +1,10 @@
+import { useSyncExternalStore } from 'react'
+import { usePageBridgeClientIfPresent } from '../transport/client-context.web'
+
+export function useShellStorage(): Readonly> | null {
+ const client = usePageBridgeClientIfPresent()
+ return useSyncExternalStore(
+ client?.onStorageUpdate ?? (() => () => undefined),
+ () => client?.getShellSession()?.storage ?? null
+ )
+}
diff --git a/mobile/src/session/MobileSessionAccessoryKeys.tsx b/mobile/src/session/MobileSessionAccessoryKeys.tsx
index 8b58de08b4c..d32d7ca3266 100644
--- a/mobile/src/session/MobileSessionAccessoryKeys.tsx
+++ b/mobile/src/session/MobileSessionAccessoryKeys.tsx
@@ -3,12 +3,9 @@ import { View, Text, ScrollView, Pressable } from 'react-native'
import { ChevronDown, Ellipsis, Keyboard as KeyboardIcon, PenLine } from 'lucide-react-native'
import type { TerminalShortcutModifier } from '../terminal/terminal-accessory-keys'
import { TERMINAL_ACCESSORY_REPEAT_DELAY_MS } from '../terminal/terminal-accessory-repeat'
-import { SPECIAL_KEY_LABELS } from '../terminal/terminal-key-definitions'
-import { createTerminalLiveAccessoryInput } from '../terminal/terminal-live-accessory-input'
-import { isTerminalPhoneDisplayMode } from './mobile-session-route-helpers'
import { isMobileTerminalTabRunningAgent } from './mobile-terminal-tab-agent'
import { TerminalArrowPad } from './TerminalArrowPad'
-import { TerminalMoreKeysSheet } from './TerminalMoreKeysSheet'
+import { MobileTerminalMoreKeysSheet } from './MobileTerminalMoreKeysSheet'
import { terminalDockStyles as styles } from './terminal-dock-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
@@ -22,42 +19,19 @@ const ESC = '\x1b'
const CTRL_C = '\x03'
const SHELL_CHARACTERS = ['/', '|', '~', '-', ':']
const AGENT_CHARACTERS = ['/', '@', '!']
-const ARROW_IDS = ['arrowUp', 'arrowDown', 'arrowLeft', 'arrowRight']
-// Built-in keys each row already shows; the ⋯ sheet offers the rest.
-const SHELL_ROW_KEY_IDS: ReadonlySet = new Set(['escape', 'tab', ...ARROW_IDS])
-const AGENT_ROW_KEY_IDS: ReadonlySet = new Set([
- 'escape',
- 'shiftTab',
- 'enter',
- 'ctrlC',
- ...ARROW_IDS
-])
-
-// The terminal's key row: a row under the input, or a column in the Duo's side rail. Every key goes straight to the terminal.
+// The terminal's key row under the input; the Duo's side rail shows the same keys. Every key goes straight to the terminal.
export function MobileSessionAccessoryKeys({
- controller,
- vertical = false
+ controller
}: {
controller: MobileSessionController
- vertical?: boolean
}) {
useMobileColorScheme()
const t = useMobileTranslation()
const {
- activeHandle,
activeSessionTab,
- customKeys,
- setShowCustomKeyModal,
- setDeleteKeyTarget,
- visibleBuiltInAccessoryKeys,
- terminalModes,
- canPaste,
canSend,
canCompose,
liveInputEnabled,
- isAttaching,
- attachImage,
- toggleDisplayMode,
handleAccessoryKey,
dockKeys: keys,
dismissSoftwareKeyboard,
@@ -67,7 +41,6 @@ export function MobileSessionAccessoryKeys({
stopAccessoryRepeat,
cancelAccessoryRepeat,
prepareAccessoryPress,
- handlePaste,
keyboardLift
} = controller
const keepsKeyboard = useKeyboardPersistingTaps('always')
@@ -83,7 +56,7 @@ export function MobileSessionAccessoryKeys({
const runsAgent =
activeSessionTab?.type === 'terminal' && isMobileTerminalTabRunningAgent(activeSessionTab)
const keyboardUp = keyboardLift > 0
- const keyStyle = vertical ? [styles.key, styles.keyVertical] : styles.key
+ const keyStyle = styles.key
const sendBytes = (bytes: string) => void handleAccessoryKey({ bytes })
const keyButton = (
@@ -212,11 +185,10 @@ export function MobileSessionAccessoryKeys({
const showKeyboard = () => (liveInputEnabled ? liveInputRef : commandInputRef).current?.focus()
return (
-
+
[
styles.modeKey,
- vertical && styles.keyVertical,
pressed && styles.keyPressed,
!canCompose && styles.disabled
]}
@@ -249,22 +221,18 @@ export function MobileSessionAccessoryKeys({
{/* Why: default tap handling makes the first key tap dismiss the keyboard and get swallowed (#5106). */}
{rowKeys}
[
- styles.ghostKey,
- vertical && styles.keyVertical,
- pressed && styles.keyPressed
- ]}
+ style={({ pressed }) => [styles.ghostKey, pressed && styles.keyPressed]}
onPress={() => setSheetVisible(true)}
accessibilityRole="button"
accessibilityLabel={t('session.accessoryKeys.moreKeys', 'More keys and actions')}
@@ -276,7 +244,6 @@ export function MobileSessionAccessoryKeys({
[
styles.iconKey,
- vertical && styles.keyVertical,
pressed && styles.keyPressed,
!keyboardUp && !canCompose && styles.disabled
]}
@@ -296,33 +263,10 @@ export function MobileSessionAccessoryKeys({
)}
- setSheetVisible(false)}
- canSend={canSend}
- canPaste={canPaste}
- isAttaching={isAttaching}
- phoneDisplay={isTerminalPhoneDisplayMode(activeHandle, terminalModes)}
- rowKeyIds={runsAgent ? AGENT_ROW_KEY_IDS : SHELL_ROW_KEY_IDS}
- builtInKeys={visibleBuiltInAccessoryKeys}
- customKeys={customKeys}
- onPaste={() => void handlePaste()}
- onAttachImage={() => void attachImage('library')}
- onAttachFile={() => void attachImage('files')}
- onToggleDisplay={() => {
- if (activeHandle) {
- void toggleDisplayMode(activeHandle)
- }
- }}
- onBuiltInKey={(key) =>
- SPECIAL_KEY_LABELS[key.id]
- ? keys.sendKey(key.id)
- : void handleAccessoryKey(createTerminalLiveAccessoryInput(key))
- }
- onSpecialKey={keys.sendKey}
- onCustomKey={(key) => sendBytes(key.bytes)}
- onDeleteCustomKey={setDeleteKeyTarget}
- onAddCustomKey={() => setShowCustomKeyModal(true)}
/>
)
diff --git a/mobile/src/session/MobileSessionActiveContent.tsx b/mobile/src/session/MobileSessionActiveContent.tsx
index b9e7be7cf33..bf9adb80be1 100644
--- a/mobile/src/session/MobileSessionActiveContent.tsx
+++ b/mobile/src/session/MobileSessionActiveContent.tsx
@@ -1,4 +1,6 @@
-import { useMemo } from 'react'
+import { useMemo, useState } from 'react'
+import { computeActiveTerminalKeyboardLift } from '../terminal/terminal-keyboard-avoidance-lift'
+import { terminalPaneDirection, terminalPaneKeyboardLayout } from './mobile-terminal-pane-layout'
import { Animated, View, Text, Pressable, ActivityIndicator } from 'react-native'
import { saveTerminalTextScale } from '../storage/preferences'
import { MobileBrowserPane } from '../browser/MobileBrowserPane'
@@ -26,6 +28,8 @@ export function MobileSessionActiveContent({
useMobileColorScheme()
const t = useMobileTranslation()
const executionHost = useWorkspaceExecutionHost()
+ const [paneFrame, setPaneFrame] = useState({ width: 0, height: 0 })
+ const paneDirection = terminalPaneDirection(paneFrame, controller.terminalSplitLayout)
const {
hostId,
worktreeId,
@@ -37,6 +41,9 @@ export function MobileSessionActiveContent({
setTerminalTextScale,
terminalTextScaleRevisionRef,
activeHandle,
+ terminalSplitActive,
+ visibleTerminalHandles,
+ notifyTerminalPaneFrame,
markdownDocs,
fileDocs,
diffComments,
@@ -47,6 +54,7 @@ export function MobileSessionActiveContent({
dictationMode,
dictationEnabled,
toastMessage,
+ handleTerminalInteraction,
handleTerminalTap,
browserScreencastSupported,
showToast,
@@ -238,7 +246,38 @@ export function MobileSessionActiveContent({
key={terminal.handle}
handle={terminal.handle}
active={terminal.handle === activeHandle}
- keyboardLift={terminal.handle === activeHandle ? activeTerminalKeyboardLift : 0}
+ visible={
+ visibleTerminalHandles?.includes(terminal.handle) ?? terminal.handle === activeHandle
+ }
+ splitIndex={
+ terminalSplitActive && visibleTerminalHandles.includes(terminal.handle)
+ ? visibleTerminalHandles.indexOf(terminal.handle)
+ : undefined
+ }
+ splitDirection={paneDirection}
+ splitFrame={paneFrame}
+ keyboardInset={keyboardLift}
+ title={terminal.title}
+ onFrame={notifyTerminalPaneFrame}
+ keyboardLift={
+ terminal.handle !== activeHandle &&
+ !(terminalSplitActive && visibleTerminalHandles.includes(terminal.handle))
+ ? 0
+ : terminalSplitActive
+ ? computeActiveTerminalKeyboardLift({
+ keyboardLift: terminalPaneKeyboardLayout(
+ paneFrame,
+ paneDirection,
+ visibleTerminalHandles.indexOf(terminal.handle),
+ keyboardLift
+ ).occlusion,
+ metrics: controller.terminalKeyboardMetrics.get(terminal.handle),
+ terminalFrameHeight:
+ controller.getTerminalPaneViewport(terminal.handle).terminalFrameRef.current
+ ?.height ?? 0
+ })
+ : activeTerminalKeyboardLift
+ }
terminalTheme={terminal.terminalTheme}
textScale={terminalTextScale}
onTextScaleChange={(scale) => {
@@ -259,6 +298,7 @@ export function MobileSessionActiveContent({
onHaptic={handleHaptic}
onTerminalInput={handleTerminalInput}
onTerminalQueryReply={handleTerminalQueryReply}
+ onTerminalInteraction={handleTerminalInteraction}
onTerminalTap={handleTerminalTap}
onFileTap={handleFileTap}
onOpenUrl={handleTerminalOpenUrl}
@@ -306,6 +346,9 @@ export function MobileSessionActiveContent({
onLayout={(e) => {
const { width, height } = e.nativeEvent.layout
notifyTerminalFrame({ width, height })
+ setPaneFrame((previous) =>
+ previous.width === width && previous.height === height ? previous : { width, height }
+ )
}}
>
{content}
diff --git a/mobile/src/session/MobileSessionCommandDock.test.tsx b/mobile/src/session/MobileSessionCommandDock.test.tsx
index 1a5ff79cda4..2287ec838f2 100644
--- a/mobile/src/session/MobileSessionCommandDock.test.tsx
+++ b/mobile/src/session/MobileSessionCommandDock.test.tsx
@@ -33,6 +33,15 @@ vi.mock('react-native', async () => {
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
}
})
+// The key row and its sheet are out of scope; the rail holding the keys leaves just the two fields.
+vi.mock('./use-session-terminal-key-rail', () => ({
+ useSessionTerminalKeyRail: () => ({
+ keysInRail: true,
+ moreKeysVisible: false,
+ closeMoreKeys: () => {}
+ })
+}))
+vi.mock('./MobileTerminalMoreKeysSheet', () => ({ MobileTerminalMoreKeysSheet: () => null }))
vi.mock('./MobileTerminalInputActions', () => ({
MobileTerminalInputActions: () => null,
MobileTerminalAttachButton: () => null,
@@ -119,8 +128,7 @@ function Harness({ handle, liveInputEnabled }: Selection) {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the real dock reads these fields; action children are inert and no send/dictation handler is invoked.
controller: controller as unknown as Parameters<
typeof MobileSessionCommandDock
- >[0]['controller'],
- keysInRail: true
+ >[0]['controller']
})
}
diff --git a/mobile/src/session/MobileSessionCommandDock.tsx b/mobile/src/session/MobileSessionCommandDock.tsx
index fe93335da7e..74733b383b8 100644
--- a/mobile/src/session/MobileSessionCommandDock.tsx
+++ b/mobile/src/session/MobileSessionCommandDock.tsx
@@ -18,6 +18,9 @@ import {
} from './MobileTerminalInputActions'
import { MobileNativeChatStopButton } from './native-chat/input/MobileNativeChatStopButton'
import { MobileSessionAccessoryKeys } from './MobileSessionAccessoryKeys'
+import { MobileTerminalMoreKeysSheet } from './MobileTerminalMoreKeysSheet'
+import { sessionCommandDockShown } from './mobile-session-command-dock-shown'
+import { useSessionTerminalKeyRail } from './use-session-terminal-key-rail'
import {
isMobileTerminalTabRunningAgent,
resolveMobileTerminalTabOwnedAgentId
@@ -34,27 +37,12 @@ const ESC = '\x1b'
// Both fields must remain focusable during the tap that hands the keyboard to the other mode.
const inactiveFieldStyle = { position: 'absolute', left: 0, right: 0, opacity: 0 } as const
-// Markdown, file, browser and chat tabs bring their own input; only terminals get the dock and its keys.
-export function sessionCommandDockShown(controller: MobileSessionController): boolean {
- return (
- !controller.activeMarkdownTab &&
- !controller.activeFileTab &&
- !controller.activeBrowserTab &&
- !controller.showNativeChat
- )
-}
-
// Keys mode types straight into the terminal; Message mode writes in the chat composer and sends on tap.
// Agent tabs open in Message, shells in Keys; the key row sends to the terminal in both.
-export function MobileSessionCommandDock({
- controller,
- keysInRail
-}: {
- controller: MobileSessionController
- keysInRail: boolean
-}) {
+export function MobileSessionCommandDock({ controller }: { controller: MobileSessionController }) {
useMobileColorScheme()
const t = useMobileTranslation()
+ const rail = useSessionTerminalKeyRail(controller)
const {
insets,
bufferedTerminalDraftState,
@@ -279,7 +267,15 @@ export function MobileSessionCommandDock({
/>
- {keysInRail ? null : }
+ {rail.keysInRail ? (
+
+ ) : (
+
+ )}
)
}
diff --git a/mobile/src/session/MobileSessionContentRow.tsx b/mobile/src/session/MobileSessionContentRow.tsx
index edd6f39e8c7..cf5af36e842 100644
--- a/mobile/src/session/MobileSessionContentRow.tsx
+++ b/mobile/src/session/MobileSessionContentRow.tsx
@@ -11,13 +11,7 @@ import { MobileSessionActiveContent } from './MobileSessionActiveContent'
import { MobileSessionCommandDock } from './MobileSessionCommandDock'
import { MobileDictationStatusAnnouncement } from './MobileDictationStatusAnnouncement'
-export function MobileSessionContentRow({
- controller,
- keysInRail
-}: {
- controller: MobileSessionController
- keysInRail: boolean
-}) {
+export function MobileSessionContentRow({ controller }: { controller: MobileSessionController }) {
useMobileColorScheme()
const t = useMobileTranslation()
const {
@@ -57,7 +51,7 @@ export function MobileSessionContentRow({
) : null}
{/* Why: translate instead of resize so keyboard toggles don't trigger a server-side PTY viewport change. */}
-
+
{canDockPanel && activePanel !== null && (
diff --git a/mobile/src/session/MobileSessionHeader.test.tsx b/mobile/src/session/MobileSessionHeader.test.tsx
new file mode 100644
index 00000000000..9b20bad7ab4
--- /dev/null
+++ b/mobile/src/session/MobileSessionHeader.test.tsx
@@ -0,0 +1,159 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, expect, it, vi } from 'vitest'
+import { mountFixture } from '../test-support/rpc-recording/recorder-fixture-shape'
+import type { MobileSessionController } from './use-mobile-session-controller'
+
+const layout = vi.hoisted(() => ({ sideRail: true, width: 927 }))
+vi.mock('react-native', () => ({ View: 'View', Text: 'Text', Pressable: 'Pressable' }))
+vi.mock('react-native-safe-area-context', () => ({ SafeAreaView: 'SafeAreaView' }))
+vi.mock('lucide-react-native', () => ({
+ ChevronLeft: 'Icon',
+ Folder: 'Icon',
+ GitBranch: 'Icon',
+ MoreHorizontal: 'Icon',
+ PanelLeftOpen: 'Icon',
+ Columns2: 'Icon',
+ Plus: 'Icon',
+ SquareChevronRight: 'Icon'
+}))
+vi.mock('./MobileSessionHeaderIconButton', () => ({
+ MobileSessionHeaderIconButton: 'HeaderButton'
+}))
+vi.mock('./MobileSessionTabStrip', () => ({ MobileSessionTabStrip: 'TabStrip' }))
+vi.mock('./MobileSessionHeaderMoreActionsSheet', () => ({
+ MobileSessionHeaderMoreActionsSheet: 'MoreActionsSheet'
+}))
+vi.mock('../components/StatusDot', () => ({ StatusDot: 'StatusDot' }))
+vi.mock('./mobile-session-styles', () => ({ styles: {} }))
+vi.mock('../layout/host-sidebar-reveal', () => ({ useHostSidebarReveal: () => null }))
+vi.mock('../layout/responsive-layout', () => ({
+ useResponsiveLayout: () => ({ isLandscapePhone: false, width: layout.width })
+}))
+vi.mock('../layout/use-top-chrome-space', () => ({ useTopChromeSpace: () => ({}) }))
+vi.mock('../layout/use-shell-side-rail', () => ({ useShellSideRail: () => layout.sideRail }))
+
+import { MobileSessionHeader } from './MobileSessionHeader'
+
+let tree: ReactTestRenderer | undefined
+
+afterEach(() => {
+ act(() => tree?.unmount())
+ tree = undefined
+ layout.sideRail = true
+ layout.width = 927
+})
+
+function renderHeader(
+ showConnectionRetry: boolean,
+ overrides: Partial = {}
+) {
+ const forceReconnectHost = vi.fn(async (_hostId: string) => {})
+ const controller = mountFixture({
+ hostId: 'host-1',
+ connState: 'connected',
+ activePanel: null,
+ presentedConnState: showConnectionRetry ? 'disconnected' : 'connected',
+ forceReconnectHost,
+ worktreeName: 'Workspace',
+ visibleTabs: [{ id: 'tab-a', type: 'terminal', terminal: 'a' }],
+ showConnectionRetry,
+ connectionEscalated: showConnectionRetry,
+ terminalSummary: showConnectionRetry ? 'Connection lost — tap to retry' : '1 tab',
+ ...overrides
+ })
+ act(() => {
+ tree = create(createElement(MobileSessionHeader, { controller }))
+ })
+ return forceReconnectHost
+}
+
+it('keeps reconnect reachable beside the Duo tabs when the connection is escalated', async () => {
+ const reconnect = renderHeader(true)
+ const retry = tree!.root.findAllByProps({ accessibilityLabel: 'Reconnect to desktop' })
+ expect(retry).toHaveLength(1)
+ expect(retry[0].props.disabled).toBe(false)
+ expect(tree!.root.find((node) => String(node.type) === 'StatusDot').props.state).toBe(
+ 'disconnected'
+ )
+ await act(async () => retry[0].props.onPress())
+ expect(reconnect.mock.calls).toEqual([['host-1']])
+})
+
+it('keeps a connected Duo header compact while its tabs stay in the top bar', () => {
+ renderHeader(false)
+ expect(tree!.root.findAllByProps({ accessibilityLabel: 'Reconnect to desktop' })).toHaveLength(0)
+ expect(tree!.root.findAll((node) => String(node.type) === 'StatusDot')).toHaveLength(0)
+ expect(tree!.root.findAll((node) => String(node.type) === 'Text')).toHaveLength(0)
+ expect(tree!.root.find((node) => String(node.type) === 'TabStrip').props.inTopBar).toBe(true)
+})
+
+it('keeps an escalated page connection visible without offering a native retry', () => {
+ renderHeader(false, {
+ connectionEscalated: true,
+ presentedConnState: 'disconnected',
+ forceReconnectHost: null,
+ terminalSummary: "Can't reach desktop"
+ })
+ expect(tree!.root.findAllByProps({ accessibilityLabel: 'Reconnect to desktop' })).toHaveLength(0)
+ expect(tree!.root.find((node) => String(node.type) === 'Pressable').props.disabled).toBe(true)
+ expect(tree!.root.find((node) => String(node.type) === 'StatusDot').props.state).toBe(
+ 'disconnected'
+ )
+ expect(tree!.root.findAll((node) => node.props.children === "Can't reach desktop")).toHaveLength(
+ 1
+ )
+})
+
+it('gives narrow Duo tabs the header width and keeps every collapsed action reachable', () => {
+ layout.width = 382
+ const controller = {
+ canSplitTerminals: true,
+ setShowTerminalLayout: vi.fn(),
+ handleOpenNewTab: vi.fn(),
+ handleOpenQuickCommands: vi.fn(),
+ handlePanelTap: vi.fn(),
+ setShowHeaderMoreActions: vi.fn(),
+ showHeaderMoreButton: false
+ }
+ renderHeader(false, controller)
+ const strip = tree!.root.find((node) => String(node.type) === 'TabStrip')
+ expect(strip.props.actionsCollapsed).toBe(true)
+ const buttons = tree!.root.findAll((node) => String(node.type) === 'HeaderButton')
+ expect(buttons.map((button) => button.props.accessibilityLabel)).toEqual([
+ 'Back to worktrees',
+ 'More session actions'
+ ])
+ act(() => buttons[1].props.onPress())
+ expect(controller.setShowHeaderMoreActions).toHaveBeenCalledWith(true)
+ const sheet = tree!.root.find((node) => String(node.type) === 'MoreActionsSheet')
+ expect(sheet.props.additionalActions.map((action: { label: string }) => action.label)).toEqual([
+ 'Terminal layout',
+ 'New tab',
+ 'Quick commands',
+ 'File explorer',
+ 'Source control'
+ ])
+ for (const action of sheet.props.additionalActions) {
+ act(() => action.onPress())
+ }
+ expect(controller.setShowTerminalLayout).toHaveBeenCalledWith(true)
+ expect(controller.handleOpenNewTab).toHaveBeenCalledOnce()
+ expect(controller.handleOpenQuickCommands).toHaveBeenCalledOnce()
+ expect(controller.handlePanelTap.mock.calls).toEqual([['files'], ['sourceControl']])
+})
+
+it('uses the measured header width when a sidebar leaves a wide window with little tab space', () => {
+ renderHeader(false)
+ const topBar = tree!.root
+ .findAll((node) => String(node.type) === 'View')
+ .find((node) => node.props.onLayout && node.parent?.type !== MobileSessionHeader)!
+ act(() => topBar.props.onLayout({ nativeEvent: { layout: { width: 540 } } }))
+ expect(tree!.root.find((node) => String(node.type) === 'TabStrip').props.actionsCollapsed).toBe(
+ true
+ )
+ act(() => topBar.props.onLayout({ nativeEvent: { layout: { width: 927 } } }))
+ expect(tree!.root.find((node) => String(node.type) === 'TabStrip').props.actionsCollapsed).toBe(
+ false
+ )
+})
diff --git a/mobile/src/session/MobileSessionHeader.tsx b/mobile/src/session/MobileSessionHeader.tsx
index 4eaeac18241..5dd91469164 100644
--- a/mobile/src/session/MobileSessionHeader.tsx
+++ b/mobile/src/session/MobileSessionHeader.tsx
@@ -1,7 +1,18 @@
+import { useState } from 'react'
import { View, Text, Pressable } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
-import { ChevronLeft, Folder, GitBranch, MoreHorizontal, PanelLeftOpen } from 'lucide-react-native'
+import {
+ ChevronLeft,
+ Folder,
+ GitBranch,
+ MoreHorizontal,
+ PanelLeftOpen,
+ Columns2,
+ Plus,
+ SquareChevronRight
+} from 'lucide-react-native'
import { MobileSessionHeaderIconButton } from './MobileSessionHeaderIconButton'
+import { MobileSessionHeaderMoreActionsSheet } from './MobileSessionHeaderMoreActionsSheet'
import { MobileSessionTabStrip } from './MobileSessionTabStrip'
import { StatusDot } from '../components/StatusDot'
import { styles } from './mobile-session-styles'
@@ -10,14 +21,10 @@ import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { useHostSidebarReveal } from '../layout/host-sidebar-reveal'
import { useResponsiveLayout } from '../layout/responsive-layout'
import type { MobileSessionController } from './use-mobile-session-controller'
+import { useTopChromeSpace } from '../layout/use-top-chrome-space'
+import { useShellSideRail } from '../layout/use-shell-side-rail'
-export function MobileSessionHeader({
- controller,
- backInRail
-}: {
- controller: MobileSessionController
- backInRail: boolean
-}) {
+export function MobileSessionHeader({ controller }: { controller: MobileSessionController }) {
useMobileColorScheme()
const t = useMobileTranslation()
const {
@@ -32,90 +39,188 @@ export function MobileSessionHeader({
requestLeaveSession,
visibleTabs,
showConnectionRetry,
+ connectionEscalated,
terminalSummary,
handlePanelTap,
- showHeaderMoreButton
+ showHeaderMoreButton,
+ showHeaderMoreActions,
+ showAgentSessionHistoryAction,
+ showChecksAction,
+ openAgentSessionHistory,
+ canSplitTerminals,
+ terminalSplitActive,
+ setShowTerminalLayout,
+ connState,
+ createTabBusy,
+ handleOpenNewTab,
+ handleOpenQuickCommands
} = controller
+ const layout = useResponsiveLayout()
+ const [headerWidth, setHeaderWidth] = useState(null)
const revealSidebar = useHostSidebarReveal()
+ const topChrome = useTopChromeSpace()
+ const sideRail = useShellSideRail()
// Why: a sideways phone is ~390pt tall, so the tabs share the top bar instead of taking a second row.
const hasTabs = visibleTabs.length > 0
- const tabsInTopBar = useResponsiveLayout().isLandscapePhone && hasTabs
+ const tabsInTopBar = (layout.isLandscapePhone || sideRail) && hasTabs
+ // Six 44pt controls otherwise leave less than two readable tabs on a narrow header.
+ const actionsCollapsed = tabsInTopBar && (headerWidth ?? layout.width) < 600
const tabStrip = hasTabs ? (
-
+
) : null
return (
-
-
- {revealSidebar ? (
-
- ) : null}
- {backInRail ? null : (
+
+
+ {
+ const width = event.nativeEvent.layout.width
+ setHeaderWidth((previous) => (previous === width ? previous : width))
+ }}
+ >
+ {revealSidebar ? (
+
+ ) : null}
- )}
-
-
- {worktreeName || t('session.header.terminalTitle', 'Terminal')}
-
- {
- if (hostId && forceReconnectHost) {
- void forceReconnectHost(hostId)
- }
- }}
- accessibilityRole={showConnectionRetry ? 'button' : undefined}
- accessibilityLabel={
- showConnectionRetry
- ? t('session.header.reconnectToDesktop', 'Reconnect to desktop')
- : undefined
- }
- >
-
-
- {terminalSummary}
-
-
+ {!sideRail || !hasTabs || showConnectionRetry || connectionEscalated ? (
+
+
+ {worktreeName || t('session.header.terminalTitle', 'Terminal')}
+
+ {
+ if (hostId && forceReconnectHost) {
+ void forceReconnectHost(hostId)
+ }
+ }}
+ accessibilityRole={showConnectionRetry ? 'button' : undefined}
+ accessibilityLabel={
+ showConnectionRetry
+ ? t('session.header.reconnectToDesktop', 'Reconnect to desktop')
+ : undefined
+ }
+ >
+
+
+ {terminalSummary}
+
+
+
+ ) : null}
+ {tabsInTopBar ? tabStrip : null}
+ {!actionsCollapsed && !isFloatingWorkspaceRoute && (
+ handlePanelTap('files')}
+ />
+ )}
+ {!isFolderWorkspaceRoute && !actionsCollapsed && !isFloatingWorkspaceRoute && (
+ handlePanelTap('sourceControl')}
+ />
+ )}
+ {actionsCollapsed || showHeaderMoreButton ? (
+ setShowHeaderMoreActions(true)}
+ />
+ ) : null}
- {tabsInTopBar ? tabStrip : null}
- {!isFloatingWorkspaceRoute && (
- handlePanelTap('files')}
- />
- )}
- {!isFolderWorkspaceRoute && !isFloatingWorkspaceRoute && (
- handlePanelTap('sourceControl')}
- />
- )}
- {showHeaderMoreButton ? (
- setShowHeaderMoreActions(true)}
- />
- ) : null}
-
- {tabsInTopBar ? null : tabStrip}
-
+ {tabsInTopBar ? null : tabStrip}
+
+ handlePanelTap('pr')}
+ onClose={() => setShowHeaderMoreActions(false)}
+ additionalActions={
+ actionsCollapsed
+ ? [
+ ...(canSplitTerminals || terminalSplitActive
+ ? [
+ {
+ label: t('session.header.terminalLayout', 'Terminal layout'),
+ icon: Columns2,
+ closeBeforePress: true,
+ onPress: () => setShowTerminalLayout(true)
+ }
+ ]
+ : []),
+ {
+ label: t('session.header.newTab', 'New tab'),
+ icon: Plus,
+ disabled: createTabBusy || connState !== 'connected',
+ closeBeforePress: true,
+ onPress: handleOpenNewTab
+ },
+ {
+ label: t('session.header.quickCommands', 'Quick commands'),
+ icon: SquareChevronRight,
+ disabled: createTabBusy || connState !== 'connected',
+ closeBeforePress: true,
+ onPress: handleOpenQuickCommands
+ },
+ ...(!isFloatingWorkspaceRoute
+ ? [
+ {
+ label: t('session.header.fileExplorer', 'File explorer'),
+ icon: Folder,
+ closeBeforePress: true,
+ onPress: () => handlePanelTap('files')
+ }
+ ]
+ : []),
+ ...(!isFolderWorkspaceRoute && !isFloatingWorkspaceRoute
+ ? [
+ {
+ label: t('session.header.sourceControl', 'Source control'),
+ icon: GitBranch,
+ closeBeforePress: true,
+ onPress: () => handlePanelTap('sourceControl')
+ }
+ ]
+ : [])
+ ]
+ : []
+ }
+ />
+
)
}
diff --git a/mobile/src/session/MobileSessionHeaderMoreActionsSheet.tsx b/mobile/src/session/MobileSessionHeaderMoreActionsSheet.tsx
index 432e090b145..d648e555b6b 100644
--- a/mobile/src/session/MobileSessionHeaderMoreActionsSheet.tsx
+++ b/mobile/src/session/MobileSessionHeaderMoreActionsSheet.tsx
@@ -1,12 +1,13 @@
import { ListChecks } from 'lucide-react-native'
import { MobileAgentSessionHistoryIcon } from '../agent-history/MobileAgentSessionHistoryIcon'
-import { ActionSheetModal } from '../components/ActionSheetModal'
+import { ActionSheetModal, type ActionSheetAction } from '../components/ActionSheetModal'
import { colors } from '../theme/mobile-theme'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
type Props = {
visible: boolean
+ additionalActions?: ActionSheetAction[]
showAgentSessionHistory: boolean
showChecks: boolean
onOpenAgentSessionHistory: () => void
@@ -16,6 +17,7 @@ type Props = {
export function MobileSessionHeaderMoreActionsSheet({
visible,
+ additionalActions,
showAgentSessionHistory,
showChecks,
onOpenAgentSessionHistory,
@@ -28,6 +30,7 @@ export function MobileSessionHeaderMoreActionsSheet({
({ CustomKeyModal: () => null }))
vi.mock('../components/MobileDictationSetupSheet', () => ({
MobileDictationSetupSheet: () => null
}))
-vi.mock('./MobileSessionHeaderMoreActionsSheet', () => ({
- MobileSessionHeaderMoreActionsSheet: () => null
+vi.mock('./MobileSessionTerminalLayoutSheet', () => ({
+ MobileSessionTerminalLayoutSheet: () => null
}))
vi.mock('./QuickCommandsSheet', () => ({ QuickCommandsSheet: () => null }))
vi.mock('./MobileBrowserTabActionSheet', () => ({ MobileBrowserTabActionSheet: () => null }))
diff --git a/mobile/src/session/MobileSessionSheets.tsx b/mobile/src/session/MobileSessionSheets.tsx
index ebb7de01ee2..d76f1e5dfb1 100644
--- a/mobile/src/session/MobileSessionSheets.tsx
+++ b/mobile/src/session/MobileSessionSheets.tsx
@@ -3,7 +3,7 @@ import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { getRepoIdFromWorktreeId } from '../../../src/shared/worktree/id'
import { useClipboardWriter } from '../platform/clipboard'
import { Copy, FileText, Globe, RefreshCw, SquareTerminal } from 'lucide-react-native'
-import { MobileSessionHeaderMoreActionsSheet } from './MobileSessionHeaderMoreActionsSheet'
+import { MobileSessionTerminalLayoutSheet } from './MobileSessionTerminalLayoutSheet'
import { QuickCommandsSheet } from './QuickCommandsSheet'
import { triggerSuccess, triggerError } from '../platform/haptics'
import { ActionSheetModal } from '../components/ActionSheetModal'
@@ -36,8 +36,8 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
setShowQuickCommands,
setShowCreateBrowserModal,
showCreateBrowserModal,
- showHeaderMoreActions,
- setShowHeaderMoreActions,
+ showTerminalLayout,
+ setShowTerminalLayout,
actionTarget,
setActionTarget,
markdownActionTarget,
@@ -86,23 +86,16 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
bulkCloseConfirm,
closeWithBulkActions,
createTabAgentActions,
- sendDiffNotesAgentActions,
- handlePanelTap,
- openAgentSessionHistory,
- showAgentSessionHistoryAction,
- showChecksAction
+ sendDiffNotesAgentActions
} = controller
const clipboard = useClipboardWriter()
const onPairedHost = useWorkspaceOnPairedHost()
return (
<>
- handlePanelTap('pr')}
- onClose={() => setShowHeaderMoreActions(false)}
+ setShowTerminalLayout(false)}
/>
-
+
{}
-
+
diff --git a/mobile/src/session/MobileSessionTabStrip.test.tsx b/mobile/src/session/MobileSessionTabStrip.test.tsx
index cad0e2db54f..a641e462a34 100644
--- a/mobile/src/session/MobileSessionTabStrip.test.tsx
+++ b/mobile/src/session/MobileSessionTabStrip.test.tsx
@@ -14,10 +14,15 @@ vi.mock('lucide-react-native', () => ({
FileText: 'Icon',
Globe: 'Icon',
Plus: 'Icon',
- SquareTerminal: 'Icon'
+ SquareTerminal: 'Icon',
+ Columns2: 'Icon',
+ Rows2: 'Icon',
+ Check: 'Icon',
+ LayoutGrid: 'Icon'
}))
vi.mock('../platform/haptics', () => ({ triggerMediumImpact: () => {} }))
vi.mock('../hooks/use-now', () => ({ useNow: () => 0 }))
+vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: () => null }))
vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: () => null }))
vi.mock('../components/AgentStateDot', () => ({ AgentStateDot: () => null }))
vi.mock('./MobileSessionHeaderIconButton', () => ({ MobileSessionHeaderIconButton: () => null }))
diff --git a/mobile/src/session/MobileSessionTabStrip.tsx b/mobile/src/session/MobileSessionTabStrip.tsx
index ec078c841a9..8c6a5fc9397 100644
--- a/mobile/src/session/MobileSessionTabStrip.tsx
+++ b/mobile/src/session/MobileSessionTabStrip.tsx
@@ -1,6 +1,6 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { View, Text, ScrollView, Pressable } from 'react-native'
-import { File, FileText, Globe, Plus, SquareTerminal } from 'lucide-react-native'
+import { File, FileText, Globe, Plus, SquareTerminal, Columns2 } from 'lucide-react-native'
import { triggerMediumImpact } from '../platform/haptics'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { MobileAgentIcon } from '../components/MobileAgentIcon'
@@ -30,15 +30,16 @@ const DOUBLE_TAP_RENAME_MS = 300
export function MobileSessionTabStrip({
controller,
- inTopBar
+ inTopBar,
+ actionsCollapsed = false
}: {
controller: MobileSessionController
inTopBar: boolean
+ actionsCollapsed?: boolean
}) {
useMobileColorScheme()
const t = useMobileTranslation()
const {
- connState,
activeSessionTabId,
activeSessionTabIdRef,
tabStripRef,
@@ -46,14 +47,10 @@ export function MobileSessionTabStrip({
tabStripViewportWidthRef,
tabStripContentWidthRef,
tabLayoutsRef,
- creating,
- creatingBrowser,
- creatingMarkdown,
- setCreateError,
- setShowCreateTabDrawer,
- setShowQuickCommands,
- quickCommandsSupported,
- showToast,
+ connState,
+ createTabBusy,
+ handleOpenNewTab,
+ handleOpenQuickCommands,
scrollActiveTabIntoView,
holdTabStripReveal,
cancelHeldTabStripReveal,
@@ -62,6 +59,9 @@ export function MobileSessionTabStrip({
setRenameTarget,
activeHandleRef,
visibleTabs,
+ canSplitTerminals,
+ terminalSplitActive,
+ setShowTerminalLayout,
terminals
} = controller
const tabBarKeepsKeyboard = useKeyboardPersistingTaps('handled')
@@ -279,38 +279,28 @@ export function MobileSessionTabStrip({
) : null}
- {/* Why: pinned outside the scroll strip so the new-agent button stays reachable however far the tabs scroll. */}
- {
- setCreateError('')
- setShowCreateTabDrawer(true)
- }}
- accessibilityLabel={t('session.tabStrip.newTab', 'New tab')}
- icon={Plus}
- />
- {/* Why: stable placement matters, while old hosts must stay gated because they strip agentPrompt. */}
- {
- if (quickCommandsSupported === true) {
- setShowQuickCommands(true)
- return
- }
- showToast(
- quickCommandsSupported === false
- ? t(
- 'session.tabStrip.quickCommandsUpdateRequired',
- 'Desktop update required for quick commands'
- )
- : t(
- 'session.tabStrip.checkingCapabilities',
- 'Checking desktop capabilities — try again in a moment'
- ),
- 1600
- )
- }}
- />
+ {!actionsCollapsed && (canSplitTerminals || terminalSplitActive) && (
+ setShowTerminalLayout(true)}
+ />
+ )}
+ {!actionsCollapsed ? (
+ <>
+
+
+ >
+ ) : null}
)
}
diff --git a/mobile/src/session/MobileSessionTerminalLayoutSheet.tsx b/mobile/src/session/MobileSessionTerminalLayoutSheet.tsx
new file mode 100644
index 00000000000..95eff4fdc5a
--- /dev/null
+++ b/mobile/src/session/MobileSessionTerminalLayoutSheet.tsx
@@ -0,0 +1,87 @@
+import { Check, Columns2, LayoutGrid, Rows2, SquareTerminal } from 'lucide-react-native'
+import { ActionSheetModal } from '../components/ActionSheetModal'
+import { useMobileTranslation } from '../i18n/use-mobile-translation'
+import type { TerminalSplitLayout } from '../storage/preferences'
+import type { MobileSessionController } from './use-mobile-session-controller'
+
+export function MobileSessionTerminalLayoutSheet({
+ controller,
+ visible,
+ onClose
+}: {
+ controller: MobileSessionController
+ visible: boolean
+ onClose: () => void
+}) {
+ const t = useMobileTranslation()
+ const {
+ canSplitTerminals,
+ terminalSplitActive,
+ toggleTerminalSplit,
+ terminalSplitLayout,
+ setTerminalSplitLayout
+ } = controller
+ const chooseSplitLayout = (layout: TerminalSplitLayout) => {
+ setTerminalSplitLayout(layout)
+ if (!terminalSplitActive) {
+ toggleTerminalSplit()
+ }
+ }
+ return (
+ {
+ if (terminalSplitActive) {
+ toggleTerminalSplit()
+ }
+ }
+ },
+ ...(
+ [
+ {
+ layout: 'auto',
+ label: t('session.terminalLayoutSheet.auto', 'Auto'),
+ icon: LayoutGrid,
+ hint: t('session.terminalLayoutSheet.autoHint', 'Split to fit the available space')
+ },
+ {
+ layout: 'side-by-side',
+ label: t('session.terminalLayoutSheet.sideBySide', 'Side by side'),
+ icon: Columns2,
+ hint: t(
+ 'session.terminalLayoutSheet.sideBySideHint',
+ 'Two columns, even with the sidebar open'
+ )
+ },
+ {
+ layout: 'stacked',
+ label: t('session.terminalLayoutSheet.stacked', 'Stacked'),
+ icon: Rows2,
+ hint: t('session.terminalLayoutSheet.stackedHint', 'Two rows in any orientation')
+ }
+ ] as const
+ ).map(({ layout, label, icon, hint }) => ({
+ label,
+ icon: terminalSplitActive && terminalSplitLayout === layout ? Check : icon,
+ hint:
+ terminalSplitActive && terminalSplitLayout === layout
+ ? t('session.terminalLayoutSheet.selectedHint', 'Selected · {{hint}}', {
+ hint
+ })
+ : hint,
+ disabled: !canSplitTerminals && !terminalSplitActive,
+ onPress: () => chooseSplitLayout(layout)
+ }))
+ ]}
+ />
+ )
+}
diff --git a/mobile/src/session/MobileTerminalMoreKeysSheet.tsx b/mobile/src/session/MobileTerminalMoreKeysSheet.tsx
new file mode 100644
index 00000000000..514af5dc41f
--- /dev/null
+++ b/mobile/src/session/MobileTerminalMoreKeysSheet.tsx
@@ -0,0 +1,78 @@
+import { SPECIAL_KEY_LABELS } from '../terminal/terminal-key-definitions'
+import { createTerminalLiveAccessoryInput } from '../terminal/terminal-live-accessory-input'
+import { isTerminalPhoneDisplayMode } from './mobile-session-route-helpers'
+import { isMobileTerminalTabRunningAgent } from './mobile-terminal-tab-agent'
+import { TerminalMoreKeysSheet } from './TerminalMoreKeysSheet'
+import type { MobileSessionController } from './use-mobile-session-controller'
+
+const ARROW_IDS = ['arrowUp', 'arrowDown', 'arrowLeft', 'arrowRight']
+// Built-in keys each row already shows; the ⋯ sheet offers the rest.
+const SHELL_ROW_KEY_IDS: ReadonlySet = new Set(['escape', 'tab', ...ARROW_IDS])
+const AGENT_ROW_KEY_IDS: ReadonlySet = new Set([
+ 'escape',
+ 'shiftTab',
+ 'enter',
+ 'ctrlC',
+ ...ARROW_IDS
+])
+
+// The ⋯ sheet of the terminal's key row, opened from the row or from the Duo's side rail.
+export function MobileTerminalMoreKeysSheet({
+ controller,
+ visible,
+ onClose
+}: {
+ controller: MobileSessionController
+ visible: boolean
+ onClose: () => void
+}) {
+ const {
+ activeHandle,
+ activeSessionTab,
+ customKeys,
+ setShowCustomKeyModal,
+ setDeleteKeyTarget,
+ visibleBuiltInAccessoryKeys,
+ terminalModes,
+ canPaste,
+ canSend,
+ isAttaching,
+ attachImage,
+ toggleDisplayMode,
+ handleAccessoryKey,
+ dockKeys: keys,
+ handlePaste
+ } = controller
+ const runsAgent =
+ activeSessionTab?.type === 'terminal' && isMobileTerminalTabRunningAgent(activeSessionTab)
+ return (
+ void handlePaste()}
+ onAttachImage={() => void attachImage('library')}
+ onAttachFile={() => void attachImage('files')}
+ onToggleDisplay={() => {
+ if (activeHandle) {
+ void toggleDisplayMode(activeHandle)
+ }
+ }}
+ onBuiltInKey={(key) =>
+ SPECIAL_KEY_LABELS[key.id]
+ ? keys.sendKey(key.id)
+ : void handleAccessoryKey(createTerminalLiveAccessoryInput(key))
+ }
+ onSpecialKey={keys.sendKey}
+ onCustomKey={(key) => void handleAccessoryKey({ bytes: key.bytes })}
+ onDeleteCustomKey={setDeleteKeyTarget}
+ onAddCustomKey={() => setShowCustomKeyModal(true)}
+ />
+ )
+}
diff --git a/mobile/src/session/TerminalPaneView.activity.test.tsx b/mobile/src/session/TerminalPaneView.activity.test.tsx
index 9acf81e500d..bd0c4bdb1da 100644
--- a/mobile/src/session/TerminalPaneView.activity.test.tsx
+++ b/mobile/src/session/TerminalPaneView.activity.test.tsx
@@ -47,6 +47,7 @@ it('retains the terminal but disables interaction on tab hiding, app backgroundi
onHaptic: vi.fn(),
onTerminalInput: vi.fn(),
onTerminalQueryReply: vi.fn(),
+ onTerminalInteraction: vi.fn(),
onTerminalTap: vi.fn(),
onFileTap: vi.fn(),
onOpenUrl: vi.fn(),
diff --git a/mobile/src/session/TerminalPaneView.test.tsx b/mobile/src/session/TerminalPaneView.test.tsx
new file mode 100644
index 00000000000..01e2f07bb9d
--- /dev/null
+++ b/mobile/src/session/TerminalPaneView.test.tsx
@@ -0,0 +1,193 @@
+import { createElement, type ReactNode } from 'react'
+import { act, create } from 'react-test-renderer'
+import { expect, it, vi } from 'vitest'
+import { mountFixture } from '../test-support/rpc-recording/recorder-fixture-shape'
+
+const hosts = vi.hoisted(() => {
+ const component = (name: string) => {
+ const Host = (props: { children?: ReactNode }) => props.children ?? null
+ Host.displayName = name
+ return Host
+ }
+ return {
+ View: component('View'),
+ Text: component('Text'),
+ Pressable: component('Pressable'),
+ TerminalWebView: component('TerminalWebView')
+ }
+})
+vi.mock('react-native', () => ({
+ AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) },
+ View: hosts.View,
+ Text: hosts.Text,
+ Pressable: hosts.Pressable,
+ ActivityIndicator: hosts.View,
+ Animated: { View: hosts.View },
+ StyleSheet: {
+ create: (value: T) => value,
+ absoluteFillObject: { position: 'absolute', top: 0, bottom: 0, left: 0, right: 0 }
+ }
+}))
+vi.mock('../terminal/TerminalWebView', () => ({ TerminalWebView: hosts.TerminalWebView }))
+vi.mock('../storage/preferences', () => ({ saveTerminalTextScale: () => Promise.resolve() }))
+vi.mock('../browser/MobileBrowserPane', () => ({ MobileBrowserPane: () => null }))
+vi.mock('./MobileNativeChatOverlay', () => ({ MobileNativeChatOverlay: () => null }))
+vi.mock('./MobileSessionFileReader', () => ({ FileReader: () => null }))
+vi.mock('./MobileSessionMarkdownReader', () => ({ MarkdownReader: () => null }))
+vi.mock('./mobile-session-styles', () => ({ styles: {} }))
+vi.mock('../transport/use-host-profile', () => ({ useHostProfile: () => null }))
+vi.mock('expo-router', async () => {
+ const { useEffect } = await import('react')
+ return { useFocusEffect: (effect: () => () => void) => useEffect(effect, [effect]) }
+})
+import { TerminalPaneView } from './TerminalPaneView'
+import { MobileSessionActiveContent } from './MobileSessionActiveContent'
+import { terminalPaneViewport } from './mobile-terminal-pane-layout'
+
+type Props = Parameters[0]
+
+it.each([0, 1])(
+ 'keeps stacked pane %i visible above a tall keyboard and preserves its measured terminal frame',
+ (index) => {
+ const onFrame = vi.fn()
+ const base = mountFixture({
+ handle: `term-${index}`,
+ active: true,
+ visible: true,
+ splitIndex: index,
+ splitDirection: 'column',
+ splitFrame: { width: 390, height: 500 },
+ keyboardInset: 0,
+ keyboardLift: 0,
+ textScale: 1,
+ onFrame,
+ onRef: vi.fn()
+ })
+ let renderer!: ReturnType
+ try {
+ act(() => {
+ renderer = create(createElement(TerminalPaneView, base))
+ })
+ const views = () => renderer.root.findAllByType(hosts.View)
+ expect(views()[2].props.style.height).toBe(222)
+ act(() => {
+ renderer.update(
+ createElement(TerminalPaneView, { ...base, keyboardInset: 300, keyboardLift: 150 })
+ )
+ })
+ const [outer, clip, measured] = views()
+ const outerStyle = Object.assign({}, ...outer.props.style.filter(Boolean))
+ expect(outerStyle.top).toBe(index * 100)
+ expect(outerStyle.height).toBe(100)
+ expect(clip.props.style.overflow).toBe('hidden')
+ expect(measured.props.style.height).toBe(222)
+ act(() => {
+ measured.props.onLayout({ nativeEvent: { layout: { width: 390, height: 222 } } })
+ })
+ expect(onFrame).toHaveBeenLastCalledWith(`term-${index}`, { width: 390, height: 222 })
+ const terminal = renderer.root.findByType(hosts.TerminalWebView)
+ const terminalStyle = Object.assign({}, ...terminal.props.style.filter(Boolean))
+ const bottom =
+ outerStyle.top + 28 + measured.props.style.height + terminalStyle.transform[0].translateY
+ expect(bottom).toBe(index * 100 + 100)
+ expect(bottom).toBeGreaterThan(outerStyle.top + 28)
+ expect(bottom).toBeLessThanOrEqual(200)
+ } finally {
+ act(() => renderer?.unmount())
+ }
+ }
+)
+
+it.each(['stacked', 'side-by-side'] as const)(
+ 'keeps visible %s terminal content above the keyboard independently of input focus',
+ (layout) => {
+ const frame = { width: 900, height: 500 }
+ const terminalHeight = layout === 'stacked' ? 222 : 472
+ const visibleHeight = layout === 'stacked' ? 72 : 172
+ const expectedLift = layout === 'stacked' ? 150 : 300
+ const geometry = new Map()
+ for (const handle of ['a', 'b', 'c']) {
+ terminalPaneViewport(geometry, handle).terminalFrameRef.current = {
+ width: layout === 'stacked' ? 900 : 450,
+ height: terminalHeight
+ }
+ }
+ const controller = mountFixture[0]['controller']>(
+ {
+ terminals: ['a', 'b', 'c'].map((handle) => ({ handle, title: handle })),
+ activeHandle: 'a',
+ terminalSplitActive: true,
+ terminalSplitLayout: layout,
+ visibleTerminalHandles: ['a', 'b'],
+ terminalKeyboardMetrics: new Map([
+ ['a', { rows: 10, rowPitch: 5, cursorY: 2, contentBottomRow: 2, altScreen: false }],
+ [
+ 'b',
+ {
+ rows: 40,
+ rowPitch: terminalHeight / 40,
+ cursorY: 39,
+ contentBottomRow: 39,
+ altScreen: true
+ }
+ ]
+ ]),
+ getTerminalPaneViewport: (handle) => terminalPaneViewport(geometry, handle),
+ keyboardLift: 0,
+ activeTerminalKeyboardLift: 0,
+ notifyTerminalFrame: vi.fn(),
+ dictation: { isRecording: false },
+ nativeChatSendError: { message: null, clear: vi.fn() }
+ }
+ )
+ let renderer!: ReturnType
+ const render = (activeHandle: string, keyboardLift: number) => {
+ const element = createElement(MobileSessionActiveContent, {
+ controller: { ...controller, activeHandle, keyboardLift }
+ })
+ act(() => {
+ if (renderer) {
+ renderer.update(element)
+ } else {
+ renderer = create(element)
+ }
+ })
+ }
+ const terminalStyle = (handle: string) => {
+ const pane = renderer.root
+ .findAllByType(TerminalPaneView)
+ .find((item) => item.props.handle === handle)!
+ const terminal = pane.findByType(hosts.TerminalWebView)
+ return Object.assign({}, ...terminal.props.style.filter(Boolean))
+ }
+ try {
+ render('a', 0)
+ act(() => {
+ renderer.root
+ .findAllByType(hosts.View)[0]
+ .props.onLayout({ nativeEvent: { layout: frame } })
+ })
+ for (const focused of ['a', 'b', 'a']) {
+ render(focused, 300)
+ expect(terminalStyle('a').transform).toBeUndefined()
+ expect(terminalStyle('b').transform).toEqual([{ translateY: -expectedLift }])
+ // A pane never shown has not mounted its terminal yet.
+ expect(renderer.root.findAllByType(hosts.TerminalWebView)).toHaveLength(2)
+ for (const pane of renderer.root.findAllByType(TerminalPaneView).slice(0, 2)) {
+ const [outer, clip, measured] = pane.findAllByType(hosts.View)
+ const outerStyle = Object.assign({}, ...outer.props.style.filter(Boolean))
+ expect(outerStyle.height - 28).toBe(visibleHeight)
+ expect(clip.props.style.overflow).toBe('hidden')
+ expect(measured.props.style.height).toBe(terminalHeight)
+ }
+ expect(terminalHeight + terminalStyle('b').transform[0].translateY).toBe(visibleHeight)
+ }
+ render('a', 0)
+ expect(terminalStyle('a').transform).toBeUndefined()
+ expect(terminalStyle('b').transform).toBeUndefined()
+ expect(renderer.root.findAllByType(hosts.TerminalWebView)).toHaveLength(2)
+ } finally {
+ act(() => renderer?.unmount())
+ }
+ }
+)
diff --git a/mobile/src/session/TerminalPaneView.tsx b/mobile/src/session/TerminalPaneView.tsx
index 6b47b7b2789..909ae468646 100644
--- a/mobile/src/session/TerminalPaneView.tsx
+++ b/mobile/src/session/TerminalPaneView.tsx
@@ -1,7 +1,13 @@
import { useCallback, useEffect, useState } from 'react'
-import { AppState, StyleSheet, View } from 'react-native'
+import { AppState, StyleSheet, View, Text, Pressable } from 'react-native'
import { useFocusEffect } from 'expo-router'
+import { useMobileTranslation } from '../i18n/use-mobile-translation'
import type { ParsedFileLinkLocation } from '../../../src/shared/file-link-location'
+import { terminalPaneKeyboardLayout } from './mobile-terminal-pane-layout'
+import { colors, spacing } from '../theme/mobile-theme'
+import { themedStyleSheet } from '../theme/themed-styles'
+import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
+import type { TerminalFrame } from '../terminal/terminal-webview-messages'
import { TerminalWebView } from '../terminal/TerminalWebView'
import type {
MobileTerminalTheme,
@@ -14,6 +20,13 @@ import type {
type TerminalPaneViewProps = {
handle: string
active: boolean
+ visible?: boolean
+ splitIndex?: number
+ splitDirection?: 'row' | 'column'
+ splitFrame?: TerminalFrame
+ keyboardInset?: number
+ title?: string
+ onFrame?: (handle: string, frame: TerminalFrame) => void
keyboardLift: number
terminalTheme?: MobileTerminalTheme
textScale: number
@@ -28,6 +41,7 @@ type TerminalPaneViewProps = {
onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
onTerminalInput: (handle: string, bytes: string, input?: TerminalPointerInput) => void
onTerminalQueryReply: (handle: string, bytes: string) => void
+ onTerminalInteraction: (handle: string) => void
onTerminalTap: (handle: string) => void
onFileTap: (handle: string, candidates: readonly ParsedFileLinkLocation[]) => void
onOpenUrl: (handle: string, url: string) => void
@@ -38,6 +52,13 @@ type TerminalPaneViewProps = {
export function TerminalPaneView({
handle,
active,
+ visible = active,
+ splitIndex,
+ splitDirection = 'row',
+ splitFrame,
+ keyboardInset = 0,
+ title,
+ onFrame,
keyboardLift,
terminalTheme,
textScale,
@@ -52,14 +73,17 @@ export function TerminalPaneView({
onHaptic,
onTerminalInput,
onTerminalQueryReply,
+ onTerminalInteraction,
onTerminalTap,
onFileTap,
onOpenUrl,
onTextScaleChange,
onCellBoxChange
}: TerminalPaneViewProps) {
- const [visited, setVisited] = useState(active)
- if (active && !visited) {
+ useMobileColorScheme()
+ const t = useMobileTranslation()
+ const [visited, setVisited] = useState(visible)
+ if (visible && !visited) {
setVisited(true)
}
const [routeFocused, setRouteFocused] = useState(false)
@@ -83,56 +107,117 @@ export function TerminalPaneView({
[handle, onRef]
)
- if (!active && !visited) {
+ const [headingHeight, setHeadingHeight] = useState(28)
+ const splitLayout =
+ splitIndex !== undefined && splitFrame && splitFrame.height > 0
+ ? terminalPaneKeyboardLayout(splitFrame, splitDirection, splitIndex, keyboardInset)
+ : null
+
+ if (!visible && !visited) {
return null
}
return (
0 && { transform: [{ translateY: -keyboardLift }] },
- !active && styles.terminalPaneHidden
+ splitIndex !== undefined &&
+ (splitDirection === 'row'
+ ? { width: '50%', left: splitIndex === 0 ? '0%' : '50%', right: undefined }
+ : { height: '50%', top: splitIndex === 0 ? '0%' : '50%', bottom: undefined }),
+ splitLayout && { top: splitLayout.top, height: splitLayout.height, bottom: undefined },
+ !visible && styles.terminalPaneHidden
]}
>
- onWebReady(handle)}
- onSelectionMode={(a) => onSelectionMode(handle, a)}
- onSelectionCopy={(t) => onSelectionCopy(handle, t)}
- onSelectionEvicted={() => onSelectionEvicted(handle)}
- onClipboardWrite={(t) => onClipboardWrite(handle, t)}
- onModesChanged={(m) => onModesChanged(handle, m)}
- onKeyboardAvoidanceMetrics={(m) => onKeyboardAvoidanceMetrics(handle, m)}
- onHaptic={onHaptic}
- onTerminalInput={(bytes, input) => onTerminalInput(handle, bytes, input)}
- onTerminalQueryReply={(bytes) => onTerminalQueryReply(handle, bytes)}
- onTerminalTap={() => onTerminalTap(handle)}
- onFileTap={(candidates) => onFileTap(handle, candidates)}
- onOpenUrl={(url) => onOpenUrl(handle, url)}
- onTextScaleChange={onTextScaleChange}
- onCellBoxChange={() => onCellBoxChange(handle)}
- />
+ {splitIndex !== undefined && (
+ onTerminalTap(handle)}
+ style={[styles.paneHeading, active && styles.paneHeadingActive]}
+ onLayout={(event) => setHeadingHeight(event.nativeEvent.layout.height)}
+ >
+
+ {active
+ ? t('session.terminalPaneView.inputPane', '{{title}} · Input', {
+ title: title || t('session.terminalPaneView.terminal', 'Terminal')
+ })
+ : title || t('session.terminalPaneView.terminal', 'Terminal')}
+
+
+ )}
+
+ onFrame?.(handle, event.nativeEvent.layout)}
+ >
+ 0 && { transform: [{ translateY: -keyboardLift }] }
+ ]}
+ terminalTheme={terminalTheme}
+ textScale={textScale}
+ shownAtMount={visible}
+ interactionActive={visible && appActive && routeFocused}
+ onWebReady={() => onWebReady(handle)}
+ onSelectionMode={(a) => onSelectionMode(handle, a)}
+ onSelectionCopy={(t) => onSelectionCopy(handle, t)}
+ onSelectionEvicted={() => onSelectionEvicted(handle)}
+ onClipboardWrite={(t) => onClipboardWrite(handle, t)}
+ onModesChanged={(m) => onModesChanged(handle, m)}
+ onKeyboardAvoidanceMetrics={(m) => onKeyboardAvoidanceMetrics(handle, m)}
+ onHaptic={onHaptic}
+ onTerminalInput={(bytes, input) => onTerminalInput(handle, bytes, input)}
+ onTerminalQueryReply={(bytes) => onTerminalQueryReply(handle, bytes)}
+ onTerminalInteraction={() => onTerminalInteraction(handle)}
+ onTerminalTap={() => onTerminalTap(handle)}
+ onFileTap={(candidates) => onFileTap(handle, candidates)}
+ onOpenUrl={(url) => onOpenUrl(handle, url)}
+ onTextScaleChange={onTextScaleChange}
+ onCellBoxChange={() => onCellBoxChange(handle)}
+ />
+
+
)
}
-const styles = StyleSheet.create({
+const styles = themedStyleSheet(() => ({
terminalPane: {
...StyleSheet.absoluteFillObject
},
+ paneViewport: { flex: 1, overflow: 'hidden' },
+ paneHeading: {
+ minHeight: 28,
+ justifyContent: 'center',
+ paddingHorizontal: spacing.sm,
+ backgroundColor: colors.bgPanel,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.borderSubtle
+ },
+ paneHeadingActive: { borderBottomColor: colors.textPrimary },
+ paneTitle: { color: colors.textSecondary, fontSize: 12 },
terminalPaneHidden: {
opacity: 0
},
terminalWebView: {
flex: 1
}
-})
+}))
diff --git a/mobile/src/session/mobile-session-command-dock-shown.ts b/mobile/src/session/mobile-session-command-dock-shown.ts
new file mode 100644
index 00000000000..3da4940dcc6
--- /dev/null
+++ b/mobile/src/session/mobile-session-command-dock-shown.ts
@@ -0,0 +1,11 @@
+import type { MobileSessionController } from './use-mobile-session-controller'
+
+// Markdown, file, browser and chat tabs bring their own input; only terminals get the dock and its keys.
+export function sessionCommandDockShown(controller: MobileSessionController): boolean {
+ return (
+ !controller.activeMarkdownTab &&
+ !controller.activeFileTab &&
+ !controller.activeBrowserTab &&
+ !controller.showNativeChat
+ )
+}
diff --git a/mobile/src/session/mobile-session-connection-retry.test.tsx b/mobile/src/session/mobile-session-connection-retry.test.tsx
index 13c27955d74..1ba2202a53e 100644
--- a/mobile/src/session/mobile-session-connection-retry.test.tsx
+++ b/mobile/src/session/mobile-session-connection-retry.test.tsx
@@ -4,6 +4,10 @@ import { describe, expect, it, vi } from 'vitest'
import type { ForceReconnect } from '../transport/rpc-client-context-contract'
vi.mock('react-native', () => ({ Platform: { OS: 'web' } }))
+vi.mock('../layout/use-shell-side-rail', () => ({
+ useShellSideRail: () => false,
+ useDirectionPadAvailable: () => false
+}))
import { useMobileSessionPresentation } from './use-mobile-session-presentation'
@@ -53,17 +57,21 @@ function present(forceReconnectHost: ForceReconnect): Presentation {
describe("the session header's tap to retry", () => {
it('is not offered on the page, and the status says what is wrong without promising a tap', () => {
- const { showConnectionRetry, terminalSummary } = present(null)
- expect({ showConnectionRetry, terminalSummary }).toEqual({
+ const { showConnectionRetry, connectionEscalated, terminalSummary } = present(null)
+ expect({ showConnectionRetry, connectionEscalated, terminalSummary }).toEqual({
showConnectionRetry: false,
+ connectionEscalated: true,
terminalSummary: "Can't reach desktop"
})
})
it('is still offered natively', () => {
- const { showConnectionRetry, terminalSummary } = present(() => Promise.resolve())
- expect({ showConnectionRetry, terminalSummary }).toEqual({
+ const { showConnectionRetry, connectionEscalated, terminalSummary } = present(() =>
+ Promise.resolve()
+ )
+ expect({ showConnectionRetry, connectionEscalated, terminalSummary }).toEqual({
showConnectionRetry: true,
+ connectionEscalated: true,
terminalSummary: "Can't reach desktop — tap to retry"
})
})
diff --git a/mobile/src/session/mobile-session-header-overflow.test.tsx b/mobile/src/session/mobile-session-header-overflow.test.tsx
new file mode 100644
index 00000000000..a644a510c2e
--- /dev/null
+++ b/mobile/src/session/mobile-session-header-overflow.test.tsx
@@ -0,0 +1,208 @@
+import { createElement, useRef, useState, type ReactNode } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, expect, it, vi } from 'vitest'
+import { mountFixture } from '../test-support/rpc-recording/recorder-fixture-shape'
+import type { MobileSessionController } from './use-mobile-session-controller'
+
+vi.mock('react-native', () => ({
+ View: 'View',
+ Text: 'Text',
+ Pressable: 'Pressable',
+ ScrollView: 'ScrollView',
+ ActivityIndicator: 'ActivityIndicator',
+ StyleSheet: { create: (v: T) => v, hairlineWidth: 1 }
+}))
+vi.mock('react-native-safe-area-context', () => ({ SafeAreaView: 'SafeAreaView' }))
+vi.mock('lucide-react-native', () => ({
+ ChevronLeft: 'Icon',
+ Folder: 'Icon',
+ GitBranch: 'Icon',
+ MoreHorizontal: 'Icon',
+ PanelLeftOpen: 'Icon',
+ Columns2: 'Icon',
+ Plus: 'Icon',
+ SquareChevronRight: 'Icon',
+ File: 'Icon',
+ FileText: 'Icon',
+ Globe: 'Icon',
+ SquareTerminal: 'Icon',
+ Check: 'Icon',
+ Rows2: 'Icon',
+ LayoutGrid: 'Icon',
+ Edit3: 'Icon',
+ Trash2: 'Icon',
+ ListChecks: 'Icon'
+}))
+vi.mock('../components/BottomDrawer', () => ({
+ BottomDrawer: ({ children, ...props }: { children: ReactNode; visible: boolean }) =>
+ createElement('Drawer', props, props.visible ? children : null)
+}))
+vi.mock('../agent-history/MobileAgentSessionHistoryIcon', () => ({
+ MobileAgentSessionHistoryIcon: () => null
+}))
+vi.mock('../components/StatusDot', () => ({ StatusDot: () => null }))
+vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: () => null }))
+vi.mock('../components/AgentStateDot', () => ({ AgentStateDot: () => null }))
+vi.mock('../platform/haptics', () => ({ triggerMediumImpact: () => {} }))
+vi.mock('../hooks/use-now', () => ({ useNow: () => 0 }))
+vi.mock('./mobile-session-styles', () => ({ styles: {} }))
+vi.mock('../layout/host-sidebar-reveal', () => ({ useHostSidebarReveal: () => null }))
+vi.mock('../layout/responsive-layout', () => ({
+ useResponsiveLayout: () => ({ width: 382, isLandscapePhone: false })
+}))
+vi.mock('../layout/use-top-chrome-space', () => ({ useTopChromeSpace: () => ({}) }))
+vi.mock('../layout/use-shell-side-rail', () => ({ useShellSideRail: () => true }))
+vi.mock('../platform/keyboard-persisting-taps', () => ({ useKeyboardPersistingTaps: () => null }))
+
+import { MobileSessionHeader } from './MobileSessionHeader'
+import { MobileSessionTerminalLayoutSheet } from './MobileSessionTerminalLayoutSheet'
+
+let tree: ReactTestRenderer | undefined
+afterEach(() => {
+ act(() => tree?.unmount())
+ tree = undefined
+})
+
+function mountHeader(overrides: Partial = {}) {
+ const callbacks = {
+ handleOpenNewTab: vi.fn(),
+ handleOpenQuickCommands: vi.fn(),
+ handlePanelTap: vi.fn(),
+ openAgentSessionHistory: vi.fn(),
+ setTerminalSplitLayout: vi.fn(),
+ toggleTerminalSplit: vi.fn()
+ }
+ function Screen() {
+ const [showHeaderMoreActions, setShowHeaderMoreActions] = useState(false)
+ const [showTerminalLayout, setShowTerminalLayout] = useState(false)
+ const [activeSessionTabId, setActiveSessionTabId] = useState('a')
+ const controller = mountFixture({
+ ...callbacks,
+ ...overrides,
+ showHeaderMoreActions,
+ setShowHeaderMoreActions,
+ showTerminalLayout,
+ setShowTerminalLayout,
+ activeSessionTabId,
+ activeSessionTabIdRef: useRef('a'),
+ activeHandleRef: useRef('term-a'),
+ tabStripRef: useRef(null),
+ tabStripOffsetRef: useRef(0),
+ tabStripViewportWidthRef: useRef(0),
+ tabStripContentWidthRef: useRef(0),
+ tabLayoutsRef: useRef(new Map()),
+ visibleTabs: [
+ { id: 'a', type: 'terminal', terminal: 'term-a', title: 'First' },
+ { id: 'b', type: 'terminal', terminal: 'term-b', title: 'Second' }
+ ],
+ terminals: [],
+ switchSessionTab: (tab) => setActiveSessionTabId(tab.id),
+ cancelHeldTabStripReveal: () => {},
+ scrollActiveTabIntoView: () => {},
+ canSplitTerminals: true,
+ connState: overrides.connState ?? 'connected',
+ showAgentSessionHistoryAction: true,
+ showChecksAction: true,
+ activePanel: null
+ })
+ return createElement(
+ 'Screen',
+ null,
+ createElement(MobileSessionHeader, { controller }),
+ createElement(MobileSessionTerminalLayoutSheet, {
+ controller,
+ visible: showTerminalLayout,
+ onClose: () => setShowTerminalLayout(false)
+ })
+ )
+ }
+ act(() => {
+ tree = create(createElement(Screen))
+ })
+ return callbacks
+}
+const native = (type: string) => tree!.root.findAll((node) => String(node.type) === type)
+function pressAction(label: string) {
+ const row = native('Pressable').find(
+ (node) =>
+ node.findAll((text) => String(text.type) === 'Text' && text.props.children === label).length >
+ 0
+ )!
+ act(() => row.props.onPress())
+}
+function openMore() {
+ act(() =>
+ native('Pressable')
+ .find((node) => node.props.accessibilityLabel === 'More session actions')!
+ .props.onPress()
+ )
+}
+function finishMenuClose() {
+ act(() => native('Drawer')[0].props.onAfterClose())
+}
+
+it('keeps tabs usable through narrow/wide changes and opens collapsed actions after dismissing the menu', () => {
+ const callbacks = mountHeader()
+ const originalTabs = native('Pressable').filter((node) => node.props.accessibilityRole === 'tab')
+ expect(
+ native('Pressable')
+ .filter((node) => node.props.accessibilityRole === 'button')
+ .map((node) => node.props.accessibilityLabel)
+ ).toEqual(['Back to worktrees', 'More session actions'])
+ act(() => originalTabs[1].props.onPress())
+ expect(originalTabs[1].props.accessibilityState.selected).toBe(true)
+ const header = native('View').find((node) => node.props.onLayout && !node.props.ref)!
+ act(() => header.props.onLayout({ nativeEvent: { layout: { width: 927 } } }))
+ expect(
+ native('Pressable').find((node) => node.props.accessibilityLabel === 'New tab')
+ ).toBeDefined()
+ act(() => header.props.onLayout({ nativeEvent: { layout: { width: 382 } } }))
+ expect(native('Pressable').filter((node) => node.props.accessibilityRole === 'tab')).toEqual(
+ originalTabs
+ )
+ openMore()
+ expect(native('Text').map((node) => node.props.children)).toContain('Agent History')
+ expect(native('Text').map((node) => node.props.children)).toContain('Checks')
+ pressAction('Terminal layout')
+ expect(native('Drawer').map((node) => node.props.visible)).toEqual([false, false])
+ finishMenuClose()
+ expect(native('Drawer').map((node) => node.props.visible)).toEqual([false, true])
+ pressAction('Side by side')
+ expect(callbacks.setTerminalSplitLayout).toHaveBeenCalledWith('side-by-side')
+ expect(callbacks.toggleTerminalSplit).toHaveBeenCalledOnce()
+ openMore()
+ pressAction('New tab')
+ expect(callbacks.handleOpenNewTab).not.toHaveBeenCalled()
+ finishMenuClose()
+ expect(callbacks.handleOpenNewTab).toHaveBeenCalledOnce()
+ openMore()
+ pressAction('Quick commands')
+ finishMenuClose()
+ expect(callbacks.handleOpenQuickCommands).toHaveBeenCalledOnce()
+ openMore()
+ pressAction('File explorer')
+ finishMenuClose()
+ openMore()
+ pressAction('Source control')
+ finishMenuClose()
+ expect(callbacks.handlePanelTap.mock.calls).toEqual([['files'], ['sourceControl']])
+})
+
+it.each([{ isFolderWorkspaceRoute: true }, { isFloatingWorkspaceRoute: true }])(
+ 'keeps workspace restrictions and connection gating in overflow (%j)',
+ (route) => {
+ mountHeader({ ...route, connState: 'disconnected' })
+ openMore()
+ const labels = native('Text').map((node) => node.props.children)
+ expect(labels).not.toContain('Source control')
+ expect(labels.includes('File explorer')).toBe(!('isFloatingWorkspaceRoute' in route))
+ for (const label of ['New tab', 'Quick commands']) {
+ const row = native('Pressable').find(
+ (node) =>
+ node.findAll((text) => String(text.type) === 'Text' && text.props.children === label)
+ .length > 0
+ )!
+ expect(row.props.disabled).toBe(true)
+ }
+ }
+)
diff --git a/mobile/src/session/mobile-session-keyboard-edge-layout.test.tsx b/mobile/src/session/mobile-session-keyboard-edge-layout.test.tsx
new file mode 100644
index 00000000000..bda88bb7dce
--- /dev/null
+++ b/mobile/src/session/mobile-session-keyboard-edge-layout.test.tsx
@@ -0,0 +1,78 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, expect, it, vi } from 'vitest'
+import { mountFixture } from '../test-support/rpc-recording/recorder-fixture-shape'
+const layout = vi.hoisted(() => ({ sideRail: false, padAvailable: false, os: 'ios' }))
+vi.mock('react-native', () => ({ Platform: { OS: 'web' } }))
+vi.mock('../platform/host-os', () => ({ hostOs: () => layout.os }))
+vi.mock('../layout/use-shell-side-rail', () => ({
+ useShellSideRail: () => layout.sideRail,
+ useDirectionPadAvailable: () => layout.padAvailable
+}))
+import { useMobileSessionPresentation } from './use-mobile-session-presentation'
+
+type Scope = Parameters[0]
+let tree: ReactTestRenderer | undefined
+afterEach(() => {
+ act(() => tree?.unmount())
+ tree = undefined
+ Object.assign(layout, { sideRail: false, padAvailable: false, os: 'ios' })
+})
+it.each(['ios', 'android'])(
+ 'keeps the keyboard boundary aligned across Duo posture changes on %s',
+ (os) => {
+ layout.os = os
+ const scope = mountFixture({
+ client: null,
+ connState: 'connected',
+ activeHandle: 'a',
+ terminalsLoaded: true,
+ visibleTabs: [{ id: 'a', type: 'terminal', terminal: 'a' }],
+ insets: { top: 0, left: 0, right: 0, bottom: 34 },
+ keyboardHeight: 334,
+ terminalKeyboardMetrics: new Map(),
+ terminalFrameRef: { current: null },
+ toastOpacityRef: { current: {} }
+ })
+ function Screen({ keyboardHeight, view }: { keyboardHeight: number; view: string }) {
+ const scene = mountFixture({
+ showNativeChat: view === 'chat',
+ activeMarkdownTab: view === 'markdown' ? {} : null,
+ activeFileTab: view === 'file' ? {} : null,
+ activeBrowserTab: view === 'browser' ? {} : null
+ })
+ const value = useMobileSessionPresentation({ ...scope, ...scene, keyboardHeight })
+ return createElement('KeyboardBoundary', value)
+ }
+ const render = (keyboardHeight = 334, view = 'terminal') =>
+ act(() => {
+ if (tree) {
+ tree.update(createElement(Screen, { keyboardHeight, view }))
+ } else {
+ tree = create(createElement(Screen, { keyboardHeight, view }))
+ }
+ })
+ const lift = () =>
+ tree!.root.find((node) => String(node.type) === 'KeyboardBoundary').props.keyboardLift
+ render()
+ expect(lift()).toBe(os === 'ios' ? 300 : 334)
+ layout.sideRail = true
+ render()
+ expect(lift()).toBe(334)
+ layout.sideRail = false
+ layout.padAvailable = true
+ render()
+ expect(lift()).toBe(334)
+ for (const view of ['chat', 'browser', 'markdown', 'file']) {
+ render(334, view)
+ expect(lift()).toBe(os === 'ios' ? 300 : 334)
+ render()
+ expect(lift()).toBe(334)
+ }
+ render(0)
+ expect(lift()).toBe(0)
+ layout.padAvailable = false
+ render()
+ expect(lift()).toBe(os === 'ios' ? 300 : 334)
+ }
+)
diff --git a/mobile/src/session/mobile-session-preference-startup.test.ts b/mobile/src/session/mobile-session-preference-startup.test.ts
index 548da69e5ef..500d344c59e 100644
--- a/mobile/src/session/mobile-session-preference-startup.test.ts
+++ b/mobile/src/session/mobile-session-preference-startup.test.ts
@@ -1,5 +1,11 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
-import { createElement } from 'react'
+import { createElement, useEffect } from 'react'
+import { createFakeBridgePortPair } from '../mobile-web-shell/bridge/bridge-port-pair-test-harness'
+import pageAsyncStorage, {
+ publishPageStorage,
+ refreshPageTerminalShortcuts
+} from '../mobile-web-shell/bridge/page-async-storage'
+import type { BridgeRpcClient } from '../mobile-web-shell/bridge/bridge-rpc-client'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TERMINAL_ACCESSORY_KEYS } from '../terminal/terminal-accessory-keys'
@@ -8,11 +14,21 @@ import { useMobileSessionLifecycle } from './use-mobile-session-lifecycle'
import { useMobileSessionPreferenceFocus } from './use-mobile-session-preference-focus'
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
+const pageBridge = vi.hoisted((): { client: BridgeRpcClient | null } => ({ client: null }))
+vi.mock('../transport/client-context.web', () => ({
+ usePageBridgeClientIfPresent: () => pageBridge.client
+}))
+vi.mock('../platform/shell-storage', async () => import('../platform/shell-storage.web'))
+
const focusEffects = vi.hoisted(() => ({
callbacks: new Map<() => void | (() => void), void | (() => void)>(),
appStateListeners: new Set<(state: string) => void>()
}))
+const shellGeometry = vi.hoisted(() => ({ sideRail: false }))
+vi.mock('../layout/use-shell-side-rail', () => ({
+ useDirectionPadAvailable: () => shellGeometry.sideRail
+}))
vi.mock('react-native', () => ({
AppState: {
currentState: 'active',
@@ -75,6 +91,7 @@ function sessionScope() {
setTerminalLinkOpenMode: vi.fn(),
setCustomKeys: vi.fn(),
setVisibleBuiltInIds: vi.fn(),
+ setDirectionPadEnabled: vi.fn(),
setHostEndpoint: vi.fn(),
connStateRef: { current: 'connecting' },
terminalRefs: { current: new Map() },
@@ -88,6 +105,8 @@ function sessionScope() {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
+ pageBridge.client = null
+ shellGeometry.sideRail = false
focusEffects.callbacks.clear()
focusEffects.appStateListeners.clear()
vi.mocked(AsyncStorage.getItem).mockReset().mockResolvedValue(null)
@@ -344,6 +363,158 @@ describe('saved session settings before the first render', () => {
})
})
+it('reloads custom visibility and order plus D-pad state when returning from Manage Shortcuts', async () => {
+ const scope = sessionScope()
+ let storedKeys = [{ id: 'one', label: 'One', bytes: '1', enter: false, visible: true }]
+ let padEnabled = true
+ vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) =>
+ key === 'orca:custom-accessory-keys'
+ ? JSON.stringify(storedKeys)
+ : JSON.stringify({
+ version: 2,
+ orderedBuiltInIds: TERMINAL_ACCESSORY_KEYS.map((key) => key.id),
+ visibleBuiltInIds: ['tab'],
+ directionPadEnabled: padEnabled
+ })
+ )
+ function Harness() {
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lifecycle reads only the supplied session members.
+ useMobileSessionLifecycle(scope as never)
+ return null
+ }
+ await act(async () => {
+ renderer = create(createElement(Harness))
+ })
+ expect(scope.setDirectionPadEnabled).toHaveBeenLastCalledWith(true)
+ storedKeys = [{ ...storedKeys[0]!, visible: false }]
+ padEnabled = false
+ await act(async () => {
+ for (const [effect, cleanup] of focusEffects.callbacks) {
+ cleanup?.()
+ focusEffects.callbacks.set(effect, effect())
+ }
+ })
+ expect(scope.setCustomKeys).toHaveBeenLastCalledWith(storedKeys)
+ expect(scope.setDirectionPadEnabled).toHaveBeenLastCalledWith(false)
+})
+
+it('finishes custom-key hydration when side-rail geometry arrives during the read', async () => {
+ const customRead = deferred()
+ vi.mocked(AsyncStorage.getItem).mockImplementation((key) =>
+ key === 'orca:custom-accessory-keys' ? customRead.promise : Promise.resolve(null)
+ )
+ const scope = sessionScope()
+ let ready = false
+ function Harness() {
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lifecycle reads only the supplied session members.
+ ready = useMobileSessionLifecycle(scope as never).accessoryPreferencesReady
+ return null
+ }
+ await act(async () => {
+ renderer = create(createElement(Harness))
+ })
+ shellGeometry.sideRail = true
+ await act(async () => renderer!.update(createElement(Harness)))
+ await act(async () => customRead.resolve('[]'))
+ expect(ready).toBe(true)
+ expect(scope.setCustomKeys).toHaveBeenLastCalledWith([])
+ expect(scope.setVisibleBuiltInIds.mock.calls.at(-1)![0]).not.toContain('arrowUp')
+})
+
+it('refreshes a mounted terminal from native shortcut settings without router focus, remount or draft writes', async () => {
+ const layoutKey = 'orca:terminal-accessory-layout'
+ const customKey = 'orca:custom-accessory-keys'
+ const draftKey = 'orca:terminalDrafts:host-a:wt-1'
+ const layout = (ids: string[], pad: boolean) =>
+ JSON.stringify({
+ version: 2,
+ orderedBuiltInIds: TERMINAL_ACCESSORY_KEYS.map((key) => key.id),
+ visibleBuiltInIds: ids,
+ directionPadEnabled: pad
+ })
+ const initial = layout(['escape', 'tab'], true)
+ const storage: Record = {
+ [layoutKey]: initial,
+ [customKey]: '[]',
+ [draftKey]: 'old draft'
+ }
+ const pair = createFakeBridgePortPair({ route: { pathname: '/h/host-a/session/wt-1' }, storage })
+ await pair.flush()
+ pageBridge.client = pair.client
+ publishPageStorage(
+ storage,
+ (key, value) => pair.client.notifyStorageWrite(key, value),
+ 'host-a',
+ '/h/host-a/session/wt-1'
+ )
+ pair.client.onStorageUpdate(() => {
+ const session = pair.client.getShellSession()!
+ refreshPageTerminalShortcuts(session.storage, session.storageOversize)
+ })
+ vi.mocked(AsyncStorage.getItem).mockImplementation(pageAsyncStorage.getItem)
+ const scope = sessionScope()
+ let mounts = 0
+ function Harness() {
+ useEffect(() => {
+ mounts += 1
+ }, [])
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lifecycle reads only the supplied session members.
+ useMobileSessionLifecycle(scope as never)
+ return null
+ }
+ await act(async () => {
+ renderer = create(createElement(Harness))
+ })
+ expect(scope.setDirectionPadEnabled).toHaveBeenLastCalledWith(true)
+ await pageAsyncStorage.setItem(draftKey, 'unsent page draft')
+ await pair.flush()
+ const writes = pair.storageWrites.length
+ storage[layoutKey] = layout(['enter', 'arrowUp'], false)
+ storage[customKey] = JSON.stringify([{ id: 'two', label: 'Two', bytes: '2', enter: false }])
+ await act(async () => {
+ pair.host.publishStorage()
+ await pair.flush()
+ })
+ expect(scope.setDirectionPadEnabled).toHaveBeenLastCalledWith(false)
+ expect(scope.setVisibleBuiltInIds).toHaveBeenLastCalledWith(['enter', 'arrowUp'])
+ expect(scope.setCustomKeys).toHaveBeenLastCalledWith([
+ { id: 'two', label: 'Two', bytes: '2', enter: false }
+ ])
+ expect(await pageAsyncStorage.getItem(draftKey)).toBe('unsent page draft')
+ expect(pair.storageWrites).toHaveLength(writes)
+ expect(mounts).toBe(1)
+ expect(pair.client.getShellSession()?.sessionId).toBe('session-a')
+
+ // A local edit can differ from the last native snapshot even when native resets to that snapshot.
+ await pageAsyncStorage.setItem(layoutKey, initial)
+ await pair.flush()
+ await act(async () => {
+ pair.host.publishKeyboardInset(222)
+ await pair.flush()
+ })
+ expect(await pageAsyncStorage.getItem(layoutKey)).toBe(initial)
+ await act(async () => {
+ pair.host.publishStorage()
+ await pair.flush()
+ })
+ expect(await pageAsyncStorage.getItem(layoutKey)).toBe(storage[layoutKey])
+ expect(scope.setDirectionPadEnabled).toHaveBeenLastCalledWith(false)
+
+ delete storage[layoutKey]
+ delete storage[customKey]
+ const afterLocalWrite = pair.storageWrites.length
+ await act(async () => {
+ pair.host.publishStorage()
+ await pair.flush()
+ })
+ expect(await pageAsyncStorage.getItem(layoutKey)).toBeNull()
+ expect(scope.setDirectionPadEnabled).toHaveBeenLastCalledWith(true)
+ expect(scope.setCustomKeys).toHaveBeenLastCalledWith([])
+ expect(pair.storageWrites).toHaveLength(afterLocalWrite)
+ pair.client.close()
+ pair.host.dispose()
+})
+
describe('session custom shortcuts after saved settings change', () => {
const oldKey = { id: 'old', label: 'Old', bytes: 'old', enter: false }
const newKey = { id: 'new', label: 'New', bytes: 'new\r', enter: false }
diff --git a/mobile/src/session/mobile-session-terminal-frame-layout.test.tsx b/mobile/src/session/mobile-session-terminal-frame-layout.test.tsx
index 010275dcb0a..8631fa85871 100644
--- a/mobile/src/session/mobile-session-terminal-frame-layout.test.tsx
+++ b/mobile/src/session/mobile-session-terminal-frame-layout.test.tsx
@@ -89,3 +89,56 @@ describe('the terminal frame on the page', () => {
expect(heights).toEqual([FRAME.height])
})
})
+
+it('shows two terminal panes while focus changes without mounting a second session', async () => {
+ const { TerminalPaneView } = await import('./TerminalPaneView')
+ const { terminalPaneViewport } = await import('./mobile-terminal-pane-layout')
+ const geometry = new Map()
+ const scope = Object.assign(controller('terminal', vi.fn()), {
+ terminals: ['a', 'b', 'c'].map((handle) => ({
+ handle,
+ title: handle,
+ isActive: handle === 'a'
+ })),
+ activeHandle: 'a',
+ visibleTerminalHandles: ['a', 'b'],
+ terminalSplitActive: true,
+ terminalKeyboardMetrics: new Map(),
+ getTerminalPaneViewport: (handle: string) => terminalPaneViewport(geometry, handle),
+ keyboardLift: 0,
+ activeTerminalKeyboardLift: 0
+ })
+ let mounted!: ReturnType
+ try {
+ act(() => {
+ mounted = create(createElement(MobileSessionActiveContent, { controller: scope }))
+ })
+ const panes = () => mounted.root.findAllByType(TerminalPaneView)
+ expect(
+ panes().map((pane) => [pane.props.handle, pane.props.visible, pane.props.active])
+ ).toEqual([
+ ['a', true, true],
+ ['b', true, false],
+ ['c', false, false]
+ ])
+ expect(
+ panes()
+ .slice(0, 2)
+ .map((pane) => pane.props.splitDirection)
+ ).toEqual(['column', 'column'])
+ act(() => {
+ mounted.update(
+ createElement(MobileSessionActiveContent, { controller: { ...scope, activeHandle: 'b' } })
+ )
+ })
+ expect(
+ panes().map((pane) => [pane.props.handle, pane.props.visible, pane.props.active])
+ ).toEqual([
+ ['a', true, false],
+ ['b', true, true],
+ ['c', false, false]
+ ])
+ } finally {
+ act(() => mounted?.unmount())
+ }
+})
diff --git a/mobile/src/session/mobile-session-toast-owner-cleanup.test.tsx b/mobile/src/session/mobile-session-toast-owner-cleanup.test.tsx
index ec2244c1cdb..bbe85cd4b34 100644
--- a/mobile/src/session/mobile-session-toast-owner-cleanup.test.tsx
+++ b/mobile/src/session/mobile-session-toast-owner-cleanup.test.tsx
@@ -56,9 +56,6 @@ vi.mock('./mobile-session-styles', () => ({ styles: { container: {}, kavInner: {
vi.mock('./MobileSessionHeader', () => ({ MobileSessionHeader: () => null }))
vi.mock('./MobileSessionContentRow', () => ({ MobileSessionContentRow: () => null }))
vi.mock('./MobileSessionSheets', () => ({ MobileSessionSheets: () => null }))
-vi.mock('./session-side-rail', () => ({
- useSessionSideRail: () => ({ backInRail: false, keysInRail: false })
-}))
import { useMobileSessionFeedbackCapabilities } from './use-mobile-session-feedback-capabilities'
import { useMobileSessionAccessorySelection } from './use-mobile-session-accessory-selection'
import { MobileSessionSurface } from './MobileSessionSurface'
diff --git a/mobile/src/session/mobile-terminal-layout-choice.test.tsx b/mobile/src/session/mobile-terminal-layout-choice.test.tsx
new file mode 100644
index 00000000000..9bc80b6164c
--- /dev/null
+++ b/mobile/src/session/mobile-terminal-layout-choice.test.tsx
@@ -0,0 +1,343 @@
+import { createElement, useLayoutEffect, useRef, useState } from 'react'
+import { act, create } from 'react-test-renderer'
+import { expect, it, vi } from 'vitest'
+import { mountFixture } from '../test-support/rpc-recording/recorder-fixture-shape'
+
+vi.mock('react-native', () => ({
+ AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) },
+ View: 'View',
+ Text: 'Text',
+ Pressable: 'Pressable',
+ ScrollView: 'ScrollView',
+ Alert: { alert: vi.fn() },
+ ActivityIndicator: 'ActivityIndicator',
+ Animated: { View: 'View' },
+ StyleSheet: { create: (v: T) => v, absoluteFillObject: {} }
+}))
+vi.mock('lucide-react-native', () => ({
+ File: 'Icon',
+ FileText: 'Icon',
+ Globe: 'Icon',
+ Plus: 'Icon',
+ SquareTerminal: 'Icon',
+ Columns2: 'Icon',
+ Rows2: 'Icon',
+ Check: 'Icon',
+ LayoutGrid: 'Icon',
+ Edit3: 'Icon',
+ Trash2: 'Icon'
+}))
+vi.mock('../components/BottomDrawer', () => ({
+ BottomDrawer: ({ visible, children }: { visible: boolean; children: unknown }) =>
+ visible ? children : null
+}))
+vi.mock('../platform/haptics', () => ({ triggerMediumImpact: vi.fn(), triggerSelection: vi.fn() }))
+vi.mock('../hooks/use-now', () => ({ useNow: () => 0 }))
+vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: () => null }))
+vi.mock('../components/AgentStateDot', () => ({ AgentStateDot: () => null }))
+vi.mock('./MobileSessionHeaderIconButton', () => ({
+ MobileSessionHeaderIconButton: 'HeaderButton'
+}))
+vi.mock('./QuickCommandsTabButton', () => ({ QuickCommandsTabButton: () => null }))
+vi.mock('./mobile-session-styles', () => ({ styles: {} }))
+vi.mock('../transport/use-host-profile', () => ({ useHostProfile: () => null }))
+vi.mock('expo-router', async () => {
+ const { useEffect } = await import('react')
+ return { useFocusEffect: (effect: () => () => void) => useEffect(effect, [effect]) }
+})
+vi.mock('../platform/keyboard-persisting-taps', () => ({ useKeyboardPersistingTaps: () => null }))
+vi.mock('../navigation/workspace-execution-host', () => ({ useWorkspaceExecutionHost: () => ({}) }))
+vi.mock('../browser/MobileBrowserPane', () => ({ MobileBrowserPane: () => null }))
+vi.mock('./MobileNativeChatOverlay', () => ({ MobileNativeChatOverlay: () => null }))
+vi.mock('./MobileSessionFileReader', () => ({ FileReader: () => null }))
+vi.mock('./MobileSessionMarkdownReader', () => ({ MarkdownReader: () => null }))
+vi.mock('../terminal/TerminalWebView', () => ({ TerminalWebView: 'TerminalWebView' }))
+const stored = vi.hoisted(() => new Map())
+vi.mock('@react-native-async-storage/async-storage', () => ({
+ default: {
+ getItem: vi.fn(async (key: string) => stored.get(key) ?? null),
+ setItem: vi.fn(async (key: string, value: string) => {
+ stored.set(key, value)
+ })
+ }
+}))
+import { useTerminalSplitLayout } from './use-terminal-split-layout'
+import { MobileSessionTabStrip } from './MobileSessionTabStrip'
+import { MobileSessionTerminalLayoutSheet } from './MobileSessionTerminalLayoutSheet'
+import { MobileSessionActiveContent } from './MobileSessionActiveContent'
+import { TerminalPaneView } from './TerminalPaneView'
+import { useMobileSessionTabSwitching } from './use-mobile-session-tab-switching'
+import { useMobileSessionTerminalWebview } from './use-mobile-session-terminal-webview'
+import { resolveTerminalPaneHandles } from './mobile-terminal-pane-layout'
+import { MobileTerminalDiagnostics } from './mobile-terminal-diagnostics'
+
+type Controller = Parameters[0]['controller']
+
+it('lets the split menu override sidebar width and rotation without remounting panes or changing focus', async () => {
+ let setKeyboard!: (height: number) => void
+ function Harness() {
+ const preference = useTerminalSplitLayout()
+ const [split, setSplit] = useState(true)
+ const [showTerminalLayout, setShowTerminalLayout] = useState(false)
+ const [active, setActive] = useState('b')
+ const [keyboard, setKeyboardState] = useState(0)
+ setKeyboard = setKeyboardState
+ const controller = mountFixture({
+ ...preference,
+ terminalSplitActive: split,
+ setShowTerminalLayout,
+ toggleTerminalSplit: () => setSplit(!split),
+ canSplitTerminals: true,
+ terminals: [
+ { handle: 'a', title: 'First' },
+ { handle: 'b', title: 'Second' }
+ ],
+ activeHandle: active,
+ visibleTerminalHandles: split ? ['a', 'b'] : [active],
+ activeHandleRef: { current: active },
+ activeSessionTabIdRef: { current: null },
+ visibleTabs: [],
+ tabStripRef: { current: null },
+ tabStripOffsetRef: { current: 0 },
+ tabStripViewportWidthRef: { current: 0 },
+ tabStripContentWidthRef: { current: 0 },
+ tabLayoutsRef: { current: new Map() },
+ terminalKeyboardMetrics: new Map(),
+ getTerminalPaneViewport: () => ({
+ terminalFrameRef: { current: null },
+ viewportRef: { current: null },
+ viewportMeasuredRef: { current: false }
+ }),
+ keyboardLift: keyboard,
+ activeTerminalKeyboardLift: 0,
+ terminalTextScale: 1,
+ nativeChatSendError: {},
+ dictation: {},
+ notifyTerminalFrame: vi.fn(),
+ setTerminalWebViewRef: vi.fn(),
+ handleTerminalTap: setActive
+ })
+ return createElement(
+ 'Screen',
+ null,
+ createElement(MobileSessionTabStrip, { controller, inTopBar: false }),
+ createElement(MobileSessionTerminalLayoutSheet, {
+ controller,
+ visible: showTerminalLayout,
+ onClose: () => setShowTerminalLayout(false)
+ }),
+ createElement(MobileSessionActiveContent, { controller })
+ )
+ }
+ let tree!: ReturnType
+ await act(async () => {
+ tree = create(createElement(Harness))
+ })
+ const frame = tree.root
+ .findByType(MobileSessionActiveContent)
+ .findAll((node) => typeof node.type === 'string' && String(node.type) === 'View')[0]
+ const resize = (width: number, height: number) =>
+ act(() => frame.props.onLayout({ nativeEvent: { layout: { width, height } } }))
+ const panes = () => tree.root.findAllByType(TerminalPaneView)
+ const geometry = (index: number) =>
+ Object.assign(
+ {},
+ ...panes()
+ [index].findAll((node) => typeof node.type === 'string' && String(node.type) === 'View')[0]
+ .props.style.filter(Boolean)
+ )
+ const originalTerminals = tree.root.findAll(
+ (node) => typeof node.type === 'string' && String(node.type) === 'TerminalWebView'
+ )
+ const choose = (label: string) => {
+ act(() =>
+ tree.root
+ .findAll((node) => typeof node.type === 'string' && String(node.type) === 'HeaderButton')
+ .find((node) => node.props.icon !== undefined && node.props.active !== undefined)!
+ .props.onPress()
+ )
+ const row = tree.root
+ .findAll((node) => typeof node.type === 'string' && String(node.type) === 'Pressable')
+ .find((node) =>
+ node
+ .findAll((node) => typeof node.type === 'string' && String(node.type) === 'Text')
+ .some((text) => text.props.children === label)
+ )
+ expect(row, `visible terminal layout choice: ${label}`).toBeDefined()
+ act(() => row!.props.onPress())
+ }
+ try {
+ resize(587, 330)
+ expect(geometry(1).top).toBe(165)
+ choose('Side by side')
+ expect(geometry(1)).toMatchObject({ width: '50%', left: '50%', top: 0 })
+ resize(927, 330)
+ expect(geometry(1).left).toBe('50%')
+ resize(390, 700)
+ expect(geometry(1).left).toBe('50%')
+ act(() => setKeyboard(300))
+ expect(geometry(1).height).toBe(400)
+ choose('Stacked')
+ expect(geometry(1)).toMatchObject({ top: 200, height: 200 })
+ act(() => setKeyboard(0))
+ resize(927, 330)
+ expect(geometry(1)).toMatchObject({ top: 165, height: 165 })
+ choose('Auto')
+ expect(geometry(1).left).toBe('50%')
+ resize(587, 330)
+ expect(geometry(1).top).toBe(165)
+ expect(panes().map((p) => [p.props.handle, p.props.active])).toEqual([
+ ['a', false],
+ ['b', true]
+ ])
+ expect(
+ tree.root.findAll(
+ (node) => typeof node.type === 'string' && String(node.type) === 'TerminalWebView'
+ )
+ ).toEqual(originalTerminals)
+ choose('One terminal')
+ expect(panes().map((p) => p.props.visible)).toEqual([false, true])
+ choose('Side by side')
+ expect(panes().map((p) => p.props.visible)).toEqual([true, true])
+ await act(async () => {})
+ expect(stored.get('orca:terminalSplitLayout')).toBe('side-by-side')
+ act(() => tree.unmount())
+ await act(async () => {
+ tree = create(createElement(Harness))
+ })
+ const restoredFrame = tree.root
+ .findByType(MobileSessionActiveContent)
+ .findAll((node) => typeof node.type === 'string' && String(node.type) === 'View')[0]
+ act(() =>
+ restoredFrame.props.onLayout({ nativeEvent: { layout: { width: 587, height: 330 } } })
+ )
+ expect(geometry(1)).toMatchObject({ width: '50%', left: '50%' })
+ } finally {
+ act(() => tree.unmount())
+ }
+})
+
+it('keeps C paired with A after replacing B and focusing A by its first terminal touch', () => {
+ const focus = vi.fn()
+ const terminals = ['a', 'b', 'c'].map((handle) => ({
+ handle,
+ title: handle,
+ isActive: handle === 'a'
+ }))
+ const tabs = terminals.map((t) => ({
+ type: 'terminal' as const,
+ id: t.handle,
+ title: t.title,
+ terminal: t.handle,
+ isActive: t.isActive
+ }))
+ let switchTo!: ReturnType['switchSessionTab']
+ let frameCallback: FrameRequestCallback | undefined
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
+ frameCallback = callback
+ return 1
+ })
+ vi.stubGlobal('cancelAnimationFrame', vi.fn())
+ function Harness() {
+ const [active, setActive] = useState('a')
+ const [pair, setPair] = useState(['a', 'b'])
+ const visible = resolveTerminalPaneHandles(
+ active,
+ pair,
+ terminals.map((t) => t.handle)
+ )
+ const activeRef = useRef(active)
+ const visibleRef = useRef(visible)
+ useLayoutEffect(() => {
+ activeRef.current = active
+ visibleRef.current = visible
+ })
+ const scope = mountFixture[0]>({
+ activeHandle: active,
+ activeHandleRef: activeRef,
+ setActiveHandle: setActive,
+ splitTerminalHandles: pair,
+ setSplitTerminalHandles: setPair,
+ visibleTerminalHandles: visible,
+ visibleTerminalHandlesRef: visibleRef,
+ terminals,
+ sessionTabs: tabs,
+ client: null,
+ connState: 'connected',
+ terminalDiagnosticsRef: useRef(new MobileTerminalDiagnostics()),
+ terminalUnsubsRef: useRef(new Map()),
+ terminalRefs: useRef(new Map()),
+ terminalPaneViewportsRef: useRef(new Map()),
+ terminalGestureInputBucketsRef: useRef(new Map()),
+ terminalGestureInputQueuesRef: useRef(new Map()),
+ terminalGestureInputInFlightRef: useRef(new Set()),
+ pendingSelectionRef: useRef(null),
+ activeSessionTabTypeRef: useRef(null),
+ switchSessionTabRef: useRef(null),
+ setActiveSessionTabId: vi.fn(),
+ defaultTerminalHandlesToLiveInput: vi.fn(),
+ subscribeToTerminal: vi.fn(),
+ unsubscribeTerminal: vi.fn(),
+ handleTerminalTap: focus
+ })
+ const switching = useMobileSessionTabSwitching(scope)
+ switchTo = switching.switchSessionTab
+ const webview = useMobileSessionTerminalWebview({ ...scope, ...switching })
+ return createElement(
+ 'Screen',
+ null,
+ terminals.map((terminal) =>
+ createElement(
+ TerminalPaneView,
+ mountFixture[0] & { key: string }>({
+ key: terminal.handle,
+ handle: terminal.handle,
+ active: active === terminal.handle,
+ visible: visible.includes(terminal.handle),
+ splitIndex: visible.includes(terminal.handle)
+ ? visible.indexOf(terminal.handle)
+ : undefined,
+ splitFrame: { width: 587, height: 330 },
+ splitDirection: 'row',
+ keyboardLift: 0,
+ textScale: 1,
+ onRef: webview.setTerminalWebViewRef,
+ onTerminalInteraction: webview.handleTerminalInteraction,
+ onTerminalTap: webview.handleTerminalTap
+ })
+ )
+ )
+ )
+ }
+ let tree!: ReturnType
+ act(() => {
+ tree = create(createElement(Harness))
+ })
+ const panes = () => tree.root.findAllByType(TerminalPaneView)
+ const visible = () =>
+ panes()
+ .filter((p) => p.props.visible)
+ .sort((a, b) => a.props.splitIndex - b.props.splitIndex)
+ .map((p) => p.props.handle)
+ const originalPanes = panes()
+ try {
+ act(() => switchTo(tabs[2]))
+ expect(visible()).toEqual(['c', 'a'])
+ const a = panes().find((p) => p.props.handle === 'a')!
+ const terminal = a.find(
+ (node) => typeof node.type === 'string' && String(node.type) === 'TerminalWebView'
+ )
+ act(() => terminal.props.onTerminalInteraction())
+ expect(visible()).toEqual(['c', 'a'])
+ act(() => {
+ terminal.props.onTerminalTap()
+ frameCallback?.(0)
+ })
+ expect(focus).toHaveBeenLastCalledWith('a')
+ expect(visible()).toEqual(['c', 'a'])
+ expect(panes()).toEqual(originalPanes)
+ } finally {
+ act(() => tree.unmount())
+ vi.unstubAllGlobals()
+ }
+})
diff --git a/mobile/src/session/mobile-terminal-pane-layout.test.ts b/mobile/src/session/mobile-terminal-pane-layout.test.ts
new file mode 100644
index 00000000000..6271ec3337a
--- /dev/null
+++ b/mobile/src/session/mobile-terminal-pane-layout.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest'
+import {
+ resolveTerminalPaneHandles,
+ terminalPaneDirection,
+ terminalPaneViewport
+} from './mobile-terminal-pane-layout'
+
+describe('mobile terminal split selection', () => {
+ it('keeps pane order when focus moves and collapses a closed companion', () => {
+ expect(resolveTerminalPaneHandles('b', ['a', 'b'], ['a', 'b', 'c'])).toEqual(['a', 'b'])
+ expect(resolveTerminalPaneHandles('a', ['a', 'b'], ['a', 'c'])).toEqual(['a'])
+ expect(resolveTerminalPaneHandles('c', ['a', 'b'], ['a', 'b', 'c'])).toEqual(['c', 'a'])
+ expect(resolveTerminalPaneHandles(null, ['a', 'b'], ['a', 'b'])).toEqual([])
+ })
+ it('uses usable bounds for folded portrait and wide landscape', () => {
+ expect(terminalPaneDirection({ width: 390, height: 700 })).toBe('column')
+ expect(terminalPaneDirection({ width: 700, height: 900 })).toBe('column')
+ expect(terminalPaneDirection({ width: 850, height: 330 })).toBe('row')
+ expect(terminalPaneDirection({ width: 1100, height: 700 })).toBe('row')
+ })
+ it('never shares geometry ownership across terminal handles', () => {
+ const states = new Map()
+ const a = terminalPaneViewport(states, 'a')
+ const b = terminalPaneViewport(states, 'b')
+ a.viewportRef.current = { cols: 40, rows: 30 }
+ expect(b.viewportRef.current).toBeNull()
+ expect(terminalPaneViewport(states, 'a')).toBe(a)
+ })
+})
+
+it('keeps both stacked pane windows above a keyboard taller than one pane without resizing their grids', async () => {
+ const { terminalPaneKeyboardLayout } = await import('./mobile-terminal-pane-layout')
+ const frame = { width: 390, height: 500 }
+ const upper = terminalPaneKeyboardLayout(frame, 'column', 0, 300)
+ const lower = terminalPaneKeyboardLayout(frame, 'column', 1, 300)
+ expect(upper).toEqual({ top: 0, height: 100, originalHeight: 250, occlusion: 150 })
+ expect(lower).toEqual({ top: 100, height: 100, originalHeight: 250, occlusion: 150 })
+ const heading = 28
+ const lastRowBottom = lower.top + heading + (lower.originalHeight - heading) - lower.occlusion
+ expect(lastRowBottom).toBe(200)
+ expect(lastRowBottom).toBeGreaterThan(lower.top + heading)
+ expect(lastRowBottom).toBeLessThanOrEqual(frame.height - 300)
+ expect(terminalPaneKeyboardLayout(frame, 'column', 1, 0).originalHeight).toBe(
+ lower.originalHeight
+ )
+})
diff --git a/mobile/src/session/mobile-terminal-pane-layout.ts b/mobile/src/session/mobile-terminal-pane-layout.ts
new file mode 100644
index 00000000000..d45e2694494
--- /dev/null
+++ b/mobile/src/session/mobile-terminal-pane-layout.ts
@@ -0,0 +1,74 @@
+import type { TerminalSplitLayout } from '../storage/preferences'
+import type { TerminalFrame } from '../terminal/terminal-webview-messages'
+
+export type TerminalPaneViewport = {
+ terminalFrameRef: { current: TerminalFrame | null }
+ viewportRef: { current: { cols: number; rows: number } | null }
+ viewportMeasuredRef: { current: boolean }
+}
+
+export function terminalPaneViewport(states: Map, handle: string) {
+ let state = states.get(handle)
+ if (!state) {
+ state = {
+ terminalFrameRef: { current: null },
+ viewportRef: { current: null },
+ viewportMeasuredRef: { current: false }
+ }
+ states.set(handle, state)
+ }
+ return state
+}
+
+export function resolveTerminalPaneHandles(
+ active: string | null,
+ pair: readonly string[] | null,
+ available: readonly string[]
+): string[] {
+ if (!active) {
+ return []
+ }
+ if (!pair) {
+ return [active]
+ }
+ const surviving = pair.filter((handle) => available.includes(handle))
+ if (surviving.includes(active)) {
+ return surviving.length === 2 ? surviving : [active]
+ }
+ const companion = surviving.find((handle) => handle !== active)
+ return companion ? [active, companion] : [active]
+}
+
+export function terminalPaneDirection(
+ frame: TerminalFrame,
+ layout: TerminalSplitLayout = 'auto'
+): 'row' | 'column' {
+ if (layout === 'side-by-side') {
+ return 'row'
+ }
+ if (layout === 'stacked') {
+ return 'column'
+ }
+ return frame.width >= 600 && frame.width > frame.height ? 'row' : 'column'
+}
+
+export function keepsTerminalPaneVisible(handle: string, visible: readonly string[] = []): boolean {
+ return visible.length === 2 && visible.includes(handle)
+}
+
+export function terminalPaneKeyboardLayout(
+ frame: TerminalFrame,
+ direction: 'row' | 'column',
+ index: number,
+ keyboardInset: number
+) {
+ const originalHeight = direction === 'column' ? frame.height / 2 : frame.height
+ const remainingHeight = Math.max(0, frame.height - Math.max(0, keyboardInset))
+ const height = direction === 'column' ? remainingHeight / 2 : remainingHeight
+ return {
+ top: direction === 'column' ? index * height : 0,
+ height,
+ originalHeight,
+ occlusion: originalHeight - height
+ }
+}
diff --git a/mobile/src/session/mobile-terminal-split-gestures.test.tsx b/mobile/src/session/mobile-terminal-split-gestures.test.tsx
new file mode 100644
index 00000000000..3dda0639beb
--- /dev/null
+++ b/mobile/src/session/mobile-terminal-split-gestures.test.tsx
@@ -0,0 +1,324 @@
+// @vitest-environment happy-dom
+import { createElement, useState } from 'react'
+import { act, create } from 'react-test-renderer'
+import { afterEach, expect, it, vi } from 'vitest'
+import { mountFixture } from '../test-support/rpc-recording/recorder-fixture-shape'
+import { createTerminalDocument } from '../terminal/document/create-terminal-document'
+import { terminalDocumentDouble } from '../terminal/document/document-terminal-double.test-support'
+import { TERMINAL_DOCUMENT_MARKUP } from '../terminal/terminal-webview-html'
+import { dispatchTerminalWebViewNotification } from '../terminal/terminal-webview-notification-dispatch'
+import { terminalPaneViewport, type TerminalPaneViewport } from './mobile-terminal-pane-layout'
+import { useMobileSessionTerminalWebview } from './use-mobile-session-terminal-webview'
+import { useMobileSessionTerminalInput } from './use-mobile-session-terminal-input'
+import { useMobileSessionTabSwitching } from './use-mobile-session-tab-switching'
+
+vi.mock('../platform/haptics', () => ({ triggerSelection: vi.fn() }))
+
+afterEach(() => {
+ document.body.innerHTML = ''
+ vi.useRealTimers()
+})
+
+function fireTouch(surface: HTMLElement, type: string, y?: number) {
+ const event = new Event(type, { bubbles: true, cancelable: true })
+ Object.defineProperty(event, 'touches', {
+ value: y === undefined ? [] : [{ identifier: 0, clientX: 40, clientY: y, target: surface }]
+ })
+ surface.dispatchEvent(event)
+}
+
+it.each(['touch', 'wheel', 'mouse', 'tap'] as const)(
+ '%s selects either split pane before its first input; only a touch tap opens the keyboard',
+ async (gesture) => {
+ vi.useFakeTimers()
+ const activeHandleRef = { current: 'a' }
+ const visibleTerminalHandlesRef = { current: ['a', 'b'] }
+ const subscriptions = {
+ current: new Map([
+ ['a', vi.fn()],
+ ['b', vi.fn()]
+ ])
+ }
+ const geometry = { current: new Map() }
+ const focus = vi.fn()
+ const unsubscribe = vi.fn()
+ const send = vi.fn(async (_handle: string, _bytes: string) => true)
+ const request = vi.fn(async (_method: string, _params?: unknown) => ({
+ id: 'test',
+ ok: true as const,
+ result: { send: { accepted: true } }
+ }))
+ const client = { sendRequest: request }
+ const modes = {
+ bracketedPasteMode: false,
+ altScreen: true,
+ mouseTrackingMode: 'none' as const,
+ sgrMouseMode: false,
+ sgrMousePixelsMode: false,
+ applicationCursorKeysMode: false,
+ kittyKeyboardFlags: 0,
+ modifyOtherKeys: 0
+ }
+ const pointerModes = { ...modes, mouseTrackingMode: 'vt200' as const, sgrMouseMode: true }
+ const liveHandles = { current: new Set() }
+ let receive!: (handle: string, message: Record) => void
+ let tap!: (handle: string) => void
+ function Probe() {
+ const [activeHandle, setActiveHandle] = useState('a')
+ const scope = mountFixture[0]>({
+ activeHandle,
+ setActiveHandle,
+ activeHandleRef,
+ visibleTerminalHandlesRef,
+ visibleTerminalHandles: ['a', 'b'],
+ terminalUnsubsRef: subscriptions,
+ terminalPaneViewportsRef: geometry,
+ getTerminalPaneViewport: (handle) => terminalPaneViewport(geometry.current, handle),
+ terminals: ['a', 'b'].map((handle) => ({
+ handle,
+ title: handle,
+ isActive: handle === activeHandle
+ })),
+ sessionTabs: ['a', 'b'].map((handle) => ({
+ id: handle,
+ type: 'terminal',
+ terminal: handle
+ })),
+ activeSessionTabTypeRef: { current: 'terminal' },
+ setActiveSessionTabId: vi.fn(),
+ pendingSelectionRef: { current: null },
+ terminalDiagnosticsRef: { current: { tabSwitch: vi.fn() } },
+ defaultTerminalHandlesToLiveInput: vi.fn(),
+ switchSessionTabRef: { current: null },
+ connState: 'connected',
+ connStateRef: { current: 'connected' },
+ client,
+ clientRef: { current: client },
+ deviceTokenRef: { current: 'test-device' },
+ handleTerminalTap: focus,
+ subscribeToTerminal: vi.fn(),
+ unsubscribeTerminal: unsubscribe,
+ activeSessionTab: null,
+ markdownDocs: new Map(),
+ fileDocs: new Map(),
+ ptyModesRef: {
+ current: new Map(
+ ['a', 'b'].map((handle) => [
+ handle,
+ gesture === 'mouse' || gesture === 'tap' ? pointerModes : modes
+ ])
+ )
+ },
+ terminalGestureInputBucketsRef: { current: new Map() },
+ terminalGestureInputQueuesRef: { current: new Map() },
+ terminalGestureInputInFlightRef: { current: new Set() },
+ liveInputTerminalHandlesRef: liveHandles,
+ sendLiveTerminalInputRef: { current: send }
+ })
+ const switching = useMobileSessionTabSwitching(scope)
+ const webview = useMobileSessionTerminalWebview(Object.assign(scope, switching))
+ const input = useMobileSessionTerminalInput(
+ mountFixture(Object.assign(scope, switching, webview))
+ )
+ tap = webview.handleTerminalTap
+ receive = (handle, message) =>
+ dispatchTerminalWebViewNotification(message, {
+ reportEngineError: (error) => {
+ throw new Error(error)
+ },
+ onTerminalInteraction: () => webview.handleTerminalInteraction?.(handle),
+ onTerminalTap: () => webview.handleTerminalTap(handle),
+ onTerminalInput: (bytes, pointer) => input.handleTerminalInput(handle, bytes, pointer)
+ })
+ return null
+ }
+ let renderer!: ReturnType
+ const docs: ReturnType[] = []
+ const surfaces = new Map()
+ const opened = vi.fn()
+ try {
+ act(() => {
+ renderer = create(createElement(Probe))
+ })
+ for (const handle of ['a', 'b']) {
+ const host = document.createElement('div')
+ host.innerHTML = TERMINAL_DOCUMENT_MARKUP
+ document.body.appendChild(host)
+ const engine = terminalDocumentDouble()
+ const started = createTerminalDocument({
+ root: host,
+ postToHost: (message) => receive(handle, message),
+ hasEngine: () => true,
+ installHostTransport: () => () => {},
+ installErrorReporter: () => () => {},
+ paintDocumentBackground: () => {},
+ viewportRect: () => ({ left: 0, top: 0, width: 400, height: 400 }),
+ observeViewport: () => () => {},
+ createUnicode11Addon: () => null,
+ createWebglAddon: () => null,
+ createTerminal: () => ({
+ ...engine.terminal,
+ modes: gesture === 'mouse' || gesture === 'tap' ? pointerModes : modes,
+ _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 15 } } } } },
+ buffer: { active: { ...engine.terminal.buffer.active, type: 'alternate' } },
+ open: (surface) => {
+ engine.terminal.open(surface)
+ opened(handle)
+ }
+ })
+ })
+ docs.push(started)
+ started.send({ type: 'init', cols: 40, rows: 24, initialData: '' })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(150)
+ })
+ if (gesture === 'mouse' || gesture === 'tap') {
+ started.send({ type: 'write', data: '\x1b[?1000h\x1b[?1006h' })
+ }
+ const surface = host.querySelector('#terminal-surface')
+ if (!surface) {
+ throw new Error('No terminal surface')
+ }
+ surfaces.set(handle, surface)
+ }
+ const openedBefore = opened.mock.calls.length
+ request.mockClear()
+ for (const handle of ['b', 'a']) {
+ const surface = surfaces.get(handle)!
+ await act(async () => {
+ if (gesture === 'tap') {
+ fireTouch(surface, 'touchstart', 150)
+ fireTouch(surface, 'touchend')
+ } else if (gesture === 'touch') {
+ fireTouch(surface, 'touchstart', 150)
+ fireTouch(surface, 'touchmove', 90)
+ fireTouch(surface, 'touchcancel')
+ } else if (gesture === 'wheel') {
+ surface.dispatchEvent(
+ new WheelEvent('wheel', {
+ bubbles: true,
+ cancelable: true,
+ deltaY: 60,
+ clientX: 40,
+ clientY: 150
+ })
+ )
+ } else {
+ surface.dispatchEvent(
+ new PointerEvent('pointerdown', {
+ bubbles: true,
+ pointerType: 'mouse',
+ button: 0,
+ clientX: 40,
+ clientY: 150
+ })
+ )
+ surface.dispatchEvent(
+ new PointerEvent('pointerup', {
+ bubbles: true,
+ pointerType: 'mouse',
+ button: 0,
+ clientX: 40,
+ clientY: 150
+ })
+ )
+ }
+ })
+ expect(activeHandleRef.current).toBe(handle)
+ if (gesture === 'mouse' || gesture === 'tap') {
+ expect(send.mock.calls.at(-1)).toEqual([
+ handle,
+ expect.stringContaining('\x1b[<0;'),
+ 'pointer'
+ ])
+ } else {
+ expect(request).toHaveBeenCalledWith(
+ 'terminal.send',
+ expect.objectContaining({ terminal: handle, text: '\x1b[B'.repeat(4) }),
+ expect.anything()
+ )
+ }
+ }
+ if (gesture === 'wheel') {
+ const a = surfaces.get('a')!
+ const b = surfaces.get('b')!
+ const wheel = (deltaY: number) =>
+ a.dispatchEvent(
+ new WheelEvent('wheel', {
+ bubbles: true,
+ cancelable: true,
+ deltaY,
+ clientX: 40,
+ clientY: 150
+ })
+ )
+ await act(async () => {
+ fireTouch(b, 'touchstart', 150)
+ fireTouch(b, 'touchcancel')
+ })
+ const sentBeforeTail = request.mock.calls.filter(
+ ([method]) => method === 'terminal.send'
+ ).length
+ await act(async () => {
+ wheel(7)
+ })
+ expect(activeHandleRef.current).toBe('b')
+ await act(async () => {
+ wheel(30)
+ })
+ expect(activeHandleRef.current).toBe('b')
+ expect(request.mock.calls.filter(([method]) => method === 'terminal.send')).toHaveLength(
+ sentBeforeTail
+ )
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(250)
+ wheel(8)
+ })
+ expect(activeHandleRef.current).toBe('a')
+ expect(request.mock.calls.filter(([method]) => method === 'terminal.send')).toHaveLength(
+ sentBeforeTail
+ )
+ await act(async () => {
+ wheel(7)
+ })
+ expect(request).toHaveBeenLastCalledWith(
+ 'terminal.send',
+ expect.objectContaining({ terminal: 'a', text: '\x1b[B' }),
+ expect.anything()
+ )
+ }
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(50)
+ })
+ if (gesture === 'tap') {
+ expect(focus).toHaveBeenCalledWith('a')
+ } else {
+ expect(focus).not.toHaveBeenCalled()
+ }
+ focus.mockClear()
+ expect(unsubscribe).not.toHaveBeenCalled()
+ expect(visibleTerminalHandlesRef.current).toEqual(['a', 'b'])
+ expect(opened.mock.calls).toHaveLength(openedBefore)
+ act(() => {
+ tap('b')
+ receive('a', { type: 'terminal-interaction' })
+ })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(50)
+ })
+ expect(focus).not.toHaveBeenCalled()
+ const inputCount = request.mock.calls.filter(([method]) => method === 'terminal.send').length
+ receive('b', { type: 'terminal-input', bytes: '\x1b[B' })
+ expect(request.mock.calls.filter(([method]) => method === 'terminal.send')).toHaveLength(
+ inputCount
+ )
+ receive('hidden', { type: 'terminal-interaction' })
+ expect(activeHandleRef.current).toBe('a')
+ } finally {
+ for (const doc of docs) {
+ doc.stop()
+ }
+ act(() => renderer?.unmount())
+ }
+ }
+)
diff --git a/mobile/src/session/mobile-terminal-split-input.test.tsx b/mobile/src/session/mobile-terminal-split-input.test.tsx
new file mode 100644
index 00000000000..ee9026c4bc2
--- /dev/null
+++ b/mobile/src/session/mobile-terminal-split-input.test.tsx
@@ -0,0 +1,110 @@
+// @vitest-environment happy-dom
+import { createElement, useState } from 'react'
+import { act, create } from 'react-test-renderer'
+import { expect, it, vi } from 'vitest'
+import { mountFixture } from '../test-support/rpc-recording/recorder-fixture-shape'
+import { useTerminalLiveInputCommit } from '../terminal/use-terminal-live-input-commit'
+import { useMobileSessionTerminalWebview } from './use-mobile-session-terminal-webview'
+import { terminalPaneViewport, type TerminalPaneViewport } from './mobile-terminal-pane-layout'
+
+vi.mock('../platform/live-input-composing-range', () => ({
+ reportedLiveInputComposing: (isComposing: boolean | undefined) => isComposing
+}))
+
+it('a pane tap moves the one input owner without dropping either stream or forwarding old preedit', async () => {
+ vi.useFakeTimers()
+ const activeHandleRef = { current: 'a' }
+ const visibleTerminalHandlesRef = { current: ['a', 'b'] }
+ const subscriptions = {
+ current: new Map([
+ ['a', vi.fn()],
+ ['b', vi.fn()]
+ ])
+ }
+ const geometry = { current: new Map() }
+ const send = vi.fn(async (_handle: string, _text: string) => true)
+ const focus = vi.fn()
+ const unsubscribe = vi.fn()
+ const handles = new Set(['a', 'b'])
+ let tap!: (handle: string) => void
+ let change!: ReturnType['handleLiveInputChange']
+ function Probe() {
+ const [activeHandle, setActiveHandle] = useState('a')
+ const webview = useMobileSessionTerminalWebview(
+ mountFixture({
+ activeHandle,
+ activeHandleRef,
+ terminals: [
+ { handle: 'a', title: 'A', isActive: true },
+ { handle: 'b', title: 'B', isActive: false }
+ ],
+ visibleTerminalHandles: ['a', 'b'],
+ visibleTerminalHandlesRef,
+ terminalPaneViewportsRef: geometry,
+ getTerminalPaneViewport: (handle) => terminalPaneViewport(geometry.current, handle),
+ setSplitTerminalHandles: vi.fn(),
+ showNativeChat: false,
+ connState: 'connected',
+ switchTab: (handle) => {
+ activeHandleRef.current = handle
+ setActiveHandle(handle)
+ },
+ handleTerminalTap: focus,
+ subscribeToTerminal: vi.fn(),
+ unsubscribeTerminal: unsubscribe,
+ terminalUnsubsRef: subscriptions,
+ activeSessionTab: null,
+ markdownDocs: new Map(),
+ fileDocs: new Map()
+ })
+ )
+ tap = webview.handleTerminalTap
+ const input = useTerminalLiveInputCommit({
+ activeHandle,
+ activeHandleRef,
+ activeSessionTabType: 'terminal',
+ activeSessionTabTypeRef: { current: 'terminal' },
+ connected: true,
+ liveInputRef: { current: null },
+ liveInputTerminalHandles: handles,
+ liveInputTerminalHandlesRef: { current: handles },
+ sendLiveTerminalInputRef: { current: send },
+ setLiveInputCapture: vi.fn()
+ })
+ change = input.handleLiveInputChange
+ return null
+ }
+ let renderer!: ReturnType
+ try {
+ act(() => {
+ renderer = create(createElement(Probe))
+ })
+ act(() => {
+ change({ nativeEvent: { text: 'ni', isComposing: true } })
+ })
+ act(() => {
+ tap('b')
+ })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(30)
+ })
+ expect(activeHandleRef.current).toBe('b')
+ expect(focus).toHaveBeenCalledWith('b')
+ expect(unsubscribe).not.toHaveBeenCalled()
+ expect(subscriptions.current.size).toBe(2)
+ act(() => {
+ change({ nativeEvent: { text: 'hello', isComposing: false } })
+ })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(300)
+ })
+ expect(send.mock.calls).toEqual([['b', 'hello']])
+ act(() => {
+ tap('hidden')
+ })
+ expect(activeHandleRef.current).toBe('b')
+ } finally {
+ act(() => renderer?.unmount())
+ vi.useRealTimers()
+ }
+})
diff --git a/mobile/src/session/mobile-worker-takeover-send-sites.test.ts b/mobile/src/session/mobile-worker-takeover-send-sites.test.ts
index bd989bc6a96..b22a59d0c9c 100644
--- a/mobile/src/session/mobile-worker-takeover-send-sites.test.ts
+++ b/mobile/src/session/mobile-worker-takeover-send-sites.test.ts
@@ -56,6 +56,15 @@ function mountSendSites(client: ReturnType, handle = 'term
clientRef: ref(client),
activeHandle: handle,
activeHandleRef,
+ terminals: [{ handle }],
+ visibleTerminalHandles: [handle],
+ visibleTerminalHandlesRef: ref([handle]),
+ setSplitTerminalHandles: vi.fn(),
+ terminalPaneViewportsRef: ref(new Map()),
+ terminalUnsubsRef: ref(new Map()),
+ subscribeToTerminal: vi.fn(),
+ unsubscribeTerminal: vi.fn(),
+ getTerminalPaneViewport: () => undefined,
activeSessionTabTypeRef,
connState: 'connected',
connStateRef: ref('connected'),
diff --git a/mobile/src/session/orphaned-markdown-draft-tabs.ts b/mobile/src/session/orphaned-markdown-draft-tabs.ts
new file mode 100644
index 00000000000..87af7f4d3d2
--- /dev/null
+++ b/mobile/src/session/orphaned-markdown-draft-tabs.ts
@@ -0,0 +1,25 @@
+import type { MarkdownDocState, MobileSessionTab } from './mobile-session-route-types'
+
+// Keep phone-local document drafts reachable after desktop tab removal so users can copy/discard them.
+export function orphanedMarkdownDraftTabs(
+ nextTabs: readonly MobileSessionTab[],
+ currentTabs: readonly MobileSessionTab[],
+ markdownDocs: ReadonlyMap,
+ activeTabId: string | null
+): MobileSessionTab[] {
+ const presentTabIds = new Set(nextTabs.map((tab) => tab.id))
+ const orphaned: MobileSessionTab[] = []
+ for (const [tabId, doc] of markdownDocs) {
+ if (doc.status !== 'ready' || !doc.isDirty || presentTabIds.has(tabId)) {
+ continue
+ }
+ const draftTab = currentTabs.find(
+ (tab): tab is Extract =>
+ tab.type === 'markdown' && tab.id === tabId
+ )
+ if (draftTab) {
+ orphaned.push({ ...draftTab, isActive: tabId === activeTabId })
+ }
+ }
+ return orphaned
+}
diff --git a/mobile/src/session/session-side-rail.tsx b/mobile/src/session/session-side-rail.tsx
deleted file mode 100644
index d1efad3c244..00000000000
--- a/mobile/src/session/session-side-rail.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import { Pressable, View } from 'react-native'
-import { ChevronLeft } from 'lucide-react-native'
-import { useResponsiveLayout } from '../layout/responsive-layout'
-import { useSideRailClaim, useSideRailEdge } from '../layout/side-rail'
-import { themedStyleSheet } from '../theme/themed-styles'
-import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
-import { colors, spacing } from '../theme/mobile-theme'
-import { useMobileTranslation } from '../i18n/use-mobile-translation'
-import { MobileSessionAccessoryKeys } from './MobileSessionAccessoryKeys'
-import { sessionCommandDockShown } from './MobileSessionCommandDock'
-import type { MobileSessionController } from './use-mobile-session-controller'
-
-export type SessionRailPlacement = { backInRail: boolean; keysInRail: boolean }
-
-// The Duo's side rail takes what would cost the session rows: the terminal keys, and Back on the cover, where no sidebar sits beside it.
-export function useSessionSideRail(controller: MobileSessionController): SessionRailPlacement {
- const edge = useSideRailEdge()
- const { isWideLayout } = useResponsiveLayout()
- const backInRail = edge !== null && !isWideLayout
- const keysInRail = edge !== null && sessionCommandDockShown(controller)
- useSideRailClaim(
- backInRail || keysInRail ? (
-
- ) : null
- )
- return { backInRail, keysInRail }
-}
-
-function SessionRail({
- controller,
- back,
- keys
-}: {
- controller: MobileSessionController
- back: boolean
- keys: boolean
-}) {
- const t = useMobileTranslation()
- useMobileColorScheme()
- return (
-
- {back ? (
- [styles.back, pressed && styles.backPressed]}
- onPress={controller.requestLeaveSession}
- accessibilityRole="button"
- accessibilityLabel={t('session.sideRail.backToWorktrees', 'Back to worktrees')}
- >
-
-
- ) : null}
- {keys ? : null}
-
- )
-}
-
-const styles = themedStyleSheet(() => ({
- rail: {
- flex: 1,
- alignItems: 'stretch',
- gap: spacing.md
- },
- back: {
- alignSelf: 'center',
- width: 44,
- height: 44,
- borderRadius: 22,
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: colors.bgRaised
- },
- backPressed: {
- backgroundColor: colors.borderSubtle
- }
-}))
diff --git a/mobile/src/session/shell-terminal-key-rail.test.tsx b/mobile/src/session/shell-terminal-key-rail.test.tsx
new file mode 100644
index 00000000000..af7118bccaf
--- /dev/null
+++ b/mobile/src/session/shell-terminal-key-rail.test.tsx
@@ -0,0 +1,193 @@
+import { Suspense, useLayoutEffect, useState, type ReactNode } from 'react'
+import { act, create } from 'react-test-renderer'
+import { ScrollView } from 'react-native'
+import { afterEach, expect, it, vi } from 'vitest'
+import type {
+ BridgeTerminalKeyId,
+ BridgeTerminalToolbar
+} from '../mobile-web-shell/bridge/bridge-terminal-key'
+
+const native = vi.hoisted(() => ({ scrollRender: vi.fn(), mount: vi.fn(), unmount: vi.fn() }))
+const insets = { top: 0, right: 84, bottom: 34, left: 0 }
+const regions = { divisions: [], occlusions: [{ x: 867, y: 0, width: 84, height: 120 }] }
+vi.mock('../layout/reserved-regions', () => ({ useReservedRegions: () => regions }))
+vi.mock('../platform/keyboard-occlusion', () => ({ useKeyboardOcclusion: () => 0 }))
+vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => insets }))
+vi.mock('expo-router', async () => ({ useFocusEffect: (await import('react')).useEffect }))
+vi.mock('react-native', async () => {
+ const { createElement } = await import('react')
+ return {
+ View: 'View',
+ Text: 'Text',
+ Pressable: 'Pressable',
+ ScrollView: (props: { children?: ReactNode }) => {
+ native.scrollRender()
+ return createElement('ScrollView', props)
+ },
+ useWindowDimensions: () => ({ width: 951, height: 669 }),
+ AppState: {
+ addEventListener: () => {
+ native.mount()
+ return { remove: native.unmount }
+ }
+ },
+ StyleSheet: { create: (styles: T) => styles }
+ }
+})
+vi.mock('lucide-react-native', () => ({
+ Keyboard: 'Keyboard',
+ ChevronDown: 'ChevronDown',
+ Monitor: 'Monitor',
+ Smartphone: 'Smartphone',
+ ChevronsRight: 'ChevronsRight',
+ Ellipsis: 'Ellipsis'
+}))
+import { SideRailProvider } from '../layout/side-rail'
+import { useShellTerminalKeyRail } from './shell-terminal-key-rail'
+
+const toolbar: BridgeTerminalToolbar = {
+ canSend: true,
+ canCompose: true,
+ keyboardVisible: false,
+ liveInputEnabled: true,
+ phoneDisplayMode: false,
+ runsAgent: false,
+ modifiers: { ctrl: 'off', alt: 'off', shift: 'off' },
+ directionPadEnabled: false
+}
+type Input = {
+ onKey: (key: BridgeTerminalKeyId) => void
+ owner: string
+ suspend?: boolean
+ onCommit?: () => void
+}
+let renderer: ReturnType | undefined
+const pending = new Promise(() => {})
+function mount() {
+ const first = vi.fn()
+ let update: (input: Input) => void = () => {}
+ function Dock() {
+ const [input, setInput] = useState({ onKey: first, owner: 'one' })
+ update = setInput
+ useShellTerminalKeyRail(true, input.onKey, input.owner, toolbar)
+ useLayoutEffect(() => input.onCommit?.(), [input])
+ if (input.suspend) {
+ throw pending
+ }
+ return null
+ }
+ act(() => {
+ renderer = create(
+
+
+
+
+
+ )
+ })
+ const arrow = () => renderer!.root.findByProps({ accessibilityLabel: 'Arrow Up' })
+ return { first, update: (input: Input) => act(() => update(input)), arrow }
+}
+const touch = { nativeEvent: { pageX: 20, pageY: 20 } }
+afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = undefined
+ vi.useRealTimers()
+ vi.clearAllMocks()
+})
+
+it('keeps the published native rail untouched on unrelated and callback-only dock renders', () => {
+ const { first, update, arrow } = mount()
+ const before = native.scrollRender.mock.calls.length
+ const scroll = renderer!.root.findByType(ScrollView)
+ update({ onKey: first, owner: 'one' })
+ expect(native.scrollRender).toHaveBeenCalledTimes(before)
+ const latest = vi.fn()
+ update({ onKey: latest, owner: 'one' })
+ expect(native.scrollRender).toHaveBeenCalledTimes(before)
+ expect(renderer!.root.findByType(ScrollView)).toBe(scroll)
+ expect(native.mount).toHaveBeenCalledTimes(1)
+ expect(native.unmount).not.toHaveBeenCalled()
+ act(() => {
+ arrow().props.onPressIn(touch)
+ arrow().props.onPress()
+ })
+ expect(first).not.toHaveBeenCalled()
+ expect(latest).toHaveBeenCalledExactlyOnceWith('arrowUp')
+})
+
+it('keeps held keys using the committed callback and cancels them when the input owner changes', async () => {
+ vi.useFakeTimers()
+ const { first, update, arrow } = mount()
+ act(() => {
+ arrow().props.onPressIn(touch)
+ arrow().props.onLongPress()
+ })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(200)
+ })
+ expect(first.mock.calls.length).toBeGreaterThan(1)
+ const before = first.mock.calls.length
+ const latest = vi.fn()
+ update({ onKey: latest, owner: 'one' })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(200)
+ })
+ expect(first).toHaveBeenCalledTimes(before)
+ expect(latest.mock.calls.length).toBeGreaterThan(0)
+ update({ onKey: latest, owner: 'two' })
+ const stopped = latest.mock.calls.length
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1000)
+ })
+ act(() => {
+ arrow().props.onPressOut()
+ arrow().props.onPress()
+ })
+ expect(latest).toHaveBeenCalledTimes(stopped)
+ act(() => {
+ arrow().props.onPressIn(touch)
+ arrow().props.onPress()
+ })
+ expect(latest).toHaveBeenCalledTimes(stopped + 1)
+})
+
+it('does not expose a callback from an uncommitted suspended dock render', () => {
+ const { first, update, arrow } = mount()
+ const uncommitted = vi.fn()
+ update({ onKey: uncommitted, owner: 'one', suspend: true })
+ act(() => {
+ arrow().props.onPressIn(touch)
+ arrow().props.onPress()
+ })
+ expect(first).toHaveBeenCalledExactlyOnceWith('arrowUp')
+ expect(uncommitted).not.toHaveBeenCalled()
+})
+
+it('rejects a held repeat between the new owner commit and the old rail publication being replaced', async () => {
+ vi.useFakeTimers()
+ const { first, update, arrow } = mount()
+ act(() => {
+ arrow().props.onPressIn(touch)
+ arrow().props.onLongPress()
+ })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0)
+ })
+ expect(first).toHaveBeenCalledExactlyOnceWith('arrowUp')
+ const before = native.scrollRender.mock.calls.length
+ const next = vi.fn()
+ const duringCommit = vi.fn(() => {
+ expect(native.scrollRender).toHaveBeenCalledTimes(before)
+ vi.advanceTimersByTime(45)
+ })
+ update({ onKey: next, owner: 'two', onCommit: duringCommit })
+ expect(duringCommit).toHaveBeenCalledTimes(1)
+ expect(next).not.toHaveBeenCalled()
+ expect(first).toHaveBeenCalledTimes(1)
+ act(() => {
+ arrow().props.onPressIn(touch)
+ arrow().props.onPress()
+ })
+ expect(next).toHaveBeenCalledExactlyOnceWith('arrowUp')
+})
diff --git a/mobile/src/session/shell-terminal-key-rail.ts b/mobile/src/session/shell-terminal-key-rail.ts
new file mode 100644
index 00000000000..c805ac362d1
--- /dev/null
+++ b/mobile/src/session/shell-terminal-key-rail.ts
@@ -0,0 +1,60 @@
+import type {
+ BridgeTerminalToolbar,
+ BridgeTerminalKeyId,
+ BridgeTerminalKeyRail
+} from '../mobile-web-shell/bridge/bridge-terminal-key'
+import { createElement, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { useFocusEffect } from 'expo-router'
+import { useSideRailContent, useSideRailStrip } from '../layout/side-rail'
+import { TerminalKeyRail, terminalKeyRailKeys } from '../mobile-web-shell/terminal-key-rail'
+
+const NO_RAIL_KEYS: BridgeTerminalKeyRail = []
+
+/**
+ * The keys the shell's rail shows beside this screen, which then drops them from its own row;
+ * `onKey` takes each press on it. Native sessions claim the same root rail directly.
+ */
+export function useShellTerminalKeyRail(
+ shown: boolean,
+ onKey: (key: BridgeTerminalKeyId) => void,
+ focusedTerminal?: string | null,
+ toolbar?: BridgeTerminalToolbar
+): BridgeTerminalKeyRail {
+ const strip = useSideRailStrip()
+ const [focused, setFocused] = useState(false)
+ useFocusEffect(
+ useCallback(() => {
+ setFocused(true)
+ return () => setFocused(false)
+ }, [])
+ )
+ const height = strip?.height ?? 0
+ const keys = useMemo(
+ () => (shown && focused && height > 0 ? terminalKeyRailKeys(height, toolbar) : NO_RAIL_KEYS),
+ [shown, focused, height, toolbar]
+ )
+ const inputRef = useRef({ onKey, focusedTerminal })
+ useLayoutEffect(() => {
+ inputRef.current = { onKey, focusedTerminal }
+ }, [onKey, focusedTerminal])
+ const node = useMemo(
+ () =>
+ keys.length
+ ? createElement(TerminalKeyRail, {
+ inputOwner: focusedTerminal,
+ keys,
+ toolbar,
+ onKey: (key) => {
+ if (inputRef.current.focusedTerminal !== focusedTerminal) {
+ return false
+ }
+ inputRef.current.onKey(key)
+ return true
+ }
+ })
+ : null,
+ [keys, focusedTerminal, toolbar]
+ )
+ useSideRailContent(node)
+ return keys
+}
diff --git a/mobile/src/session/shell-terminal-key-rail.web.test.tsx b/mobile/src/session/shell-terminal-key-rail.web.test.tsx
new file mode 100644
index 00000000000..c46b4bcfc2d
--- /dev/null
+++ b/mobile/src/session/shell-terminal-key-rail.web.test.tsx
@@ -0,0 +1,219 @@
+import { createElement, startTransition, Suspense } from 'react'
+import { ScrollView } from 'react-native'
+import { act, create } from 'react-test-renderer'
+import { afterEach, expect, it, vi } from 'vitest'
+import { createBridgeClientTerminalKeys } from '../mobile-web-shell/bridge/bridge-client-terminal-keys'
+import type {
+ BridgeTerminalKeyId,
+ BridgeTerminalToolbar
+} from '../mobile-web-shell/bridge/bridge-terminal-key'
+
+const bridge = vi.hoisted(() => ({ client: vi.fn() }))
+vi.mock('../transport/client-context.web', () => ({ usePageBridgeClient: bridge.client }))
+vi.mock('expo-router', async () => ({ useFocusEffect: (await import('react')).useEffect }))
+vi.mock('react-native', () => ({
+ View: 'View',
+ Text: 'Text',
+ Pressable: 'Pressable',
+ ScrollView: 'ScrollView',
+ AppState: { addEventListener: () => ({ remove() {} }) },
+ StyleSheet: { create: (styles: T) => styles }
+}))
+vi.mock('lucide-react-native', () => ({
+ Keyboard: 'Keyboard',
+ ChevronDown: 'ChevronDown',
+ Monitor: 'Monitor',
+ Smartphone: 'Smartphone',
+ ChevronsRight: 'ChevronsRight',
+ Ellipsis: 'Ellipsis'
+}))
+vi.mock('../layout/side-rail', () => ({
+ useSideRailContent: vi.fn(),
+ useSideRailDirectionPadAvailable: () => false,
+ useSideRailStrip: () => ({ height: 500 })
+}))
+import { useShellTerminalKeyRail } from './shell-terminal-key-rail.web'
+import { TerminalKeyRail } from '../mobile-web-shell/terminal-key-rail'
+import { useShellKeyRail } from '../mobile-web-shell/use-shell-key-rail'
+
+const initialToolbar: BridgeTerminalToolbar = {
+ canSend: true,
+ canCompose: true,
+ keyboardVisible: false,
+ liveInputEnabled: true,
+ phoneDisplayMode: false,
+ runsAgent: false,
+ modifiers: { ctrl: 'off', alt: 'off', shift: 'off' }
+}
+let page: ReturnType | undefined
+let native: ReturnType | undefined
+afterEach(() => {
+ act(() => {
+ page?.unmount()
+ native?.unmount()
+ })
+ page = native = undefined
+})
+
+function mount() {
+ type Frame = Parameters[0]>
+ const frames: Frame[] = []
+ const terminal = createBridgeClientTerminalKeys((...frame) => frames.push(frame))
+ let reportClaim: (...frame: Frame) => void = () => {}
+ const onKey = vi.fn()
+ const keys: string[] = []
+ bridge.client.mockReturnValue({
+ onTerminalKeyRailUpdate: () => () => {},
+ getShellSession: () => ({ terminalKeyRail: keys }),
+ claimTerminalKeyRail: terminal.claim
+ })
+ function Native() {
+ const rail = useShellKeyRail({
+ sessionId: 'one',
+ overlay: false,
+ routeGrants: ['terminalKeyRail']
+ })
+ reportClaim = rail.reportClaim
+ return rail.keys.length
+ ? createElement(TerminalKeyRail, {
+ inputOwner: rail.inputOwner,
+ keys: rail.keys,
+ toolbar: rail.toolbar,
+ onKey: () => true
+ })
+ : null
+ }
+ function Page({
+ toolbar,
+ shown = true,
+ focusedTerminal = 'one'
+ }: {
+ toolbar: BridgeTerminalToolbar
+ shown?: boolean
+ focusedTerminal?: string
+ }) {
+ useShellTerminalKeyRail(shown, onKey, focusedTerminal, toolbar)
+ return null
+ }
+ const deliver = () => {
+ // Each bridge message may commit separately across the WebView boundary.
+ for (const frame of frames.splice(0)) {
+ act(() => reportClaim(...frame))
+ }
+ }
+ act(() => {
+ native = create(createElement(Native))
+ page = create(createElement(Page, { toolbar: initialToolbar }))
+ })
+ const owner = frames.at(-1)?.[1]
+ deliver()
+ return { Page, frames, deliver, owner, terminal, onKey }
+}
+
+it('keeps the native scroll view mounted across toolbar-only updates without releasing ownership', () => {
+ const { Page, frames, deliver, owner } = mount()
+ const scroll = native!.root.findByType(ScrollView)
+ for (const update of [
+ { keyboardVisible: true },
+ { liveInputEnabled: false },
+ { phoneDisplayMode: true },
+ { canSend: false },
+ { modifiers: { ctrl: 'once', alt: 'off', shift: 'off' } as const }
+ ]) {
+ const toolbar = { ...initialToolbar, ...update }
+ act(() => page!.update(createElement(Page, { toolbar })))
+ expect(frames).toEqual([[true, owner, toolbar]])
+ deliver()
+ expect(native!.root.findByType(ScrollView)).toBe(scroll)
+ }
+})
+
+it('updates pane ownership without releasing the rail and still releases when covered', () => {
+ const { Page, frames, deliver, owner, terminal, onKey } = mount()
+ act(() => page!.update(createElement(Page, { toolbar: initialToolbar, focusedTerminal: 'two' })))
+ expect(frames.map(([claimed]) => claimed)).toEqual([true])
+ const nextOwner = frames.at(-1)?.[1]
+ expect(nextOwner).not.toBe(owner)
+ terminal.accept('enter', owner)
+ expect(onKey).not.toHaveBeenCalled()
+ terminal.accept('enter', nextOwner)
+ expect(onKey).toHaveBeenCalledExactlyOnceWith('enter')
+ deliver()
+ act(() =>
+ page!.update(
+ createElement(Page, { toolbar: initialToolbar, shown: false, focusedTerminal: 'two' })
+ )
+ )
+ expect(frames).toEqual([[false, undefined, undefined]])
+ terminal.accept('enter', nextOwner)
+ expect(onKey).toHaveBeenCalledTimes(1)
+ deliver()
+ expect(native!.root.findAllByType(ScrollView)).toHaveLength(0)
+})
+
+it.each(['one', 'two'])(
+ 'keeps committed bridge input while a transition to pane %s is suspended',
+ (nextTerminal) => {
+ let owner: string | undefined
+ const terminal = createBridgeClientTerminalKeys((_claimed, inputOwner) => {
+ owner = inputOwner
+ })
+ const session = { terminalKeyRail: [], terminalKeyInputOwners: true }
+ bridge.client.mockReturnValue({
+ onTerminalKeyRailUpdate: () => () => {},
+ getShellSession: () => session,
+ claimTerminalKeyRail: terminal.claim
+ })
+ const committed = vi.fn()
+ const next = vi.fn()
+ const pending = new Promise(() => {})
+ function Dock({
+ callback,
+ focusedTerminal = 'one',
+ suspend = false
+ }: {
+ callback: (key: BridgeTerminalKeyId) => void
+ focusedTerminal?: string
+ suspend?: boolean
+ }) {
+ useShellTerminalKeyRail(true, callback, focusedTerminal, initialToolbar)
+ if (suspend) {
+ throw pending
+ }
+ return null
+ }
+ act(() => {
+ page = create(
+
+
+
+ )
+ })
+ const committedOwner = owner
+ act(() => {
+ startTransition(() =>
+ page!.update(
+
+
+
+ )
+ )
+ })
+ expect(owner).toBe(committedOwner)
+ act(() => terminal.accept('enter', committedOwner))
+ expect({ committed: committed.mock.calls, uncommitted: next.mock.calls }).toEqual({
+ committed: [['enter']],
+ uncommitted: []
+ })
+ act(() => {
+ page!.update(
+
+
+
+ )
+ })
+ act(() => terminal.accept('tab', owner))
+ expect(committed).toHaveBeenCalledTimes(1)
+ expect(next).toHaveBeenCalledExactlyOnceWith('tab')
+ }
+)
diff --git a/mobile/src/session/shell-terminal-key-rail.web.ts b/mobile/src/session/shell-terminal-key-rail.web.ts
new file mode 100644
index 00000000000..0f72732d75d
--- /dev/null
+++ b/mobile/src/session/shell-terminal-key-rail.web.ts
@@ -0,0 +1,79 @@
+import {
+ useCallback,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+ useSyncExternalStore
+} from 'react'
+import { useFocusEffect } from 'expo-router'
+import type {
+ BridgeTerminalToolbar,
+ BridgeTerminalKeyId,
+ BridgeTerminalKeyRail
+} from '../mobile-web-shell/bridge/bridge-terminal-key'
+import { usePageBridgeClient } from '../transport/client-context.web'
+
+let nextInputOwner = 0
+
+const NO_RAIL_KEYS: BridgeTerminalKeyRail = []
+
+/**
+ * The keys the shell's rail shows beside this page, which then drops them from its own row; `onKey`
+ * takes each press on it. Claimed only while `shown`, the terminal and its key row being on screen:
+ * the shell draws the rail for a granted page, on a window with a side strip, while it holds one.
+ */
+export function useShellTerminalKeyRail(
+ shown: boolean,
+ onKey: (key: BridgeTerminalKeyId) => void,
+ focusedTerminal?: string | null,
+ toolbar?: BridgeTerminalToolbar
+): BridgeTerminalKeyRail {
+ const client = usePageBridgeClient()
+ const keys = useSyncExternalStore(
+ client.onTerminalKeyRailUpdate,
+ () => client.getShellSession()?.terminalKeyRail ?? NO_RAIL_KEYS
+ )
+ // The page's own stack keeps the session mounted under a screen pushed over it, as Files is.
+ const [focused, setFocused] = useState(false)
+ useFocusEffect(
+ useCallback(() => {
+ setFocused(true)
+ return () => setFocused(false)
+ }, [])
+ )
+ const claiming = shown && focused
+ const inputOwner = useMemo(() => `rail-${++nextInputOwner}`, [focusedTerminal, claiming])
+ const ownerRef = useRef(inputOwner)
+ const onKeyRef = useRef(onKey)
+ const toolbarRef = useRef(toolbar)
+ const claimRef = useRef | null>(null)
+ useLayoutEffect(() => {
+ ownerRef.current = inputOwner
+ onKeyRef.current = onKey
+ toolbarRef.current = toolbar
+ }, [inputOwner, onKey, toolbar])
+ useLayoutEffect(() => {
+ if (!claiming) {
+ return
+ }
+ const claim = client.claimTerminalKeyRail(
+ (key, receivedOwner) => {
+ if (ownerRef.current === receivedOwner) {
+ onKeyRef.current(key)
+ }
+ },
+ ownerRef.current,
+ toolbarRef.current
+ )
+ claimRef.current = claim
+ return () => {
+ claimRef.current = null
+ claim()
+ }
+ }, [client, claiming])
+ useLayoutEffect(() => {
+ claimRef.current?.update(inputOwner, toolbar)
+ }, [inputOwner, toolbar])
+ return claiming ? keys : NO_RAIL_KEYS
+}
diff --git a/mobile/src/session/terminal-dock-styles.ts b/mobile/src/session/terminal-dock-styles.ts
index 6a91c16ec13..f37decd24fc 100644
--- a/mobile/src/session/terminal-dock-styles.ts
+++ b/mobile/src/session/terminal-dock-styles.ts
@@ -49,32 +49,10 @@ export const terminalDockStyles = themedStyleSheet(() => ({
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
- // The Duo's side rail is 84pt wide; keys span it less 4pt each side.
- keyRail: {
- flex: 1,
- minHeight: 0,
- gap: spacing.sm,
- paddingHorizontal: spacing.xs,
- paddingVertical: spacing.xs
- },
keyScroll: {
flex: 1,
minWidth: 0
},
- keyScrollVertical: {
- flex: 1,
- minHeight: 0
- },
- keyScrollContentVertical: {
- alignItems: 'stretch',
- gap: spacing.sm
- },
- keyVertical: {
- width: 'auto',
- height: 44,
- alignSelf: 'stretch',
- paddingHorizontal: 2
- },
keyScrollContent: {
alignItems: 'center',
gap: 6,
diff --git a/mobile/src/session/use-mobile-session-controller.ts b/mobile/src/session/use-mobile-session-controller.ts
index cba3a8fa156..e84e874156f 100644
--- a/mobile/src/session/use-mobile-session-controller.ts
+++ b/mobile/src/session/use-mobile-session-controller.ts
@@ -1,3 +1,4 @@
+import { useLayoutEffect } from 'react'
import { useNotificationPaneNavigation } from './use-notification-pane-navigation'
import { useMobileSessionFoundation } from './use-mobile-session-foundation'
import { useMobileSessionScreenState } from './use-mobile-session-screen-state'
@@ -46,6 +47,16 @@ export function useMobileSessionController() {
feedbackCapabilities,
useMobileSessionNativeChatDictation(feedbackCapabilities, sendLiveTerminalInput)
)
+ // Chat covers a split, so only its own terminal stays visible; this is the ref's one writer per commit.
+ if (nativeChatDictation.showNativeChat) {
+ nativeChatDictation.visibleTerminalHandles = nativeChatDictation.activeHandle
+ ? [nativeChatDictation.activeHandle]
+ : []
+ }
+ const { visibleTerminalHandles, visibleTerminalHandlesRef } = nativeChatDictation
+ useLayoutEffect(() => {
+ visibleTerminalHandlesRef.current = visibleTerminalHandles
+ })
const subscriptionFoundation = Object.assign(
nativeChatDictation,
useMobileSessionTerminalSubscriptionFoundation(nativeChatDictation)
diff --git a/mobile/src/session/use-mobile-session-keyboard-state.test.tsx b/mobile/src/session/use-mobile-session-keyboard-state.test.tsx
index 5579f28d617..e9fda0432be 100644
--- a/mobile/src/session/use-mobile-session-keyboard-state.test.tsx
+++ b/mobile/src/session/use-mobile-session-keyboard-state.test.tsx
@@ -71,6 +71,8 @@ function Harness(): null {
setShowCustomKeyModal: () => {},
setKeyboardHeight: harness.setKeyboardHeight,
activeHandleRef: { current: null },
+ visibleTerminalHandlesRef: { current: [] },
+ terminalPaneViewportsRef: { current: new Map() },
clientRef: { current: null },
deviceTokenRef: { current: null },
showNativeChatRef: { current: false },
diff --git a/mobile/src/session/use-mobile-session-keyboard-state.ts b/mobile/src/session/use-mobile-session-keyboard-state.ts
index 288a42bfed1..9738de28ce4 100644
--- a/mobile/src/session/use-mobile-session-keyboard-state.ts
+++ b/mobile/src/session/use-mobile-session-keyboard-state.ts
@@ -10,6 +10,8 @@ import type { MobileSessionLifecycleModel } from './use-mobile-session-lifecycle
export type MobileSessionKeyboardScope = Pick<
MobileSessionLifecycleModel,
| 'activeHandleRef'
+ | 'visibleTerminalHandlesRef'
+ | 'terminalPaneViewportsRef'
| 'activeSessionTabId'
| 'clientRef'
| 'connState'
@@ -59,6 +61,8 @@ export function useMobileSessionKeyboardState(scope: MobileSessionKeyboardScope)
viewportMeasuredRef,
terminalRefs,
activeHandleRef,
+ visibleTerminalHandlesRef,
+ terminalPaneViewportsRef,
terminalFrameRef,
showNativeChatRef,
unsubscribeTerminal,
@@ -71,6 +75,8 @@ export function useMobileSessionKeyboardState(scope: MobileSessionKeyboardScope)
notifyTerminalCellBoxChange
} = useTerminalViewportRefit({
activeHandleRef,
+ visibleTerminalHandlesRef,
+ terminalPaneViewportsRef,
terminalRefs,
terminalFrameRef,
viewportRef,
diff --git a/mobile/src/session/use-mobile-session-lifecycle.ts b/mobile/src/session/use-mobile-session-lifecycle.ts
index 7e94cb592be..643f9cc0305 100644
--- a/mobile/src/session/use-mobile-session-lifecycle.ts
+++ b/mobile/src/session/use-mobile-session-lifecycle.ts
@@ -1,3 +1,5 @@
+import { useShellStorage } from '../platform/shell-storage'
+import { useDirectionPadAvailable } from '../layout/use-shell-side-rail'
import { settleSessionPreferenceRead } from './session-preference-read'
import { useEffect, useRef, useCallback, useState } from 'react'
import { AppState, type AppStateStatus, Platform } from 'react-native'
@@ -12,6 +14,8 @@ import { loadCustomKeys, subscribeStoredCustomKeys } from '../terminal/terminal-
import type { MobileSessionTabReconciliationModel } from './use-mobile-session-tab-reconciliation'
export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationModel) {
+ const shellStorage = useShellStorage()
+ const padAvailable = useDirectionPadAvailable()
const [customKeysLoaded, setCustomKeysLoaded] = useState(false)
const [accessoryLayoutLoaded, setAccessoryLayoutLoaded] = useState(false)
const {
@@ -20,11 +24,13 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
customKeys,
setCustomKeys,
setVisibleBuiltInIds,
+ setDirectionPadEnabled,
setHostEndpoint,
connStateRef,
terminalRefs,
initializedHandlesRef,
activeHandleRef,
+ visibleTerminalHandlesRef,
scheduleDelayedAction,
unsubscribeTerminal,
subscribeToTerminal
@@ -70,7 +76,7 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
customKeysReadRevisionRef.current += 1
}
}
- }, [])
+ }, [shellStorage])
useEffect(
() =>
@@ -87,10 +93,11 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
const layoutReadRevisionRef = useRef(0)
const refreshAccessoryLayout = useCallback(() => {
const revision = ++layoutReadRevisionRef.current
- void settleSessionPreferenceRead(loadTerminalAccessoryLayout()).then((layout) => {
+ void settleSessionPreferenceRead(loadTerminalAccessoryLayout(padAvailable)).then((layout) => {
if (layoutReadRevisionRef.current === revision) {
if (layout) {
setVisibleBuiltInIds(layout.visibleBuiltInIds)
+ setDirectionPadEnabled(layout.directionPadEnabled !== false)
}
setAccessoryLayoutLoaded(true)
}
@@ -100,7 +107,7 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
layoutReadRevisionRef.current += 1
}
}
- }, [])
+ }, [padAvailable, shellStorage])
useFocusEffect(
useCallback(() => {
@@ -146,6 +153,7 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
// WKWebView can resume blank without ready; clear the latch so init waits for a fresh pong.
const outcome = recoverActiveTerminalAfterForeground({
activeHandleRef,
+ visibleTerminalHandlesRef,
terminalRefs,
initializedHandlesRef,
connStateRef,
@@ -171,6 +179,7 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
}
recoverActiveTerminalAfterForeground({
activeHandleRef,
+ visibleTerminalHandlesRef,
terminalRefs,
initializedHandlesRef,
connStateRef,
diff --git a/mobile/src/session/use-mobile-session-panel-route-actions.tsx b/mobile/src/session/use-mobile-session-panel-route-actions.tsx
index a8c3bc42882..905f274bf85 100644
--- a/mobile/src/session/use-mobile-session-panel-route-actions.tsx
+++ b/mobile/src/session/use-mobile-session-panel-route-actions.tsx
@@ -43,7 +43,11 @@ export function useMobileSessionPanelRouteActions(scope: MobileSessionPresentati
agentSessionHistorySupported,
clearDeliveredDiffComments,
sendDiffNotesToNewAgent,
- handleCreateTerminal
+ handleCreateTerminal,
+ setCreateError,
+ setShowQuickCommands,
+ quickCommandsSupported,
+ showToast
} = scope
const otherRuntimeAgentRow = {
id: 'otherServerAgents',
@@ -211,6 +215,28 @@ export function useMobileSessionPanelRouteActions(scope: MobileSessionPresentati
})
const showHeaderMoreButton = showAgentSessionHistoryAction || showChecksAction
const createTabBusy = creating || creatingBrowser || creatingMarkdown
+ const handleOpenNewTab = () => {
+ setCreateError('')
+ setShowCreateTabDrawer(true)
+ }
+ const handleOpenQuickCommands = () => {
+ if (quickCommandsSupported === true) {
+ setShowQuickCommands(true)
+ return
+ }
+ showToast(
+ quickCommandsSupported === false
+ ? t(
+ 'session.tabStrip.quickCommandsUpdateRequired',
+ 'Desktop update required for quick commands'
+ )
+ : t(
+ 'session.tabStrip.checkingCapabilities',
+ 'Checking desktop capabilities — try again in a moment'
+ ),
+ 1600
+ )
+ }
return {
createTabAgentActions,
sendDiffNotesAgentActions,
@@ -220,7 +246,9 @@ export function useMobileSessionPanelRouteActions(scope: MobileSessionPresentati
showAgentSessionHistoryAction,
showChecksAction,
showHeaderMoreButton,
- createTabBusy
+ createTabBusy,
+ handleOpenNewTab,
+ handleOpenQuickCommands
}
}
diff --git a/mobile/src/session/use-mobile-session-presentation.ts b/mobile/src/session/use-mobile-session-presentation.ts
index a3c7cdb03ac..d36cd82d1a8 100644
--- a/mobile/src/session/use-mobile-session-presentation.ts
+++ b/mobile/src/session/use-mobile-session-presentation.ts
@@ -8,6 +8,7 @@ import { useInitialSessionTerminalAutoCreate } from './use-initial-session-termi
import { MOBILE_SESSION_STATUS_LABELS } from './mobile-session-route-helpers'
import type { MobileSessionBulkCloseModel } from './use-mobile-session-bulk-close'
import { hostOs } from '../platform/host-os'
+import { useDirectionPadAvailable, useShellSideRail } from '../layout/use-shell-side-rail'
import { useCalmConnectionState } from '../transport/calm-connection-state'
export function useMobileSessionPresentation(scope: MobileSessionBulkCloseModel) {
@@ -31,6 +32,7 @@ export function useMobileSessionPresentation(scope: MobileSessionBulkCloseModel)
hostEndpoint,
initialSessionAutoCreateRef,
terminalFrameRef,
+ getTerminalPaneViewport,
handleCreateTerminal,
visibleTabs,
forceReconnectHost
@@ -78,17 +80,29 @@ export function useMobileSessionPresentation(scope: MobileSessionBulkCloseModel)
? verdictDisplayLabel(connectionVerdict)
: MOBILE_SESSION_STATUS_LABELS[presentedConnState]
- // Why: iOS keyboard height includes the home-indicator inset; Android IME height does not.
+ const [sideRail, padAvailable] = [useShellSideRail(), useDirectionPadAvailable()]
+ const dockShown =
+ !scope.showNativeChat &&
+ !scope.activeMarkdownTab &&
+ !scope.activeFileTab &&
+ !scope.activeBrowserTab
+ const dockBottomInset = dockShown && (sideRail || padAvailable) ? 0 : insets.bottom
+ // Subtract only the space actually reserved below the input dock.
const keyboardLift =
keyboardHeight > 0
? hostOs() === 'ios'
- ? Math.max(0, keyboardHeight - insets.bottom)
+ ? Math.max(0, keyboardHeight - dockBottomInset)
: keyboardHeight
: 0
const activeTerminalKeyboardLift = computeActiveTerminalKeyboardLift({
keyboardLift,
metrics: activeHandle ? terminalKeyboardMetrics.get(activeHandle) : undefined,
- terminalFrameHeight: terminalFrameRef.current?.height ?? 0
+ terminalFrameHeight:
+ (activeHandle
+ ? getTerminalPaneViewport?.(activeHandle).terminalFrameRef.current?.height
+ : null) ??
+ terminalFrameRef.current?.height ??
+ 0
})
const toastAnimatedStyle = {
opacity: toastOpacityRef.current,
@@ -100,6 +114,7 @@ export function useMobileSessionPresentation(scope: MobileSessionBulkCloseModel)
showEmptyState,
connectionVerdict,
showConnectionRetry,
+ connectionEscalated,
terminalSummary,
keyboardLift,
activeTerminalKeyboardLift,
diff --git a/mobile/src/session/use-mobile-session-screen-state.ts b/mobile/src/session/use-mobile-session-screen-state.ts
index c9d2776a8ef..096d5bbdf0c 100644
--- a/mobile/src/session/use-mobile-session-screen-state.ts
+++ b/mobile/src/session/use-mobile-session-screen-state.ts
@@ -1,4 +1,5 @@
import { useState, useRef, useMemo } from 'react'
+import { useTerminalSplitLayout } from './use-terminal-split-layout'
import { Animated, type ScrollView } from 'react-native'
import type { MobileTerminalLinkOpenMode } from '../storage/preferences'
import type { TerminalKeyboardAvoidanceMetrics } from '../terminal/terminal-webview-contract'
@@ -29,6 +30,7 @@ import type { MobileSessionFoundationModel } from './use-mobile-session-foundati
export function useMobileSessionScreenState(scope: MobileSessionFoundationModel) {
const { worktreeId, hostId, initialCreateWarning } = scope
+ const terminalSplitLayout = useTerminalSplitLayout()
const [terminals, setTerminals] = useState([])
const terminalsRef = useRef([])
const [sessionTabs, setSessionTabs] = useState([])
@@ -92,6 +94,7 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel)
const [createTabAgentOptions, setCreateTabAgentOptions] = useState([])
const [showCreateBrowserModal, setShowCreateBrowserModal] = useState(false)
const [showHeaderMoreActions, setShowHeaderMoreActions] = useState(false)
+ const [showTerminalLayout, setShowTerminalLayout] = useState(false)
const sessionTabActionTargets = useMobileSessionTabActionTargets()
const [discardMarkdownTarget, setDiscardMarkdownTarget] = useState(null)
const [renameTarget, setRenameTarget] = useState(null)
const [customKeys, setCustomKeys] = useState([])
+ const [directionPadEnabled, setDirectionPadEnabled] = useState(true)
const [visibleBuiltInIds, setVisibleBuiltInIds] = useState(
getDefaultTerminalAccessoryBuiltInIds
)
@@ -126,6 +130,7 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel)
const toastHideTimerRef = useRef | null>(null)
const toastSeqRef = useRef(0)
return {
+ ...terminalSplitLayout,
terminals,
setTerminals,
terminalsRef,
@@ -202,6 +207,8 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel)
setShowCreateBrowserModal,
showHeaderMoreActions,
setShowHeaderMoreActions,
+ showTerminalLayout,
+ setShowTerminalLayout,
...sessionTabActionTargets,
discardMarkdownTarget,
setDiscardMarkdownTarget,
@@ -211,6 +218,8 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel)
setRenameTarget,
customKeys,
setCustomKeys,
+ directionPadEnabled,
+ setDirectionPadEnabled,
visibleBuiltInIds,
setVisibleBuiltInIds,
showCustomKeyModal,
diff --git a/mobile/src/session/use-mobile-session-startup.ts b/mobile/src/session/use-mobile-session-startup.ts
index e5f07cc1871..d6cf0378e5b 100644
--- a/mobile/src/session/use-mobile-session-startup.ts
+++ b/mobile/src/session/use-mobile-session-startup.ts
@@ -42,6 +42,8 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel)
clearDelayedActionTimers,
showToast,
clearTerminalCache,
+ setSplitTerminalHandles,
+ terminalPaneViewportsRef,
fetchTerminals,
ensureSessionTabs
} = scope
@@ -51,6 +53,8 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel)
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
sessionTabActionSheetKeyboardHideSubRef.current = null
clearTerminalCache()
+ setSplitTerminalHandles(null)
+ terminalPaneViewportsRef.current.clear()
activeHandleRef.current = null
activeSessionTabTypeRef.current = null
pendingSelectionRef.current = null
@@ -90,6 +94,8 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel)
clearPendingLiveInputCommit,
bufferedTerminalDraftState.clearPendingRestorations,
clearTerminalCache,
+ setSplitTerminalHandles,
+ terminalPaneViewportsRef,
hostId,
bufferedTerminalDraftState.resetDrafts,
worktreeId
diff --git a/mobile/src/session/use-mobile-session-tab-application.ts b/mobile/src/session/use-mobile-session-tab-application.ts
index 0ee615d9cb2..c058d1e8ed0 100644
--- a/mobile/src/session/use-mobile-session-tab-application.ts
+++ b/mobile/src/session/use-mobile-session-tab-application.ts
@@ -1,3 +1,5 @@
+import { keepsTerminalPaneVisible } from './mobile-terminal-pane-layout'
+import { orphanedMarkdownDraftTabs } from './orphaned-markdown-draft-tabs'
import {
pendingSelectionHandle,
pendingSelectionTabId,
@@ -55,6 +57,7 @@ export function useMobileSessionTabApplication(scope: MobileSessionTerminalListM
markdownDocsRef,
terminalDiagnosticsRef,
activeHandleRef,
+ visibleTerminalHandlesRef,
activeSessionTabTypeRef,
pendingSelectionRef,
pendingBrowserFocusPageIdRef,
@@ -83,23 +86,13 @@ export function useMobileSessionTabApplication(scope: MobileSessionTerminalListM
),
sessionTabsRef.current
)
- const presentTabIds = new Set(nextTabs.map((tab) => tab.id))
- const orphanedDraftTabs: MobileSessionTab[] = []
- const currentMarkdownDocs = markdownDocsRef.current
const currentSessionTabs = sessionTabsRef.current
- for (const [tabId, doc] of currentMarkdownDocs) {
- if (doc.status !== 'ready' || !doc.isDirty || presentTabIds.has(tabId)) {
- continue
- }
- const draftTab = currentSessionTabs.find(
- (tab): tab is Extract =>
- tab.type === 'markdown' && tab.id === tabId
- )
- if (draftTab) {
- // Keep phone-local document drafts reachable after desktop tab removal so users can copy/discard them.
- orphanedDraftTabs.push({ ...draftTab, isActive: tabId === activeSessionTabIdRef.current })
- }
- }
+ const orphanedDraftTabs = orphanedMarkdownDraftTabs(
+ nextTabs,
+ currentSessionTabs,
+ markdownDocsRef.current,
+ activeSessionTabIdRef.current
+ )
if (orphanedDraftTabs.length > 0) {
nextTabs = [...orphanedDraftTabs, ...nextTabs]
}
@@ -269,7 +262,9 @@ export function useMobileSessionTabApplication(scope: MobileSessionTerminalListM
if (typeof active.terminal !== 'string') {
const previous = activeHandleRef.current
if (previous) {
- unsubscribeTerminal(previous)
+ if (!keepsTerminalPaneVisible(previous, visibleTerminalHandlesRef?.current)) {
+ unsubscribeTerminal(previous)
+ }
}
activeHandleRef.current = null
setActiveHandle(null)
@@ -277,7 +272,9 @@ export function useMobileSessionTabApplication(scope: MobileSessionTerminalListM
}
const previous = activeHandleRef.current
if (previous && previous !== active.terminal) {
- unsubscribeTerminal(previous)
+ if (!keepsTerminalPaneVisible(previous, visibleTerminalHandlesRef?.current)) {
+ unsubscribeTerminal(previous)
+ }
}
activeHandleRef.current = active.terminal
setActiveHandle(active.terminal)
@@ -286,7 +283,9 @@ export function useMobileSessionTabApplication(scope: MobileSessionTerminalListM
// Transient empty snapshots may omit a live terminal; only explicit successful close clears it.
const previous = activeHandleRef.current
if (previous) {
- unsubscribeTerminal(previous)
+ if (!keepsTerminalPaneVisible(previous, visibleTerminalHandlesRef?.current)) {
+ unsubscribeTerminal(previous)
+ }
}
activeHandleRef.current = null
setActiveHandle(null)
diff --git a/mobile/src/session/use-mobile-session-tab-switching.ts b/mobile/src/session/use-mobile-session-tab-switching.ts
index b238081080c..f81a628d5ab 100644
--- a/mobile/src/session/use-mobile-session-tab-switching.ts
+++ b/mobile/src/session/use-mobile-session-tab-switching.ts
@@ -1,3 +1,4 @@
+import { keepsTerminalPaneVisible } from './mobile-terminal-pane-layout'
import { useCallback } from 'react'
import { STARTUP_CWD_FALLBACK_SENTENCE } from '../../../src/shared/startup-cwd-fallback'
import { triggerSelection } from '../platform/haptics'
@@ -15,6 +16,7 @@ export function useMobileSessionTabSwitching(scope: MobileSessionKeyboardStateMo
sessionTabs,
defaultTerminalHandlesToLiveInput,
setActiveHandle,
+ setSplitTerminalHandles,
setActiveSessionTabId,
activeSessionTabId,
markdownDocs,
@@ -22,6 +24,7 @@ export function useMobileSessionTabSwitching(scope: MobileSessionKeyboardStateMo
terminalUnsubsRef,
terminalDiagnosticsRef,
activeHandleRef,
+ visibleTerminalHandlesRef,
activeSessionTabTypeRef,
pendingSelectionRef,
switchSessionTabRef,
@@ -52,13 +55,25 @@ export function useMobileSessionTabSwitching(scope: MobileSessionKeyboardStateMo
activeSessionTabTypeRef.current = 'terminal'
defaultTerminalHandlesToLiveInput([handle])
setActiveSessionTabId(matchingTab?.id ?? null)
+ const visible = visibleTerminalHandlesRef?.current
+ if (visible?.length === 2 && !visible.includes(handle)) {
+ const pair = [handle, visible[0]]
+ visibleTerminalHandlesRef.current = pair
+ setSplitTerminalHandles(pair)
+ }
const prev = activeHandleRef.current
activeHandleRef.current = handle
setActiveHandle(handle)
if (prev && prev !== handle) {
- unsubscribeTerminal(prev)
+ if (!keepsTerminalPaneVisible(prev, visibleTerminalHandlesRef?.current)) {
+ unsubscribeTerminal(prev)
+ }
}
- if (terminalUnsubsRef.current.has(handle)) {
+ // Force a fresh subscribe even if eagerly subscribed without viewport
+ if (
+ terminalUnsubsRef.current.has(handle) &&
+ (visibleTerminalHandlesRef?.current.length ?? 0) < 2
+ ) {
unsubscribeTerminal(handle)
}
subscribeToTerminal(handle)
@@ -81,6 +96,7 @@ export function useMobileSessionTabSwitching(scope: MobileSessionKeyboardStateMo
defaultTerminalHandlesToLiveInput,
reportActivation,
sessionTabs,
+ setSplitTerminalHandles,
subscribeToTerminal,
unsubscribeTerminal,
worktreeId
@@ -104,7 +120,9 @@ export function useMobileSessionTabSwitching(scope: MobileSessionKeyboardStateMo
setActiveSessionTabId(tab.id)
const prev = activeHandleRef.current
if (prev) {
- unsubscribeTerminal(prev)
+ if (!keepsTerminalPaneVisible(prev, visibleTerminalHandlesRef?.current)) {
+ unsubscribeTerminal(prev)
+ }
}
activeHandleRef.current = null
setActiveHandle(null)
@@ -129,7 +147,9 @@ export function useMobileSessionTabSwitching(scope: MobileSessionKeyboardStateMo
setActiveSessionTabId(tab.id)
const prev = activeHandleRef.current
if (prev) {
- unsubscribeTerminal(prev)
+ if (!keepsTerminalPaneVisible(prev, visibleTerminalHandlesRef?.current)) {
+ unsubscribeTerminal(prev)
+ }
}
activeHandleRef.current = null
setActiveHandle(null)
diff --git a/mobile/src/session/use-mobile-session-terminal-runtime.ts b/mobile/src/session/use-mobile-session-terminal-runtime.ts
index 36bb3cab059..f118575a9f1 100644
--- a/mobile/src/session/use-mobile-session-terminal-runtime.ts
+++ b/mobile/src/session/use-mobile-session-terminal-runtime.ts
@@ -1,3 +1,8 @@
+import {
+ terminalPaneViewport,
+ resolveTerminalPaneHandles,
+ type TerminalPaneViewport
+} from './mobile-terminal-pane-layout'
import type { PendingSessionSelection } from './pending-session-selection'
import { useState, useRef, useCallback } from 'react'
import type { TerminalFrame } from '../terminal/terminal-webview-messages'
@@ -35,6 +40,7 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM
client,
clientId,
sessionTabs,
+ terminals,
setLiveInputCapture,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
@@ -58,6 +64,19 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM
// Carry measured phone dimensions on subscribes so the host fits before snapshot serialization.
const viewportRef = useRef<{ cols: number; rows: number } | null>(null)
const viewportMeasuredRef = useRef(false)
+ const terminalPaneViewportsRef = useRef(new Map())
+ const getTerminalPaneViewport = useCallback(
+ (handle: string) => terminalPaneViewport(terminalPaneViewportsRef.current, handle),
+ []
+ )
+ const [splitTerminalHandles, setSplitTerminalHandles] = useState(null)
+ const visibleTerminalHandles = resolveTerminalPaneHandles(
+ activeHandle,
+ splitTerminalHandles,
+ terminals.map((terminal) => terminal.handle)
+ )
+ // The controller publishes the committed list, once native chat has had its say.
+ const visibleTerminalHandlesRef = useRef(visibleTerminalHandles)
const terminalRefs = useRef