diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx
index d88fb6699aa..529a132b54f 100644
--- a/mobile/app/h/[hostId]/index.tsx
+++ b/mobile/app/h/[hostId]/index.tsx
@@ -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
- }
- return
+ return
}
function ListSeparator() {
diff --git a/mobile/src/tasks/MobileTaskHostRoute.test.ts b/mobile/src/tasks/MobileTaskHostRoute.test.ts
new file mode 100644
index 00000000000..026e83db12a
--- /dev/null
+++ b/mobile/src/tasks/MobileTaskHostRoute.test.ts
@@ -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 })
+ })
+})
diff --git a/mobile/src/tasks/MobileTaskHostRoute.tsx b/mobile/src/tasks/MobileTaskHostRoute.tsx
new file mode 100644
index 00000000000..ce4214d1fa1
--- /dev/null
+++ b/mobile/src/tasks/MobileTaskHostRoute.tsx
@@ -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
+ }
+ return isWideLayout ? :
+}
diff --git a/mobile/src/tasks/mobile-task-navigation.test.ts b/mobile/src/tasks/mobile-task-navigation.test.ts
index ffdd564a531..a71012e24d4 100644
--- a/mobile/src/tasks/mobile-task-navigation.test.ts
+++ b/mobile/src/tasks/mobile-task-navigation.test.ts
@@ -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()
})
})
diff --git a/mobile/src/tasks/mobile-task-navigation.ts b/mobile/src/tasks/mobile-task-navigation.ts
index 77de7596484..dceb4e1d4e9 100644
--- a/mobile/src/tasks/mobile-task-navigation.ts
+++ b/mobile/src/tasks/mobile-task-navigation.ts
@@ -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))
}