diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index db2a6fd8806..2c60ec6df8d 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 5.3m + + downloads: 5.4m @@ -15,7 +15,7 @@ downloads downloads - 5.3m - 5.3m + 5.4m + 5.4m diff --git a/mobile/app.json b/mobile/app.json index e2d6a248f85..cb740d1f545 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.27", + "version": "0.0.30", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index b3bd5c85b14..1059c95502a 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -178,6 +178,10 @@ export default function RootLayout() { + diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index b4e7207d6a5..d4d1b35896f 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -184,6 +184,7 @@ import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-n import { acceptSessionSnapshot, applyClosedTabTombstones, + confirmsMirroredTabSelection, type AppliedSnapshotMarker } from '../../../../src/session/session-tab-snapshot-gate' import { @@ -197,8 +198,14 @@ import { isDictationSetupRequiredError } from '../../../../src/dictation/mobile-dictation-setup' import { TerminalPaneView } from '../../../../src/session/TerminalPaneView' +import { + activateMobileSessionTab, + focusMobileTerminal +} from '../../../../src/session/mobile-session-tab-activation' +import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics' import { getRepoIdFromMobileWorktreeId, + getActiveTabIdForHandle, isFileExistsErrorMessage, isGestureMouseTrackingMode, MOBILE_SESSION_STATUS_LABELS, @@ -206,7 +213,8 @@ import { TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS, TERMINAL_GESTURE_INPUT_MAX_PENDING_SEQUENCES, TERMINAL_GESTURE_INPUT_MAX_QUEUE_AGE_MS, - TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND + TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND, + updateTerminalCwdFromStreamEvent } from '../../../../src/session/mobile-session-route-helpers' import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout' import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll' @@ -245,21 +253,6 @@ type TerminalLiveAccessoryInput = ReturnType => - tab.type === 'terminal' && tab.terminal === terminalHandle - )?.id ?? terminalHandle - ) -} - function MarkdownReader({ documentId, doc, @@ -800,21 +793,6 @@ function FileReader({ return renderSourceText(doc.content) } -function updateTerminalCwdFromStreamEvent( - handle: string, - data: Record, - terminalCwd: Map -): void { - if (!('cwd' in data)) { - return - } - if (typeof data.cwd === 'string' && data.cwd.trim().length > 0) { - terminalCwd.set(handle, data.cwd) - return - } - terminalCwd.delete(handle) -} - export default function SessionScreen() { const { hostId, @@ -1031,6 +1009,7 @@ export default function SessionScreen() { const terminalUnsubsRef = useRef void>>(new Map()) const subscribingHandlesRef = useRef>(new Set()) const initializedHandlesRef = useRef>(new Set()) + const terminalDiagnosticsRef = useRef(new MobileTerminalDiagnostics()) // Why: WebViews load xterm.js from CDN asynchronously. Hidden WebViews // (opacity:0) may have delayed JS execution on iOS. We must not subscribe // until the WebView has fired web-ready, otherwise init() messages queue @@ -1315,6 +1294,7 @@ export default function SessionScreen() { terminalUnsubsRef.current.get(handle)?.() terminalUnsubsRef.current.delete(handle) subscribingHandlesRef.current.delete(handle) + terminalDiagnosticsRef.current.terminalUnsubscribed(handle) subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1) // Why: a fresh subscription will land on a new server-side state machine // run (or the same one with a higher seq); reset the high-water mark so @@ -1329,6 +1309,7 @@ export default function SessionScreen() { terminalUnsubsRef.current.clear() subscribingHandlesRef.current.clear() initializedHandlesRef.current.clear() + terminalDiagnosticsRef.current.clearTerminalCache() webReadyHandlesRef.current.clear() subscribeSeqRef.current.clear() layoutSeqRef.current.clear() @@ -1350,6 +1331,7 @@ export default function SessionScreen() { const dims = await getTerminalRef(handle)?.measureFitDimensions( terminalFrameHeightRef.current || undefined ) + terminalDiagnosticsRef.current.viewportMeasured(handle, dims, terminalFrameHeightRef.current) if (dims) { viewportRef.current = dims viewportMeasuredRef.current = true @@ -1360,25 +1342,34 @@ export default function SessionScreen() { const subscribeToTerminal = useCallback( (handle: string) => { + const diagnostics = terminalDiagnosticsRef.current + const logSkippedGate = (reason: string) => + diagnostics.streamSkipped(handle, reason, handle === activeHandleRef.current) if (!client) { + logSkippedGate('no-client') return } if (terminalUnsubsRef.current.has(handle)) { + logSkippedGate('already-subscribed') return } if (subscribingHandlesRef.current.has(handle)) { + logSkippedGate('subscribe-in-flight') return } if (!getTerminalRef(handle)) { + logSkippedGate('no-webview-ref') return } if (!webReadyHandlesRef.current.has(handle)) { + logSkippedGate('webview-not-ready') return } subscribingHandlesRef.current.add(handle) const seq = (subscribeSeqRef.current.get(handle) ?? 0) + 1 subscribeSeqRef.current.set(handle, seq) + diagnostics.streamArmed(handle, seq, viewportRef.current) // Why: server handles auto-fit on subscribe — no terminal.focus call needed. // The viewport is embedded in the subscribe params so the server resizes @@ -1397,6 +1388,7 @@ export default function SessionScreen() { return } const data = result as Record + diagnostics.firstStreamEvent(handle, seq, data.type) // Why: stale-event filter. Server-side state machine bumps a // monotonic seq on every applyLayout. Drop `resized` events // whose seq is strictly older than what we've already observed @@ -1429,6 +1421,7 @@ export default function SessionScreen() { return } if (data.type === 'scrollback') { + diagnostics.streamScrollback(handle, seq, eventSeq, data) if (initializedHandlesRef.current.has(handle)) { return } @@ -1520,6 +1513,7 @@ export default function SessionScreen() { // server still has a null viewport for THIS subscriber // record — we MUST resubscribe so the server stores it. if (dims) { + diagnostics.streamResubscribing(handle, seq, dims) viewportRef.current = dims viewportMeasuredRef.current = true unsubscribeTerminal(handle) @@ -1564,6 +1558,7 @@ export default function SessionScreen() { const cols = (data.cols as number) || 80 const rows = (data.rows as number) || 24 const serialized = typeof data.serialized === 'string' ? data.serialized : null + diagnostics.streamResized(handle, seq, eventSeq, data, getTerminalRef(handle) != null) const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined if (serialized != null) { getTerminalRef(handle)?.init(cols, rows, serialized, true, oscLinks) @@ -1733,6 +1728,7 @@ export default function SessionScreen() { const applySessionTabs = useCallback( (result: SessionTabsResult) => { + const diagnostics = terminalDiagnosticsRef.current // Reject out-of-order snapshots, then suppress just-closed tabs until the // publisher confirms their absence. See session-tab-snapshot-gate. if (!acceptSessionSnapshot(result, appliedSnapshotMarkerRef.current)) { @@ -1789,15 +1785,21 @@ export default function SessionScreen() { const pendingActiveSessionTabId = pendingActiveSessionTabIdRef.current const pendingActiveTerminalHandle = pendingActiveTerminalHandleRef.current let active = snapshotActive + let selectionSource = 'snapshot' if (pendingActiveSessionTabId) { if (snapshotActive?.id === pendingActiveSessionTabId) { - pendingActiveSessionTabIdRef.current = null + if (confirmsMirroredTabSelection(result.publicationEpoch)) { + pendingActiveSessionTabIdRef.current = null + } else { + selectionSource = 'pending-tab-local-ack' + } } else { const pendingTab = nextTabs.find((tab) => tab.id === pendingActiveSessionTabId) if (pendingTab) { // Why: desktop tab snapshots can lag a mobile tap while activate RPC // is in flight. Keep the locally selected tab to avoid snapping back. active = pendingTab + selectionSource = 'pending-tab' } else { pendingActiveSessionTabIdRef.current = null } @@ -1815,12 +1817,17 @@ export default function SessionScreen() { snapshotActive?.type === 'terminal' && snapshotActive.terminal === pendingActiveTerminalHandle ) { - pendingActiveTerminalHandleRef.current = null + if (confirmsMirroredTabSelection(result.publicationEpoch)) { + pendingActiveTerminalHandleRef.current = null + } else { + selectionSource = 'pending-handle-local-ack' + } } else if (pendingTerminalTab) { // Why: desktop active flags can lag a mobile terminal tap. Key by // terminal handle too, because fallback PTY tabs may not yet have a // stable session tab id during new-worktree startup. active = pendingTerminalTab + selectionSource = 'pending-handle-tab' } else if (pendingTerminalExists) { const nextActiveTabId = getActiveTabIdForHandle(nextTabs, pendingActiveTerminalHandle) activeSessionTabIdRef.current = nextActiveTabId @@ -1833,6 +1840,7 @@ export default function SessionScreen() { pendingActiveTerminalHandleRef.current = null } } + diagnostics.tabsApplied(result, nextTabs, active, selectionSource) activeSessionTabTypeRef.current = active?.type ?? null activeSessionTabIdRef.current = active?.id ?? null setActiveSessionTabId(active?.id ?? null) @@ -2377,20 +2385,25 @@ export default function SessionScreen() { const fetchSessionTabs = useCallback(async () => { if (!client) { + terminalDiagnosticsRef.current.tabsFetchSkipped('no-client') return } if (fetchSessionTabsInFlightRef.current) { + terminalDiagnosticsRef.current.tabsFetchSkipped('already-in-flight') return } fetchSessionTabsInFlightRef.current = true + terminalDiagnosticsRef.current.tabsFetchStarted(worktreeId) try { const response = await client.sendRequest('session.tabs.list', { worktree: `id:${worktreeId}` }) if (!response.ok) { + terminalDiagnosticsRef.current.tabsFetchFailed((response as RpcFailure).error.code) return } const result = (response as RpcSuccess).result as SessionTabsResult + terminalDiagnosticsRef.current.tabsFetchSucceeded(result) applySessionTabs(result) // Focus a just-opened browser tab once it appears in the snapshot, via the // normal activate path so it sticks and the user can still switch away. @@ -2404,7 +2417,8 @@ export default function SessionScreen() { switchSessionTabRef.current?.(browserTab) } } - } catch { + } catch (error) { + terminalDiagnosticsRef.current.tabsFetchErrored(error) // Keep the last tab snapshot visible during reconnect/backoff. } finally { fetchSessionTabsInFlightRef.current = false @@ -2689,6 +2703,7 @@ export default function SessionScreen() { pendingBrowserFocusPageIdRef.current = null pendingTerminalActivationAttemptRef.current = null initialEmptySessionAutoCreateRef.current = null + terminalDiagnosticsRef.current.resetRoute() appliedSnapshotMarkerRef.current = { epoch: null, version: -1 } closedTabTombstonesRef.current.clear() for (const queued of terminalGestureInputQueuesRef.current.values()) { @@ -2912,6 +2927,7 @@ export default function SessionScreen() { (tab): tab is Extract => tab.type === 'terminal' && tab.terminal === handle ) + terminalDiagnosticsRef.current.tabSwitch('terminal', matchingTab?.id ?? '', false, handle) pendingActiveSessionTabIdRef.current = matchingTab?.id ?? null pendingActiveTerminalHandleRef.current = handle activeSessionTabTypeRef.current = 'terminal' @@ -2931,15 +2947,15 @@ export default function SessionScreen() { } subscribeToTerminal(handle) if (client) { - void client.sendRequest('terminal.focus', { terminal: handle }).catch(() => {}) + void focusMobileTerminal(client, handle).catch(() => {}) if (matchingTab) { - void client - .sendRequest('session.tabs.activate', { - worktree: `id:${worktreeId}`, - tabId: matchingTab.id, - notifyClients: false - }) - .catch(() => {}) + // Why: persist selection for headless hosts; the snapshot gate keeps + // this phone-local acknowledgement from impersonating desktop focus. + void activateMobileSessionTab(client, { + worktree: `id:${worktreeId}`, + tabId: matchingTab.id, + notifyClients: false + }).catch(() => {}) } } }, @@ -2960,6 +2976,7 @@ export default function SessionScreen() { switchTab(tab.terminal) return } + terminalDiagnosticsRef.current.tabSwitch('terminal', tab.id, true) triggerSelection() pendingActiveSessionTabIdRef.current = tab.id pendingActiveTerminalHandleRef.current = null @@ -2973,18 +2990,17 @@ export default function SessionScreen() { activeHandleRef.current = null setActiveHandle(null) if (client) { - void client - .sendRequest('session.tabs.activate', { - worktree: `id:${worktreeId}`, - tabId: tab.id, - notifyClients: false - }) - .catch(() => {}) + void activateMobileSessionTab(client, { + worktree: `id:${worktreeId}`, + tabId: tab.id, + notifyClients: false + }).catch(() => {}) } return } triggerSelection() + terminalDiagnosticsRef.current.tabSwitch(tab.type, tab.id, false) pendingActiveSessionTabIdRef.current = tab.id pendingActiveTerminalHandleRef.current = null activeSessionTabTypeRef.current = tab.type @@ -2997,13 +3013,11 @@ export default function SessionScreen() { activeHandleRef.current = null setActiveHandle(null) if (client) { - void client - .sendRequest('session.tabs.activate', { - worktree: `id:${worktreeId}`, - tabId: tab.id, - notifyClients: false - }) - .catch(() => {}) + void activateMobileSessionTab(client, { + worktree: `id:${worktreeId}`, + tabId: tab.id, + notifyClients: false + }).catch(() => {}) } if (tab.type === 'browser') { return @@ -3031,6 +3045,7 @@ export default function SessionScreen() { // init messages. This prevents the blank terminal race where init() was // queued before the WebView loaded. const setTerminalWebViewRef = useCallback((handle: string, ref: TerminalWebViewHandle | null) => { + terminalDiagnosticsRef.current.webViewRef(handle, ref != null) if (ref) { terminalRefs.current.set(handle, ref) } else { @@ -3049,6 +3064,11 @@ export default function SessionScreen() { (handle: string) => { const wasAlreadyReady = webReadyHandlesRef.current.has(handle) webReadyHandlesRef.current.add(handle) + terminalDiagnosticsRef.current.webViewReady( + handle, + wasAlreadyReady, + handle === activeHandleRef.current + ) if (wasAlreadyReady && initializedHandlesRef.current.has(handle)) { // Why: the native WebView reloaded (Metro hot reload or Android // process churn). The old xterm buffer is gone, so force a fresh @@ -3085,7 +3105,7 @@ export default function SessionScreen() { })() } }, - [measureViewportOnce, subscribeToTerminal, unsubscribeTerminal] + [getTerminalRef, measureViewportOnce, subscribeToTerminal, unsubscribeTerminal] ) useEffect(() => { @@ -4302,13 +4322,12 @@ export default function SessionScreen() { // Why: a hydrated headless/server-owned tab can already be active but still // pending; activation is the RPC that materializes or focuses its PTY handle. pendingTerminalActivationAttemptRef.current = activationKey - void client - .sendRequest('session.tabs.activate', { - worktree: `id:${worktreeId}`, - tabId: activePendingTerminalTab.id, - leafId: activePendingTerminalTab.leafId, - notifyClients: false - }) + void activateMobileSessionTab(client, { + worktree: `id:${worktreeId}`, + tabId: activePendingTerminalTab.id, + leafId: activePendingTerminalTab.leafId, + notifyClients: false + }) .then((response) => { if (!response.ok) { if (pendingTerminalActivationAttemptRef.current === activationKey) { diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 45821de712b..e26893afd84 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -36,6 +36,7 @@ import { } from '../src/transport/client-context' import { classifyConnection } from '../src/transport/connection-health' import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications' +import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate' import type { ConnectionState, HostProfile } from '../src/transport/types' import { triggerMediumImpact } from '../src/platform/haptics' import { OrcaLogo } from '../src/components/OrcaLogo' @@ -317,6 +318,7 @@ export default function HomeScreen() { const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>( null ) + const notificationOptInCheckedRef = useRef(false) // Why: read shared clients from the per-host store. Replaces the prior // pattern of opening N independent WebSockets here. See @@ -395,9 +397,18 @@ export default function HomeScreen() { useFocusEffect( useCallback(() => { let stale = false - void loadHosts().then((h) => { - if (!stale) { - setHosts(h) + void loadHosts().then(async (h) => { + if (stale) { + return + } + setHosts(h) + if (h.length === 0 || notificationOptInCheckedRef.current) { + return + } + notificationOptInCheckedRef.current = true + const showNotificationOptIn = await shouldPresentNotificationOptIn() + if (!stale && showNotificationOptIn) { + router.replace('/notification-opt-in') } }) void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => { @@ -419,7 +430,7 @@ export default function HomeScreen() { return () => { stale = true } - }, []) + }, [router]) ) const sortedHosts = useMemo( diff --git a/mobile/app/notification-opt-in.tsx b/mobile/app/notification-opt-in.tsx new file mode 100644 index 00000000000..a4476687e09 --- /dev/null +++ b/mobile/app/notification-opt-in.tsx @@ -0,0 +1,236 @@ +import { useCallback, useState } from 'react' +import { + ActivityIndicator, + BackHandler, + Pressable, + ScrollView, + StyleSheet, + Text, + View +} from 'react-native' +import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' +import { SafeAreaView } from 'react-native-safe-area-context' +import { BellRing } from 'lucide-react-native' +import { OrcaLogo } from '../src/components/OrcaLogo' +import { ensureNotificationPermissions } from '../src/notifications/mobile-notifications' +import { savePushNotificationsEnabled } from '../src/storage/preferences' +import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' + +export default function NotificationOptInScreen() { + const router = useRouter() + const params = useLocalSearchParams<{ hostId?: string | string[] }>() + const hostId = Array.isArray(params.hostId) ? params.hostId[0] : params.hostId + const [busyChoice, setBusyChoice] = useState<'enable' | 'skip' | null>(null) + const [error, setError] = useState(null) + + // Why: this one-time screen requires an explicit Enable or Not now choice; + // disabling back gestures alone would still leave Android hardware back open. + useFocusEffect( + useCallback(() => { + const subscription = BackHandler.addEventListener('hardwareBackPress', () => true) + return () => subscription.remove() + }, []) + ) + + const continueToApp = useCallback(() => { + router.replace(hostId ? `/h/${hostId}` : '/') + }, [hostId, router]) + + const choose = useCallback( + async (choice: 'enable' | 'skip') => { + if (busyChoice) { + return + } + setBusyChoice(choice) + setError(null) + try { + const enabled = choice === 'enable' ? await ensureNotificationPermissions() : false + await savePushNotificationsEnabled(enabled) + continueToApp() + } catch { + setError('Notification settings could not be updated. Try again.') + setBusyChoice(null) + } + }, + [busyChoice, continueToApp] + ) + + return ( + + + + + Orca + + + + + + + Notifications + Stay updated while away + + Get notified on this device when an agent needs your input or finishes a task. + + + + + {error ? ( + + {error} + + ) : null} + [ + styles.primaryButton, + pressed && styles.buttonPressed, + busyChoice !== null && styles.buttonDisabled + ]} + onPress={() => void choose('enable')} + > + {busyChoice === 'enable' ? ( + + ) : ( + Enable notifications + )} + + [ + styles.secondaryButton, + pressed && styles.buttonPressed, + busyChoice !== null && styles.buttonDisabled + ]} + onPress={() => void choose('skip')} + > + {busyChoice === 'skip' ? ( + + ) : ( + Not now + )} + + You can change this any time in Settings. + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + paddingHorizontal: spacing.xl + }, + // Why: this decision screen cannot be dismissed with Back, so every action + // must remain reachable in landscape and with accessibility text scaling. + scrollContent: { + flexGrow: 1 + }, + brandRow: { + minHeight: 52, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + brandName: { + color: colors.textPrimary, + fontSize: 17, + fontWeight: '700' + }, + content: { + flexGrow: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.xl + }, + iconSurface: { + width: 64, + height: 64, + borderRadius: radii.card, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgRaised, + marginBottom: spacing.xl + }, + eyebrow: { + color: colors.textMuted, + fontSize: 11, + fontWeight: '600', + letterSpacing: 0.55, + textTransform: 'uppercase', + marginBottom: spacing.sm + }, + title: { + maxWidth: 420, + color: colors.textPrimary, + fontSize: 26, + fontWeight: '700', + letterSpacing: -0.3, + textAlign: 'center' + }, + body: { + maxWidth: 420, + color: colors.textSecondary, + fontSize: typography.bodySize, + lineHeight: 21, + textAlign: 'center', + marginTop: spacing.md + }, + footer: { + width: '100%', + maxWidth: 420, + alignSelf: 'center', + paddingBottom: spacing.lg + }, + primaryButton: { + minHeight: 44, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + backgroundColor: colors.surfaceBright, + paddingVertical: spacing.sm + }, + primaryButtonText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + }, + secondaryButton: { + minHeight: 44, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + marginTop: spacing.xs, + paddingVertical: spacing.sm + }, + secondaryButtonText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '500' + }, + buttonPressed: { + opacity: 0.72 + }, + buttonDisabled: { + opacity: 0.58 + }, + footerNote: { + color: colors.textMuted, + fontSize: typography.metaSize, + lineHeight: 18, + textAlign: 'center', + marginTop: spacing.sm + }, + error: { + color: colors.statusRed, + fontSize: typography.metaSize, + lineHeight: 18, + textAlign: 'center', + marginBottom: spacing.sm + } +}) diff --git a/mobile/app/notifications.tsx b/mobile/app/notifications.tsx index f7922074d6e..d9696251a94 100644 --- a/mobile/app/notifications.tsx +++ b/mobile/app/notifications.tsx @@ -17,7 +17,8 @@ import { const DEFAULT_PERMISSION_STATE: NotificationPermissionState = { granted: false, status: 'undetermined', - canAskAgain: true + canAskAgain: true, + authorizationReflectsUserChoice: false } export default function NotificationsScreen() { @@ -69,7 +70,7 @@ export default function NotificationsScreen() { const notificationsBlocked = permissionState.status === 'denied' const hint = notificationsBlocked ? 'Notifications are disabled in system settings.' - : 'Receive notifications when an agent task completes on your desktop.' + : 'Get notified on this device when an agent needs your input or finishes a task.' return ( @@ -82,7 +83,7 @@ export default function NotificationsScreen() { - Push Notifications + Agent notifications ({ })) vi.mock('react-native', () => ({ - Platform: { OS: 'ios' } + Platform: { OS: 'ios', Version: 18 } })) // Why: mobile-notifications now persists the catch-up watermark to @@ -35,6 +37,32 @@ vi.mock('../storage/preferences', () => ({ loadPushNotificationsEnabled: vi.fn() })) +beforeEach(() => { + Object.assign(Platform, { OS: 'ios', Version: 18 }) +}) + +describe('getNotificationPermissionState', () => { + it.each([ + { os: 'android', version: 32, expected: false }, + { os: 'android', version: 33, expected: true }, + { os: 'ios', version: 18, expected: true } + ])( + 'reports whether a granted $os $version authorization reflects user choice', + async ({ os, version, expected }) => { + Object.assign(Platform, { OS: os, Version: version }) + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ + status: 'granted', + canAskAgain: true + } as never) + + await expect(getNotificationPermissionState()).resolves.toMatchObject({ + granted: true, + authorizationReflectsUserChoice: expected + }) + } + ) +}) + describe('subscribeToDesktopNotifications', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts index f91e5981366..d3866393116 100644 --- a/mobile/src/notifications/mobile-notifications.ts +++ b/mobile/src/notifications/mobile-notifications.ts @@ -82,6 +82,7 @@ export type NotificationPermissionState = { granted: boolean status: string canAskAgain: boolean + authorizationReflectsUserChoice: boolean } export async function getNotificationPermissionState(): Promise { @@ -89,7 +90,11 @@ export async function getNotificationPermissionState(): Promise= 33) } } diff --git a/mobile/src/notifications/notification-opt-in-gate.test.ts b/mobile/src/notifications/notification-opt-in-gate.test.ts new file mode 100644 index 00000000000..ded3d7eca39 --- /dev/null +++ b/mobile/src/notifications/notification-opt-in-gate.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + readPushNotificationsPreference, + savePushNotificationsEnabled +} from '../storage/preferences' +import { getNotificationPermissionState } from './mobile-notifications' +import { shouldPresentNotificationOptIn } from './notification-opt-in-gate' + +vi.mock('../storage/preferences', () => ({ + readPushNotificationsPreference: vi.fn(), + savePushNotificationsEnabled: vi.fn() +})) + +vi.mock('./mobile-notifications', () => ({ + getNotificationPermissionState: vi.fn() +})) + +describe('notification opt-in gate', () => { + beforeEach(() => { + vi.mocked(readPushNotificationsPreference).mockReset() + vi.mocked(savePushNotificationsEnabled).mockReset() + vi.mocked(getNotificationPermissionState).mockReset() + }) + + it('presents only when the local preference and system decision are both unset', async () => { + vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true }) + vi.mocked(getNotificationPermissionState).mockResolvedValue({ + granted: false, + status: 'undetermined', + canAskAgain: true, + authorizationReflectsUserChoice: false + }) + + await expect(shouldPresentNotificationOptIn()).resolves.toBe(true) + expect(savePushNotificationsEnabled).not.toHaveBeenCalled() + }) + + it.each([true, false])('preserves an existing %s mobile preference', async (value) => { + vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value, loaded: true }) + + await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) + expect(getNotificationPermissionState).not.toHaveBeenCalled() + }) + + it('adopts existing system authorization without prompting', async () => { + vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true }) + vi.mocked(getNotificationPermissionState).mockResolvedValue({ + granted: true, + status: 'granted', + canAskAgain: true, + authorizationReflectsUserChoice: true + }) + + await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) + expect(savePushNotificationsEnabled).toHaveBeenCalledWith(true) + }) + + it('still presents when a pre-Android 13 default grant is not an opt-in decision', async () => { + vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true }) + vi.mocked(getNotificationPermissionState).mockResolvedValue({ + granted: true, + status: 'granted', + canAskAgain: true, + authorizationReflectsUserChoice: false + }) + + await expect(shouldPresentNotificationOptIn()).resolves.toBe(true) + expect(savePushNotificationsEnabled).not.toHaveBeenCalled() + }) + + it('skips the gate when iOS has already denied permission', async () => { + vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true }) + vi.mocked(getNotificationPermissionState).mockResolvedValue({ + granted: false, + status: 'denied', + canAskAgain: false, + authorizationReflectsUserChoice: false + }) + + await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) + expect(savePushNotificationsEnabled).toHaveBeenCalledWith(false) + }) + + it('does not block startup when storage or permission checks fail', async () => { + vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: false }) + await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) + + vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true }) + vi.mocked(getNotificationPermissionState).mockRejectedValue(new Error('unavailable')) + await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) + }) +}) diff --git a/mobile/src/notifications/notification-opt-in-gate.ts b/mobile/src/notifications/notification-opt-in-gate.ts new file mode 100644 index 00000000000..a909fad16d7 --- /dev/null +++ b/mobile/src/notifications/notification-opt-in-gate.ts @@ -0,0 +1,36 @@ +import { + readPushNotificationsPreference, + savePushNotificationsEnabled +} from '../storage/preferences' +import { getNotificationPermissionState } from './mobile-notifications' + +export async function shouldPresentNotificationOptIn(): Promise { + const preference = await readPushNotificationsPreference() + if (!preference.loaded || preference.value !== null) { + return false + } + + try { + const permission = await getNotificationPermissionState() + if (permission.granted) { + if (!permission.authorizationReflectsUserChoice) { + return true + } + // Why: an already-authorized device should inherit the useful default + // without seeing an onboarding decision it has effectively made. + await savePushNotificationsEnabled(true) + return false + } + if (permission.status === 'denied' || !permission.canAskAgain) { + // Why: iOS cannot show its authorization prompt again, so a blocking + // onboarding screen would be a dead end; Settings remains the recovery. + await savePushNotificationsEnabled(false) + return false + } + return permission.status === 'undetermined' + } catch { + // Why: permission or persistence failures must not trap startup behind a + // decision screen whose result cannot be applied reliably. + return false + } +} diff --git a/mobile/src/session/mobile-session-route-helpers.ts b/mobile/src/session/mobile-session-route-helpers.ts index 18434e21f5a..b5a5049dd06 100644 --- a/mobile/src/session/mobile-session-route-helpers.ts +++ b/mobile/src/session/mobile-session-route-helpers.ts @@ -33,3 +33,31 @@ export function isGestureMouseTrackingMode( ): boolean { return mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any' } + +export function getActiveTabIdForHandle( + tabs: ReadonlyArray<{ id: string; type: string; terminal?: string | null }>, + terminalHandle: string | null +): string | null { + if (!terminalHandle) { + return null + } + return ( + tabs.find((tab) => tab.type === 'terminal' && tab.terminal === terminalHandle)?.id ?? + terminalHandle + ) +} + +export function updateTerminalCwdFromStreamEvent( + handle: string, + data: Readonly>, + terminalCwd: Map +): void { + if (!('cwd' in data)) { + return + } + if (typeof data.cwd === 'string' && data.cwd.trim().length > 0) { + terminalCwd.set(handle, data.cwd) + return + } + terminalCwd.delete(handle) +} diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts index aca17cfb509..c4083b1a545 100644 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ b/mobile/src/session/mobile-session-startup-source.test.ts @@ -58,7 +58,7 @@ describe('mobile session startup', () => { expect(pendingActivationEffect).toContain( 'pendingTerminalActivationAttemptRef.current === activationKey' ) - expect(pendingActivationEffect).toContain("sendRequest('session.tabs.activate'") + expect(pendingActivationEffect).toContain('activateMobileSessionTab(client,') expect(pendingActivationEffect).toContain('tabId: activePendingTerminalTab.id') expect(pendingActivationEffect).toContain('leafId: activePendingTerminalTab.leafId') expect(pendingActivationEffect).toContain('notifyClients: false') @@ -68,8 +68,19 @@ describe('mobile session startup', () => { expect(pendingActivationEffect).toContain('scheduleDelayedAction(() => void fetchSessionTabs()') }) - it('keeps mobile session tab activation local to the phone', () => { - const activationRequests = source.split("sendRequest('session.tabs.activate'").slice(1) + it('mirrors ready terminal taps while persisting them for headless hosts', () => { + const readyTerminalSwitch = sliceBetween( + 'const switchTab = useCallback(', + 'const switchSessionTab = useCallback(' + ) + + expect(readyTerminalSwitch).toContain('focusMobileTerminal(client, handle)') + expect(readyTerminalSwitch).toContain('activateMobileSessionTab(client,') + expect(readyTerminalSwitch).toContain('notifyClients: false') + }) + + it('keeps background and pending session-tab activation local to the phone', () => { + const activationRequests = source.split('activateMobileSessionTab(client,').slice(1) expect(activationRequests).toHaveLength(4) for (const request of activationRequests) { diff --git a/mobile/src/session/mobile-session-tab-activation.test.ts b/mobile/src/session/mobile-session-tab-activation.test.ts new file mode 100644 index 00000000000..d448d7e2f53 --- /dev/null +++ b/mobile/src/session/mobile-session-tab-activation.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest' +import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' +import { activateMobileSessionTab, focusMobileTerminal } from './mobile-session-tab-activation' + +function success(): RpcResponse { + return { id: 'rpc-1', ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } } +} + +function clientWith(sendRequest: RpcClient['sendRequest']): Pick { + return { sendRequest } +} + +describe('mobile session tab activation', () => { + it('retries terminal focus once on the authenticated replacement after cutover', async () => { + const sendRequest = vi + .fn() + .mockRejectedValueOnce(new LogicalClientCutoverError()) + .mockResolvedValueOnce(success()) + + await expect(focusMobileTerminal(clientWith(sendRequest), 'terminal-1')).resolves.toMatchObject( + { + ok: true + } + ) + expect(sendRequest).toHaveBeenCalledTimes(2) + expect(sendRequest).toHaveBeenNthCalledWith(1, 'terminal.focus', { terminal: 'terminal-1' }) + expect(sendRequest).toHaveBeenNthCalledWith(2, 'terminal.focus', { terminal: 'terminal-1' }) + }) + + it('retries session-tab activation with the same target after cutover', async () => { + const sendRequest = vi + .fn() + .mockRejectedValueOnce(new LogicalClientCutoverError()) + .mockResolvedValueOnce(success()) + const params = { + worktree: 'id:worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + notifyClients: false as const + } + + await expect(activateMobileSessionTab(clientWith(sendRequest), params)).resolves.toMatchObject({ + ok: true + }) + expect(sendRequest).toHaveBeenCalledTimes(2) + expect(sendRequest).toHaveBeenNthCalledWith(1, 'session.tabs.activate', params) + expect(sendRequest).toHaveBeenNthCalledWith(2, 'session.tabs.activate', params) + }) + + it('does not retry unrelated transport failures', async () => { + const sendRequest = vi.fn().mockRejectedValue(new Error('offline')) + + await expect(focusMobileTerminal(clientWith(sendRequest), 'terminal-1')).rejects.toThrow( + 'offline' + ) + expect(sendRequest).toHaveBeenCalledOnce() + }) + + it('retries at most once when consecutive cutovers interrupt activation', async () => { + const sendRequest = vi + .fn() + .mockRejectedValue(new LogicalClientCutoverError()) + + await expect( + activateMobileSessionTab(clientWith(sendRequest), { + worktree: 'id:worktree-1', + tabId: 'tab-1', + notifyClients: false + }) + ).rejects.toBeInstanceOf(LogicalClientCutoverError) + expect(sendRequest).toHaveBeenCalledTimes(2) + }) +}) diff --git a/mobile/src/session/mobile-session-tab-activation.ts b/mobile/src/session/mobile-session-tab-activation.ts new file mode 100644 index 00000000000..4b07bff1eee --- /dev/null +++ b/mobile/src/session/mobile-session-tab-activation.ts @@ -0,0 +1,90 @@ +import type { RpcClient } from '../transport/rpc-client' +import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import type { RpcResponse } from '../transport/types' +import { + getMobileTerminalDiagnosticErrorName, + logMobileTerminalDiagnostic, + shortenMobileTerminalDiagnosticId +} from './mobile-terminal-diagnostics' + +type ActivationClient = Pick + +type MobileSessionTabActivationParams = { + worktree: string + tabId: string + leafId?: string + notifyClients: false +} + +async function retryIdempotentActivationAfterCutover( + request: () => Promise, + operation: 'terminal.focus' | 'session.tabs.activate', + target: string +): Promise { + const diagnosticTarget = shortenMobileTerminalDiagnosticId(target) + logMobileTerminalDiagnostic('activation-request', { operation, target: diagnosticTarget }) + try { + const response = await request() + logMobileTerminalDiagnostic('activation-result', { + operation, + target: diagnosticTarget, + ok: response.ok, + rpcCode: response.ok ? null : response.error.code + }) + return response + } catch (error) { + if (!(error instanceof LogicalClientCutoverError)) { + logMobileTerminalDiagnostic('activation-error', { + operation, + target: diagnosticTarget, + errorName: getMobileTerminalDiagnosticErrorName(error) + }) + throw error + } + logMobileTerminalDiagnostic('activation-cutover-retry', { + operation, + target: diagnosticTarget + }) + // Why: cutover rejects ambiguous in-flight work after the replacement is + // active; these state-setting requests are idempotent and safe to repeat once. + try { + const response = await request() + logMobileTerminalDiagnostic('activation-result', { + operation, + target: diagnosticTarget, + ok: response.ok, + rpcCode: response.ok ? null : response.error.code + }) + return response + } catch (retryError) { + logMobileTerminalDiagnostic('activation-error', { + operation, + target: diagnosticTarget, + errorName: getMobileTerminalDiagnosticErrorName(retryError) + }) + throw retryError + } + } +} + +export function focusMobileTerminal( + client: ActivationClient, + terminal: string +): Promise { + return retryIdempotentActivationAfterCutover( + () => client.sendRequest('terminal.focus', { terminal }), + 'terminal.focus', + terminal + ) +} + +export function activateMobileSessionTab( + client: ActivationClient, + params: MobileSessionTabActivationParams +): Promise { + return retryIdempotentActivationAfterCutover( + () => client.sendRequest('session.tabs.activate', params), + 'session.tabs.activate', + params.tabId + ) +} diff --git a/mobile/src/session/mobile-terminal-diagnostics.test.ts b/mobile/src/session/mobile-terminal-diagnostics.test.ts new file mode 100644 index 00000000000..831ad4bfccc --- /dev/null +++ b/mobile/src/session/mobile-terminal-diagnostics.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest' +import { + getMobileTerminalDiagnosticErrorName, + logMobileTerminalDiagnostic, + MobileTerminalDiagnostics, + shortenMobileTerminalDiagnosticId +} from './mobile-terminal-diagnostics' + +describe('mobile terminal diagnostics', () => { + it('keeps only the correlatable suffix of identifiers', () => { + expect(shortenMobileTerminalDiagnosticId('terminal-secret-prefix-12345678')).toBe('12345678') + expect(shortenMobileTerminalDiagnosticId('short')).toBe('short') + expect(shortenMobileTerminalDiagnosticId(null)).toBeNull() + }) + + it('reports thrown error types without copying potentially sensitive messages', () => { + expect(getMobileTerminalDiagnosticErrorName(new TypeError('/private/worktree failed'))).toBe( + 'TypeError' + ) + expect(getMobileTerminalDiagnosticErrorName('raw failure')).toBe('string') + }) + + it('uses one filterable structured log tag', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + + logMobileTerminalDiagnostic('stream-armed', { handle: '12345678', seq: 2 }) + + expect(log).toHaveBeenCalledWith('[terminal-diagnostic]', 'stream-armed', { + handle: '12345678', + seq: 2 + }) + log.mockRestore() + }) + + it('forgets first-event state when a terminal unsubscribes', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const diagnostics = new MobileTerminalDiagnostics() + + diagnostics.firstStreamEvent('terminal-1', 1, 'subscribed') + diagnostics.terminalUnsubscribed('terminal-1') + diagnostics.firstStreamEvent('terminal-1', 1, 'subscribed') + + expect(log).toHaveBeenCalledTimes(2) + log.mockRestore() + }) +}) diff --git a/mobile/src/session/mobile-terminal-diagnostics.ts b/mobile/src/session/mobile-terminal-diagnostics.ts new file mode 100644 index 00000000000..8695928ed03 --- /dev/null +++ b/mobile/src/session/mobile-terminal-diagnostics.ts @@ -0,0 +1,291 @@ +const MOBILE_TERMINAL_DIAGNOSTIC_TAG = '[terminal-diagnostic]' + +type MobileTerminalDiagnosticValue = string | number | boolean | null | undefined + +export type MobileTerminalDiagnosticDetails = Readonly< + Record +> + +type DiagnosticTab = { + readonly id: string + readonly type: string + readonly isActive: boolean + readonly terminal?: string | null +} + +type DiagnosticTabsSnapshot = { + readonly publicationEpoch?: string + readonly snapshotVersion: number + readonly tabs: readonly DiagnosticTab[] +} + +type DiagnosticDimensions = { readonly cols: number; readonly rows: number } | null | undefined + +// Why: full runtime identifiers make shared logs unnecessarily sensitive; the +// suffix is enough to correlate lifecycle events within one reproduction. +export function shortenMobileTerminalDiagnosticId(value: string | null | undefined): string | null { + if (!value) { + return null + } + return value.slice(-8) +} + +export function getMobileTerminalDiagnosticErrorName(error: unknown): string { + if (error instanceof Error && error.name) { + return error.name + } + return typeof error +} + +export function logMobileTerminalDiagnostic( + event: string, + details: MobileTerminalDiagnosticDetails = {} +): void { + // Why: lifecycle diagnostics are intentionally available for HMR repros, + // but high-frequency WebView events must not add production log overhead. + if (typeof __DEV__ !== 'undefined' && !__DEV__) { + return + } + // Keep this structured and content-free so users can safely share a filtered log. + console.log(MOBILE_TERMINAL_DIAGNOSTIC_TAG, event, details) +} + +export class MobileTerminalDiagnostics { + private readonly streamGateByHandle = new Map() + private readonly firstStreamEventSeqByHandle = new Map() + private lastFetchedTabsSignature: string | null = null + private lastAppliedTabsSignature: string | null = null + private lastTabsFetchStartAt = 0 + private tabsFetchSkipLogged = false + + clearTerminalCache(): void { + this.streamGateByHandle.clear() + this.firstStreamEventSeqByHandle.clear() + } + + resetRoute(): void { + this.clearTerminalCache() + this.lastFetchedTabsSignature = null + this.lastAppliedTabsSignature = null + this.lastTabsFetchStartAt = 0 + this.tabsFetchSkipLogged = false + } + + terminalUnsubscribed(handle: string): void { + this.streamGateByHandle.delete(handle) + this.firstStreamEventSeqByHandle.delete(handle) + } + + viewportMeasured(handle: string, dims: DiagnosticDimensions, frameHeight: number): void { + logMobileTerminalDiagnostic('viewport-measure', { + handle: shortenMobileTerminalDiagnosticId(handle), + ok: dims != null, + cols: dims?.cols, + rows: dims?.rows, + frameHeight: Math.round(frameHeight) + }) + } + + streamSkipped(handle: string, reason: string, isActive: boolean): void { + if (this.streamGateByHandle.get(handle) === reason) { + return + } + this.streamGateByHandle.set(handle, reason) + logMobileTerminalDiagnostic('stream-skipped', { + handle: shortenMobileTerminalDiagnosticId(handle), + reason, + isActive + }) + } + + streamArmed(handle: string, seq: number, viewport: DiagnosticDimensions): void { + this.streamGateByHandle.delete(handle) + logMobileTerminalDiagnostic('stream-armed', { + handle: shortenMobileTerminalDiagnosticId(handle), + seq, + hasViewport: viewport != null, + viewportCols: viewport?.cols, + viewportRows: viewport?.rows + }) + } + + firstStreamEvent(handle: string, seq: number, type: unknown): void { + if (this.firstStreamEventSeqByHandle.get(handle) === seq) { + return + } + this.firstStreamEventSeqByHandle.set(handle, seq) + logMobileTerminalDiagnostic('stream-first-event', { + handle: shortenMobileTerminalDiagnosticId(handle), + seq, + type: typeof type === 'string' ? type : 'unknown' + }) + } + + streamScrollback( + handle: string, + seq: number, + eventSeq: number | null, + data: Readonly> + ): void { + logMobileTerminalDiagnostic('stream-scrollback', { + handle: shortenMobileTerminalDiagnosticId(handle), + seq, + eventSeq, + cols: typeof data.cols === 'number' ? data.cols : null, + rows: typeof data.rows === 'number' ? data.rows : null, + serializedLength: typeof data.serialized === 'string' ? data.serialized.length : 0, + displayMode: typeof data.displayMode === 'string' ? data.displayMode : null, + source: typeof data.source === 'string' ? data.source : null, + scrollbackRows: typeof data.scrollbackRows === 'number' ? data.scrollbackRows : null, + truncated: data.truncated === true || data.truncatedByByteBudget === true + }) + } + + streamResubscribing(handle: string, seq: number, dims: { cols: number; rows: number }): void { + logMobileTerminalDiagnostic('stream-resubscribe-for-viewport', { + handle: shortenMobileTerminalDiagnosticId(handle), + seq, + cols: dims.cols, + rows: dims.rows + }) + } + + streamResized( + handle: string, + seq: number, + eventSeq: number | null, + data: Readonly>, + hasRef: boolean + ): void { + logMobileTerminalDiagnostic('stream-resized', { + handle: shortenMobileTerminalDiagnosticId(handle), + seq, + eventSeq, + cols: typeof data.cols === 'number' ? data.cols : null, + rows: typeof data.rows === 'number' ? data.rows : null, + serializedLength: typeof data.serialized === 'string' ? data.serialized.length : 0, + hasRef + }) + } + + tabsApplied( + snapshot: DiagnosticTabsSnapshot, + tabs: readonly DiagnosticTab[], + activeTab: DiagnosticTab | null, + selectionSource: string + ): void { + const activeHandle = + activeTab?.type === 'terminal' && typeof activeTab.terminal === 'string' + ? activeTab.terminal + : null + const appliedSnapshot = { ...snapshot, tabs } + const signature = [ + appliedSnapshot.publicationEpoch ?? '', + appliedSnapshot.snapshotVersion, + activeTab?.id ?? '', + activeHandle ?? '', + selectionSource + ].join(':') + if (this.lastAppliedTabsSignature === signature) { + return + } + this.lastAppliedTabsSignature = signature + this.logTabs('tabs-applied', appliedSnapshot, activeTab, activeHandle, { selectionSource }) + } + + tabsFetchSkipped(reason: string): void { + if (reason === 'already-in-flight' && this.tabsFetchSkipLogged) { + return + } + this.tabsFetchSkipLogged = reason === 'already-in-flight' + logMobileTerminalDiagnostic('tabs-fetch-skipped', { reason }) + } + + tabsFetchStarted(worktreeId: string): void { + this.tabsFetchSkipLogged = false + const now = Date.now() + if (this.lastFetchedTabsSignature != null && now - this.lastTabsFetchStartAt < 10_000) { + return + } + this.lastTabsFetchStartAt = now + logMobileTerminalDiagnostic('tabs-fetch-start', { + worktree: shortenMobileTerminalDiagnosticId(worktreeId) + }) + } + + tabsFetchFailed(rpcCode: string): void { + logMobileTerminalDiagnostic('tabs-fetch-rpc-failure', { rpcCode }) + } + + tabsFetchErrored(error: unknown): void { + logMobileTerminalDiagnostic('tabs-fetch-error', { + errorName: getMobileTerminalDiagnosticErrorName(error) + }) + } + + tabsFetchSucceeded(snapshot: DiagnosticTabsSnapshot): void { + const activeTab = snapshot.tabs.find((tab) => tab.isActive) ?? null + const signature = [ + snapshot.publicationEpoch ?? '', + snapshot.snapshotVersion, + snapshot.tabs.length, + activeTab?.id ?? '', + activeTab?.type ?? '' + ].join(':') + if (this.lastFetchedTabsSignature === signature) { + return + } + this.lastFetchedTabsSignature = signature + const activeHandle = + activeTab?.type === 'terminal' && typeof activeTab.terminal === 'string' + ? activeTab.terminal + : null + this.logTabs('tabs-fetch-success', snapshot, activeTab, activeHandle) + } + + tabSwitch(tabType: string, tabId: string, pending: boolean, handle?: string): void { + logMobileTerminalDiagnostic('tab-switch', { + tabType, + tab: shortenMobileTerminalDiagnosticId(tabId), + handle: shortenMobileTerminalDiagnosticId(handle), + pending + }) + } + + webViewRef(handle: string, attached: boolean): void { + logMobileTerminalDiagnostic('webview-ref', { + handle: shortenMobileTerminalDiagnosticId(handle), + attached + }) + } + + webViewReady(handle: string, reload: boolean, isActive: boolean): void { + logMobileTerminalDiagnostic('webview-ready', { + handle: shortenMobileTerminalDiagnosticId(handle), + reload, + isActive + }) + } + + private logTabs( + event: 'tabs-applied' | 'tabs-fetch-success', + snapshot: DiagnosticTabsSnapshot, + activeTab: DiagnosticTab | null, + activeHandle: string | null, + extra: MobileTerminalDiagnosticDetails = {} + ): void { + logMobileTerminalDiagnostic(event, { + publication: shortenMobileTerminalDiagnosticId(snapshot.publicationEpoch), + snapshotVersion: snapshot.snapshotVersion, + tabCount: snapshot.tabs.length, + terminalTabCount: snapshot.tabs.filter((tab) => tab.type === 'terminal').length, + pendingTerminalCount: snapshot.tabs.filter( + (tab) => tab.type === 'terminal' && typeof tab.terminal !== 'string' + ).length, + activeTab: shortenMobileTerminalDiagnosticId(activeTab?.id), + activeType: activeTab?.type ?? null, + activeHandle: shortenMobileTerminalDiagnosticId(activeHandle), + ...extra + }) + } +} diff --git a/mobile/src/session/session-tab-snapshot-gate.test.ts b/mobile/src/session/session-tab-snapshot-gate.test.ts index 865c297fdb2..9cb742629ec 100644 --- a/mobile/src/session/session-tab-snapshot-gate.test.ts +++ b/mobile/src/session/session-tab-snapshot-gate.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { acceptSessionSnapshot, applyClosedTabTombstones, + confirmsMirroredTabSelection, type AppliedSnapshotMarker } from './session-tab-snapshot-gate' @@ -50,6 +51,18 @@ describe('acceptSessionSnapshot', () => { }) }) +describe('confirmsMirroredTabSelection', () => { + it('does not treat phone-local persistence as desktop focus confirmation', () => { + expect(confirmsMirroredTabSelection('mobile-local:abc')).toBe(false) + }) + + it('accepts renderer, headless, and legacy publications as confirmation', () => { + expect(confirmsMirroredTabSelection('renderer:abc')).toBe(true) + expect(confirmsMirroredTabSelection('headless:abc')).toBe(true) + expect(confirmsMirroredTabSelection()).toBe(true) + }) +}) + describe('applyClosedTabTombstones', () => { const tab = (id: string): { id: string } => ({ id }) diff --git a/mobile/src/session/session-tab-snapshot-gate.ts b/mobile/src/session/session-tab-snapshot-gate.ts index f3d7601ef4a..1bcf9cadb99 100644 --- a/mobile/src/session/session-tab-snapshot-gate.ts +++ b/mobile/src/session/session-tab-snapshot-gate.ts @@ -33,6 +33,12 @@ export function acceptSessionSnapshot( return true } +export function confirmsMirroredTabSelection(publicationEpoch?: string): boolean { + // Why: phone-local persistence acknowledges the mutation, not host focus; + // keep the pending guard until any host publication confirms the selection. + return !publicationEpoch?.startsWith('mobile-local:') +} + /** * Drops tabs the user just closed locally (tombstoned) until the publisher's * snapshot also drops them or the tombstone expires. Mutates `tombstones`, diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index 1e5aee95de1..f8e07fb4d98 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -10,11 +10,14 @@ import { clampHostSidebarWidth, loadDisabledTerminalLiveInputHandles, loadHostSidebarWidth, + loadPushNotificationsEnabled, loadTerminalAutocompleteEnabled, loadTerminalLinkOpenMode, + readPushNotificationsPreference, readDisabledTerminalLiveInputHandlesPreference, saveDisabledTerminalLiveInputHandles, saveHostSidebarWidth, + savePushNotificationsEnabled, saveTerminalAutocompleteEnabled, saveTerminalLinkOpenMode } from './preferences' @@ -26,6 +29,46 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ } })) +describe('push notification preference', () => { + beforeEach(() => { + vi.mocked(AsyncStorage.getItem).mockReset() + vi.mocked(AsyncStorage.setItem).mockReset() + }) + + it('distinguishes an unset preference from an explicit disabled choice', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(null) + await expect(readPushNotificationsPreference()).resolves.toEqual({ + value: null, + loaded: true + }) + await expect(loadPushNotificationsEnabled()).resolves.toBe(false) + + vi.mocked(AsyncStorage.getItem).mockResolvedValue('false') + await expect(readPushNotificationsPreference()).resolves.toEqual({ + value: false, + loaded: true + }) + }) + + it('reports storage failures without enabling notifications', async () => { + vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable')) + + await expect(readPushNotificationsPreference()).resolves.toEqual({ + value: null, + loaded: false + }) + await expect(loadPushNotificationsEnabled()).resolves.toBe(false) + }) + + it('persists the onboarding decision in the existing mobile toggle', async () => { + await savePushNotificationsEnabled(true) + expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:pushNotificationsEnabled', 'true') + + await savePushNotificationsEnabled(false) + expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:pushNotificationsEnabled', 'false') + }) +}) + describe('terminal autocomplete preference', () => { beforeEach(() => { vi.mocked(AsyncStorage.getItem).mockReset() diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index d5b0db3a6e7..5173ac5bc8a 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -3,23 +3,29 @@ import AsyncStorage from '@react-native-async-storage/async-storage' const PINS_PREFIX = 'orca:pins:' const NOTIF_KEY = 'orca:pushNotificationsEnabled' -// Why: default-off so the iOS notification permission prompt never -// fires until the user explicitly opts in via Settings → Notifications. -// Apple's review guideline 4.5.4 and HIG both prefer user-initiated -// permission prompts; default-on would fire the prompt the moment the -// desktop sent its first notification, which can read as unsolicited. -export async function loadPushNotificationsEnabled(): Promise { +export type PushNotificationsPreference = { + readonly value: boolean | null + readonly loaded: boolean +} + +// Why: null distinguishes people who have never made the one-time onboarding +// decision from people who explicitly chose Not now or disabled notifications. +export async function readPushNotificationsPreference(): Promise { try { const raw = await AsyncStorage.getItem(NOTIF_KEY) - if (raw === null) { - return false - } - return raw === 'true' + return { value: raw === null ? null : raw === 'true', loaded: true } } catch { - return false + return { value: null, loaded: false } } } +// Why: default-off prevents background notification events from opening the +// system prompt; only the onboarding CTA or Settings switch requests permission. +export async function loadPushNotificationsEnabled(): Promise { + const preference = await readPushNotificationsPreference() + return preference.value ?? false +} + export async function savePushNotificationsEnabled(enabled: boolean): Promise { await AsyncStorage.setItem(NOTIF_KEY, String(enabled)) } diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index 69b2e6f6b51..e9d188b74ef 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -6,6 +6,7 @@ import { TERMINAL_TEXT_SCALES } from '../storage/preferences' import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected' import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated' import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected' +import { TERMINAL_SURFACE_SWAP_JS } from './terminal-webview-surface-swap-injected' import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected' import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected' import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected' @@ -217,6 +218,7 @@ window.onerror = function(msg) { var statusDotPendingSelector = false; var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096; var term = null; ${TERMINAL_QUERY_REPLY_JS} + ${TERMINAL_SURFACE_SWAP_JS} var scrollIndicator = document.getElementById('scroll-indicator'); var scrollThumb = document.getElementById('scroll-thumb'); var scrollIndicatorHideTimer = null; @@ -713,22 +715,8 @@ ${TERMINAL_WEBGL_RECOVERY_JS} initialOscLinks = Array.isArray(nextOscLinks) ? nextOscLinks : []; initialOscLinkRowOffset = 0; initialOscLinkEvictionReady = false; - var oldTerm = term; - var oldSurface = surface; - var nextSurface = null; - disposeTermObservers(); - if (oldTerm) { - nextSurface = document.createElement('div'); - nextSurface.id = 'terminal-surface'; - nextSurface.style.visibility = 'hidden'; - nextSurface.style.position = 'absolute'; - nextSurface.style.left = '0'; - nextSurface.style.top = '0'; - document.getElementById('terminal-container').appendChild(nextSurface); - surface = nextSurface; - attachSurfaceEventHandlers(surface); - oldSurface.removeAttribute('id'); - } + var surfaceSwap = beginTerminalSurfaceSwap(); + var nextSurface = surfaceSwap.nextSurface; applyTerminalTheme(nextTheme); term = new Terminal({ @@ -749,6 +737,8 @@ ${TERMINAL_WEBGL_RECOVERY_JS} convertEol: false, allowProposedApi: true }); + var nextTerm = term; + pendingTerm = nextTerm; term.open(surface); attachWebglAddon(true); if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {} @@ -768,14 +758,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} everReady = true; afterWritesDrained(function() { if (gen !== terminalGeneration) return; - if (nextSurface && oldSurface) { - nextSurface.style.visibility = 'visible'; - nextSurface.style.position = ''; - nextSurface.style.left = ''; - nextSurface.style.top = ''; - oldSurface.remove(); - if (oldTerm) oldTerm.dispose(); - } + commitTerminalSurfaceSwap(surfaceSwap, nextTerm); // Why: restore the reader's place after the rewrapped buffer replays. // Replay lands at bottom, so only act when they were scrolled up (rows>0). if (scrollAnchorRows > 0 && term && term.buffer && term.buffer.active) { diff --git a/mobile/src/terminal/terminal-webview-init-surface.test.ts b/mobile/src/terminal/terminal-webview-init-surface.test.ts new file mode 100644 index 00000000000..6c7c4e5f173 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-init-surface.test.ts @@ -0,0 +1,132 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { XTERM_HTML } from './terminal-webview-html' + +function iifeSource(): string { + const start = XTERM_HTML.indexOf('(function() {') + const end = XTERM_HTML.lastIndexOf('})();') + return XTERM_HTML.slice(start, end + '})();'.length) +} + +function bodyMarkup(): string { + const start = XTERM_HTML.indexOf('') + ''.length + const end = XTERM_HTML.indexOf('