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 ( ) } -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>(new Map()) const liveInputRef = useRef(null) const commandInputRef = useRef(null) @@ -194,6 +213,12 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM connStateRef, viewportRef, viewportMeasuredRef, + terminalPaneViewportsRef, + getTerminalPaneViewport, + splitTerminalHandles, + setSplitTerminalHandles, + visibleTerminalHandles, + visibleTerminalHandlesRef, terminalRefs, liveInputRef, commandInputRef, diff --git a/mobile/src/session/use-mobile-session-terminal-stream-display.ts b/mobile/src/session/use-mobile-session-terminal-stream-display.ts index 45623f12cf9..c272734783a 100644 --- a/mobile/src/session/use-mobile-session-terminal-stream-display.ts +++ b/mobile/src/session/use-mobile-session-terminal-stream-display.ts @@ -13,6 +13,7 @@ export function useMobileSessionTerminalStreamDisplay( terminalModes, deviceTokenRef, viewportRef, + getTerminalPaneViewport, terminalUnsubsRef, subscribingHandlesRef, leaseOnlyHandlesRef, @@ -50,6 +51,7 @@ export function useMobileSessionTerminalStreamDisplay( // Phone is observed state; request auto or desktop rather than persisting it. const next: 'auto' | 'desktop' = current === 'auto' || current === 'phone' ? 'desktop' : 'auto' + const viewport = getTerminalPaneViewport?.(handle).viewportRef.current ?? viewportRef.current toggleInFlightRef.current.add(handle) try { await terminalDisplayModeSet.request(client, { @@ -60,7 +62,7 @@ export function useMobileSessionTerminalStreamDisplay( ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } : {}), // Measure viewport live so pre-measure subscriptions can switch to auto successfully. - ...(viewportRef.current && next === 'auto' ? { viewport: viewportRef.current } : {}) + ...(viewport && next === 'auto' ? { viewport } : {}) }) } catch { // Mode change failed — server state unchanged, UI stays in sync. diff --git a/mobile/src/session/use-mobile-session-terminal-subscription.test.tsx b/mobile/src/session/use-mobile-session-terminal-subscription.test.tsx index 2d39ff695a6..8a5b09b9a74 100644 --- a/mobile/src/session/use-mobile-session-terminal-subscription.test.tsx +++ b/mobile/src/session/use-mobile-session-terminal-subscription.test.tsx @@ -15,6 +15,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' import type { TerminalWebViewHandle } from '../terminal/terminal-webview-contract' import { MobileTerminalDiagnostics } from './mobile-terminal-diagnostics' +import { terminalPaneViewport, type TerminalPaneViewport } from './mobile-terminal-pane-layout' import { TerminalViewportResubscribeBudget } from './mobile-terminal-viewport-resubscribe' import type { MobileSessionTerminalSubscriptionFoundationModel } from './use-mobile-session-terminal-subscription-foundation' import { useMobileSessionTerminalSubscription } from './use-mobile-session-terminal-subscription' @@ -32,6 +33,7 @@ function subscriptionHarness(opts: { frameWidth?: number viewport?: typeof PHONE synchronousEvents?: unknown[] + split?: boolean }) { let fit = opts.fit const order: string[] = [] @@ -87,6 +89,15 @@ function subscriptionHarness(opts: { viewportResubscribeBudgetRef: { current: new TerminalViewportResubscribeBudget() }, webReadyHandlesRef, activeHandleRef: { current: HANDLE }, + activeHandle: HANDLE, + terminals: [{ handle: HANDLE, title: 'Terminal', isActive: true }], + splitTerminalHandles: null, + setSplitTerminalHandles: vi.fn(), + visibleTerminalHandles: [HANDLE], + visibleTerminalHandlesRef: { current: [HANDLE] }, + showNativeChat: false, + switchTab: vi.fn(), + handleTerminalTap: vi.fn(), subscribeSeqRef, layoutSeqRef: { current: new Map() }, terminalFrameRef, @@ -105,6 +116,7 @@ function subscriptionHarness(opts: { unsubscribeTerminalRef: { current: vi.fn() }, signalTerminalInventoryRecovery: vi.fn(), terminalRefs: { current: new Map([[HANDLE, terminal]]) }, + terminalPaneViewportsRef: { current: new Map() }, pendingSelectionRef: { current: null }, nativeChatStream: { notifyWebReady: vi.fn() }, terminalGestureInputBucketsRef: { current: new Map() }, @@ -118,20 +130,57 @@ function subscriptionHarness(opts: { notifyTerminalFrameHeight: vi.fn(), notifyTerminalFrameWidth: vi.fn() } + const second = { + ...terminal, + write: vi.fn(), + init: vi.fn(), + fitDimensions: vi.fn(() => ({ cols: 30, rows: 20 })) + } + const secondGeometry: TerminalPaneViewport = { + terminalFrameRef: { current: { width: 240, height: 320 } }, + viewportRef: { current: null }, + viewportMeasuredRef: { current: false } + } + fields.terminalPaneViewportsRef.current.set(HANDLE, { + terminalFrameRef: fields.terminalFrameRef, + viewportRef: fields.viewportRef, + viewportMeasuredRef: fields.viewportMeasuredRef + }) + fields.terminalPaneViewportsRef.current.set('term-2', secondGeometry) + if (opts.split) { + fields.terminals.push({ handle: 'term-2', title: 'Second', isActive: false }) + fields.visibleTerminalHandles.push('term-2') + fields.visibleTerminalHandlesRef.current.push('term-2') + fields.webReadyHandlesRef.current.add('term-2') + fields.terminalRefs.current.set('term-2', second) + } + fields.getTerminalRef = (handle: string | null) => + handle === HANDLE ? currentTerminal : handle === 'term-2' && opts.split ? second : undefined fields.unsubscribeTerminalRef.current.mockImplementation(fields.unsubscribeTerminal) let subscribe: ((handle: string) => void) | undefined let webReady: ((handle: string) => void) | undefined + let setRef: ((handle: string, ref: TerminalWebViewHandle | null) => void) | undefined let notifyFrame: ((frame: { width: number; height: number }) => void) | undefined function Probe() { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook destructures only the fields built above. - const scope = fields as unknown as MobileSessionTerminalSubscriptionFoundationModel + const scope = { + ...fields, + getTerminalPaneViewport: (handle: string) => + terminalPaneViewport(fields.terminalPaneViewportsRef.current, handle) + } as unknown as MobileSessionTerminalSubscriptionFoundationModel subscribe = useMobileSessionTerminalSubscription(scope).subscribeToTerminal - const withSubscribe = { ...fields, subscribeToTerminal: subscribe } + const withSubscribe = { + ...fields, + subscribeToTerminal: subscribe, + getTerminalPaneViewport: (handle: string) => + terminalPaneViewport(fields.terminalPaneViewportsRef.current, handle) + } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the web-ready handler reads only the fields built above. const webviewScope = withSubscribe as unknown as MobileSessionTabSwitchingModel const webview = useMobileSessionTerminalWebview(webviewScope) webReady = webview.handleTerminalWebReady - notifyFrame = webview.notifyTerminalFrame + setRef = webview.setTerminalWebViewRef + notifyFrame = (frame) => webview.notifyTerminalPaneFrame(HANDLE, frame) return null } act(() => { @@ -145,6 +194,8 @@ function subscriptionHarness(opts: { order, stopStream, subscribe: () => act(() => subscribe!(HANDLE)), + subscribeHandle: (handle: string) => act(() => subscribe!(handle)), + second, scrollback, terminal, event: (index: number, data: unknown) => act(() => handlers[index](data)), @@ -152,6 +203,13 @@ function subscriptionHarness(opts: { currentTerminal = next fields.terminalRefs.current.set(HANDLE, next) }, + replaceImperativeHandle: () => + act(() => { + setRef!(HANDLE, null) + currentTerminal = { ...terminal } + setRef!(HANDLE, currentTerminal) + }), + // The document's web-ready, carrying the box xterm laid out. documentReady: () => { act(() => webReady!(HANDLE)) }, @@ -230,9 +288,7 @@ describe('a terminal first subscribe', () => { harness.layOutFrame(360.5, 700) expect(harness.terminalFrameRef.current).toEqual({ width: 360.5, height: 700 }) expect(notifyTerminalFrameWidth).toHaveBeenCalledTimes(1) - expect(notifyTerminalFrameHeight.mock.calls.map(([height]) => height)).toEqual([ - 0, 751, 700, 0, 700 - ]) + expect(notifyTerminalFrameHeight.mock.calls.map(([height]) => height)).toEqual([751, 700, 700]) }) it('goes without dims when no cell box was reported, and the fit pass resubscribes once', async () => { @@ -460,3 +516,52 @@ describe('foreground snapshots preserve the terminal reading position', () => { } ) }) + +describe('two visible terminal subscriptions', () => { + it('retains a split pane frame when a mounted document replaces its imperative handle', () => { + const harness = subscriptionHarness({ fit: PHONE, webReady: true, split: true }) + harness.fields.unsubscribeTerminal(HANDLE) + vi.mocked(harness.terminal.fitDimensions).mockImplementation((frame) => ({ + cols: Math.floor(frame.width / 8), + rows: Math.floor(frame.height / 16) + })) + harness.layOutFrame(376, 224) + harness.fields.unsubscribeTerminal(HANDLE) + const priorSubscriptions = harness.fields.client.subscribe.mock.calls.length + harness.replaceImperativeHandle() + harness.subscribe() + expect(harness.fields.client.subscribe).toHaveBeenCalledTimes(priorSubscriptions + 1) + expect(harness.fields.client.subscribe.mock.calls.at(-1)?.[1]).toMatchObject({ + terminal: HANDLE, + viewport: { cols: 47, rows: 14 } + }) + expect( + harness.fields.terminalPaneViewportsRef.current.get(HANDLE)?.terminalFrameRef.current + ).toEqual({ width: 376, height: 224 }) + }) + + it('subscribes and renders both streams with their own measured grids', () => { + const harness = subscriptionHarness({ fit: PHONE, webReady: true, split: true }) + harness.subscribe() + harness.subscribeHandle('term-2') + const requests = harness.fields.client.subscribe.mock.calls + expect(requests.map((call) => [call[1].terminal, call[1].viewport])).toEqual([ + [HANDLE, PHONE], + ['term-2', { cols: 30, rows: 20 }] + ]) + harness.scrollback(0, PHONE.cols, PHONE.rows) + harness.scrollback(1, 30, 20) + harness.event(0, { type: 'data', chunk: 'first' }) + harness.event(1, { type: 'data', chunk: 'second' }) + expect(harness.terminal.write).toHaveBeenCalledWith('first') + expect(harness.terminal.write).not.toHaveBeenCalledWith('second') + expect(harness.second.write).toHaveBeenCalledWith('second') + expect(harness.second.init).toHaveBeenCalledWith( + expect.objectContaining({ cols: 30, rows: 20, frame: { width: 240, height: 320 } }) + ) + harness.fields.activeHandleRef.current = 'term-2' + harness.event(0, { type: 'data', chunk: 'still live' }) + expect(harness.terminal.write).toHaveBeenCalledWith('still live') + expect(harness.fields.terminalUnsubsRef.current.size).toBe(2) + }) +}) diff --git a/mobile/src/session/use-mobile-session-terminal-subscription.ts b/mobile/src/session/use-mobile-session-terminal-subscription.ts index 5c9be094e9c..8de24b2031d 100644 --- a/mobile/src/session/use-mobile-session-terminal-subscription.ts +++ b/mobile/src/session/use-mobile-session-terminal-subscription.ts @@ -23,8 +23,7 @@ export function useMobileSessionTerminalSubscription( clientId, setTerminalModes, terminalCwdRef, - viewportRef, - viewportMeasuredRef, + getTerminalPaneViewport, terminalUnsubsRef, subscribingHandlesRef, leaseOnlyHandlesRef, @@ -35,7 +34,6 @@ export function useMobileSessionTerminalSubscription( activeHandleRef, subscribeSeqRef, layoutSeqRef, - terminalFrameRef, scheduleDelayedAction, showToast, markNativeChatInputLeaseReady, @@ -47,6 +45,8 @@ export function useMobileSessionTerminalSubscription( } = scope const subscribeToTerminal = useCallback( (handle: string): boolean => { + const { viewportRef, viewportMeasuredRef, terminalFrameRef } = + getTerminalPaneViewport?.(handle) ?? scope const diagnostics = terminalDiagnosticsRef.current const logSkippedGate = (reason: string) => diagnostics.streamSkipped(handle, reason, handle === activeHandleRef.current) diff --git a/mobile/src/session/use-mobile-session-terminal-webview.ts b/mobile/src/session/use-mobile-session-terminal-webview.ts index 3906f9c5277..9a870e25dc6 100644 --- a/mobile/src/session/use-mobile-session-terminal-webview.ts +++ b/mobile/src/session/use-mobile-session-terminal-webview.ts @@ -1,6 +1,6 @@ import { terminalInputSend } from '../terminal/mobile-terminal-operations' -import { pendingSelectionHandle, pendingSelectionWantsHandle } from './pending-session-selection' -import { useEffect, useCallback } from 'react' +import { pendingSelectionWantsHandle } from './pending-session-selection' +import { useEffect, useCallback, useRef } from 'react' import type { TerminalWebViewHandle } from '../terminal/terminal-webview-contract' import type { TerminalFrame } from '../terminal/terminal-webview-messages' import type { MobileSessionTabSwitchingModel } from './use-mobile-session-tab-switching' @@ -18,6 +18,17 @@ export function useMobileSessionTerminalWebview(scope: MobileSessionTabSwitching terminalDiagnosticsRef, webReadyHandlesRef, activeHandleRef, + activeHandle, + terminals, + setSplitTerminalHandles, + visibleTerminalHandles, + visibleTerminalHandlesRef, + getTerminalPaneViewport, + terminalPaneViewportsRef, + connState, + showNativeChat, + switchTab, + handleTerminalTap: focusTerminalTap, pendingSelectionRef, activeSessionTab, unsubscribeTerminal, @@ -53,6 +64,16 @@ export function useMobileSessionTerminalWebview(scope: MobileSessionTabSwitching } }, []) + useEffect(() => { + // Imperative handles change without layout events; inventory removal owns geometry cleanup. + const liveHandles = new Set(terminals.map((terminal) => terminal.handle)) + for (const handle of terminalPaneViewportsRef.current.keys()) { + if (!liveHandles.has(handle)) { + terminalPaneViewportsRef.current.delete(handle) + } + } + }, [terminals, terminalPaneViewportsRef]) + const handleTerminalWebReady = useCallback( (handle: string) => { const wasAlreadyReady = webReadyHandlesRef.current.has(handle) @@ -66,14 +87,14 @@ export function useMobileSessionTerminalWebview(scope: MobileSessionTabSwitching if (wasAlreadyReady && initializedHandlesRef.current.has(handle)) { // Reload loses the xterm buffer; request fresh scrollback. unsubscribeTerminal(handle) - if (handle === activeHandleRef.current) { + if (visibleTerminalHandlesRef.current.includes(handle)) { subscribeToTerminal(handle) } return } // Lagging snapshots can clear a new tab’s active handle; its pending marker still authorizes ready subscribe. const isIntendedActive = - handle === activeHandleRef.current || + visibleTerminalHandlesRef.current.includes(handle) || pendingSelectionWantsHandle(pendingSelectionRef.current, handle) if (isIntendedActive && !terminalUnsubsRef.current.has(handle)) { subscribeToTerminal(handle) @@ -82,37 +103,113 @@ export function useMobileSessionTerminalWebview(scope: MobileSessionTabSwitching [nativeChatStream, subscribeToTerminal, unsubscribeTerminal] ) - const subscribeIntendedActiveTerminal = useCallback(() => { - const handle = pendingSelectionHandle(pendingSelectionRef.current) ?? activeHandleRef.current - if (handle && !terminalUnsubsRef.current.has(handle)) { - subscribeToTerminal(handle) - } - }, [activeHandleRef, pendingSelectionRef, subscribeToTerminal, terminalUnsubsRef]) - const notifyTerminalFrame = useCallback( (frame: TerminalFrame) => { - // Imperative frame-height notification refits the PTY without rerendering SessionScreen. - notifyTerminalFrameHeight(Math.round(frame.height)) - // Hidden pages report 0x0; preserve their last laid-out box. - if (frame.width <= 0) { + terminalFrameRef.current = frame + }, + [terminalFrameRef] + ) + + const notifyTerminalPaneFrame = useCallback( + (handle: string, frame: TerminalFrame) => { + if (frame.width <= 0 || frame.height <= 0) { return } - const previous = terminalFrameRef.current - terminalFrameRef.current = frame - if (!previous) { - subscribeIntendedActiveTerminal() - } else if (frame.width !== previous.width) { - notifyTerminalFrameWidth() + const state = getTerminalPaneViewport(handle) + const previous = state.terminalFrameRef.current + state.terminalFrameRef.current = frame + if (!previous || previous.width !== frame.width || previous.height !== frame.height) { + state.viewportMeasuredRef.current = false + if (visibleTerminalHandlesRef.current.includes(handle)) { + if (!terminalUnsubsRef.current.has(handle)) { + subscribeToTerminal(handle) + } + notifyTerminalFrameHeight(Math.round(frame.height)) + if (previous && previous.width !== frame.width) { + notifyTerminalFrameWidth() + } + } } }, [ + getTerminalPaneViewport, notifyTerminalFrameHeight, notifyTerminalFrameWidth, - subscribeIntendedActiveTerminal, - terminalFrameRef + subscribeToTerminal ] ) + const canSplitTerminals = + !!activeHandle && + terminals.some((terminal) => terminal.handle !== activeHandle) && + !showNativeChat + const terminalSplitActive = !showNativeChat && visibleTerminalHandles.length === 2 + const toggleTerminalSplit = useCallback(() => { + if (terminalSplitActive) { + setSplitTerminalHandles(null) + return + } + const companion = terminals.find((terminal) => terminal.handle !== activeHandle) + if (activeHandle && companion) { + setSplitTerminalHandles([activeHandle, companion.handle]) + } + }, [activeHandle, terminalSplitActive, setSplitTerminalHandles, terminals]) + + const focusFrameRef = useRef | null>(null) + const handleTerminalInteraction = useCallback( + (handle: string) => { + if (!visibleTerminalHandlesRef.current.includes(handle)) { + return + } + if (focusFrameRef.current !== null) { + cancelAnimationFrame(focusFrameRef.current) + focusFrameRef.current = null + } + if (activeHandleRef.current !== handle) { + switchTab(handle) + } + }, + [switchTab] + ) + const handleTerminalTap = useCallback( + (handle: string) => { + if (!visibleTerminalHandlesRef.current.includes(handle)) { + return + } + handleTerminalInteraction(handle) + focusFrameRef.current = requestAnimationFrame(() => { + focusFrameRef.current = null + if (activeHandleRef.current === handle) { + focusTerminalTap(handle) + } + }) + }, + [focusTerminalTap, handleTerminalInteraction] + ) + useEffect( + () => () => { + if (focusFrameRef.current !== null) { + cancelAnimationFrame(focusFrameRef.current) + } + }, + [] + ) + + const visibleKey = visibleTerminalHandles.join('\0') + useEffect(() => { + const visible = visibleTerminalHandlesRef.current + for (const handle of terminalUnsubsRef.current.keys()) { + if (!visible.includes(handle)) { + unsubscribeTerminal(handle) + } + } + if (connState === 'connected') { + for (const handle of visible) { + subscribeToTerminal(handle) + } + } + }, [visibleKey, connState, subscribeToTerminal, unsubscribeTerminal]) + useEffect(() => { if (activeSessionTab?.type !== 'markdown') { return @@ -136,7 +233,13 @@ export function useMobileSessionTerminalWebview(scope: MobileSessionTabSwitching sendTerminalUserInput, setTerminalWebViewRef, handleTerminalWebReady, - notifyTerminalFrame + notifyTerminalFrame, + notifyTerminalPaneFrame, + canSplitTerminals, + terminalSplitActive, + toggleTerminalSplit, + handleTerminalInteraction, + handleTerminalTap } } diff --git a/mobile/src/session/use-session-terminal-key-rail.ts b/mobile/src/session/use-session-terminal-key-rail.ts new file mode 100644 index 00000000000..b2a543b07f7 --- /dev/null +++ b/mobile/src/session/use-session-terminal-key-rail.ts @@ -0,0 +1,119 @@ +import { useMemo, useState } from 'react' +import type { + BridgeTerminalKeyId, + BridgeTerminalToolbar +} from '../mobile-web-shell/bridge/bridge-terminal-key' +import { sessionCommandDockShown } from './mobile-session-command-dock-shown' +import { isTerminalPhoneDisplayMode } from './mobile-session-route-helpers' +import { isMobileTerminalTabRunningAgent } from './mobile-terminal-tab-agent' +import { useShellTerminalKeyRail } from './shell-terminal-key-rail' +import type { MobileSessionController } from './use-mobile-session-controller' + +const ESC = '\x1b' +const CTRL_C = '\x03' + +/** + * Hands the terminal's key row to the shell's side rail where the window has one (the iPhone Duo) + * and sends each press as the row would. `keysInRail` drops the row while the rail shows it. + */ +export function useSessionTerminalKeyRail(controller: MobileSessionController): { + keysInRail: boolean + moreKeysVisible: boolean + closeMoreKeys: () => void +} { + const { + activeHandle, + activeSessionTab, + terminalModes, + canSend, + canCompose, + liveInputEnabled, + keyboardHeight, + keyboardLift, + directionPadEnabled, + dockKeys + } = controller + const [moreKeysVisible, setMoreKeysVisible] = useState(false) + const railShown = + sessionCommandDockShown(controller) && + !controller.showCustomKeyModal && + !controller.deleteKeyTarget + const keyboardVisible = keyboardHeight > 0 || keyboardLift > 0 + const runsAgent = + activeSessionTab?.type === 'terminal' && isMobileTerminalTabRunningAgent(activeSessionTab) + const phoneDisplayMode = isTerminalPhoneDisplayMode(activeHandle, terminalModes) + const { modifiers } = dockKeys + const toolbar = useMemo( + () => ({ + canSend, + canCompose, + keyboardVisible, + liveInputEnabled, + phoneDisplayMode, + runsAgent, + modifiers, + directionPadEnabled + }), + [ + canSend, + canCompose, + keyboardVisible, + liveInputEnabled, + phoneDisplayMode, + runsAgent, + modifiers, + directionPadEnabled + ] + ) + const railKeys = useShellTerminalKeyRail( + railShown, + (id: BridgeTerminalKeyId) => { + // A press queued for a terminal that is no longer active must not land on the new one. + if (!railShown || controller.activeHandleRef.current !== activeHandle) { + return + } + if (id === 'toggleKeyboard') { + if (keyboardVisible) { + controller.dismissSoftwareKeyboard() + } else if (liveInputEnabled ? canSend : canCompose) { + const field = liveInputEnabled ? controller.liveInputRef : controller.commandInputRef + field.current?.focus() + } + return + } + if (id === 'toggleInputMode') { + if (canCompose) { + controller.toggleLiveInput() + } + return + } + if (id === 'moreKeys') { + setMoreKeysVisible(true) + return + } + if (!canSend) { + return + } + if (id === 'toggleDisplayMode') { + if (activeHandle) { + void controller.toggleDisplayMode(activeHandle) + } + } else if (id === 'ctrl' || id === 'alt' || id === 'shift') { + dockKeys.tapModifier(id) + } else if (id === 'shiftTab') { + void controller.handleAccessoryKey({ bytes: `${ESC}[Z` }) + } else if (id === 'ctrlC') { + void controller.handleAccessoryKey({ bytes: CTRL_C }) + } else { + dockKeys.sendKey(id.startsWith('char:') ? id.slice('char:'.length) : id) + } + }, + activeHandle, + toolbar + ) + return { + keysInRail: railKeys.length > 0, + moreKeysVisible, + closeMoreKeys: () => setMoreKeysVisible(false) + } +} diff --git a/mobile/src/session/use-terminal-split-layout.test.tsx b/mobile/src/session/use-terminal-split-layout.test.tsx new file mode 100644 index 00000000000..da2f295ed41 --- /dev/null +++ b/mobile/src/session/use-terminal-split-layout.test.tsx @@ -0,0 +1,45 @@ +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { expect, it, vi } from 'vitest' +import { Alert } from 'react-native' +import { useTerminalSplitLayout } from './use-terminal-split-layout' +import { loadTerminalSplitLayout, saveTerminalSplitLayout } from '../storage/preferences' + +vi.mock('react-native', () => ({ Alert: { alert: vi.fn() } })) +vi.mock('../storage/preferences', () => ({ + loadTerminalSplitLayout: vi.fn(), + saveTerminalSplitLayout: vi.fn().mockResolvedValue(undefined) +})) + +it('keeps a user choice made before storage hydration and reports failed persistence', async () => { + let resolveRead!: (layout: 'stacked') => void + vi.mocked(loadTerminalSplitLayout).mockReturnValue( + new Promise((resolve) => { + resolveRead = resolve + }) + ) + let preference!: ReturnType + function Harness() { + preference = useTerminalSplitLayout() + return null + } + let tree!: ReturnType + act(() => { + tree = create(createElement(Harness)) + }) + try { + expect(preference.terminalSplitLayout).toBe('auto') + await act(async () => preference.setTerminalSplitLayout('side-by-side')) + await act(async () => resolveRead('stacked')) + expect(preference.terminalSplitLayout).toBe('side-by-side') + vi.mocked(saveTerminalSplitLayout).mockRejectedValueOnce(new Error('storage unavailable')) + await act(async () => preference.setTerminalSplitLayout('stacked')) + expect(preference.terminalSplitLayout).toBe('stacked') + expect(Alert.alert).toHaveBeenCalledWith( + 'Layout could not be saved', + expect.stringContaining('retry') + ) + } finally { + act(() => tree.unmount()) + } +}) diff --git a/mobile/src/session/use-terminal-split-layout.ts b/mobile/src/session/use-terminal-split-layout.ts new file mode 100644 index 00000000000..77f853463a6 --- /dev/null +++ b/mobile/src/session/use-terminal-split-layout.ts @@ -0,0 +1,41 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Alert } from 'react-native' +import { + loadTerminalSplitLayout, + saveTerminalSplitLayout, + type TerminalSplitLayout +} from '../storage/preferences' +import { translate } from '../i18n/mobile-i18n' + +export function useTerminalSplitLayout() { + const [terminalSplitLayout, setLayout] = useState('auto') + const edited = useRef(false) + useEffect(() => { + let active = true + void loadTerminalSplitLayout().then( + (layout) => { + if (active && !edited.current) { + setLayout(layout) + } + }, + (error: unknown) => console.warn('Terminal split layout could not be loaded', error) + ) + return () => { + active = false + } + }, []) + const setTerminalSplitLayout = useCallback((layout: TerminalSplitLayout) => { + edited.current = true + setLayout(layout) + void saveTerminalSplitLayout(layout).catch(() => { + Alert.alert( + translate('session.terminalSplitLayout.saveFailedTitle', 'Layout could not be saved'), + translate( + 'session.terminalSplitLayout.saveFailedMessage', + 'This layout applies for now. Choose it again to retry saving.' + ) + ) + }) + }, []) + return { terminalSplitLayout, setTerminalSplitLayout } +} diff --git a/mobile/src/storage/mirrored-storage-keys.ts b/mobile/src/storage/mirrored-storage-keys.ts index fa744fe4e47..b461d92ac2f 100644 --- a/mobile/src/storage/mirrored-storage-keys.ts +++ b/mobile/src/storage/mirrored-storage-keys.ts @@ -74,7 +74,8 @@ export async function readMirroredKeyOrStore(key: string): Promise void +type MirroredWriteSource = 'persisted' | 'page' +type MirroredKeyListener = (value: string | null, source: MirroredWriteSource) => void const keyListeners = new Map>() export function subscribeMirroredKey(key: string, listener: MirroredKeyListener): () => void { @@ -86,7 +87,7 @@ export function subscribeMirroredKey(key: string, listener: MirroredKeyListener) } } -function note(key: string, value: string | null): void { +function note(key: string, value: string | null, source: MirroredWriteSource): void { writeCount += 1 const owner = owners.get(key) if (owner) { @@ -94,7 +95,7 @@ function note(key: string, value: string | null): void { mirror.set(key, value) } for (const listener of keyListeners.get(key) ?? []) { - listener(value) + listener(value, source) } } @@ -105,7 +106,7 @@ export function persistMirrored(key: string, value: string | null): Promise AsyncStorage.getItem(key)) .then((stored) => { - note(key, stored) + note(key, stored, 'persisted') }) } @@ -116,7 +117,7 @@ export function writeMirroredStorage(key: string, value: string | null): void { const release = retainMirroredStorage([key]) let write: Promise try { - note(key, value) + note(key, value, 'page') write = value === null ? AsyncStorage.removeItem(key) : AsyncStorage.setItem(key, value) } catch (error) { release() diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index 18eeb08fabc..21bdeaffec6 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -479,18 +479,18 @@ describe('host sidebar width preference', () => { expect(clampHostSidebarWidth(337.6)).toBe(338) }) - it('has no saved width for missing, invalid, or unreadable storage', async () => { + it('falls back to the default width for missing, invalid, or unreadable storage', async () => { vi.mocked(AsyncStorage.getItem).mockResolvedValue(null) - await expect(loadHostSidebarWidth()).resolves.toBeNull() + await expect(loadHostSidebarWidth()).resolves.toBe(340) vi.mocked(AsyncStorage.getItem).mockResolvedValue('not-a-number') - await expect(loadHostSidebarWidth()).resolves.toBeNull() + await expect(loadHostSidebarWidth()).resolves.toBe(340) vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable')) - await expect(loadHostSidebarWidth()).resolves.toBeNull() + await expect(loadHostSidebarWidth()).resolves.toBe(340) }) it('loads and persists clamped sidebar widths', async () => { diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index fc245ed31c6..439e7384e4b 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -1,7 +1,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import { persistMirrored } from './mirrored-storage-keys' import { TERMINAL_TEXT_SCALES } from '../terminal/terminal-text-scales' -import { HOST_SIDEBAR_MIN_WIDTH } from '../layout/responsive-layout-metrics' const PINS_PREFIX = 'orca:pins:' // Push-service consent is independent of legacy socket notifications. @@ -159,20 +158,26 @@ export async function saveDisabledTerminalLiveInputHandles( const SIDEBAR_WIDTH_KEY = 'orca:hostSidebarWidth' // Caller also caps this tablet/foldable sidebar against the window to preserve detail space. -export { HOST_SIDEBAR_MIN_WIDTH } +export const HOST_SIDEBAR_MIN_WIDTH = 280 export const HOST_SIDEBAR_MAX_WIDTH = 560 +export const HOST_SIDEBAR_DEFAULT_WIDTH = 340 export function clampHostSidebarWidth(width: number): number { + if (!Number.isFinite(width)) { + return HOST_SIDEBAR_DEFAULT_WIDTH + } return Math.min(HOST_SIDEBAR_MAX_WIDTH, Math.max(HOST_SIDEBAR_MIN_WIDTH, Math.round(width))) } -// Null until the user drags the sidebar; the layout then sizes it from the window. -export async function loadHostSidebarWidth(): Promise { +export async function loadHostSidebarWidth(): Promise { try { - const width = Number((await AsyncStorage.getItem(SIDEBAR_WIDTH_KEY)) ?? Number.NaN) - return Number.isFinite(width) ? clampHostSidebarWidth(width) : null + const raw = await AsyncStorage.getItem(SIDEBAR_WIDTH_KEY) + if (raw === null) { + return HOST_SIDEBAR_DEFAULT_WIDTH + } + return clampHostSidebarWidth(Number(raw)) } catch { - return null + return HOST_SIDEBAR_DEFAULT_WIDTH } } @@ -265,3 +270,23 @@ export async function savePinnedIds(hostId: string, ids: Set): Promise = Promise.resolve() + +export async function loadTerminalSplitLayout(): Promise { + await terminalSplitLayoutWrite + const raw = await AsyncStorage.getItem(TERMINAL_SPLIT_LAYOUT_KEY) + return raw === 'side-by-side' || raw === 'stacked' ? raw : 'auto' +} + +export function saveTerminalSplitLayout(layout: TerminalSplitLayout): Promise { + const write = terminalSplitLayoutWrite.then(() => + persistMirrored(TERMINAL_SPLIT_LAYOUT_KEY, layout) + ) + terminalSplitLayoutWrite = write.catch((error: unknown) => { + console.warn('Terminal split layout could not be saved', error) + }) + return write +} diff --git a/mobile/src/terminal/document/mouse-click-drag.ts b/mobile/src/terminal/document/mouse-click-drag.ts index a7e6b23df7d..d249cd8b147 100644 --- a/mobile/src/terminal/document/mouse-click-drag.ts +++ b/mobile/src/terminal/document/mouse-click-drag.ts @@ -134,7 +134,9 @@ export function attachSurfaceMouseClickDragHandler( if (dispatcherShouldBlockSurface(scope) || !scope.term) { return } - // Clear stale gestures when outside-WebView pointerup was lost. + notify(scope, { type: 'terminal-interaction' }) + // Why: a pointerup lost outside the WebView must not leave the previous + // gesture latched (tracking press with no release) when the next one lands. if (scope.mouseGesture) { abandonMouseGesture(scope) } diff --git a/mobile/src/terminal/document/surface-touch-gestures.ts b/mobile/src/terminal/document/surface-touch-gestures.ts index 7b76ae8ff8e..8d12e3ee4bd 100644 --- a/mobile/src/terminal/document/surface-touch-gestures.ts +++ b/mobile/src/terminal/document/surface-touch-gestures.ts @@ -102,6 +102,7 @@ export function attachSurfaceEventHandlers( if (targetSurface !== scope.surface || dispatcherShouldBlockSurface(scope)) { return } + notify(scope, { type: 'terminal-interaction' }) if (scope.touchGesture.momentumId) { cancelDocumentFrame(scope, scope.touchGesture.momentumId) scope.touchGesture.momentumId = null diff --git a/mobile/src/terminal/document/wheel-scroll.ts b/mobile/src/terminal/document/wheel-scroll.ts index d71d3357488..44fb73d8aee 100644 --- a/mobile/src/terminal/document/wheel-scroll.ts +++ b/mobile/src/terminal/document/wheel-scroll.ts @@ -1,4 +1,5 @@ import { getCellHeight, getTotalScale } from './cell-metrics' +import { notify } from './host-notify' import { routeScrollLines, shouldRouteScrollToTerminalInput } from './mouse-input-encoding' import { enqueueNormalBufferScrollDelta, @@ -6,6 +7,8 @@ import { } from './normal-buffer-smooth-scroll' import type { TerminalDocumentScope } from './document-scope' +const WHEEL_SEQUENCE_IDLE_MS = 150 + export function wheelEventPixelDeltaY(scope: TerminalDocumentScope, e: WheelEvent) { const delta = e.deltaY if (typeof delta !== 'number' || !Number.isFinite(delta) || delta === 0) { @@ -25,6 +28,7 @@ export function attachSurfaceWheelHandler( scope: TerminalDocumentScope, targetSurface: HTMLElement ) { + let lastWheelAt: number | null = null targetSurface.addEventListener( 'wheel', function (e) { @@ -48,6 +52,12 @@ export function attachSurfaceWheelHandler( return } + const now = performance.now() + if (lastWheelAt === null || now - lastWheelAt > WHEEL_SEQUENCE_IDLE_MS) { + scope.wheelAccumDeltaY = 0 + notify(scope, { type: 'terminal-interaction' }) + } + lastWheelAt = now if (shouldRouteScrollToTerminalInput(scope)) { resetSmoothScrollOffset(scope) const effectiveCellH = getCellHeight(scope) * getTotalScale(scope) diff --git a/mobile/src/terminal/terminal-accessory-layout.test.ts b/mobile/src/terminal/terminal-accessory-layout.test.ts index 38b28bf4a11..c8ad440652b 100644 --- a/mobile/src/terminal/terminal-accessory-layout.test.ts +++ b/mobile/src/terminal/terminal-accessory-layout.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { hasTerminalDirectionPadSpace, type SideRailStrip } from '../layout/side-rail-strip' import { createTerminalAccessoryLayoutPreference, @@ -47,6 +48,7 @@ describe('terminal accessory layout', () => { it('default layout shows every built-in in canonical order', () => { expect(getDefaultTerminalAccessoryLayout()).toEqual({ + directionPadEnabled: true, orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(), visibleBuiltInIds: getDefaultTerminalAccessoryBuiltInIds() }) @@ -92,6 +94,7 @@ describe('terminal accessory layout', () => { visibleBuiltInIds: ['tab', 'escape'] }) ).toEqual({ + directionPadEnabled: true, version: 2, orderedBuiltInIds: reversed, visibleBuiltInIds: ['tab', 'escape'] @@ -111,6 +114,7 @@ describe('terminal accessory layout', () => { current ) ).toEqual({ + directionPadEnabled: true, version: 2, orderedBuiltInIds: ['tab', 'escape', 'enter'], visibleBuiltInIds: ['tab', 'escape'] @@ -130,6 +134,7 @@ describe('terminal accessory layout', () => { current ) ).toEqual({ + directionPadEnabled: true, version: 2, orderedBuiltInIds: ['enter', 'tab', 'space', 'escape'], visibleBuiltInIds: ['enter', 'space', 'escape'] @@ -159,6 +164,7 @@ describe('terminal accessory layout', () => { knownBuiltInIds: getDefaultTerminalAccessoryBuiltInIds() }) ).toEqual({ + directionPadEnabled: true, version: 2, orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(), visibleBuiltInIds: ['escape', 'tab'] @@ -247,6 +253,7 @@ describe('terminal accessory layout', () => { const layout = { orderedBuiltInIds: ['tab', 'escape'], visibleBuiltInIds: ['tab'] } expect(setTerminalAccessoryBuiltInVisible(layout, 'escape', true, ['escape', 'tab'])).toEqual({ + directionPadEnabled: true, orderedBuiltInIds: ['tab', 'escape'], visibleBuiltInIds: ['tab', 'escape'] }) @@ -259,6 +266,7 @@ describe('terminal accessory layout', () => { ).visibleBuiltInIds ).toEqual(['escape']) expect(setTerminalAccessoryBuiltInVisible(layout, 'unknown', true, ['escape', 'tab'])).toEqual({ + directionPadEnabled: true, orderedBuiltInIds: ['tab', 'escape'], visibleBuiltInIds: ['tab'] }) @@ -277,6 +285,7 @@ describe('terminal accessory layout', () => { ['escape', 'tab', 'enter'] ) ).toEqual({ + directionPadEnabled: true, orderedBuiltInIds: ['enter', 'escape', 'tab'], visibleBuiltInIds: ['enter', 'escape'] }) @@ -307,6 +316,7 @@ describe('terminal accessory layout', () => { 'orca:terminal-accessory-layout', JSON.stringify({ version: 2, + directionPadEnabled: true, orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(), visibleBuiltInIds: ['tab'] }) @@ -330,3 +340,45 @@ describe('terminal accessory layout', () => { ).toEqual(['escape']) }) }) + +it('uses arrow-free Duo defaults only for an untouched layout and preserves explicit choices', async () => { + asyncStorageMock.getItem.mockResolvedValue(null) + const duo = await loadTerminalAccessoryLayout(true) + expect(duo.directionPadEnabled).toBe(true) + expect(duo.visibleBuiltInIds.filter((id) => id.startsWith('arrow'))).toEqual([]) + const strips: (SideRailStrip | null)[] = [ + null, + { side: 'right', width: 84, height: 200, top: 0, bottom: 0 }, + { side: 'right', width: 59, height: 500, top: 0, bottom: 0 } + ] + for (const strip of strips) { + expect( + (await loadTerminalAccessoryLayout(hasTerminalDirectionPadSpace(strip))).visibleBuiltInIds + ).toContain('arrowUp') + } + for (const side of ['left', 'right'] as const) { + const strip = { side, width: 84, height: 500, top: 0, bottom: 0 } + expect( + (await loadTerminalAccessoryLayout(hasTerminalDirectionPadSpace(strip))).visibleBuiltInIds + ).not.toContain('arrowUp') + } + const explicit = setTerminalAccessoryBuiltInVisible( + { ...duo, directionPadEnabled: false }, + 'arrowUp', + true + ) + asyncStorageMock.setItem.mockResolvedValue(undefined) + await saveTerminalAccessoryLayout(explicit) + const saved = asyncStorageMock.setItem.mock.calls.at(-1)![1] + asyncStorageMock.getItem.mockResolvedValue(saved) + expect((await loadTerminalAccessoryLayout(true)).visibleBuiltInIds).toContain('arrowUp') + expect((await loadTerminalAccessoryLayout(true)).directionPadEnabled).toBe(false) + expect((await loadTerminalAccessoryLayout(false)).visibleBuiltInIds).toContain('arrowUp') + const legacy = { + version: 2, + orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(), + visibleBuiltInIds: ['arrowDown', 'tab'] + } + asyncStorageMock.getItem.mockResolvedValue(JSON.stringify(legacy)) + expect((await loadTerminalAccessoryLayout(true)).visibleBuiltInIds).toEqual(['tab', 'arrowDown']) +}) diff --git a/mobile/src/terminal/terminal-accessory-layout.ts b/mobile/src/terminal/terminal-accessory-layout.ts index bcb5ee9bb36..1642ecfdf87 100644 --- a/mobile/src/terminal/terminal-accessory-layout.ts +++ b/mobile/src/terminal/terminal-accessory-layout.ts @@ -8,6 +8,7 @@ export const TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY = 'orca:terminal-accessory-la export type TerminalAccessoryLayout = { orderedBuiltInIds: string[] visibleBuiltInIds: string[] + directionPadEnabled?: boolean } export type TerminalAccessoryLayoutPreference = TerminalAccessoryLayout & { @@ -18,11 +19,17 @@ function builtInIds(): string[] { return TERMINAL_ACCESSORY_KEYS.map((key) => key.id) } -function defaultPreference(ids = builtInIds()): TerminalAccessoryLayoutPreference { +function defaultPreference( + ids = builtInIds(), + directionPadAvailable = false +): TerminalAccessoryLayoutPreference { return { version: 2, orderedBuiltInIds: [...ids], - visibleBuiltInIds: [...ids] + visibleBuiltInIds: directionPadAvailable + ? ids.filter((id) => !id.startsWith('arrow')) + : [...ids], + directionPadEnabled: true } } @@ -78,25 +85,32 @@ export function getDefaultTerminalAccessoryBuiltInIds(): string[] { return builtInIds() } -export function getDefaultTerminalAccessoryLayout(): TerminalAccessoryLayout { - const ids = builtInIds() - return { orderedBuiltInIds: ids, visibleBuiltInIds: [...ids] } +export function getDefaultTerminalAccessoryLayout( + directionPadAvailable = false +): TerminalAccessoryLayout { + const { orderedBuiltInIds, visibleBuiltInIds, directionPadEnabled } = defaultPreference( + builtInIds(), + directionPadAvailable + ) + return { orderedBuiltInIds, visibleBuiltInIds, directionPadEnabled } } export function normalizeTerminalAccessoryLayoutPreference( value: unknown, - currentBuiltInIds = builtInIds() + currentBuiltInIds = builtInIds(), + directionPadAvailable = false ): TerminalAccessoryLayoutPreference { - const fallback = defaultPreference(currentBuiltInIds) + const fallback = defaultPreference(currentBuiltInIds, directionPadAvailable) if (!value || typeof value !== 'object') { return fallback } - const candidate = value as { - version?: unknown - orderedBuiltInIds?: unknown - visibleBuiltInIds?: unknown - knownBuiltInIds?: unknown + const candidate = { + version: 'version' in value ? value.version : undefined, + orderedBuiltInIds: 'orderedBuiltInIds' in value ? value.orderedBuiltInIds : undefined, + visibleBuiltInIds: 'visibleBuiltInIds' in value ? value.visibleBuiltInIds : undefined, + knownBuiltInIds: 'knownBuiltInIds' in value ? value.knownBuiltInIds : undefined, + directionPadEnabled: 'directionPadEnabled' in value ? value.directionPadEnabled : undefined } const builtInSet = new Set(currentBuiltInIds) @@ -116,6 +130,7 @@ export function normalizeTerminalAccessoryLayoutPreference( } return { version: 2, + directionPadEnabled: candidate.directionPadEnabled !== false, orderedBuiltInIds: ordered, visibleBuiltInIds: ordered.filter((id) => visibleSet.has(id)) } @@ -137,6 +152,7 @@ export function normalizeTerminalAccessoryLayoutPreference( // Version-one layouts had no custom order; migrate them to canonical order. return { version: 2, + directionPadEnabled: candidate.directionPadEnabled !== false, orderedBuiltInIds: [...currentBuiltInIds], visibleBuiltInIds: currentBuiltInIds.filter((id) => visibleSet.has(id)) } @@ -157,6 +173,7 @@ export function createTerminalAccessoryLayoutPreference( const visibleSet = new Set(dedupeKnownIds(layout.visibleBuiltInIds, builtInSet)) return { version: 2, + directionPadEnabled: layout.directionPadEnabled !== false, orderedBuiltInIds: ordered, visibleBuiltInIds: ordered.filter((id) => visibleSet.has(id)) } @@ -171,6 +188,7 @@ export function setTerminalAccessoryBuiltInVisible( const preference = createTerminalAccessoryLayoutPreference(layout, currentBuiltInIds) if (!new Set(currentBuiltInIds).has(id)) { return { + directionPadEnabled: preference.directionPadEnabled, orderedBuiltInIds: preference.orderedBuiltInIds, visibleBuiltInIds: preference.visibleBuiltInIds } @@ -182,6 +200,7 @@ export function setTerminalAccessoryBuiltInVisible( visibleSet.delete(id) } return { + directionPadEnabled: preference.directionPadEnabled, orderedBuiltInIds: preference.orderedBuiltInIds, visibleBuiltInIds: preference.orderedBuiltInIds.filter((builtInId) => visibleSet.has(builtInId)) } @@ -193,34 +212,45 @@ export function reorderTerminalAccessoryBuiltInIds( currentBuiltInIds = builtInIds() ): TerminalAccessoryLayout { const preference = createTerminalAccessoryLayoutPreference( - { orderedBuiltInIds, visibleBuiltInIds: layout.visibleBuiltInIds }, + { ...layout, orderedBuiltInIds }, currentBuiltInIds ) return { + directionPadEnabled: preference.directionPadEnabled, orderedBuiltInIds: preference.orderedBuiltInIds, visibleBuiltInIds: preference.visibleBuiltInIds } } export function getVisibleTerminalAccessoryKeys( - visibleBuiltInIds: string[] + visibleBuiltInIds: string[], + includeDirectionKeys = false ): TerminalAccessoryKey[] { + const ids = includeDirectionKeys + ? [...visibleBuiltInIds, ...builtInIds().filter((id) => id.startsWith('arrow'))] + : visibleBuiltInIds const byId = new Map(TERMINAL_ACCESSORY_KEYS.map((key) => [key.id, key])) - return dedupeKnownIds(visibleBuiltInIds, new Set(byId.keys())).flatMap((id) => { + return dedupeKnownIds(ids, new Set(byId.keys())).flatMap((id) => { const key = byId.get(id) return key ? [key] : [] }) } -export async function loadTerminalAccessoryLayout(): Promise { +export async function loadTerminalAccessoryLayout( + directionPadAvailable = false +): Promise { try { const raw = await AsyncStorage.getItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY) if (!raw) { - return defaultPreference() + return defaultPreference(builtInIds(), directionPadAvailable) } - return normalizeTerminalAccessoryLayoutPreference(JSON.parse(raw)) + return normalizeTerminalAccessoryLayoutPreference( + JSON.parse(raw), + builtInIds(), + directionPadAvailable + ) } catch { - return defaultPreference() + return defaultPreference(builtInIds(), directionPadAvailable) } } diff --git a/mobile/src/terminal/terminal-cell-box-refit.test.tsx b/mobile/src/terminal/terminal-cell-box-refit.test.tsx index 10a744cedd6..cd364bd5333 100644 --- a/mobile/src/terminal/terminal-cell-box-refit.test.tsx +++ b/mobile/src/terminal/terminal-cell-box-refit.test.tsx @@ -1,3 +1,7 @@ +import { + terminalPaneViewport, + type TerminalPaneViewport +} from '../session/mobile-terminal-pane-layout' import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -12,7 +16,8 @@ vi.mock('react-native', () => ({ const HANDLE = 'term-1' -function refitHarness(cellWidth: number) { +/** The session's refit over one open terminal whose document reported a `cellWidth` box. */ +function refitHarness(cellWidth: number, split = false) { const terminal: TerminalWebViewHandle = { prepareForForegroundRecovery: vi.fn(), write: vi.fn(), @@ -36,11 +41,30 @@ function refitHarness(cellWidth: number) { const terminalFrameRef: { current: { width: number; height: number } | null } = { current: { width: 427, height: 710 } } + const terminalPaneViewportsRef = { current: new Map() } + const firstGeometry = terminalPaneViewport(terminalPaneViewportsRef.current, HANDLE) + firstGeometry.terminalFrameRef.current = { width: 300, height: 350 } + const secondGeometry = terminalPaneViewport(terminalPaneViewportsRef.current, 'term-2') + secondGeometry.terminalFrameRef.current = { width: 250, height: 350 } + const second: TerminalWebViewHandle = { + ...terminal, + fitDimensions: vi.fn((frame) => ({ + cols: Math.floor(frame.width / 10), + rows: Math.floor(frame.height / 20) + })) + } + const visibleTerminalHandlesRef = { current: [HANDLE, 'term-2'] } let refit: ReturnType | undefined function Probe() { refit = useTerminalViewportRefit({ activeHandleRef: { current: HANDLE }, - terminalRefs: { current: new Map([[HANDLE, terminal]]) }, + terminalRefs: { + current: new Map([ + [HANDLE, terminal], + ['term-2', second] + ]) + }, + ...(split ? { terminalPaneViewportsRef, visibleTerminalHandlesRef } : {}), terminalFrameRef, viewportRef, viewportMeasuredRef: { current: true }, @@ -60,6 +84,10 @@ function refitHarness(cellWidth: number) { }) return { terminal, + second, + firstGeometry, + secondGeometry, + visibleTerminalHandlesRef, viewportRef, subscribeToTerminal, report: (handle: string) => act(() => refit!.notifyTerminalCellBoxChange(handle)), @@ -123,3 +151,24 @@ describe('a new cell box for the open terminal', () => { expect(harness.viewportRef.current).toEqual({ cols: 54, rows: 47 }) }) }) + +describe('visible split viewport refits', () => { + it('refits both panes independently using legacy resubscribe and ignores hidden panes', async () => { + const harness = refitHarness(10, true) + harness.report('term-2') + await act(async () => { + await vi.advanceTimersByTimeAsync(150) + }) + expect(harness.firstGeometry.viewportRef.current).toEqual({ cols: 30, rows: 47 }) + expect(harness.secondGeometry.viewportRef.current).toEqual({ cols: 25, rows: 17 }) + expect(harness.subscribeToTerminal.mock.calls).toEqual([[HANDLE], ['term-2']]) + harness.subscribeToTerminal.mockClear() + harness.visibleTerminalHandlesRef.current = [HANDLE] + harness.secondGeometry.terminalFrameRef.current = { width: 500, height: 700 } + harness.report('term-2') + await act(async () => { + await vi.advanceTimersByTimeAsync(150) + }) + expect(harness.subscribeToTerminal).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/terminal/terminal-custom-keys.ts b/mobile/src/terminal/terminal-custom-keys.ts index 4360322ec0b..62990ccc1bc 100644 --- a/mobile/src/terminal/terminal-custom-keys.ts +++ b/mobile/src/terminal/terminal-custom-keys.ts @@ -8,6 +8,7 @@ export type CustomKey = { label: string bytes: string enter: boolean + visible?: boolean } function isCustomKey(value: unknown): value is CustomKey { @@ -21,7 +22,8 @@ function isCustomKey(value: unknown): value is CustomKey { 'bytes' in value && typeof value.bytes === 'string' && 'enter' in value && - typeof value.enter === 'boolean' + typeof value.enter === 'boolean' && + (!('visible' in value) || typeof value.visible === 'boolean') ) } diff --git a/mobile/src/terminal/terminal-foreground-recovery.test.ts b/mobile/src/terminal/terminal-foreground-recovery.test.ts index 155df9fd5f5..435908c3a05 100644 --- a/mobile/src/terminal/terminal-foreground-recovery.test.ts +++ b/mobile/src/terminal/terminal-foreground-recovery.test.ts @@ -145,3 +145,23 @@ describe('terminal foreground recovery', () => { expect(harness.subscribeToTerminal).not.toHaveBeenCalled() }) }) + +describe('split terminal foreground recovery', () => { + it('replays both visible panes but does not revive a pane closed during the delay', () => { + const harness = createHarness() + harness.terminalRefs.current.set('term-2', harness.terminalRefs.current.get('term-1')!) + harness.initializedHandlesRef.current.add('term-2') + const visibleTerminalHandlesRef = { current: ['term-1', 'term-2'] } + recoverActiveTerminalAfterForeground({ ...harness, visibleTerminalHandlesRef }) + expect(harness.unsubscribeTerminal.mock.calls).toEqual([['term-1'], ['term-2']]) + harness.activeHandleRef.current = 'term-2' + harness.runScheduled() + expect(harness.subscribeToTerminal.mock.calls).toEqual([['term-1'], ['term-2']]) + harness.initializedHandlesRef.current = new Set(['term-1', 'term-2']) + harness.subscribeToTerminal.mockClear() + recoverActiveTerminalAfterForeground({ ...harness, visibleTerminalHandlesRef }) + visibleTerminalHandlesRef.current = ['term-2'] + harness.runScheduled() + expect(harness.subscribeToTerminal.mock.calls).toEqual([['term-2']]) + }) +}) diff --git a/mobile/src/terminal/terminal-foreground-recovery.ts b/mobile/src/terminal/terminal-foreground-recovery.ts index c17faeb5287..4261c7cdda8 100644 --- a/mobile/src/terminal/terminal-foreground-recovery.ts +++ b/mobile/src/terminal/terminal-foreground-recovery.ts @@ -9,6 +9,7 @@ export type TerminalForegroundRecoveryOutcome = 'recovered' | 'deferred' | 'skip type TerminalForegroundRecoveryOptions = { activeHandleRef: RefObject + visibleTerminalHandlesRef?: RefObject terminalRefs: RefObject> initializedHandlesRef: RefObject> connStateRef: RefObject @@ -32,6 +33,7 @@ export function shouldRecoverTerminalOnAppStateChange( export function recoverActiveTerminalAfterForeground({ activeHandleRef, + visibleTerminalHandlesRef, terminalRefs, initializedHandlesRef, connStateRef, @@ -49,28 +51,28 @@ export function recoverActiveTerminalAfterForeground({ if (initializedMountedHandles.length === 0) { return 'skipped' } - const handle = activeHandleRef.current - const shouldRecoverActive = - !!handle && terminalRefs.current.has(handle) && initializedHandlesRef.current.has(handle) + const visible = + visibleTerminalHandlesRef?.current ?? (activeHandleRef.current ? [activeHandleRef.current] : []) + const recovering = visible.filter((handle) => initializedMountedHandles.includes(handle)) // Opacity-hidden WebViews can also lose iOS backing stores; their next activation must accept replay. for (const initializedHandle of initializedMountedHandles) { initializedHandlesRef.current.delete(initializedHandle) } - if (!shouldRecoverActive || !handle) { - return 'recovered' + for (const handle of recovering) { + unsubscribeTerminal(handle) + schedule(() => { + if (connStateRef.current !== 'connected') { + return + } + const stillVisible = visibleTerminalHandlesRef + ? visibleTerminalHandlesRef.current.includes(handle) + : activeHandleRef.current === handle + if (stillVisible && terminalRefs.current.has(handle)) { + subscribeToTerminal(handle) + } + }, delayMs) } - - unsubscribeTerminal(handle) - schedule(() => { - if (connStateRef.current !== 'connected') { - return - } - if (activeHandleRef.current !== handle || !terminalRefs.current.has(handle)) { - return - } - subscribeToTerminal(handle) - }, delayMs) return 'recovered' } diff --git a/mobile/src/terminal/terminal-viewport-refit-options.ts b/mobile/src/terminal/terminal-viewport-refit-options.ts new file mode 100644 index 00000000000..f11e3fa7aa8 --- /dev/null +++ b/mobile/src/terminal/terminal-viewport-refit-options.ts @@ -0,0 +1,28 @@ +import type { RefObject } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' +import type { TerminalWebViewHandle } from './TerminalWebView' +import type { TerminalFrame } from './terminal-webview-messages' +import type { TerminalViewportDims } from './terminal-viewport-refit' +import type { TerminalPaneViewport } from '../session/mobile-terminal-pane-layout' + +export type TerminalViewportRefitOptions = { + activeHandleRef: RefObject + visibleTerminalHandlesRef?: RefObject + terminalPaneViewportsRef?: RefObject> + terminalRefs: RefObject> + // The terminal frame React Native laid out, unrounded; null until its first layout. + terminalFrameRef: RefObject + viewportRef: RefObject + viewportMeasuredRef: RefObject + // Why: while native chat covers the active terminal, a refit would push phone dims into a PTY nobody on this device is viewing. + nativeChatCoveredRef: RefObject + clientRef: RefObject + deviceTokenRef: RefObject + connState: ConnectionState + tabStripVisible: boolean + // Why: text size (font scale); changing it changes cell size, so the PTY must be re-fitted to a new column count. + textScale: number + unsubscribeTerminal: (handle: string) => void + subscribeToTerminal: (handle: string) => void +} diff --git a/mobile/src/terminal/terminal-viewport-refit.ts b/mobile/src/terminal/terminal-viewport-refit.ts index ed851cd7ee9..b692147fe33 100644 --- a/mobile/src/terminal/terminal-viewport-refit.ts +++ b/mobile/src/terminal/terminal-viewport-refit.ts @@ -1,9 +1,7 @@ -import { useCallback, useEffect, useRef, type RefObject } from 'react' +import type { TerminalViewportRefitOptions } from './terminal-viewport-refit-options' +import { terminalPaneViewport } from '../session/mobile-terminal-pane-layout' +import { useCallback, useEffect, useRef } from 'react' import { AppState, Platform, useWindowDimensions, type AppStateStatus } from 'react-native' -import type { RpcClient } from '../transport/rpc-client' -import type { ConnectionState } from '../transport/types' -import type { TerminalWebViewHandle } from './TerminalWebView' -import type { TerminalFrame } from './terminal-webview-messages' import { shouldRecoverTerminalOnAppStateChange } from './terminal-foreground-recovery' import { terminalViewportUpdate } from './mobile-terminal-operations' import { @@ -17,27 +15,12 @@ import { export type TerminalViewportDims = { cols: number; rows: number } -type TerminalViewportRefitOptions = { - activeHandleRef: RefObject - terminalRefs: RefObject> - terminalFrameRef: RefObject - viewportRef: RefObject - viewportMeasuredRef: RefObject - // Covered native-chat terminals must not push phone dimensions into an unseen PTY. - nativeChatCoveredRef: RefObject - clientRef: RefObject - deviceTokenRef: RefObject - connState: ConnectionState - tabStripVisible: boolean - textScale: number - unsubscribeTerminal: (handle: string) => void - subscribeToTerminal: (handle: string) => void -} - -// Re-measure layout changes outside subscription so fold/rotate/strip changes cannot leave a clipped PTY. +// Why: re-measure on layout changes outside the subscribe path (tab strip, fold/rotate/resize), or a PTY renders "cut in half" (#4579). export function useTerminalViewportRefit(options: TerminalViewportRefitOptions) { const { activeHandleRef, + visibleTerminalHandlesRef, + terminalPaneViewportsRef, terminalRefs, terminalFrameRef, viewportRef, @@ -52,6 +35,12 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions) subscribeToTerminal } = options + const invalidateViewports = useCallback(() => { + viewportMeasuredRef.current = false + terminalPaneViewportsRef?.current.forEach((state) => { + state.viewportMeasuredRef.current = false + }) + }, [viewportMeasuredRef, terminalPaneViewportsRef]) const refitTimerRef = useRef | null>(null) const refitRunSeqRef = useRef(0) const forceNextRefitRef = useRef(false) @@ -64,11 +53,11 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions) }) const heightOriginatedRefitRef = useRef(false) const scheduleViewportRefit = useCallback( - (options?: { heightOriginated?: boolean }) => { + (trigger?: { heightOriginated?: boolean }) => { if (refitTimerRef.current) { clearTimeout(refitTimerRef.current) } - heightOriginatedRefitRef.current = options?.heightOriginated ?? false + heightOriginatedRefitRef.current = trigger?.heightOriginated ?? false refitTimerRef.current = setTimeout(() => { refitTimerRef.current = null // Height debounce can outlive keyboard reopening; recheck before reflowing the PTY. @@ -84,78 +73,91 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions) } const runSeq = refitRunSeqRef.current + 1 refitRunSeqRef.current = runSeq - const handle = activeHandleRef.current - if (!handle) { - return - } - // Return-to-terminal resubscribe measures again; resizing now would affect a covered PTY. - if (nativeChatCoveredRef.current) { - return - } - const ref = terminalRefs.current.get(handle) - if (!ref) { - return - } - const isCurrentTarget = () => - isTerminalViewportRefitTargetCurrent({ - activeHandle: activeHandleRef.current, - expectedHandle: handle, - currentRef: terminalRefs.current.get(handle), - expectedRef: ref, - nativeChatCovered: nativeChatCoveredRef.current, - disposed: disposedRef.current, - runSeq, - currentRunSeq: refitRunSeqRef.current - }) - void (async () => { - await ref.awaitReady() - if (!isCurrentTarget()) { + const handles = + visibleTerminalHandlesRef?.current ?? + (activeHandleRef.current ? [activeHandleRef.current] : []) + const forceRefit = forceNextRefitRef.current + forceNextRefitRef.current = false + handles.forEach((handle) => { + const geometry = terminalPaneViewportsRef + ? terminalPaneViewport(terminalPaneViewportsRef.current, handle) + : options + const { terminalFrameRef, viewportRef, viewportMeasuredRef } = geometry + // Why: the trigger already marked the viewport stale, and the return-to-terminal resubscribe re-measures — refitting now would resize a covered PTY. + if (nativeChatCoveredRef.current && handle === activeHandleRef.current) { return } - const frame = terminalFrameRef.current - const dims = frame ? ref.fitDimensions(frame) : null - if (!dims) { + const ref = terminalRefs.current.get(handle) + if (!ref) { return } - const forceRefit = forceNextRefitRef.current - forceNextRefitRef.current = false - const prev = viewportRef.current - if (!forceRefit && prev && prev.cols === dims.cols && prev.rows === dims.rows) { - return - } - viewportRef.current = dims - viewportMeasuredRef.current = true - // Prefer in-place updates to preserve subscriber presence ownership. - const rpc = clientRef.current - const deviceToken = deviceTokenRef.current - if (rpc && deviceToken && updateViewportCapabilityRef.current !== 'unsupported') { - try { - const reply = await terminalViewportUpdate.request(rpc, { - terminal: handle, - client: { id: deviceToken, type: 'mobile' as const }, - viewport: dims - }) - if (!isCurrentTarget()) { - return - } - updateViewportCapabilityRef.current = resolveTerminalUpdateViewportCapability(reply) - const outcome = terminalViewportUpdate.interpret(reply) - if (outcome?.updated) { - rpc.updateTerminalSubscriptionViewport(handle, dims) - if (outcome.applied) { - // In-place updates replay only visible rows; locally rewrap old scrollback to match the new width. - ref.reflow(dims.cols, dims.rows, frame) + const isCurrentTarget = () => + isTerminalViewportRefitTargetCurrent({ + activeHandle: visibleTerminalHandlesRef + ? visibleTerminalHandlesRef.current.includes(handle) + ? handle + : null + : activeHandleRef.current, + expectedHandle: handle, + currentRef: terminalRefs.current.get(handle), + expectedRef: ref, + nativeChatCovered: nativeChatCoveredRef.current && handle === activeHandleRef.current, + disposed: disposedRef.current, + runSeq, + currentRunSeq: refitRunSeqRef.current + }) + void (async () => { + await ref.awaitReady() + if (!isCurrentTarget()) { + return + } + const frame = terminalFrameRef.current + const dims = frame ? ref.fitDimensions(frame) : null + if (!dims) { + return + } + const prev = viewportRef.current + if (!forceRefit && prev && prev.cols === dims.cols && prev.rows === dims.rows) { + // A width change marked it unmeasured; the same grid still fits, so it is measured again. + viewportMeasuredRef.current = true + return + } + viewportRef.current = dims + viewportMeasuredRef.current = true + // Why: prefer in-place updateViewport over resubscribe to keep the mobile subscriber record alive. See docs/mobile-presence-lock.md. + const rpc = clientRef.current + const deviceToken = deviceTokenRef.current + if (rpc && deviceToken && updateViewportCapabilityRef.current !== 'unsupported') { + try { + const reply = await terminalViewportUpdate.request(rpc, { + terminal: handle, + client: { id: deviceToken, type: 'mobile' as const }, + viewport: dims + }) + if (!isCurrentTarget()) { + return } - return + updateViewportCapabilityRef.current = resolveTerminalUpdateViewportCapability(reply) + const outcome = terminalViewportUpdate.interpret(reply) + if (outcome?.updated) { + rpc.updateTerminalSubscriptionViewport(handle, dims) + if (outcome.applied) { + // Why: updateViewport re-streams only the visible screen, so local scrollback stays wrapped at the old width — reflow it locally. + ref.reflow(dims.cols, dims.rows, frame) + } + return + } + } catch { + // Fall through to legacy resubscribe. } - } catch {} - } - if (!isCurrentTarget()) { - return - } - unsubscribeTerminal(handle) - subscribeToTerminal(handle) - })() + } + if (!isCurrentTarget()) { + return + } + unsubscribeTerminal(handle) + subscribeToTerminal(handle) + })() + }) }, 150) }, [ @@ -182,7 +184,7 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions) return } prevTabStripVisibleRef.current = tabStripVisible - viewportMeasuredRef.current = false + invalidateViewports() scheduleViewportRefit() }, [tabStripVisible, viewportMeasuredRef, scheduleViewportRefit]) @@ -199,7 +201,7 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions) if (prev.width === windowWidth && frameHeightRefitStateRef.current.keyboardVisible) { return } - viewportMeasuredRef.current = false + invalidateViewports() scheduleViewportRefit() }, [windowWidth, windowHeight, viewportMeasuredRef, scheduleViewportRefit]) @@ -210,29 +212,15 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions) return } prevTextScaleRef.current = textScale - viewportMeasuredRef.current = false + invalidateViewports() scheduleViewportRefit() }, [textScale, viewportMeasuredRef, scheduleViewportRefit]) - // Dock/sidebar changes alter width without window changes; ignore subpixel jitter if the current grid still fits. + // Dock/sidebar changes alter width without window changes; the refit keeps the grid if it still fits. const notifyTerminalFrameWidth = useCallback(() => { - const handle = activeHandleRef.current - const frame = terminalFrameRef.current - const fit = handle && frame ? terminalRefs.current.get(handle)?.fitDimensions(frame) : null - const grid = viewportRef.current - if (fit && grid && fit.cols === grid.cols && fit.rows === grid.rows) { - return - } - viewportMeasuredRef.current = false + invalidateViewports() scheduleViewportRefit() - }, [ - activeHandleRef, - terminalFrameRef, - terminalRefs, - viewportRef, - viewportMeasuredRef, - scheduleViewportRefit - ]) + }, [invalidateViewports, scheduleViewportRefit]) const notifyFrameHeightRefitEvent = useCallback( (event: TerminalFrameHeightRefitEvent) => { @@ -241,7 +229,7 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions) if (!transition.shouldRefit) { return } - viewportMeasuredRef.current = false + invalidateViewports() scheduleViewportRefit({ heightOriginated: true }) }, [viewportMeasuredRef, scheduleViewportRefit] @@ -259,10 +247,14 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions) // Renderer/DPR swaps can change cell metrics without changing the grid. const notifyTerminalCellBoxChange = useCallback( (handle: string) => { - if (handle !== activeHandleRef.current) { + if ( + visibleTerminalHandlesRef + ? !visibleTerminalHandlesRef.current.includes(handle) + : handle !== activeHandleRef.current + ) { return } - viewportMeasuredRef.current = false + invalidateViewports() scheduleViewportRefit() }, [activeHandleRef, viewportMeasuredRef, scheduleViewportRefit] @@ -283,8 +275,8 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions) if (!shouldRefit) { return } - // Cached dimensions can match while the host resized offline; reassert them after iOS resume. - viewportMeasuredRef.current = false + // Why: cached grid can match while the host PTY changed in background; reassert equal dims to converge after iOS resume. + invalidateViewports() scheduleForcedViewportRefit() }) return () => sub.remove() @@ -299,8 +291,8 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions) } // Reconnect can reach an upgraded host; clear cached method-not-found capability. updateViewportCapabilityRef.current = 'unknown' - // Reassert even equal cached dims because the PTY may have changed while disconnected. - viewportMeasuredRef.current = false + // Why: reconnect can restore a PTY resized while the socket was down, so equal cached dims still need reassertion. + invalidateViewports() scheduleForcedViewportRefit() }, [connState, viewportMeasuredRef, scheduleForcedViewportRefit]) diff --git a/mobile/src/terminal/terminal-web-document-mount.test.ts b/mobile/src/terminal/terminal-web-document-mount.test.ts index 8aca077a726..0a112b37a19 100644 --- a/mobile/src/terminal/terminal-web-document-mount.test.ts +++ b/mobile/src/terminal/terminal-web-document-mount.test.ts @@ -207,6 +207,7 @@ describe('terminal panes start when first selected', () => { onHaptic: noop, onTerminalInput: noop, onTerminalQueryReply: noop, + onTerminalInteraction: noop, onTerminalTap: noop, onFileTap: noop, onOpenUrl: noop diff --git a/mobile/src/terminal/terminal-webview-contract.ts b/mobile/src/terminal/terminal-webview-contract.ts index 3dd6ad093a6..409ac81d680 100644 --- a/mobile/src/terminal/terminal-webview-contract.ts +++ b/mobile/src/terminal/terminal-webview-contract.ts @@ -77,6 +77,7 @@ export type TerminalSelectionEvents = { onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void onTerminalInput?: (bytes: string, input?: TerminalPointerInput) => void onTerminalQueryReply?: (bytes: string) => void + onTerminalInteraction?: () => void onTerminalTap?: () => void // Every reading of the tapped text (hard wraps, overlapping detections); like desktop, the longest that exists opens. onFileTap?: (candidates: readonly ParsedFileLinkLocation[]) => void diff --git a/mobile/src/terminal/terminal-webview-notification-dispatch.ts b/mobile/src/terminal/terminal-webview-notification-dispatch.ts index 7d2af06c9bb..bddc69d3c73 100644 --- a/mobile/src/terminal/terminal-webview-notification-dispatch.ts +++ b/mobile/src/terminal/terminal-webview-notification-dispatch.ts @@ -64,6 +64,8 @@ export function dispatchTerminalWebViewNotification( handlers.onTerminalInput?.(bytes) } } + } else if (msg.type === 'terminal-interaction') { + handlers.onTerminalInteraction?.() } else if (msg.type === 'terminal-tap') { handlers.onTerminalTap?.() } else if (msg.type === 'terminal-file-tap') { diff --git a/mobile/src/terminal/use-terminal-webview-controller.ts b/mobile/src/terminal/use-terminal-webview-controller.ts index 03789cdcd86..017a6ae40c2 100644 --- a/mobile/src/terminal/use-terminal-webview-controller.ts +++ b/mobile/src/terminal/use-terminal-webview-controller.ts @@ -44,6 +44,7 @@ export function useTerminalWebViewController( onHaptic, onTerminalInput, onTerminalQueryReply, + onTerminalInteraction, onTerminalTap, onFileTap, onOpenUrl, @@ -173,6 +174,7 @@ export function useTerminalWebViewController( onKeyboardAvoidanceMetrics, onHaptic, onTerminalInput: interactionActive ? onTerminalInput : undefined, + onTerminalInteraction: interactionActive ? onTerminalInteraction : undefined, onTerminalTap: interactionActive ? onTerminalTap : undefined, onFileTap: interactionActive ? onFileTap : undefined, onOpenUrl: interactionActive ? onOpenUrl : undefined, @@ -194,6 +196,7 @@ export function useTerminalWebViewController( onHaptic, onTerminalInput, onTerminalQueryReply, + onTerminalInteraction, onTerminalTap, onFileTap, onOpenUrl, diff --git a/mobile/src/test-support/rpc-recording/adapters/session-startup-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/session-startup-mount-adapters.ts index 5282b56a38d..f6c7f57832d 100644 --- a/mobile/src/test-support/rpc-recording/adapters/session-startup-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/session-startup-mount-adapters.ts @@ -51,6 +51,8 @@ export function sessionStartupMountAdapters( activeHandle = typeof update === 'function' ? update(activeHandle) : update }, setActiveSessionTabId: () => {}, + setSplitTerminalHandles: () => {}, + terminalPaneViewportsRef: { current: new Map() }, setMarkdownDocs: () => {}, setFileDocs: () => {}, terminalGestureInputQueuesRef, diff --git a/mobile/src/test-support/rpc-recording/mutants/operation-mutations.ts b/mobile/src/test-support/rpc-recording/mutants/operation-mutations.ts index caf54f186d3..8c494a8967a 100644 --- a/mobile/src/test-support/rpc-recording/mutants/operation-mutations.ts +++ b/mobile/src/test-support/rpc-recording/mutants/operation-mutations.ts @@ -219,8 +219,8 @@ export const OPERATION_MUTATIONS = { // Send unmeasured viewport cells on auto toggles, replacing stored dimensions with null. 'display-mode-unmeasured-viewport': { file: 'use-mobile-session-terminal-stream-display.ts', - before: ` ...(viewportRef.current && next === 'auto' ? { viewport: viewportRef.current } : {})`, - after: ` ...(next === 'auto' ? { viewport: viewportRef.current } : {})` + before: ` ...(viewport && next === 'auto' ? { viewport } : {})`, + after: ` ...(next === 'auto' ? { viewport } : {})` }, // Send null instead of omitting afterTabId; only sessions without an active tab detect it. 'create-after-tab-id-null': { diff --git a/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts b/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts index 79807f22e4b..6638cdaf7e1 100644 --- a/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts +++ b/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts @@ -127,6 +127,26 @@ describe('mobile web bundle manifest reply reader', () => { ).toBe(true) }) + it.each([undefined, true, false, 'true', 1, null])( + 'reads cross-layout host ownership %s without upgrading legacy or malformed declarations', + (value) => { + const route = { + pathname: '/h/[hostId]', + grants: ['navigate'], + canOwnHostArea: true, + ...(value === undefined ? {} : { canOwnHostAreaAcrossLayouts: value }) + } + const parsed = MobileWebBundleManifestReadSchema.safeParse( + manifestReply({ routes: [route] }).manifest + ) + expect(parsed.success).toBe(true) + expect(parsed.data?.routes?.[0]?.canOwnHostArea).toBe(true) + expect(parsed.data?.routes?.[0]?.canOwnHostAreaAcrossLayouts).toBe( + typeof value === 'boolean' ? value : undefined + ) + } + ) + it('reads a route pattern with a trailing rest segment, which no field has to change for', () => { // Keep catch-all routes readable on both phone and host so later desktop route additions can reach page policy. const pathname = '/h/[hostId]/[...page]' diff --git a/mobile/src/transport/mobile-web-bundle-reply-schemas.ts b/mobile/src/transport/mobile-web-bundle-reply-schemas.ts index dd5422a99a8..38961697345 100644 --- a/mobile/src/transport/mobile-web-bundle-reply-schemas.ts +++ b/mobile/src/transport/mobile-web-bundle-reply-schemas.ts @@ -30,7 +30,8 @@ const pageRouteSchema = z.looseObject({ .max(MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS) .optional(), // A value this build cannot read is no declaration, never a refused bundle. - canOwnHostArea: z.boolean().optional().catch(undefined) + canOwnHostArea: z.boolean().optional().catch(undefined), + canOwnHostAreaAcrossLayouts: z.boolean().optional().catch(undefined) }) const assetSchema = z.looseObject({ diff --git a/mobile/vitest.setup.ts b/mobile/vitest.setup.ts index e1a3e32a6e2..64c1715428b 100644 --- a/mobile/vitest.setup.ts +++ b/mobile/vitest.setup.ts @@ -11,4 +11,7 @@ vi.mock('react-native-svg', () => ({ Stop: 'Stop' })) +// The reserved-regions module is iOS-only native code; tests run as a platform without it. +vi.mock('./modules/orca-reserved-regions/src', () => ({ OrcaReservedRegions: null })) + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) diff --git a/mobile/web-entry/index.tsx b/mobile/web-entry/index.tsx index b3ec0d10fab..9c54a506af2 100644 --- a/mobile/web-entry/index.tsx +++ b/mobile/web-entry/index.tsx @@ -16,7 +16,10 @@ import { type PageMountTarget } from '../src/mobile-web-shell/bridge/page-bootstrap' import { storageReadResultSchema } from '../src/mobile-web-shell/bridge/bridge-native-verbs' -import { publishPageStorage } from '../src/mobile-web-shell/bridge/page-async-storage' +import { + publishPageStorage, + refreshPageTerminalShortcuts +} from '../src/mobile-web-shell/bridge/page-async-storage' import { RouteScreenPaintProvider, createRouteScreenPaintReporter @@ -156,6 +159,12 @@ bootstrapShellPage({ ).value : null ) + client.onStorageUpdate(() => { + const current = client.getShellSession() + if (current !== null) { + refreshPageTerminalShortcuts(current.storage, current.storageOversize) + } + }) // Activation settles to English on failure, so the page always mounts. void settleLatest(() => uiLocaleActivation).then(() => createRoot(container).render( diff --git a/mobile/web-entry/web-overrides.json b/mobile/web-entry/web-overrides.json index e96b06048eb..0fafb1fd882 100644 --- a/mobile/web-entry/web-overrides.json +++ b/mobile/web-entry/web-overrides.json @@ -188,6 +188,18 @@ { "file": "src/app-update/use-wall-app-update.web.ts", "reason": "The update checker runs in the app, not the page. The native file hands the compatibility wall the release that check found; this one offers none, so the wall inside the page keeps its store or releases link and the page bundle never carries the checker." + }, + { + "file": "src/layout/use-fold-split.web.ts", + "reason": "The fold is a native reserved region the WebView cannot see: WebKit exposes no viewport segments, and the native file reads an Expo module the page does not have. This one reads the fold the shell measured over the page from `init`, already in the page's px." + }, + { + "file": "src/session/shell-terminal-key-rail.web.ts", + "reason": "The iPhone Duo key rail is drawn by the shell in a side inset strip the page cannot reach. The native file has no shell around it and answers no rail; this one reads whether the shell shows one from `init` and takes its presses from the bridge." + }, + { + "file": "src/platform/shell-storage.web.ts", + "reason": "Native settings screens can cover a still-mounted page. Observe same-session storage snapshots so preference readers refresh on return without remounting the terminal." } ] } diff --git a/src/shared/mobile-web-bundle/manifest-contract.test.ts b/src/shared/mobile-web-bundle/manifest-contract.test.ts index 350bb46b59d..5d07acc5d17 100644 --- a/src/shared/mobile-web-bundle/manifest-contract.test.ts +++ b/src/shared/mobile-web-bundle/manifest-contract.test.ts @@ -169,6 +169,15 @@ describe('the page routes a manifest declares', () => { expect(withRoutes([{ pathname: '/h', grants: ['browser.screencast'] }])).toBe(false) }) + it('accepts explicit cross-layout host ownership without changing legacy route declarations', () => { + const legacy = { pathname: '/h/[hostId]', grants: ['navigate'], canOwnHostArea: true } + expect(withRoutes([legacy])).toBe(true) + expect(withRoutes([{ ...legacy, canOwnHostAreaAcrossLayouts: true }])).toBe(true) + for (const invalid of [false, 'true', 1, null]) { + expect(withRoutes([{ ...legacy, canOwnHostAreaAcrossLayouts: invalid }])).toBe(false) + } + }) + it('refuses a route carrying a field the contract does not declare', () => { expect(withRoutes([{ pathname: '/h', grants: [], screen: 'x' }])).toBe(false) }) @@ -191,30 +200,16 @@ describe('the page routes a manifest declares', () => { expect(withRoutes([{ pathname: '/h', grants: [], optionalGrants: [''] }])).toBe(false) }) - it('bounds the two lists together, not one at a time', () => { + it('bounds each list on its own, as released shells read them', () => { const names = (count, prefix) => Array.from({ length: count }, (_value, index) => `${prefix}${String(index)}`) - const half = MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS / 2 - // At the ceiling exactly, split across the lanes. + const cap = MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS expect( - withRoutes([ - { pathname: '/h', grants: names(half, 'req'), optionalGrants: names(half, 'opt') } - ]) + withRoutes([{ pathname: '/h', grants: names(cap, 'req'), optionalGrants: names(cap, 'opt') }]) ).toBe(true) - // Individually valid arrays can exceed the combined session grant cap. + expect(withRoutes([{ pathname: '/h', grants: names(cap + 1, 'req') }])).toBe(false) expect( - withRoutes([ - { pathname: '/h', grants: names(half + 1, 'req'), optionalGrants: names(half, 'opt') } - ]) - ).toBe(false) - expect( - withRoutes([ - { - pathname: '/h', - grants: [], - optionalGrants: names(MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS + 1, 'opt') - } - ]) + withRoutes([{ pathname: '/h', grants: [], optionalGrants: names(cap + 1, 'opt') }]) ).toBe(false) }) diff --git a/src/shared/mobile-web-bundle/manifest-contract.ts b/src/shared/mobile-web-bundle/manifest-contract.ts index 82ef3564e0a..0bef6d91899 100644 --- a/src/shared/mobile-web-bundle/manifest-contract.ts +++ b/src/shared/mobile-web-bundle/manifest-contract.ts @@ -16,6 +16,8 @@ export const MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES = 32 * 1024 * 1024 export const MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES = 10 * 1024 * 1024 export const MOBILE_WEB_BUNDLE_MAX_ROUTES = 64 export const MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS = 16 +// What a route's two lists can grant together; released shells cap each list alone, never the pair. +export const MOBILE_WEB_BUNDLE_MAX_GRANTED_ROUTE_GRANTS = 2 * MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS export const SHA256_PATTERN = /^[a-f0-9]{64}$/ const ASSET_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/ @@ -108,22 +110,11 @@ export const MobileWebBundleRouteSchema = z .max(MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS) .optional(), /** The page can draw the wide host area, sidebar included, when `init` says it owns it. */ - canOwnHostArea: z.literal(true).optional() + canOwnHostArea: z.literal(true).optional(), + /** This page also renders the narrow host area; legacy ownership remains wide-only. */ + canOwnHostAreaAcrossLayouts: z.literal(true).optional() }) .strict() - // Cap the combined session grant set as well as each input array; separate caps would allow twice the limit. - .superRefine((route, context) => { - if ( - route.grants.length + (route.optionalGrants?.length ?? 0) > - MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS - ) { - context.addIssue({ - code: 'custom', - path: ['optionalGrants'], - message: 'grants and optionalGrants together must not exceed the route grant ceiling' - }) - } - }) export type MobileWebBundleRoute = z.infer