diff --git a/mobile/src/navigation/host-stack-navigation.ts b/mobile/src/navigation/host-stack-navigation.ts index cb3b87cd98f..fa28df9ca12 100644 --- a/mobile/src/navigation/host-stack-navigation.ts +++ b/mobile/src/navigation/host-stack-navigation.ts @@ -7,7 +7,7 @@ export type HostStackNavigationState = Readonly<{ export type HostStackNavigationRoute = Readonly<{ key?: string name: string - params?: Readonly<{ hostId?: unknown }> + params?: Readonly<{ hostId?: unknown; worktreeId?: unknown }> state?: HostStackNavigationState }> @@ -27,7 +27,16 @@ export type HostStackReplaceAction = Readonly<{ export type HostStackRootNavigation = { addListener: (event: 'state', listener: () => void) => () => void - dispatch: (action: HostStackReplaceAction) => void + dispatch: ( + action: + | HostStackReplaceAction + | Readonly<{ + type: 'SET_PARAMS' + target: string + source: string + payload: { params: Readonly> } + }> + ) => void // Why: the root layout's navigator has no committed state until it hydrates, and a // notification tap can arm the transition before that first commit. getState: () => HostStackNavigationState | undefined @@ -124,6 +133,25 @@ export function navigateToHostStackRoute( hostId: string, target: HostStackRouteTarget ): HostStackNavigationController { + const state = navigation.getState() + const hostState = state && focusedHostRoute(state)?.state + const focused = hostState?.routes[hostState.index] + if ( + hostState?.key && + focused?.key && + focused.name === target.name && + hostParamMatches(focused.params?.hostId, hostId) && + hostParamMatches(focused.params?.worktreeId, target.params.worktreeId) + ) { + navigation.dispatch({ + type: 'SET_PARAMS', + target: hostState.key, + source: focused.key, + payload: { params: target.params } + }) + return { cancel: () => {}, isActive: () => false, retarget: () => {} } + } + let active = true let hostRouteSeen = false let selectedTarget = target diff --git a/mobile/src/notifications/NotificationDeliverySection.tsx b/mobile/src/notifications/NotificationDeliverySection.tsx index df1f3dc0683..73594e66ae7 100644 --- a/mobile/src/notifications/NotificationDeliverySection.tsx +++ b/mobile/src/notifications/NotificationDeliverySection.tsx @@ -40,7 +40,7 @@ export function NotificationDeliverySection({ value, disabled, onChange }: Props {row( 'suppressWhileViewing', 'Suppress while focused', - 'Skip alerts for the workspace open on this phone.' + 'Skip alerts for this workspace only while Orca is open.' )} diff --git a/mobile/src/notifications/notification-route-coordination.test.ts b/mobile/src/notifications/notification-route-coordination.test.ts index eab13fdc046..6f1b8a4aeec 100644 --- a/mobile/src/notifications/notification-route-coordination.test.ts +++ b/mobile/src/notifications/notification-route-coordination.test.ts @@ -115,3 +115,78 @@ describe('notification route coordination', () => { expect(notificationEffect).not.toContain('router.push(') }) }) + +it('reuses the current workspace screen and targets the notification pane without pushing', () => { + const target = getNotificationNavigationTarget({ + hostId: 'host', + worktreeId: 'folder::/workspace', + paneKey: 'agent-tab:leaf' + })! + const harness = navigationHarness( + rootLayoutScopedState({ + index: 1, + routes: [ + { name: 'index' }, + { + name: 'h', + state: { + key: 'host-stack', + index: 0, + routes: [ + { + key: 'existing-workspace', + name: target.sessionTarget!.name, + params: { hostId: 'host', worktreeId: 'folder::/workspace' } + } + ] + } + } + ] + }) + ) + const router = { push: vi.fn(), replace: vi.fn() } + const controller = navigateToHostStackRoute( + harness.navigation, + router, + target.hostId, + target.sessionTarget! + ) + expect(router.push).not.toHaveBeenCalled() + expect(router.replace).not.toHaveBeenCalled() + expect(harness.navigation.dispatch).toHaveBeenCalledExactlyOnceWith({ + type: 'SET_PARAMS', + target: 'host-stack', + source: 'existing-workspace', + payload: { params: target.sessionTarget!.params } + }) + expect(controller.isActive()).toBe(false) +}) + +it('does not reuse a different workspace on the same host', () => { + const target = getNotificationNavigationTarget({ hostId: 'host', worktreeId: 'workspace-b' })! + const harness = navigationHarness( + rootLayoutScopedState({ + index: 0, + routes: [ + { + name: 'h', + state: { + key: 'host-stack', + index: 0, + routes: [ + { + key: 'workspace-a', + name: target.sessionTarget!.name, + params: { hostId: 'host', worktreeId: 'workspace-a' } + } + ] + } + } + ] + }) + ) + const router = { push: vi.fn(), replace: vi.fn() } + navigateToHostStackRoute(harness.navigation, router, target.hostId, target.sessionTarget!) + expect(router.push).toHaveBeenCalledOnce() + expect(harness.navigation.dispatch).not.toHaveBeenCalled() +}) diff --git a/mobile/src/notifications/push-receive.test.ts b/mobile/src/notifications/push-receive.test.ts index 8bcaef51e83..0af8963d3b2 100644 --- a/mobile/src/notifications/push-receive.test.ts +++ b/mobile/src/notifications/push-receive.test.ts @@ -272,3 +272,28 @@ it('preflight does not consume the final presentation claim and observes later d await expect(canPresentForegroundPush(payload)).resolves.toBe(false) await expect(shouldSuppressForegroundPush(apnsData(payload))).resolves.toBe(true) }) + +it('allows the viewed workspace after backgrounding during eligibility reads', async () => { + const payload = { + hostFingerprint, + worktreeId: 'workspace', + notificationId: 'background-transition', + notificationEpoch: 'epoch', + notificationSeq: 1 + } + setNotificationViewingWorkspace({ hostId: 'host-1', worktreeId: 'workspace' }) + AppState.currentState = 'active' + await expect(canPresentForegroundPush(payload)).resolves.toBe(false) + let resolveHosts!: (value: HostCatalogEntry[]) => void + vi.mocked(loadHostCatalog).mockReturnValueOnce( + new Promise((resolve) => { + resolveHosts = resolve + }) + ) + const eligibility = canPresentForegroundPush(payload) + await vi.waitFor(() => expect(resolveHosts).toBeDefined()) + AppState.currentState = 'background' + resolveHosts(hosts) + await expect(eligibility).resolves.toBe(true) + await expect(shouldSuppressForegroundPush(apnsData(payload))).resolves.toBe(false) +})