From 681916af88caa4e14e873e4da06ad8cb776ea1e5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:02:39 -0700 Subject: [PATCH] fix(mobile): redirect tasks after host mount --- mobile/app/h/[hostId]/index.tsx | 8 +--- mobile/src/tasks/MobileTaskHostRoute.test.ts | 44 +++++++++++++++++++ mobile/src/tasks/MobileTaskHostRoute.tsx | 20 +++++++++ .../src/tasks/mobile-task-navigation.test.ts | 35 +++++++-------- mobile/src/tasks/mobile-task-navigation.ts | 36 ++++++++++++--- 5 files changed, 112 insertions(+), 31 deletions(-) create mode 100644 mobile/src/tasks/MobileTaskHostRoute.test.ts create mode 100644 mobile/src/tasks/MobileTaskHostRoute.tsx 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)) }