fix(mobile): reuse the current workspace on notification taps

This commit is contained in:
Jinwoo-H
2026-09-12 02:12:19 -04:00
parent eedd35645e
commit 68cd4a547d
4 changed files with 131 additions and 3 deletions
+30 -2
View File
@@ -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<Record<string, string>> }
}>
) => 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
@@ -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.'
)}
</View>
<Text style={styles.footer}>
@@ -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()
})
@@ -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)
})