fix(mobile): redirect tasks after host mount

This commit is contained in:
Brennan Benson
2026-07-31 18:02:39 -07:00
parent 71d0023236
commit 681916af88
5 changed files with 112 additions and 31 deletions
+2 -6
View File
@@ -65,7 +65,6 @@ import { BottomDrawer } from '../../../src/components/BottomDrawer'
import { useHostProtocolGates } from '../../../src/components/HostProtocolGate'
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
import { MobileSearchField } from '../../../src/components/MobileSearchField'
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
import { getCachedWorktrees, setCachedWorktrees } from '../../../src/cache/worktree-cache'
import { setCachedRepos } from '../../../src/cache/repo-cache'
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
@@ -102,6 +101,7 @@ import {
import type { RepoSummary } from '../../../src/worktree/host-worktree-rpc-types'
import type { WorkspaceStatusDefinition } from '../../../../src/shared/types'
import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from '../../../src/worktree/mobile-workspace-statuses'
import { MobileTaskHostRoute } from '../../../src/tasks/MobileTaskHostRoute'
function isErrorVerdict(v: ConnectionVerdict): boolean {
return v.kind === 'warning' || v.kind === 'unreachable' || v.kind === 'auth-failed'
@@ -1396,11 +1396,7 @@ export function HostScreen({
// On wide layouts the sidebar hosts the list, so this route is just the empty detail pane.
export default function HostWorktreeRoute() {
const { isWideLayout } = useResponsiveLayout()
if (isWideLayout) {
return <WorkspaceDetailPlaceholder />
}
return <HostScreen />
return <MobileTaskHostRoute hostScreen={HostScreen} />
}
function ListSeparator() {
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileTaskHostRoute } from './MobileTaskHostRoute'
const mocks = vi.hoisted(() => ({
params: {} as { hostId?: string; action?: string; taskSource?: string },
isWideLayout: false
}))
vi.mock('expo-router', () => ({
Redirect: 'Redirect',
useLocalSearchParams: () => mocks.params
}))
vi.mock('../layout/responsive-layout', () => ({
useResponsiveLayout: () => ({ isWideLayout: mocks.isWideLayout })
}))
vi.mock('../components/WorkspaceDetailPlaceholder', () => ({
WorkspaceDetailPlaceholder: 'WorkspaceDetailPlaceholder'
}))
function HostScreen() {
return null
}
afterEach(() => {
mocks.params = {}
mocks.isWideLayout = false
})
describe('MobileTaskHostRoute', () => {
it('redirects a mounted host action to its concrete Tasks route', () => {
mocks.params = { hostId: 'host/1', action: 'tasks', taskSource: 'github' }
expect(MobileTaskHostRoute({ hostScreen: HostScreen })).toMatchObject({
type: 'Redirect',
props: { href: '/h/host%2F1/tasks?taskSource=github' }
})
})
it('renders the normal host screen without the Tasks action', () => {
expect(MobileTaskHostRoute({ hostScreen: HostScreen })).toMatchObject({ type: HostScreen })
})
})
+20
View File
@@ -0,0 +1,20 @@
import type { ComponentType } from 'react'
import { Redirect, useLocalSearchParams } from 'expo-router'
import { useResponsiveLayout } from '../layout/responsive-layout'
import { WorkspaceDetailPlaceholder } from '../components/WorkspaceDetailPlaceholder'
import { mobileTasksRouteFromHostAction } from './mobile-task-navigation'
export function MobileTaskHostRoute({ hostScreen: HostScreen }: { hostScreen: ComponentType }) {
const params = useLocalSearchParams<{
hostId?: string
action?: string
taskSource?: string
}>()
const { isWideLayout } = useResponsiveLayout()
const tasksRoute = mobileTasksRouteFromHostAction(params.hostId, params.action, params.taskSource)
if (tasksRoute) {
return <Redirect href={tasksRoute} />
}
return isWideLayout ? <WorkspaceDetailPlaceholder /> : <HostScreen />
}
+17 -18
View File
@@ -1,9 +1,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mobileTasksRoute, navigateToMobileTasks } from './mobile-task-navigation'
afterEach(() => {
vi.unstubAllGlobals()
})
import { describe, expect, it, vi } from 'vitest'
import {
mobileTasksHostRoute,
mobileTasksRoute,
mobileTasksRouteFromHostAction,
navigateToMobileTasks
} from './mobile-task-navigation'
describe('mobileTasksRoute', () => {
it('builds a concrete encoded host route', () => {
@@ -11,20 +12,18 @@ describe('mobileTasksRoute', () => {
expect(mobileTasksRoute('host/1', 'linear')).toBe('/h/host%2F1/tasks?taskSource=linear')
})
it('mounts a cold host navigator before replacing its index with Tasks', () => {
let nextFrame: FrameRequestCallback | null = null
it('routes through the host index so its nested navigator owns the redirect', () => {
const push = vi.fn()
const replace = vi.fn()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
nextFrame = callback
return 1
})
navigateToMobileTasks({ push, replace }, 'host/1', 'github')
expect(push).toHaveBeenCalledWith('/h/host%2F1')
expect(replace).not.toHaveBeenCalled()
navigateToMobileTasks({ push }, 'host/1', 'github')
expect(push).toHaveBeenCalledWith(mobileTasksHostRoute('host/1', 'github'))
})
nextFrame?.(0)
expect(replace).toHaveBeenCalledWith(mobileTasksRoute('host/1', 'github'))
it('resolves the host-owned Tasks action after the nested navigator mounts', () => {
expect(mobileTasksRouteFromHostAction('host/1', 'tasks', 'linear')).toBe(
'/h/host%2F1/tasks?taskSource=linear'
)
expect(mobileTasksRouteFromHostAction('host-1', 'newWorktree', 'github')).toBeNull()
expect(mobileTasksRouteFromHostAction(undefined, 'tasks', 'github')).toBeNull()
})
})
+29 -7
View File
@@ -4,9 +4,12 @@ export type MobileTasksRoute =
| `/h/${string}/tasks`
| `/h/${string}/tasks?taskSource=${TaskProvider}`
export type MobileTasksHostRoute =
| `/h/${string}?action=tasks`
| `/h/${string}?action=tasks&taskSource=${TaskProvider}`
type MobileTasksRouter = {
push: (href: `/h/${string}`) => void
replace: (href: MobileTasksRoute) => void
push: (href: MobileTasksHostRoute) => void
}
export function mobileTasksRoute(hostId: string, provider?: TaskProvider): MobileTasksRoute {
@@ -15,14 +18,33 @@ export function mobileTasksRoute(hostId: string, provider?: TaskProvider): Mobil
return provider ? `${pathname}?taskSource=${provider}` : pathname
}
export function mobileTasksHostRoute(
hostId: string,
provider?: TaskProvider
): MobileTasksHostRoute {
const pathname = `/h/${encodeURIComponent(hostId)}?action=tasks` as const
return provider ? `${pathname}&taskSource=${provider}` : pathname
}
export function mobileTasksRouteFromHostAction(
hostId: string | undefined,
action: string | undefined,
taskSource: string | undefined
): MobileTasksRoute | null {
if (!hostId || action !== 'tasks') {
return null
}
const provider =
taskSource === 'github' || taskSource === 'gitlab' || taskSource === 'linear'
? taskSource
: undefined
return mobileTasksRoute(hostId, provider)
}
export function navigateToMobileTasks(
router: MobileTasksRouter,
hostId: string,
provider?: TaskProvider
): void {
// Why: a cold nested host navigator resolves a deep push to its index route.
router.push(`/h/${encodeURIComponent(hostId)}`)
requestAnimationFrame(() => {
router.replace(mobileTasksRoute(hostId, provider))
})
router.push(mobileTasksHostRoute(hostId, provider))
}