fix(mobile): break the transport and terminal-document import cycles, and gate cycles after mobile's install (#30290)

* fix(mobile): break the transport and terminal-document import cycles and gate them after mobile's install

client-context re-exported the host-client hooks, which import client-context back: the re-export
barrel is gone and importers (and their test mocks) name host-client-hooks directly. In the terminal
document, the cell-metric getters move to a leaf cell-metrics module and select-mode entry/exit to
selection-mode, so fit, transform, overlay and range no longer import each other. pr.yml lints imports
before mobile's install, when mobile's tsconfig can't load, so Mobile verify now runs the same import
rules over mobile after its install with zero warnings allowed.

* fix(mobile): the browser-pane render harness imports useHostClient from host-client-hooks

Its route source is a template string, so no typecheck saw the removed client-context re-export; the mobile web bundle check did.

* fix(ci): the e2e typecheck ratchet counts the same with or without mobile's install

Specs reach mobile/src/transport, whose expo-crypto and async-storage imports resolve only after mobile's install and then load React Native's globals (numeric timers, a required NODE_ENV), adding errors in unrelated specs. An empty stub pins both, so the static job reads 232 either way.
This commit is contained in:
Neil
2026-10-18 04:00:24 -07:00
committed by GitHub
parent c82cf2b35d
commit aa07d5891a
82 changed files with 176 additions and 170 deletions
+7
View File
@@ -32,6 +32,7 @@ on:
- '.github/workflows/mobile.yml'
- 'config/mobile-ci.vitest.config.mjs'
- 'config/scripts/run-mobile-ci-tests.mjs'
- 'config/oxlint-code-quality-native-plugins.json'
- 'config/.bun-version'
- '.github/actions/install-node-dependencies/**'
- '.github/actions/restore-pnpm-verification/**'
@@ -154,6 +155,12 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Why here: pr.yml runs these import rules before mobile's install, when mobile's tsconfig
# (which extends expo's) can't load, so its import cycles were never seen. Zero tolerance.
- name: Lint mobile imports with mobile dependencies installed
working-directory: .
run: node node_modules/oxlint/bin/oxlint --config config/oxlint-code-quality-native-plugins.json mobile --deny-warnings
# Call installed tools so pnpm's dependency refresh cannot race between checks.
- name: Typecheck
id: production-types
@@ -0,0 +1,2 @@
// Stands in for mobile's native-only packages in config/tsconfig.e2e.json (see its `paths`).
export {}
@@ -96,7 +96,13 @@ it('gates Ruby independently and retains mobile static validation', () => {
for (const step of gated) {
expect(step.if).toBe("steps.ruby-scope.outputs.should_run != 'false'")
}
for (const name of ['Typecheck', 'Typecheck tests (ratchet)', 'Lint', 'Check formatting']) {
for (const name of [
'Lint mobile imports with mobile dependencies installed',
'Typecheck',
'Typecheck tests (ratchet)',
'Lint',
'Check formatting'
]) {
expect(steps.find((step) => step.name === name)?.if).toBeUndefined()
expect(steps.some((step) => step.name === name)).toBe(true)
}
@@ -82,7 +82,7 @@ const OVER_CAP_FRAME = { width: 2400, height: 2160 }
* because the capability probe is the session screen's to make and C7's to prove.
*/
const ROUTE_SOURCE = `
import { useHostClient } from '../../../src/transport/client-context'
import { useHostClient } from '../../../src/transport/host-client-hooks'
import { MobileBrowserPane } from '../../../src/browser/MobileBrowserPane'
const TAB = {
+6 -1
View File
@@ -20,7 +20,12 @@
"types": ["node"],
"paths": {
"@renderer/*": ["../src/renderer/src/*"],
"@/*": ["../src/renderer/src/*"]
"@/*": ["../src/renderer/src/*"],
// Why: specs reach mobile/src/transport, whose native imports resolve only once mobile is
// installed and then load React Native's globals (numeric timers, a required NODE_ENV).
// An empty stub keeps the error count the same with or without mobile's install.
"expo-crypto": ["./e2e-typecheck-mobile-native-stub.ts"],
"@react-native-async-storage/async-storage": ["./e2e-typecheck-mobile-native-stub.ts"]
}
}
}
+2 -1
View File
@@ -4,7 +4,8 @@ import { useLocalSearchParams, useRouter } from 'expo-router'
import * as Clipboard from 'expo-clipboard'
import { loadHosts } from '../src/transport/host-store'
import { connectionLogStore } from '../src/transport/persisted-connection-log-store'
import { useHostClient, useRpcClientContext } from '../src/transport/client-context'
import { useRpcClientContext } from '../src/transport/client-context'
import { useHostClient } from '../src/transport/host-client-hooks'
import {
useConnectionPathStatus,
useReconnectAttempt
+1 -1
View File
@@ -17,7 +17,7 @@ import { colors, radii, spacing, typography } from '../../../src/theme/mobile-th
import { loadHosts, updateHostNameAndEndpoint } from '../../../src/transport/host-store'
import { displayHostEndpoint } from '../../../src/transport/host-endpoint'
import { resolveHostEndpointEdit } from '../../../src/transport/host-endpoint-edit'
import { usePrimeHosts, useRefreshHostClient } from '../../../src/transport/client-context'
import { usePrimeHosts, useRefreshHostClient } from '../../../src/transport/host-client-hooks'
import type { HostProfile } from '../../../src/transport/types'
import { hostOs } from '../../../src/platform/host-os'
+1 -1
View File
@@ -9,7 +9,7 @@ import {
type PreProfilePairingAttempt
} from '../src/transport/pre-profile-pairing-coordinator'
import type { ConnectionLogEntry } from '../src/transport/types'
import { useRefreshHostClient } from '../src/transport/client-context'
import { useRefreshHostClient } from '../src/transport/host-client-hooks'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { ConnectionLog } from '../src/components/ConnectionLog'
import {
+1 -1
View File
@@ -17,7 +17,7 @@ import {
type PreProfilePairingAttempt
} from '../src/transport/pre-profile-pairing-coordinator'
import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types'
import { useRefreshHostClient } from '../src/transport/client-context'
import { useRefreshHostClient } from '../src/transport/host-client-hooks'
import { colors, spacing } from '../src/theme/mobile-theme'
import { TextInputModal } from '../src/components/TextInputModal'
import { ConnectionLog } from '../src/components/ConnectionLog'
+1 -1
View File
@@ -4,7 +4,7 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useFocusEffect, useLocalSearchParams } from 'expo-router'
import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native'
import { loadHosts } from '../transport/host-store'
import { useHostClient } from '../transport/client-context'
import { useHostClient } from '../transport/host-client-hooks'
import { colors, spacing } from '../theme/mobile-theme'
import { styles } from './mobile-accounts-screen-styles'
import { useNow } from '../hooks/use-now'
@@ -60,7 +60,7 @@ vi.mock('lucide-react-native', () => ({
vi.mock('../transport/host-store', () => ({ loadHosts: dependencies.loadHosts }))
vi.mock('../transport/client-context', () => {
vi.mock('../transport/host-client-hooks', () => {
const client = {
sendRequest: async (method: string, params?: unknown, options?: unknown) => {
if (method === 'status.get') {
@@ -10,7 +10,7 @@ const connection = vi.hoisted((): { client: RpcClient | null; state: ConnectionS
client: null,
state: 'connected'
}))
vi.mock('../transport/client-context', () => ({
vi.mock('../transport/host-client-hooks', () => ({
useHostClient: () => connection,
useForceReconnect: () => vi.fn()
}))
@@ -51,7 +51,7 @@ vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: () => null })
vi.mock('../navigation/route-handoff', () => ({
useRouteHandoff: () => ({ push: notifications.push, back: vi.fn(), canGoBack: () => false })
}))
vi.mock('../transport/client-context', () => ({
vi.mock('../transport/host-client-hooks', () => ({
useHostClient: () => ({ client: state.client, state: state.connection }),
useForceReconnect: () => vi.fn()
}))
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useForceReconnect } from '../transport/client-context'
import { useForceReconnect } from '../transport/host-client-hooks'
import { useWorkspaceClient } from '../transport/use-workspace-client'
import type { RpcClient } from '../transport/rpc-client'
import { connectionRetryAction } from '../transport/connection-retry-action'
@@ -35,7 +35,7 @@ vi.mock('expo-router', () => ({
const hostClient = vi.hoisted(() => ({
current: { client: null as RpcClient | null, state: 'disconnected' as string }
}))
vi.mock('../transport/client-context', () => ({
vi.mock('../transport/host-client-hooks', () => ({
useHostClient: () => hostClient.current
}))
// The wall's release offer: the native hook imports the update checker module.
+1 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, type ReactNode } from 'react'
import { ActivityIndicator, StyleSheet, View } from 'react-native'
import { useHostClient } from '../transport/client-context'
import { useHostClient } from '../transport/host-client-hooks'
import { useHostStatusGates } from '../transport/host-status-gates'
import { colors } from '../theme/mobile-theme'
import { HostStatusGatesContext } from './host-protocol-gates-context'
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { useHostClient } from '../transport/client-context'
import { useHostClient } from '../transport/host-client-hooks'
import { fetchMobileWebBundle } from '../transport/mobile-web-bundle-fetch'
import { readMobileWebBundleErrorCode } from '../transport/mobile-web-bundle-operations'
import { startDiagnosticFetchTimeout } from './diagnostic-fetch-timeout'
@@ -81,7 +81,7 @@ vi.mock('../platform/haptics', () => ({
triggerSelection: vi.fn()
}))
vi.mock('../transport/client-context', () => ({
vi.mock('../transport/host-client-hooks', () => ({
useForceReconnect: () => mockTransport.forceReconnect,
useHostClient: () => ({
client: mockTransport.client,
+1 -1
View File
@@ -10,7 +10,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context'
import { ChevronLeft, X } from 'lucide-react-native'
import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff'
import { useForceReconnect } from '../transport/client-context'
import { useForceReconnect } from '../transport/host-client-hooks'
import { useWorkspaceClient } from '../transport/use-workspace-client'
import { connectionRetryAction } from '../transport/connection-retry-action'
import { getWorktreeLabel } from '../session/worktree-label'
+1 -1
View File
@@ -5,7 +5,7 @@ import { ChevronLeft, Save } from 'lucide-react-native'
import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff'
import { getWorktreeLabel } from '../session/worktree-label'
import { colors, spacing } from '../theme/mobile-theme'
import { useForceReconnect } from '../transport/client-context'
import { useForceReconnect } from '../transport/host-client-hooks'
import { useWorkspaceClient } from '../transport/use-workspace-client'
import { connectionRetryAction } from '../transport/connection-retry-action'
import {
@@ -38,7 +38,7 @@ vi.mock('../components/ConfirmModal', () => ({ ConfirmModal: () => null }))
vi.mock('./MobileFileMarkdownPreview', () => ({ MobileFileMarkdownPreview: () => null }))
vi.mock('./MobileFilePreviewSourceText', () => ({ MobileFilePreviewSourceText: () => null }))
// A host the shell has not reached by default: no client, so the screen settles on `waiting`.
vi.mock('../transport/client-context', () => ({
vi.mock('../transport/host-client-hooks', () => ({
useForceReconnect: () => transport.forceReconnect,
useHostClient: () => ({ ...transport.connection, clientId: null })
}))
+1 -1
View File
@@ -17,7 +17,7 @@ import {
useDisconnectHostClient,
useForceReconnect,
useForgetHostClient
} from '../transport/client-context'
} from '../transport/host-client-hooks'
import { hostEndpointLabel } from '../transport/host-endpoint-label'
import { resolveHomeHostConnectionState } from '../transport/home-host-auto-connect'
import { removeHostAndCloseClient } from '../transport/host-removal-lifecycle'
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { decodeAccountsSnapshot } from '../components/AccountUsage'
import { subscribeToDesktopNotifications } from '../notifications/mobile-notifications'
import { usePrimeHosts } from '../transport/client-context'
import { usePrimeHosts } from '../transport/host-client-hooks'
import { createHostConnectRefetchGate } from '../transport/host-connect-refetch-gate'
import { selectHomeAutoConnectHostIds } from '../transport/home-host-auto-connect'
import type { RpcClient } from '../transport/rpc-client'
@@ -41,7 +41,7 @@ vi.mock('./transport/host-store', () => ({
updateHostNameAndEndpoint: dependencies.updateHostNameAndEndpoint
}))
vi.mock('./transport/client-context', () => ({
vi.mock('./transport/host-client-hooks', () => ({
usePrimeHosts: () => dependencies.primeHosts,
useRefreshHostClient: () => dependencies.refreshHostClient
}))
+1 -1
View File
@@ -42,7 +42,7 @@ vi.mock('./transport/host-store', () => ({
updateHostNameAndEndpoint: dependencies.updateHostNameAndEndpoint
}))
vi.mock('./transport/client-context', () => ({
vi.mock('./transport/host-client-hooks', () => ({
usePrimeHosts: () => dependencies.primeHosts,
useRefreshHostClient: () => dependencies.refreshHostClient
}))
@@ -8,7 +8,11 @@ import { resolveHostRouteActionState } from '../host-route-action-state'
import { useActiveWorktreeScroll } from '../hooks/use-active-worktree-scroll'
import { useNow } from '../hooks/use-now'
import { useResponsiveLayout } from '../layout/responsive-layout'
import { useForgetHostClient, useForceReconnect, useHostClient } from '../transport/client-context'
import {
useForgetHostClient,
useForceReconnect,
useHostClient
} from '../transport/host-client-hooks'
import {
useLastConnectedAt,
useReconnectAttempt,
@@ -2,7 +2,7 @@ import { useCallback } from 'react'
import type { useRouter } from 'expo-router'
import { floatingWorkspaceSessionPath } from '../session/floating-workspace'
import { savePinnedIds } from '../storage/preferences'
import type { useForgetHostClient } from '../transport/client-context'
import type { useForgetHostClient } from '../transport/host-client-hooks'
import { removeHostAndCloseClient } from '../transport/host-removal-lifecycle'
import { isPageHostRemovalUnavailable } from '../transport/page-host-removal-refusal'
import type { RpcClient } from '../transport/rpc-client'
@@ -20,7 +20,7 @@ vi.mock('react-native-safe-area-context', mocks['react-native-safe-area-context'
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/client-context', mocks['../transport/client-context'])
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'])
@@ -117,7 +117,7 @@ vi.mock('../../modules/orca-mobile-web-shell/src', async () => {
vi.mock('../app-update/use-wall-app-update', () => ({
useWallAppUpdate: () => null
}))
vi.mock('../transport/client-context', () => ({ useHostClient: () => ({ client: null }) }))
vi.mock('../transport/host-client-hooks', () => ({ useHostClient: () => ({ client: null }) }))
vi.mock('./use-page-host-snapshot', () => ({
usePageHostSnapshot: () => ({
snapshot: SNAPSHOT,
@@ -57,7 +57,7 @@ vi.mock('../transport/mobile-endpoint-supervisor-support', () => ({
vi.mock('../components/host-protocol-gates-context', () => ({
useHostProtocolGates: () => doubles.gates
}))
vi.mock('../transport/client-context', () => ({
vi.mock('../transport/host-client-hooks', () => ({
useHostClient: () => ({ client: {}, state: 'connected' })
}))
vi.mock('../transport/rpc-operation', () => ({
@@ -36,7 +36,7 @@ vi.mock('lucide-react-native', mocks['lucide-react-native'])
vi.mock('react-native-safe-area-context', mocks['react-native-safe-area-context'])
vi.mock('../../modules/orca-mobile-web-shell/src', mocks['../../modules/orca-mobile-web-shell/src'])
vi.mock('../app-update/use-wall-app-update', mocks['../app-update/use-wall-app-update'])
vi.mock('../transport/client-context', mocks['../transport/client-context'])
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'])
@@ -75,7 +75,7 @@ vi.mock('lucide-react-native', mocks['lucide-react-native'])
vi.mock('react-native-safe-area-context', mocks['react-native-safe-area-context'])
vi.mock('../../modules/orca-mobile-web-shell/src', mocks['../../modules/orca-mobile-web-shell/src'])
vi.mock('../app-update/use-wall-app-update', mocks['../app-update/use-wall-app-update'])
vi.mock('../transport/client-context', mocks['../transport/client-context'])
vi.mock('../transport/host-client-hooks', mocks['../transport/host-client-hooks'])
vi.mock('./use-page-host-snapshot', mocks['./use-page-host-snapshot'])
// The screen's session is the reducer's, as `sessionFor` carried it.
vi.mock('./use-mobile-web-shell-session', () => ({
@@ -53,7 +53,7 @@ vi.mock('lucide-react-native', mocks['lucide-react-native'])
vi.mock('react-native-safe-area-context', mocks['react-native-safe-area-context'])
vi.mock('../../modules/orca-mobile-web-shell/src', mocks['../../modules/orca-mobile-web-shell/src'])
vi.mock('../app-update/use-wall-app-update', mocks['../app-update/use-wall-app-update'])
vi.mock('../transport/client-context', mocks['../transport/client-context'])
vi.mock('../transport/host-client-hooks', mocks['../transport/host-client-hooks'])
vi.mock('./use-page-host-snapshot', mocks['./use-page-host-snapshot'])
// Each host route's session as the reducer leaves it under a declaring page on a wide layout.
vi.mock('./use-mobile-web-shell-session', () => ({
@@ -28,7 +28,7 @@ vi.mock('react-native', () => ({
}))
// The workspace route reads the desktop's connection to decide whether a server is reachable.
vi.mock('../transport/client-context', () => ({
vi.mock('../transport/host-client-hooks', () => ({
useHostClient: () => ({ client: null, state: 'disconnected' })
}))
vi.mock('../transport/workspace-server', () => ({
@@ -54,7 +54,7 @@ vi.mock('../transport/mobile-endpoint-supervisor-support', () => ({
vi.mock('../components/host-protocol-gates-context', () => ({
useHostProtocolGates: () => doubles.gates
}))
vi.mock('../transport/client-context', () => ({ useHostClient: () => doubles.connection }))
vi.mock('../transport/host-client-hooks', () => ({ useHostClient: () => doubles.connection }))
vi.mock('../transport/rpc-operation', () => ({
defineRpcOperation: (definition: unknown) => definition,
runRpcOperation: async () => {
@@ -28,7 +28,7 @@ vi.mock('react-native', () => ({
}))
// The workspace route reads the desktop's connection to decide whether a server is reachable.
vi.mock('../transport/client-context', () => ({
vi.mock('../transport/host-client-hooks', () => ({
useHostClient: () => ({ client: null, state: 'disconnected' })
}))
vi.mock('../transport/workspace-server', () => ({
@@ -20,7 +20,7 @@ vi.mock('react-native-safe-area-context', mocks['react-native-safe-area-context'
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/client-context', mocks['../transport/client-context'])
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'])
@@ -45,7 +45,7 @@ vi.mock('../transport/mobile-endpoint-supervisor-support', () => ({
vi.mock('../components/host-protocol-gates-context', () => ({
useHostProtocolGates: () => doubles.gates
}))
vi.mock('../transport/client-context', () => ({ useHostClient: () => doubles.connection }))
vi.mock('../transport/host-client-hooks', () => ({ useHostClient: () => doubles.connection }))
vi.mock('../transport/rpc-operation', () => ({
defineRpcOperation: (definition: unknown) => definition,
runRpcOperation: async () => {
@@ -20,7 +20,7 @@ vi.mock('react-native-safe-area-context', mocks['react-native-safe-area-context'
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/client-context', mocks['../transport/client-context'])
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'])
@@ -150,7 +150,7 @@ export function screenModuleMocks(dependencies: ScreenDependencies) {
'../app-update/use-wall-app-update': () => ({ useWallAppUpdate: () => null }),
// The real bridge hook runs, so the props it owns are the ones the view is handed here; only the
// client lookup is stubbed, because reaching it imports the Expo runtime this test does not have.
'../transport/client-context': () => ({
'../transport/host-client-hooks': () => ({
useHostClient: () => ({ client: dependencies.client })
}),
// Reaching the real one imports the host store and expo-secure-store, whose module touches an Expo
@@ -31,7 +31,7 @@ vi.mock('react-native', () => ({
}))
// The workspace route reads the desktop's connection to decide whether a server is reachable.
vi.mock('../transport/client-context', () => ({
vi.mock('../transport/host-client-hooks', () => ({
useHostClient: () => ({ client: null, state: 'disconnected' })
}))
vi.mock('../transport/workspace-server', () => ({
@@ -37,7 +37,7 @@ vi.mock('react-native', () => ({
}))
// The workspace route reads the desktop's connection to decide whether a server is reachable.
vi.mock('../transport/client-context', () => ({
vi.mock('../transport/host-client-hooks', () => ({
useHostClient: () => ({ client: null, state: 'disconnected' })
}))
vi.mock('../transport/workspace-server', () => ({
@@ -30,7 +30,7 @@ import type { FakeRpcClient } from './bridge-host-test-fakes'
const doubles = vi.hoisted((): { client: FakeRpcClient | null } => ({ client: null }))
// Reaching the real one imports the Expo runtime this test does not have; the hook reads one field.
vi.mock('../transport/client-context', () => ({
vi.mock('../transport/host-client-hooks', () => ({
useHostClient: () => ({ client: doubles.client })
}))
@@ -3,7 +3,7 @@ import type {
MobileWebShellBridgeMessagePayload,
OrcaMobileWebShellViewHandle
} from '../../modules/orca-mobile-web-shell/src'
import { useHostClient } from '../transport/client-context'
import { useHostClient } from '../transport/host-client-hooks'
import { createBridgeDiagnosticReporter } from './bridge-diagnostic-log'
import { bridgeDiagnosticLine, frameWithoutHostLine } from './shell-bridge-log-lines'
import { recordShellLifecycleLine } from './shell-lifecycle-line-recorder'
@@ -77,7 +77,7 @@ vi.mock('../transport/mobile-endpoint-supervisor-support', () => ({
vi.mock('../components/host-protocol-gates-context', () => ({
useHostProtocolGates: () => doubles.gates
}))
vi.mock('../transport/client-context', () => ({ useHostClient: () => doubles.connection }))
vi.mock('../transport/host-client-hooks', () => ({ useHostClient: () => doubles.connection }))
vi.mock('../transport/rpc-operation', () => ({
defineRpcOperation: (definition: unknown) => definition,
runRpcOperation: async (client: unknown) => {
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { MobileWebShellFailureReason } from '../../modules/orca-mobile-web-shell/src/load-state'
import { useHostProtocolGates } from '../components/host-protocol-gates-context'
import { useHostClient } from '../transport/client-context'
import { useHostClient } from '../transport/host-client-hooks'
import type { GenerationStore } from './generation-store'
import { deriveHostCacheKey } from './host-cache-key'
import { download, openCache, readManifest } from './mobile-web-shell-session-effects'
@@ -36,7 +36,7 @@ vi.mock('react-native', () => ({
Text: 'Text',
Pressable: 'Pressable'
}))
vi.mock('../transport/client-context', () => ({
vi.mock('../transport/host-client-hooks', () => ({
useHostClient: () => ({ client: route.client, state: 'connected' })
}))
// The server's status is read for real; only the desktop's persisted records are stubbed.
+1 -1
View File
@@ -8,7 +8,7 @@ import {
useOptionalHostProtocolGates
} from '../components/host-protocol-gates-context'
import { PageRouteUnavailableScreen } from '../mobile-web-shell/PageRouteUnavailableScreen'
import { useHostClient } from '../transport/client-context'
import { useHostClient } from '../transport/host-client-hooks'
import { WorkspaceServerContext } from '../transport/use-workspace-client'
import { useWorkspaceServer } from '../transport/workspace-server'
@@ -4,7 +4,7 @@ import { MobileDiffReviewScreenView } from '../components/MobileDiffReviewScreen
import { firstReviewParam, normalizeReviewFilterParam } from './mobile-diff-review-screen-model'
import { normalizeReviewAreaParam } from './mobile-diff-review-positioning'
import { useMobileDiffReviewController } from './use-mobile-diff-review-controller'
import { useForceReconnect } from '../transport/client-context'
import { useForceReconnect } from '../transport/host-client-hooks'
import { useWorkspaceClient } from '../transport/use-workspace-client'
import { useHostProtocolGates } from '../components/host-protocol-gates-context'
import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff'
@@ -3,7 +3,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams } from 'expo-router'
import { useRouteHandoff } from '../navigation/route-handoff'
import { HOST_DOCK_MIN_WIDTH } from '../storage/preferences'
import { useForceReconnect } from '../transport/client-context'
import { useForceReconnect } from '../transport/host-client-hooks'
import { useWorkspaceClient } from '../transport/use-workspace-client'
import {
useLastConnectedAt,
@@ -31,7 +31,7 @@ const transport = vi.hoisted(
forceReconnect: () => Promise.resolve()
})
)
vi.mock('../transport/client-context', () => ({
vi.mock('../transport/host-client-hooks', () => ({
useForceReconnect: () => transport.forceReconnect
}))
@@ -4,7 +4,7 @@ import { ChevronDown, ChevronRight } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { ConnectionState } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { useForceReconnect } from '../transport/client-context'
import { useForceReconnect } from '../transport/host-client-hooks'
import { connectionRetryAction } from '../transport/connection-retry-action'
import { gitCommitCompareRead } from './mobile-git-read-operations'
import type { MobileGitChangedFile } from './git-compare-reply-schema'
@@ -1,6 +1,6 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useForceReconnect } from '../transport/client-context'
import { useForceReconnect } from '../transport/host-client-hooks'
import { useWorkspaceClient } from '../transport/use-workspace-client'
import { getWorktreeLabel } from '../session/worktree-label'
import { useMobileGitRequests } from './use-mobile-git-requests'
@@ -55,7 +55,7 @@ export {
} from 'lucide-react-native'
export type { RpcClient } from '../transport/rpc-client'
export type { RpcSuccess } from '../transport/types'
export { useHostClient } from '../transport/client-context'
export { useHostClient } from '../transport/host-client-hooks'
export {
useLastConnectedAt,
useRelayRecoveryStatus,
@@ -1,5 +1,4 @@
import { getCellHeight } from './fit-scale'
import { getCellWidth, getTotalScale } from './viewport-transform'
import { getCellHeight, getCellWidth, getTotalScale } from './cell-metrics'
import type { TerminalDocumentScope } from './document-scope'
export function cellToViewportPx(scope: TerminalDocumentScope, col: number, absRow: number) {
@@ -0,0 +1,31 @@
import type { TerminalDocumentScope } from './document-scope'
// Why: a leaf of pure reads, so fit, transform and overlay modules can share them without a cycle.
/** 0 until the renderer has measured a cell. */
export function getMeasuredCellHeight(scope: TerminalDocumentScope) {
const core = scope.term?._core
if (core && core._renderService && core._renderService.dimensions) {
return core._renderService.dimensions.css.cell.height || 0
}
return 0
}
export function getCellHeight(scope: TerminalDocumentScope) {
return getMeasuredCellHeight(scope) || 15
}
export function getCellWidth(scope: TerminalDocumentScope) {
if (!scope.term || !scope.term._core) {
return 0
}
const core = scope.term._core
if (core._renderService && core._renderService.dimensions) {
return core._renderService.dimensions.css.cell.width || 0
}
return 0
}
export function getTotalScale(scope: TerminalDocumentScope) {
return scope.currentScale * scope.userScale
}
+2 -12
View File
@@ -1,21 +1,11 @@
import { repositionOverlay } from './selection-overlay'
import {
computeFitScale,
flog,
getCellWidth,
getMeasuredCellHeight,
getTotalScale,
updateTransform
} from './viewport-transform'
import { computeFitScale, flog, updateTransform } from './viewport-transform'
import { getCellWidth, getTotalScale } from './cell-metrics'
import type { TerminalDocumentScope } from './document-scope'
import type { TerminalViewportChange } from './document-host-seams'
import { scheduleDocumentFrame } from './document-frame-registry'
import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics'
export function getCellHeight(scope: TerminalDocumentScope) {
return getMeasuredCellHeight(scope) || 15
}
// Why: clamp pan so the terminal content always covers the viewport
// when zoomed in. When content is smaller than viewport in a
// dimension, pin to top-left (no floating in the middle).
@@ -6,7 +6,7 @@ import { emitModesIfChanged } from './mode-mirroring'
import { reflow } from './reflow'
import { resumeTerminalDataReplyAuthority } from './query-reply'
import { repositionOverlay } from './selection-overlay'
import { cancelSelect } from './selection-range'
import { cancelSelect } from './selection-mode'
import { resetEvictionCounter } from './selection-state-and-eviction'
import { applyTerminalTheme } from './terminal-theme'
import { init, resize, write } from './terminal-init'
@@ -1,4 +1,4 @@
import { getMeasuredCellHeight, getTotalScale } from './viewport-transform'
import { getMeasuredCellHeight, getTotalScale } from './cell-metrics'
import { notify } from './host-notify'
import type {
TerminalDocumentCell,
@@ -1,5 +1,6 @@
import { handleDragMove, repositionOverlay, stopEdgeScroll } from './selection-overlay'
import { applyXtermSelection, cancelSelect } from './selection-range'
import { cancelSelect } from './selection-mode'
import { applyXtermSelection } from './selection-range'
import { notify } from './host-notify'
import { getMouseTrackingMode, isSafeSgrMouseCoordinate } from './mouse-input-encoding'
import { viewportToCell } from './viewport-cell'
@@ -1,5 +1,4 @@
import { getCellHeight } from './fit-scale'
import { getCellWidth, getTotalScale } from './viewport-transform'
import { getCellHeight, getCellWidth, getTotalScale } from './cell-metrics'
import { viewportPoint } from './viewport-cell'
import type { TerminalDocumentScope } from './document-scope'
@@ -313,8 +313,8 @@ describe('the bundled native document', () => {
expect(inputs.filter((input) => input.includes('node_modules'))).toEqual([])
// 50: `laid-out-cell-box` reads the cell box xterm laid out, for web-ready and each render;
// `terminal-grid-fit` is the one fit of that box and `terminal-cell-box` its one comparison,
// both shared with the app.
expect(inputs).toHaveLength(50)
// both shared with the app. 52: `cell-metrics` and `selection-mode` split out to break cycles.
expect(inputs).toHaveLength(52)
expect(script).not.toContain('__commonJS')
// `__esm` wrappers are esbuild's answer to a cycle, and a cycle would make a module's top level
// run at first import rather than where the bundle places it.
@@ -1,5 +1,5 @@
import { getCellHeight } from './fit-scale'
import { getTotalScale, updateScrollIndicator } from './viewport-transform'
import { getCellHeight, getTotalScale } from './cell-metrics'
import { updateScrollIndicator } from './viewport-transform'
import type { TerminalDocumentScope } from './document-scope'
import { scheduleDocumentFrame } from './document-frame-registry'
@@ -1,6 +1,6 @@
import type { TerminalDocumentScope } from './document-scope'
import { notify } from './host-notify'
import { cancelSelect } from './selection-range'
import { cancelSelect } from './selection-mode'
import { repositionOverlay } from './selection-overlay'
export function startSelectionMenuButtons(scope: TerminalDocumentScope) {
@@ -0,0 +1,36 @@
import type { TerminalDocumentScope } from './document-scope'
import { notify } from './host-notify'
import { repositionOverlay, stopEdgeScroll } from './selection-overlay'
import { seedWordSelection } from './selection-range'
// Why: entering and leaving select mode drives the overlay; kept out of selection-range so the
// range math the overlay reads doesn't import the overlay back.
export function cancelSelect(scope: TerminalDocumentScope) {
scope.selMode = 'navigate'
scope.sel = null
stopEdgeScroll(scope)
if (scope.term) {
try {
scope.term.clearSelection()
} catch {}
// Why: some xterm renderers cache cells and skip repaint on
// clearSelection alone, leaving the previously-highlighted cells
// visually selected. Force a full refresh so the selection layer
// actually clears on screen.
try {
scope.term.refresh(0, scope.term.rows - 1)
} catch {}
}
scope.selectionOverlay!.classList.remove('active')
notify(scope, { type: 'set-select-mode', enabled: false })
}
export function enterSelect(scope: TerminalDocumentScope, col: number, absRow: number) {
scope.selMode = 'select'
seedWordSelection(scope, col, absRow)
scope.selectionOverlay!.classList.add('active')
notify(scope, { type: 'set-select-mode', enabled: true })
notify(scope, { type: 'haptic', kind: 'selection' })
repositionOverlay(scope)
}
@@ -1,10 +1,9 @@
import { getCellHeight, getTotalScale } from './cell-metrics'
import { cellToViewportPx } from './cell-geometry'
import type { TerminalDocumentScope } from './document-scope'
import { getCellHeight } from './fit-scale'
import { notify } from './host-notify'
import { applyXtermSelection, selRange } from './selection-range'
import { viewportPoint, viewportToCell } from './viewport-cell'
import { getTotalScale } from './viewport-transform'
/** How close to an edge a handle drag starts scrolling, in pixels. */
const EDGE_SCROLL_PX = 40
@@ -1,7 +1,5 @@
import { getLineText } from './cell-geometry'
import type { TerminalDocumentScope, TerminalDocumentSelection } from './document-scope'
import { notify } from './host-notify'
import { repositionOverlay, stopEdgeScroll } from './selection-overlay'
/** What counts as one word for select-all and for word seeding. */
const WORD_RE = /[\p{L}\p{N}_./:@~+=?&#%-]/u
@@ -87,32 +85,3 @@ export function applyXtermSelection(scope: TerminalDocumentScope) {
scope.term.select(r.start.col, r.start.row, length)
} catch {}
}
export function cancelSelect(scope: TerminalDocumentScope) {
scope.selMode = 'navigate'
scope.sel = null
stopEdgeScroll(scope)
if (scope.term) {
try {
scope.term.clearSelection()
} catch {}
// Why: some xterm renderers cache cells and skip repaint on
// clearSelection alone, leaving the previously-highlighted cells
// visually selected. Force a full refresh so the selection layer
// actually clears on screen.
try {
scope.term.refresh(0, scope.term.rows - 1)
} catch {}
}
scope.selectionOverlay!.classList.remove('active')
notify(scope, { type: 'set-select-mode', enabled: false })
}
export function enterSelect(scope: TerminalDocumentScope, col: number, absRow: number) {
scope.selMode = 'select'
seedWordSelection(scope, col, absRow)
scope.selectionOverlay!.classList.add('active')
notify(scope, { type: 'set-select-mode', enabled: true })
notify(scope, { type: 'haptic', kind: 'selection' })
repositionOverlay(scope)
}
@@ -1,6 +1,6 @@
import { elementInRoot } from './document-host-seams'
import { repositionOverlay } from './selection-overlay'
import { cancelSelect } from './selection-range'
import { cancelSelect } from './selection-mode'
import { notify } from './host-notify'
import type { TerminalDocumentScope } from './document-scope'
@@ -1,7 +1,8 @@
import { getCellHeight, getTotalScale } from './cell-metrics'
import type { TerminalDocumentScope } from './document-scope'
import { scheduleDocumentFrame } from './document-frame-registry'
import { touchesInRoot } from './document-host-seams'
import { clampPan, getCellHeight } from './fit-scale'
import { clampPan } from './fit-scale'
import { notify } from './host-notify'
import { attachSurfaceMouseClickDragHandler } from './mouse-click-drag'
import { routeScrollLines, shouldRouteScrollToTerminalInput } from './mouse-input-encoding'
@@ -17,7 +18,7 @@ import {
MIN_TEXT_SCALE,
snapToTextScalePreset
} from './text-scaling'
import { getTotalScale, updateTransform } from './viewport-transform'
import { updateTransform } from './viewport-transform'
import { viewportPoint } from './viewport-cell'
import { attachSurfaceWheelHandler } from './wheel-scroll'
+1 -1
View File
@@ -1,5 +1,5 @@
import { handleDragMove, stopEdgeScroll } from './selection-overlay'
import { cancelSelect, enterSelect } from './selection-range'
import { cancelSelect, enterSelect } from './selection-mode'
import { notify } from './host-notify'
import { viewportToCell } from './viewport-cell'
import type { TerminalDocumentScope } from './document-scope'
@@ -14,7 +14,7 @@ import {
} from './mouse-mode-decset-scan'
import { captureInitialOscLinkTexts } from './osc-link-tap'
import { attachTerminalQueryReplyBridge, resetTerminalDataReplyAuthority } from './query-reply'
import { cancelSelect } from './selection-range'
import { cancelSelect } from './selection-mode'
import { resetEvictionCounter } from './selection-state-and-eviction'
import { beginTerminalSurfaceSwap, commitTerminalSurfaceSwap } from './surface-swap'
import { attachTermObservers } from './term-observers'
+2 -2
View File
@@ -1,10 +1,10 @@
import { getCellHeight, getCellWidth } from './cell-metrics'
import { elementInRoot } from './document-host-seams'
import { TERMINAL_TEXT_SCALES } from '../terminal-text-scales'
import type { TerminalDocumentScope } from './document-scope'
import { scheduleDocumentFrame } from './document-frame-registry'
import { applyFitScale, getCellHeight } from './fit-scale'
import { applyFitScale } from './fit-scale'
import { fitDimensionsFromCell } from '../terminal-grid-fit'
import { getCellWidth } from './viewport-transform'
// Why: init() flips ready false on every re-init (live width reflow included)
// while the old surface stays visible; a document-scoped latch drives the
@@ -1,6 +1,5 @@
import { getCellHeight, getCellWidth, getTotalScale } from './cell-metrics'
import type { TerminalDocumentScope } from './document-scope'
import { getCellHeight } from './fit-scale'
import { getCellWidth, getTotalScale } from './viewport-transform'
/**
* A client point in the grid's own frame, which is where pan, cells and overlays are measured.
@@ -1,6 +1,7 @@
import { repositionOverlay } from './selection-overlay'
import { shouldRouteScrollToTerminalInput } from './mouse-input-encoding'
import type { TerminalDocumentScope } from './document-scope'
import { getCellWidth, getTotalScale } from './cell-metrics'
// Why: after init() the initial scrollback applyFitScale may have run
// against an empty buffer (or one without the widest line yet). Re-fit
@@ -19,26 +20,6 @@ export function flog(scope: TerminalDocumentScope, tag: string, payload: Record<
} catch {}
}
/** 0 until the renderer has measured a cell. */
export function getMeasuredCellHeight(scope: TerminalDocumentScope) {
const core = scope.term?._core
if (core && core._renderService && core._renderService.dimensions) {
return core._renderService.dimensions.css.cell.height || 0
}
return 0
}
export function getCellWidth(scope: TerminalDocumentScope) {
if (!scope.term || !scope.term._core) {
return 0
}
const core = scope.term._core
if (core._renderService && core._renderService.dimensions) {
return core._renderService.dimensions.css.cell.width || 0
}
return 0
}
// Why: width measurement strategy.
// 1. Prefer cellWidth × term.cols — this is what xterm's renderer uses
// to lay out and is independent of buffer content. It's the "logical
@@ -67,10 +48,6 @@ export function computeFitScale(scope: TerminalDocumentScope) {
return Math.min(1, vpWidth / termWidth)
}
export function getTotalScale(scope: TerminalDocumentScope) {
return scope.currentScale * scope.userScale
}
export function updateTransform(scope: TerminalDocumentScope) {
scope.surface!.style.transform =
'translate(' + scope.panX + 'px,' + scope.panY + 'px) scale(' + getTotalScale(scope) + ')'
+1 -2
View File
@@ -1,6 +1,5 @@
import { getCellHeight } from './fit-scale'
import { getCellHeight, getTotalScale } from './cell-metrics'
import { routeScrollLines, shouldRouteScrollToTerminalInput } from './mouse-input-encoding'
import { getTotalScale } from './viewport-transform'
import { dispatcherShouldBlockSurface } from './tap-dispatch'
import {
enqueueNormalBufferScrollDelta,
+2 -2
View File
@@ -44,14 +44,14 @@ vi.mock('./connection-log-background-flush', () => ({
subscribeConnectionLogBackgroundFlush: () => () => {}
}))
import { RpcClientProvider } from './client-context'
import {
RpcClientProvider,
useDisconnectHostClient,
useForceReconnect,
useHostClient,
usePrimeHosts,
useRefreshHostClient
} from './client-context'
} from './host-client-hooks'
import { useAllHostClients } from './use-all-host-clients'
import { useRelayRecoveryStatus } from './client-context-connection-metrics'
import { selectHomeAutoConnectHostIds } from './home-host-auto-connect'
-9
View File
@@ -35,15 +35,6 @@ import {
import type { ConnectionState, HostProfile } from './types'
import type { RpcClientContextValue } from './rpc-client-context-contract'
export {
useDisconnectHostClient,
useForceReconnect,
useForgetHostClient,
useHostClient,
usePrimeHosts,
useRefreshHostClient
} from './host-client-hooks'
type StoreEntry = HostClientStoreEntry
const Ctx = createContext<RpcClientContextValue | null>(null)
@@ -7,15 +7,6 @@ import { BRIDGE_PAGE_CLIENT_ID } from '../mobile-web-shell/bridge/bridge-page-cl
import type { ConnectionState, HostProfile } from './types'
import type { RpcClientContextValue } from './rpc-client-context-contract'
export {
useDisconnectHostClient,
useForceReconnect,
useForgetHostClient,
useHostClient,
usePrimeHosts,
useRefreshHostClient
} from './host-client-hooks'
const Ctx = createContext<RpcClientContextValue | null>(null)
/** The page's own client, which is more than an `RpcClient`: the route seam reads the session off
* it to decide which screens are this document's. Separate from `Ctx` so the shared contract above
@@ -35,14 +35,14 @@ vi.mock('./connection-log-background-flush', () => ({
subscribeConnectionLogBackgroundFlush: () => () => {}
}))
import { RpcClientProvider } from './client-context'
import {
useDisconnectHostClient,
RpcClientProvider,
useForgetHostClient,
useForceReconnect,
useHostClient,
useRefreshHostClient
} from './client-context'
} from './host-client-hooks'
const HOST = {
id: 'host-1',
@@ -8,11 +8,10 @@ import { useAllHostClients } from './use-all-host-clients'
import { useFocusedSettingsHostClients } from './settings-host-client-connections'
import {
RpcClientProvider,
useHostClient,
usePrimeHosts,
useRpcClientContext,
type RpcClientContextValue
} from './client-context'
import { useHostClient, usePrimeHosts } from './host-client-hooks'
import { selectHomeAutoConnectHostIds } from './home-host-auto-connect'
import type { RpcClient } from './rpc-client'
import type { ConnectionState, HostProfile } from './types'
+1 -1
View File
@@ -1,5 +1,5 @@
import { createContext, useContext } from 'react'
import { useHostClient } from './client-context'
import { useHostClient } from './host-client-hooks'
import type { RpcClient } from './rpc-client'
import type { ConnectionState } from './types'
+1 -1
View File
@@ -139,7 +139,7 @@
},
{
"file": "src/components/AuthFailedBannerActions.web.tsx",
"reason": "The auth-failed banner offers Retry, Re-pair and Remove, and on the page only Re-pair works. /pair-scan sits outside the page's route root of app/h (config/scripts/mobile-web-app-route-manifest.mjs:6), so route-handoff.web.ts:207 hands the push to the shell, which opens the native scan screen and returns to the same page document on Back. Retry and Remove are inert here: forceReconnect is null (client-context.web.tsx:57, read through host-client-hooks.ts:88) and removal refuses (page-host-removal-refusal.ts). The banner is reachable on the page because the shell forwards the native client's state (mobile-web-shell/bridge-host.ts:301) and auth-failed is in the wire enum (bridge/bridge-envelope.ts:44). So this one renders Re-pair as native does and one line naming the app for reconnect and removal."
"reason": "The auth-failed banner offers Retry, Re-pair and Remove, and on the page only Re-pair works. /pair-scan sits outside the page's route root of app/h (config/scripts/mobile-web-app-route-manifest.mjs:6), so route-handoff.web.ts:207 hands the push to the shell, which opens the native scan screen and returns to the same page document on Back. Retry and Remove are inert here: forceReconnect is null (client-context.web.tsx:45, read through host-client-hooks.ts:88) and removal refuses (page-host-removal-refusal.ts). The banner is reachable on the page because the shell forwards the native client's state (mobile-web-shell/bridge-host.ts:301) and auth-failed is in the wire enum (bridge/bridge-envelope.ts:44). So this one renders Re-pair as native does and one line naming the app for reconnect and removal."
},
{
"file": "src/components/use-html-preview-link-grant.web.ts",