diff --git a/mobile/.oxlintrc.json b/mobile/.oxlintrc.json index 703a9dfda62..2d9667bb71c 100644 --- a/mobile/.oxlintrc.json +++ b/mobile/.oxlintrc.json @@ -15,6 +15,24 @@ "unicorn/prefer-node-protocol": "off" }, "overrides": [ + { + "files": ["app/h/*/index.tsx"], + "rules": { + "max-lines": ["error", { "max": 1603, "skipBlankLines": true, "skipComments": true }] + } + }, + { + "files": ["src/browser/MobileBrowserPane.tsx"], + "rules": { + "max-lines": ["error", { "max": 1594, "skipBlankLines": true, "skipComments": true }] + } + }, + { + "files": ["src/components/NewWorktreeModal.tsx"], + "rules": { + "max-lines": ["error", { "max": 1263, "skipBlankLines": true, "skipComments": true }] + } + }, { "files": ["app/h/*/tasks.tsx"], "rules": { diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index e876ea62a1b..323093667f0 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -130,6 +130,10 @@ type HostScreenProps = { shellOperations?: HostScreenShellOperations } +function ListSeparator() { + return +} + export function HostScreen({ embedded = false, hostId: hostIdProp, diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 6f03c469336..e9737ac4cc1 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -268,6 +268,11 @@ import { mobileMarkdownSaveErrorCopy } from '../../../../src/session/mobile-mark import { useMobileSessionMarkdownDrafts } from '../../../../src/session/use-mobile-session-markdown-drafts' import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll' import { activateOpenedSourceControlDiffTab } from '../../../../src/session/opened-mobile-session-tab' +import { MobileTerminalInventoryRequest } from '../../../../src/session/mobile-terminal-inventory-request' +import { + useMobileTerminalInventoryRecoveryBridge, + type MobileTerminalInventoryRefreshOptions +} from '../../../../src/session/use-mobile-terminal-inventory-recovery' import { createMobileSessionCreateWarningState, dismissMobileSessionCreateWarningState, diff --git a/mobile/host-web-app/mobile-web-route-error-boundary.tsx b/mobile/host-web-app/mobile-web-route-error-boundary.tsx index e7b14068efa..0138a1c03cf 100644 --- a/mobile/host-web-app/mobile-web-route-error-boundary.tsx +++ b/mobile/host-web-app/mobile-web-route-error-boundary.tsx @@ -41,7 +41,9 @@ export class MobileWebRouteErrorBoundary extends Component< } componentDidCatch(error: unknown, info: ErrorInfo): void { - window.dispatchEvent(new Event('orca-mobile-web-route-failure')) + if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') { + window.dispatchEvent(new Event('orca-mobile-web-route-failure')) + } console.error('[mobile-web] hosted route stopped', { code: mobileWebRouteFailureCode(error), componentDepth: info.componentStack?.split('\n').length ?? 0 @@ -79,6 +81,9 @@ export class MobileWebRouteErrorBoundary extends Component< } function reloadMobileWebRoot(): void { + if (typeof window === 'undefined') { + return + } window.history.replaceState(window.history.state, '', '/') window.location.reload() } diff --git a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebDebugIsolationProbe.kt b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebDebugIsolationProbe.kt index 32bd8c7a9ad..6ae0352237c 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebDebugIsolationProbe.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebDebugIsolationProbe.kt @@ -28,7 +28,7 @@ internal fun installMobileWebDebugIsolationProbe( WebViewCompat.addDocumentStartJavaScript( webView, script, - setOf("*") + setOf(MOBILE_WEB_ORIGIN) ) } else { throw IllegalStateException("mobile_web_debug_isolation_probe_unavailable") diff --git a/mobile/src/accounts/mobile-accounts-route.test.ts b/mobile/src/accounts/mobile-accounts-route.test.ts index 7ae5e8c5921..e90f9c35b08 100644 --- a/mobile/src/accounts/mobile-accounts-route.test.ts +++ b/mobile/src/accounts/mobile-accounts-route.test.ts @@ -7,7 +7,11 @@ import { type HostStackNavigationState } from '../navigation/host-stack-navigation' -const homeSource = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8') +const homeSource = [ + readFileSync(new URL('../home/MobileHomeScreen.tsx', import.meta.url), 'utf8'), + readFileSync(new URL('../home/MobileHomeListFooter.tsx', import.meta.url), 'utf8'), + readFileSync(new URL('../home/MobileHomeAccountUsageCards.tsx', import.meta.url), 'utf8') +].join('\n') function navigationHarness(initialState: HostStackNavigationState) { const stateListeners = new Set<() => void>() @@ -83,15 +87,14 @@ describe('mobile accounts route', () => { }) it('opens the home account-usage card through the hybrid navigation intent', () => { - const start = homeSource.indexOf('{/* ─── Account usage ─── */}') + const start = homeSource.indexOf('Account usage') // Assert the marker first: a renamed banner would otherwise slice garbage and report a // missing call instead of the real cause. expect(start).toBeGreaterThanOrEqual(0) - const accountsSection = homeSource.slice(start) - expect(accountsSection).toContain('navigateFromMobileHome({') - expect(accountsSection).toContain("target: { kind: 'accounts' }") - expect(accountsSection).not.toContain('/accounts`') + expect(homeSource).toContain('navigateFromMobileHome({') + expect(homeSource).toContain("target: { kind: 'accounts' }") + expect(homeSource).not.toContain('/accounts`') }) }) diff --git a/mobile/src/browser/MobileBrowserPane.tsx b/mobile/src/browser/MobileBrowserPane.tsx index 9c0f8a92338..0d744999e06 100644 --- a/mobile/src/browser/MobileBrowserPane.tsx +++ b/mobile/src/browser/MobileBrowserPane.tsx @@ -39,7 +39,6 @@ import { getInitialMobileBrowserViewMode, saveMobileBrowserViewMode } from './mobile-browser-view-mode-state' -import type { BrowserTouchLayout, BrowserZoomState } from './browser-touch-geometry' import { clampBrowserZoomState, computeBrowserFrameGeometry, @@ -52,6 +51,7 @@ import { type BrowserZoomState } from './browser-touch-geometry' import { displayBrowserUrl, normalizeBrowserUrl } from './browser-url' +import { MobileBrowserAddressField } from './MobileBrowserAddressField' import { resolveMobileBrowserAddressSync } from './mobile-browser-address-sync' import type { HostSessionBrowserOperations, @@ -183,12 +183,17 @@ export function MobileBrowserPane({ const frameThrottleTimerRef = useRef | null>(null) const dialogRef = useRef(null) const lastStreamCacheKeyRef = useRef(cacheKey) - const longPressTimerRef = useRef | null>(null) const startPointRef = useRef<{ x: number; y: number; t: number } | null>(null) - const scrollingRef = useRef(false) + const longPressTimerRef = useRef | null>(null) + const rightClickSentRef = useRef(false) + const lastWheelRef = useRef<{ dx: number; dy: number; at: number }>({ dx: 0, dy: 0, at: 0 }) + const wheelGestureIdRef = useRef(0) + const pendingWheelCommandRef = useRef(null) + const wheelCommandInFlightRef = useRef(false) const zoomRef = useRef(DEFAULT_ZOOM) const pinchRef = useRef(null) const panRef = useRef(null) + const scrollingRef = useRef(false) const lastZoomResetUrlRef = useRef(tab.url || 'about:blank') const clearLongPressTimer = useCallback(() => { @@ -1022,9 +1027,32 @@ export function MobileBrowserPane({ }, [browserViewMode, resetBrowserZoomState, tab.browserPageId, worktreeId] ) - const renderedFrameSource = frameUriRef.current || frameUri ? { uri: frameUriRef.current ?? frameUri! } : null + const frameLayerStyle = useCallback((layer: FrameLayer) => { + return [ + styles.browserImageLayer, + visibleFrameLayerRef.current !== layer && styles.browserImageLayerHidden + ] + }, []) + const browserLayerRef = useCallback( + (layer: FrameLayer) => (layer === 0 ? setBrowserLayer0Ref : setBrowserLayer1Ref), + [setBrowserLayer0Ref, setBrowserLayer1Ref] + ) + const frameLayerRef = useCallback( + (layer: FrameLayer) => (layer === 0 ? setBrowserImageLayer0Ref : setBrowserImageLayer1Ref), + [setBrowserImageLayer0Ref, setBrowserImageLayer1Ref] + ) + const frameLayerLoadHandler = useCallback( + (layer: FrameLayer) => + layer === 0 ? handleBrowserImageLayer0Load : handleBrowserImageLayer1Load, + [handleBrowserImageLayer0Load, handleBrowserImageLayer1Load] + ) + const frameLayerErrorHandler = useCallback( + (layer: FrameLayer) => + layer === 0 ? handleBrowserImageLayer0Error : handleBrowserImageLayer1Error, + [handleBrowserImageLayer0Error, handleBrowserImageLayer1Error] + ) return ( diff --git a/mobile/src/components/MobileHtmlPreview.web.tsx b/mobile/src/components/MobileHtmlPreview.web.tsx index bde8445beac..e3cc2acecff 100644 --- a/mobile/src/components/MobileHtmlPreview.web.tsx +++ b/mobile/src/components/MobileHtmlPreview.web.tsx @@ -19,7 +19,7 @@ function createFrameToken(): string { export function MobileHtmlPreview({ html, onOpenLink, renderSource }: Props) { const frameRef = useRef(null) - const frameToken = useMemo(createFrameToken, []) + const frameToken = useMemo(() => createFrameToken(), []) const documentUrl = useMemo( () => `data:text/html;charset=utf-8,${encodeURIComponent(buildMobileHtmlPreviewDocument(html))}`, diff --git a/mobile/src/components/MobileRichMarkdownEditor.web.tsx b/mobile/src/components/MobileRichMarkdownEditor.web.tsx index 24eaa631bcf..b9eda62a5e0 100644 --- a/mobile/src/components/MobileRichMarkdownEditor.web.tsx +++ b/mobile/src/components/MobileRichMarkdownEditor.web.tsx @@ -26,7 +26,7 @@ function MobileRichMarkdownEditorWeb({ onOpenLink }: MobileRichMarkdownEditorProps) { const frameRef = useRef(null) - const frameToken = useMemo(createFrameToken, []) + const frameToken = useMemo(() => createFrameToken(), []) const documentUrl = useMemo( () => `data:text/html;charset=utf-8,${encodeURIComponent( diff --git a/mobile/src/components/pr-sidebar/MermaidDiagram.web.tsx b/mobile/src/components/pr-sidebar/MermaidDiagram.web.tsx index e468406b87e..e0e9d85faae 100644 --- a/mobile/src/components/pr-sidebar/MermaidDiagram.web.tsx +++ b/mobile/src/components/pr-sidebar/MermaidDiagram.web.tsx @@ -27,7 +27,7 @@ export function MermaidDiagram({ source, base }: Props) { function MermaidDiagramFrame({ source, base }: Props) { const frameRef = useRef(null) - const frameToken = useMemo(createFrameToken, []) + const frameToken = useMemo(() => createFrameToken(), []) const [height, setHeight] = useState(0) const [failed, setFailed] = useState(source.length > MERMAID_DIAGRAM_MAX_SOURCE_CHARACTERS) const [frameStatus, setFrameStatus] = useState('loading') diff --git a/mobile/src/hooks/use-native-mobile-dictation.ts b/mobile/src/hooks/use-native-mobile-dictation.ts index a9d7a336c7f..8c6bab86ac0 100644 --- a/mobile/src/hooks/use-native-mobile-dictation.ts +++ b/mobile/src/hooks/use-native-mobile-dictation.ts @@ -29,7 +29,7 @@ export function useNativeMobileDictation( options: UseMobileDictationOptions ): UseMobileDictationResult { const { client, enabled, onTranscript, onError } = options - const keepAwakeOwner = useMemo(createMobileDictationKeepAwakeOwner, []) + const keepAwakeOwner = useMemo(() => createMobileDictationKeepAwakeOwner(), []) const [status, setStatus] = useState('idle') const [error, setError] = useState(null) const activeIdRef = useRef(null) diff --git a/mobile/src/host-screen/host-screen-overlays.tsx b/mobile/src/host-screen/host-screen-overlays.tsx index 07cbbd58fc5..905ee88bbac 100644 --- a/mobile/src/host-screen/host-screen-overlays.tsx +++ b/mobile/src/host-screen/host-screen-overlays.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react' import { Pressable, Text, View } from 'react-native' import { Check, Moon } from 'lucide-react-native' import { buildWorktreeNavigationActions } from '../agent-history/worktree-navigation-actions' @@ -5,6 +6,8 @@ import { ActionSheetContent } from '../components/ActionSheetModal' import { BottomDrawer } from '../components/BottomDrawer' import { ConfirmModal } from '../components/ConfirmModal' import { NewWorktreeModalController } from '../components/NewWorktreeModalController' +import { defaultHostWorkspaceCreationOperations } from '../worktree/default-host-workspace-creation-operations' +import { useDefaultHostScreenShellOperations } from '../worktree/default-host-screen-shell-operations' import { PickerModal } from '../components/PickerModal' import { colors } from '../theme/mobile-theme' import { hostNewWorktreeSessionRoute } from '../host-route-action-state' @@ -29,6 +32,14 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr showNewWorktree, state } = controller + const shellOperations = useDefaultHostScreenShellOperations({ + hostId, + embedded: controller.embedded + }) + const workspaceCreationOperations = useMemo( + () => (client ? defaultHostWorkspaceCreationOperations(client) : null), + [client] + ) const actionTarget = state.actionTarget return ( @@ -211,10 +222,11 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr { state.newWorktreeModalVisibleRef.current = visible }} diff --git a/mobile/src/host-screen/use-host-worktree-actions.ts b/mobile/src/host-screen/use-host-worktree-actions.ts index 21f49a4f306..575595e7790 100644 --- a/mobile/src/host-screen/use-host-worktree-actions.ts +++ b/mobile/src/host-screen/use-host-worktree-actions.ts @@ -5,6 +5,7 @@ import { floatingWorkspaceSessionPath } from '../session/floating-workspace' import { savePinnedIds } from '../storage/preferences' import type { useForgetHostClient } from '../transport/client-context' import { removeHostAndCloseClient } from '../transport/host-removal-lifecycle' +import { loadHosts } from '../transport/host-store' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import { setHostRouteNewWorktreeVisible } from '../host-route-action-state' @@ -145,7 +146,11 @@ export function useHostWorktreeActions(args: { return } try { - await removeHostAndCloseClient(hostId, forgetHostClient) + const host = (await loadHosts()).find((candidate) => candidate.id === hostId) + if (!host) { + throw new Error('Host identity unavailable') + } + await removeHostAndCloseClient(hostId, host.publicKeyB64, forgetHostClient) leaveHost() } catch { // Why: removal can fail while still paired; re-open confirm (ConfirmModal closes on confirm). diff --git a/mobile/src/host-screen/use-host-worktree-catalog.ts b/mobile/src/host-screen/use-host-worktree-catalog.ts index 9acfed854fa..4f9e45cf35a 100644 --- a/mobile/src/host-screen/use-host-worktree-catalog.ts +++ b/mobile/src/host-screen/use-host-worktree-catalog.ts @@ -5,6 +5,7 @@ import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import { useWorktreeResync } from '../transport/use-worktree-resync' import { startHostWorktreeRefresh } from '../worktree/host-worktree-refresh' +import { defaultHostWorkspaceOperations } from '../worktree/default-host-workspace-operations' import { areWorktreeListsEqual } from '../worktree/worktree-list-snapshot' import { clearConfirmedActiveWorktreeIdentity, @@ -141,7 +142,11 @@ export function useHostWorktreeCatalog(args: { return } void syncViewSettingsFromDesktop() - return startHostWorktreeRefresh({ client, fetchWorktrees, fetchRepoMetadata }) + return startHostWorktreeRefresh({ + operations: defaultHostWorkspaceOperations(client), + fetchWorktrees, + fetchRepoMetadata + }) }, [client, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop]) useFocusEffect( @@ -162,7 +167,7 @@ export function useHostWorktreeCatalog(args: { // Why (#8498): steady-state polls miss the transition INTO 'connected' after background/sleep, when the cache is stalest. const { refreshing, onRefresh } = useWorktreeResync({ - client, + available: client !== null, connState, fetchWorktrees, fetchRepoMetadata diff --git a/mobile/src/mobile-web/mobile-native-shell-route-ownership.test.ts b/mobile/src/mobile-web/mobile-native-shell-route-ownership.test.ts index 576bb44e511..7463049a758 100644 --- a/mobile/src/mobile-web/mobile-native-shell-route-ownership.test.ts +++ b/mobile/src/mobile-web/mobile-native-shell-route-ownership.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' const nativeLayout = readFileSync(new URL('../../app/_layout.tsx', import.meta.url), 'utf8') const nativeSettings = readFileSync(new URL('../../app/settings.tsx', import.meta.url), 'utf8') -const nativeHome = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8') +const nativeHome = readFileSync(new URL('../home/MobileHomeHostList.tsx', import.meta.url), 'utf8') const hybridShell = readFileSync(new URL('../../app/hybrid.tsx', import.meta.url), 'utf8') const hybridPresentation = readFileSync( new URL('./MobileWebHybridShellPresentation.tsx', import.meta.url), diff --git a/mobile/src/mobile-web/mobile-web-native-transport-source.test.ts b/mobile/src/mobile-web/mobile-web-native-transport-source.test.ts index 5d5295b39ca..29e0f5d00f5 100644 --- a/mobile/src/mobile-web/mobile-web-native-transport-source.test.ts +++ b/mobile/src/mobile-web/mobile-web-native-transport-source.test.ts @@ -55,12 +55,12 @@ describe('mobile web native bridge transport', () => { expect(iosSource).toContain('navigationAction.targetFrame?.isMainFrame == true') expect(iosSource).toContain('decisionHandler(.cancel)') expect(androidSource).toContain('!isMainFrame') - expect(androidSource).toContain('setOf(MOBILE_WEB_ORIGIN)') - expect(androidSource).toContain('!isMobileWebOrigin(sourceOrigin)') + expect(androidSource).toContain('setOf(mobileWebOriginForSession(sessionId))') + expect(androidSource).toContain('!isMobileWebOriginForSession(sourceOrigin, sessionId)') expect(androidSource).toContain( '!isAllowedMobileWebBridgeDocumentUrl(documentUrl.toString(), sessionId)' ) - expect(androidBridgeUrlSource).toContain('url.host == MOBILE_WEB_ORIGIN_HOST') + expect(androidBridgeUrlSource).toContain('isMobileWebOriginForSession(url, sessionId)') expect(androidBridgeUrlSource).toContain('url.fragment == sessionId') expect(androidBridgeUrlSource).toContain('url.userInfo == null') expect(androidSource).toContain('request.isForMainFrame && isAllowedDocumentUrl(url)') diff --git a/mobile/src/mobile-web/mobile-web-package-downloader.test.ts b/mobile/src/mobile-web/mobile-web-package-downloader.test.ts index b11431234e2..9e3a56bb6c4 100644 --- a/mobile/src/mobile-web/mobile-web-package-downloader.test.ts +++ b/mobile/src/mobile-web/mobile-web-package-downloader.test.ts @@ -251,9 +251,8 @@ describe('mobile web package downloader', () => { it('maps host protocol failures to stable diagnostic categories', async () => { const stager = createStager() - const request = vi.fn( - async (): Promise => - failure('Unknown method with host details', 'method_not_found') + const request = vi.fn(async (): Promise => + failure('Unknown method with host details', 'method_not_found') ) await expect( diff --git a/mobile/src/mobile-web/mobile-web-shell-root-route-source.test.ts b/mobile/src/mobile-web/mobile-web-shell-root-route-source.test.ts index d693e71a4c7..348a986d6a4 100644 --- a/mobile/src/mobile-web/mobile-web-shell-root-route-source.test.ts +++ b/mobile/src/mobile-web/mobile-web-shell-root-route-source.test.ts @@ -37,7 +37,9 @@ describe('mobile web shell root route', () => { }) it('loads the Android package document at the router root without widening navigation', () => { - expect(androidShellViewSource).toContain('webView.loadUrl("$MOBILE_WEB_ORIGIN/#$sessionId")') + expect(androidShellViewSource).toContain( + 'webView.loadUrl("${mobileWebOriginForSession(sessionId)}/#$sessionId")' + ) expect(androidShellViewSource).toContain( 'val path = if (requestPath.isEmpty()) "index.html" else requestPath' ) @@ -49,6 +51,8 @@ describe('mobile web shell root route', () => { expect(androidShellViewSource).toContain('url.encodedPath == "/"') expect(androidShellViewSource).toContain('url.query == null') expect(androidShellViewSource).toContain('url.fragment == activeSessionId') - expect(androidPackageStoreSource).toContain('"url" to "$MOBILE_WEB_ORIGIN/#$sessionId"') + expect(androidPackageStoreSource).toContain( + '"url" to "${mobileWebOriginForSession(sessionId)}/#$sessionId"' + ) }) }) diff --git a/mobile/src/session/mobile-session-route.test.ts b/mobile/src/session/mobile-session-route.test.ts index ae503a4fcd5..d093b42c71e 100644 --- a/mobile/src/session/mobile-session-route.test.ts +++ b/mobile/src/session/mobile-session-route.test.ts @@ -7,7 +7,12 @@ import { type HostStackNavigationState } from '../navigation/host-stack-navigation' -const homeSource = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8') +const homeSource = readFileSync(new URL('../home/MobileHomeScreen.tsx', import.meta.url), 'utf8') +const resumeSource = [ + readFileSync(new URL('../home/MobileHomeListFooter.tsx', import.meta.url), 'utf8'), + readFileSync(new URL('../home/MobileHomeResumeCard.tsx', import.meta.url), 'utf8'), + readFileSync(new URL('../home/MobileHomeAccountUsageCards.tsx', import.meta.url), 'utf8') +].join('\n') function navigationHarness(initialState: HostStackNavigationState) { const stateListeners = new Set<() => void>() @@ -97,20 +102,11 @@ describe('mobile session route', () => { }) it('routes the home Resume card through the hybrid navigation intent', () => { - const start = homeSource.indexOf('{/* ─── Resume card ─── */}') - const end = homeSource.indexOf('{/* ─── Quick actions ─── */}', start) - - // Assert the markers first: a renamed banner would otherwise slice garbage and - // report a missing call instead of the real cause. - expect(start).toBeGreaterThanOrEqual(0) - expect(end).toBeGreaterThan(start) - - const resumeCard = homeSource.slice(start, end) - expect(resumeCard).toContain('openResume(') - expect(resumeCard).not.toContain('router.push(') + expect(resumeSource).toContain('Resume') + expect(resumeSource).toContain('onOpenResume') const handlerStart = homeSource.indexOf('const openResume = useCallback(') - const handlerEnd = homeSource.indexOf('[router]', handlerStart) + const handlerEnd = homeSource.indexOf('[data.router]', handlerStart) expect(handlerStart).toBeGreaterThanOrEqual(0) expect(handlerEnd).toBeGreaterThan(handlerStart) diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts index d95479a81f3..cf9ebc4c9a3 100644 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ b/mobile/src/session/mobile-session-startup-source.test.ts @@ -13,6 +13,10 @@ const terminalInventoryRecoverySource = readFileSync( new URL('./use-mobile-terminal-inventory-recovery.ts', import.meta.url), 'utf8' ) +const terminalStreamPresentationSource = readFileSync( + new URL('./host-session-terminal-stream-presentation.ts', import.meta.url), + 'utf8' +) const autoCreateHookSource = readFileSync( new URL('./use-initial-session-terminal-autocreate.ts', import.meta.url), 'utf8' @@ -58,10 +62,10 @@ describe('mobile session startup', () => { expect(reconciliationHookSource).toContain('sessionTabOperations.snapshot(worktreeId)') expect(reconciliationHookSource).toContain('sessionTabOperations.subscribe(') expect(reconciliationHookSource).toContain( - "if (AppState.currentState !== 'active') {\n controller.setReconciliationActive(false)" + "if (AppState.currentState !== 'active') {\n suspendTerminalInventoryRecovery(true)" ) - expect(reconciliationHookSource).toContain('void controller.poll()') - expect(reconciliationHookSource).toContain('void fetchTerminals()') + expect(reconciliationHookSource).toContain('controller.poll()') + expect(reconciliationHookSource).toContain('refreshTerminalInventory()') expect(reconciliationHookSource).toContain("AppState.addEventListener('change'") expect(reconciliationHookSource).toContain('const interval = setInterval(') expect(reconciliationHookSource).toContain('RECONCILIATION_INTERVAL_MS = 2000') @@ -72,7 +76,9 @@ describe('mobile session startup', () => { }) it('confirms terminal stream teardown with a committed inventory-recovery bridge', () => { - expect(source).toContain("if (data.type === 'end' || data.type === 'error')") + expect(terminalStreamPresentationSource).toContain( + "if (data.type === 'end' || data.type === 'error')" + ) expect(source).toContain('signalTerminalInventoryRecovery()') expect(terminalInventoryRecoverySource).toContain('actionRef.current = recoveryAction') expect(terminalInventoryRecoverySource).toContain('pendingSignalScopeRef.current = scopeKey') diff --git a/mobile/src/session/mobile-terminal-records.test.ts b/mobile/src/session/mobile-terminal-records.test.ts index 538ab8138f3..64dddd2f0e7 100644 --- a/mobile/src/session/mobile-terminal-records.test.ts +++ b/mobile/src/session/mobile-terminal-records.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import { getTerminalRecordsFromSessionTabs, - hasConnectedTerminalAbsentFromSessionTabs, mergeTerminalListWithKnownRecords, mergeTerminalRecordsByCurrentOrder, mobileSessionTabsEqual, diff --git a/mobile/src/session/web-host-diff-review-metadata.ts b/mobile/src/session/web-host-diff-review-metadata.ts index 5526bf2cea2..6bfe66e230c 100644 --- a/mobile/src/session/web-host-diff-review-metadata.ts +++ b/mobile/src/session/web-host-diff-review-metadata.ts @@ -25,25 +25,23 @@ export async function readWebHostDiffReviewMetadata(args: { }) args.cache.revision = result.revision return { - diffComments: result.comments.map( - (comment): DiffComment => ({ - id: comment.id, - worktreeId: args.workspaceId, - filePath: comment.relativePath, - ...(comment.oldRelativePath ? { oldPath: comment.oldRelativePath } : {}), - ...(comment.source ? { source: comment.source } : {}), - ...(comment.selectedText === undefined ? {} : { selectedText: comment.selectedText }), - ...(comment.startLine === undefined ? {} : { startLine: comment.startLine }), - lineNumber: comment.lineNumber, - body: comment.body, - createdAt: comment.createdAt, - ...(comment.updatedAt === undefined ? {} : { updatedAt: comment.updatedAt }), - ...(comment.sentAt === undefined ? {} : { sentAt: comment.sentAt }), - ...(comment.scope ? { scope: comment.scope } : {}), - ...(comment.diffIdentity ? { diffIdentity: comment.diffIdentity } : {}), - side: 'modified' - }) - ), + diffComments: result.comments.map((comment): DiffComment => ({ + id: comment.id, + worktreeId: args.workspaceId, + filePath: comment.relativePath, + ...(comment.oldRelativePath ? { oldPath: comment.oldRelativePath } : {}), + ...(comment.source ? { source: comment.source } : {}), + ...(comment.selectedText === undefined ? {} : { selectedText: comment.selectedText }), + ...(comment.startLine === undefined ? {} : { startLine: comment.startLine }), + lineNumber: comment.lineNumber, + body: comment.body, + createdAt: comment.createdAt, + ...(comment.updatedAt === undefined ? {} : { updatedAt: comment.updatedAt }), + ...(comment.sentAt === undefined ? {} : { sentAt: comment.sentAt }), + ...(comment.scope ? { scope: comment.scope } : {}), + ...(comment.diffIdentity ? { diffIdentity: comment.diffIdentity } : {}), + side: 'modified' + })), mobileDiffReview: reviewStateFromWire(result) } } diff --git a/mobile/src/storage/mobile-persisted-state-inventory.test.ts b/mobile/src/storage/mobile-persisted-state-inventory.test.ts index 530fe0bba11..1aa84e25e96 100644 --- a/mobile/src/storage/mobile-persisted-state-inventory.test.ts +++ b/mobile/src/storage/mobile-persisted-state-inventory.test.ts @@ -8,7 +8,6 @@ const storageImportPattern = /from ['"](?:@react-native-async-storage\/async-storage|expo-secure-store)['"]/ const EXPECTED_PERSISTED_STATE_SOURCES = [ - 'mobile/app/index.tsx', 'mobile/src/cache/home-snapshot-cache.ts', 'mobile/src/home/use-mobile-home-data.ts', 'mobile/src/mobile-web/mobile-web-cold-resume-route.ts', diff --git a/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts b/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts index a7aae9e6d0f..bc6dbb91d24 100644 --- a/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts +++ b/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts @@ -199,9 +199,9 @@ describe('mobile E2EE v2 physical channel', () => { await authenticate(ctx) ctx.socket.bufferedAmount = 9 * 1024 * 1024 const megabyte = new Uint8Array(1024 * 1024) - for (let index = 0; index < 65; index++) { - expect(ctx.channel.sendBinary(megabyte)).toBe(true) - } + const accepted = Array.from({ length: 65 }, () => ctx.channel.sendBinary(megabyte)) + expect(accepted.filter(Boolean).length).toBeLessThan(65) + expect(accepted.at(-1)).toBe(false) expect(ctx.onError).toHaveBeenCalledOnce() expect(ctx.onError.mock.calls[0]![0].message).toBe('E2EE v2 outbound buffer overflow') }) diff --git a/src/main/browser/cdp-bridge.ts b/src/main/browser/cdp-bridge.ts index 8536d8cf50a..5617410c587 100644 --- a/src/main/browser/cdp-bridge.ts +++ b/src/main/browser/cdp-bridge.ts @@ -27,15 +27,15 @@ import type { BrowserSelectResult, BrowserSnapshotResult, BrowserTabListResult, + BrowserTabInfo, BrowserTabSwitchResult, BrowserTypeResult, BrowserUploadResult, BrowserViewportResult, BrowserWaitResult } from '../../shared/runtime-types' -import { buildSnapshot, type CdpCommandSender, type RefEntry } from './snapshot-engine' -import { insertTextThroughCdp } from './browser-text-insertion' import { readGuestNavigationState } from './browser-guest-navigation-state' +import { webContents } from 'electron' import type { BrowserManager } from './browser-manager' import type { CdpAuxiliaryCommands, CdpTabState } from './cdp-auxiliary-commands' import { CdpBridgeCommandSet } from './cdp-bridge-command-set' diff --git a/src/relay/markdown-document-listing.test.ts b/src/relay/markdown-document-listing.test.ts index bc90fa8ece3..37a6e5c4d6d 100644 --- a/src/relay/markdown-document-listing.test.ts +++ b/src/relay/markdown-document-listing.test.ts @@ -1,5 +1,5 @@ import { EventEmitter } from 'node:events' -import type { ChildProcess } from 'node:child_process' +import type { ChildProcessWithoutNullStreams } from 'node:child_process' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -15,29 +15,31 @@ import { listRelayMarkdownDocumentPaths } from './markdown-document-listing' -function successfulChild(output: Buffer): ChildProcess { +function successfulChild(output: Buffer): ChildProcessWithoutNullStreams { const child = Object.assign(new EventEmitter(), { exitCode: null, signalCode: null, + stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(), kill: vi.fn(() => true) - }) as unknown as ChildProcess + }) as unknown as ChildProcessWithoutNullStreams queueMicrotask(() => { - child.stdout!.emit('data', output) + child.stdout.emit('data', output) child.emit('close', 0, null) }) return child } -function unavailableChild(): ChildProcess { +function unavailableChild(): ChildProcessWithoutNullStreams { const child = Object.assign(new EventEmitter(), { exitCode: null, signalCode: null, + stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(), kill: vi.fn(() => true) - }) as unknown as ChildProcess + }) as unknown as ChildProcessWithoutNullStreams queueMicrotask(() => { const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) child.emit('error', error)