From bef24d841b04ce7cc5a20249a20938ddc96a98ad Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 12 Nov 2026 06:41:14 +0000 Subject: [PATCH] fix(mobile): return to Home's agent lists in one Back from an agent's session --- .../app/h/[hostId]/session/[worktreeId].tsx | 4 ++- .../src/agents/use-agent-list-row-actions.tsx | 2 ++ .../use-mobile-session-markdown-actions.ts | 7 ++-- .../mobile-web-shell/MobileWebShellScreen.tsx | 6 +++- .../src/navigation/host-stack-navigation.ts | 36 +++++++++++++++---- .../use-reported-page-location.web.ts | 6 +++- .../navigation/use-shell-open-target.web.ts | 11 ++++-- .../src/session/mobile-session-route.test.ts | 31 ++++++++++++++++ mobile/src/session/mobile-session-route.ts | 14 ++++++-- .../session/use-mobile-session-foundation.ts | 6 +++- 10 files changed, 106 insertions(+), 17 deletions(-) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index d167737e3b1..bfc6a69989d 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -6,6 +6,7 @@ import { import { useCallback } from 'react' import { useLocalSearchParams, useRouter } from 'expo-router' import { firstParam } from '../../../../src/navigation/route-param-reader' +import { STANDALONE_SESSION_PARAM } from '../../../../src/navigation/host-stack-navigation' import { shellScreenRoute, shellScreenRouteKey @@ -25,6 +26,7 @@ export default withWorkspaceRoute(function MobileSessionScreen() { created?: string | string[] warning?: string | string[] paneKey?: string | string[] + standalone?: string | string[] }>() const hostId = firstParam(params.hostId) const worktreeId = firstParam(params.worktreeId) @@ -45,7 +47,7 @@ export default withWorkspaceRoute(function MobileSessionScreen() { // Omit empty params: created=1 is one-shot, warning is host text, absent name derives a label, and empty paneKey marks a spent tap. const routeParams: Record = { ...Object.fromEntries( - (['name', 'created', 'warning', 'paneKey'] as const) + (['name', 'created', 'warning', 'paneKey', STANDALONE_SESSION_PARAM] as const) .map((key) => [key, firstParam(params[key])] as const) .filter(([, value]) => value !== '') ), diff --git a/mobile/src/agents/use-agent-list-row-actions.tsx b/mobile/src/agents/use-agent-list-row-actions.tsx index f5b45eda889..837183789c8 100644 --- a/mobile/src/agents/use-agent-list-row-actions.tsx +++ b/mobile/src/agents/use-agent-list-row-actions.tsx @@ -35,6 +35,8 @@ export function useAgentListRowActions( hostId: row.hostId, worktreeId: row.entry.worktreeId, name: row.entry.workspaceName, + // Home's lists span desktops, so Back returns to them rather than to this desktop's list. + standalone: true, ...(withPane ? { paneKey: row.entry.agent.paneKey } : {}) }), [openMobileSession] diff --git a/mobile/src/files/session-documents/use-mobile-session-markdown-actions.ts b/mobile/src/files/session-documents/use-mobile-session-markdown-actions.ts index ac139204736..41502a7ba5e 100644 --- a/mobile/src/files/session-documents/use-mobile-session-markdown-actions.ts +++ b/mobile/src/files/session-documents/use-mobile-session-markdown-actions.ts @@ -19,6 +19,7 @@ export type MobileSessionMarkdownActionsScope = Pick< | 'hostId' | 'worktreeId' | 'router' + | 'standalone' | 'client' | 'sessionTabs' | 'setMarkdownDocs' @@ -39,6 +40,7 @@ export function useMobileSessionMarkdownActions(scope: MobileSessionMarkdownActi hostId, worktreeId, router, + standalone, client, sessionTabs, setMarkdownDocs, @@ -124,13 +126,14 @@ export function useMobileSessionMarkdownActions(scope: MobileSessionMarkdownActi }, [markdownDocs, sessionTabs, t]) const leaveSession = useCallback(() => { - if (router.canGoBack()) { + // A standalone session has no list beneath; the page's Back hands the exit to the shell. + if (router.canGoBack() || standalone) { router.back() return } // Why: Android back can fire at the root route; replace avoids React Navigation's dev-only GO_BACK warning. router.replace(`/h/${hostId}`) - }, [hostId, router]) + }, [hostId, router, standalone]) // The sheet's Copy and Discard both settle the phone's drafts, so none is offered back next time. const leaveSessionDroppingDrafts = useCallback( diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx index 11e05d21660..e9aacdff0a8 100644 --- a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx @@ -37,6 +37,7 @@ import { useShellPageStorageRefresh } from './use-shell-page-storage-refresh' import { usePageHostSnapshot } from './use-page-host-snapshot' import { useReportedHostAreaServing } from './host-area-serving' import { isHostRouteOf } from './page-route-policy' +import { isStandaloneSessionHref } from '../navigation/host-stack-navigation' import { nativeStackReturn } from './native-stack-return' import { useResponsiveLayout } from '../layout/responsive-layout' import { storageReadParamsSchema, type BridgeNativeVerb } from './bridge/bridge-native-verbs' @@ -188,7 +189,10 @@ export function MobileWebShellScreen({ router.dismissTo(href) return } - return void (replace ? router.replace(href) : router.push(href)) + // A standalone session takes the host list's place, so Back leaves the host. + return void (replace || isStandaloneSessionHref(href) + ? router.replace(href) + : router.push(href)) } const openTarget = usePageOpenTarget({ route, diff --git a/mobile/src/navigation/host-stack-navigation.ts b/mobile/src/navigation/host-stack-navigation.ts index d7c81415119..6f01d9cecd3 100644 --- a/mobile/src/navigation/host-stack-navigation.ts +++ b/mobile/src/navigation/host-stack-navigation.ts @@ -75,11 +75,26 @@ export type PendingHostStackNavigation = Readonly<{ export const HOST_SESSION_ROUTE = '[hostId]/session/[worktreeId]' const HOST_INDEX_ROUTE = '[hostId]/index' -// A session keeps its host's list beneath it so Back lands there; other targets take the list's place. -function opensOverHostList(target: HostStackRouteTarget): boolean { +// A session opened in its host list's place (Home's agent rows), so Back leaves the host instead of showing the list. +export const STANDALONE_SESSION_PARAM = 'standalone' + +export function isStandaloneSessionHref(href: string): boolean { + const [path = '', query = ''] = (href.split('#')[0] ?? '').split('?') + return ( + /^\/h\/[^/]+\/session\//.test(path) && + query.split('&').includes(`${STANDALONE_SESSION_PARAM}=1`) + ) +} + +function isSessionRoute(target: HostStackRouteTarget): boolean { return target.name === HOST_SESSION_ROUTE } +// A session keeps its host's list beneath it so Back lands there, unless it is standalone; other targets take the list's place. +function opensOverHostList(target: HostStackRouteTarget): boolean { + return isSessionRoute(target) && target.params[STANDALONE_SESSION_PARAM] !== '1' +} + const SETTLED_NAVIGATION: HostStackNavigationController = { cancel: () => {}, isActive: () => false, @@ -183,7 +198,7 @@ export function navigateToHostStackRoute( return SETTLED_NAVIGATION } // The host screen opens the session inside its document when the page it activates can, else pushes it as before. - const href = openInHostDocument && opensOverHostList(target) ? sessionOpenHref(target) : null + const href = openInHostDocument && isSessionRoute(target) ? sessionOpenHref(target) : null // useLocalSearchParams decodes every value once more, which would undo the target's own escapes. const open = href === null ? null : encodeURIComponent(href) const inThisHost = hostState?.key && hostParamMatches(focused?.params?.hostId, hostId) @@ -197,8 +212,17 @@ export function navigateToHostStackRoute( return SETTLED_NAVIGATION } // Already inside this host: pushing the host route again would stack a second list. - if (opensOverHostList(target) && inThisHost) { - navigation.dispatch({ type: 'PUSH', target: hostState.key, payload: target }) + if (isSessionRoute(target) && inThisHost) { + if (!opensOverHostList(target) && focused?.key && focused.name === HOST_INDEX_ROUTE) { + navigation.dispatch({ + type: 'REPLACE', + target: hostState.key, + source: focused.key, + payload: target + }) + } else { + navigation.dispatch({ type: 'PUSH', target: hostState.key, payload: target }) + } return SETTLED_NAVIGATION } return open === null @@ -261,7 +285,7 @@ function openWhenHostStackCommits( return } dispose() - if (latest && opensOverHostList(latest[0])) { + if (latest && isSessionRoute(latest[0])) { navigateToHostStackRoute(navigation, router, hostId, ...latest) } else if (latest) { navigation.dispatch({ diff --git a/mobile/src/navigation/use-reported-page-location.web.ts b/mobile/src/navigation/use-reported-page-location.web.ts index 4cbb148b469..37e2c5678a2 100644 --- a/mobile/src/navigation/use-reported-page-location.web.ts +++ b/mobile/src/navigation/use-reported-page-location.web.ts @@ -4,6 +4,7 @@ import { normalizeExecutionHostId } from '../../../src/shared/execution-host' import { usePageBridgeClient } from '../transport/connection/client-context.web' import { firstParam } from './route-param-reader' import { workspaceRouteHref } from './workspace-execution-host' +import { STANDALONE_SESSION_PARAM } from './host-stack-navigation' // Tell the shell which session this document shows, so it can treat a notification for it as already seen. export function useReportedPageLocation(): void { @@ -13,13 +14,16 @@ export function useReportedPageLocation(): void { hostId?: string | string[] worktreeId?: string | string[] executionHost?: string | string[] + standalone?: string | string[] }>() const host = `/h/${encodeURIComponent(firstParam(params.hostId))}` const worktreeId = firstParam(params.worktreeId) + // Keep standalone so a restored document again opens without the list beneath. + const standalone = firstParam(params[STANDALONE_SESSION_PARAM]) === '1' const href = segments.at(-2) === 'session' && worktreeId !== '' ? workspaceRouteHref( - `${host}/session/${encodeURIComponent(worktreeId)}`, + `${host}/session/${encodeURIComponent(worktreeId)}${standalone ? `?${STANDALONE_SESSION_PARAM}=1` : ''}`, normalizeExecutionHostId(firstParam(params.executionHost)) ?? undefined ) : host diff --git a/mobile/src/navigation/use-shell-open-target.web.ts b/mobile/src/navigation/use-shell-open-target.web.ts index 80a8a6724ca..1db6151f2f4 100644 --- a/mobile/src/navigation/use-shell-open-target.web.ts +++ b/mobile/src/navigation/use-shell-open-target.web.ts @@ -10,6 +10,7 @@ import type { BridgeInitRoute } from '../mobile-web-shell/bridge/bridge-init-rou import { BRIDGE_OPEN_ROUTE_PARAM } from '../mobile-web-shell/bridge/bridge-route-update' import { usePageBridgeClient } from '../transport/connection/client-context.web' import { createRefusalReporter, handOffRoute, servedHere } from './route-handoff.web' +import { isStandaloneSessionHref } from './host-stack-navigation' type StackState = ReturnType['state'] type StackRoute = { name: string; params?: object } @@ -74,7 +75,9 @@ function planOpenSplice( return null } delete params[BRIDGE_OPEN_ROUTE_PARAM] - return { target, state: { ...state, index: 1, routes: [{ ...only, params }, opened] } } + // A standalone session takes the list's place, leaving no in-page Back to the list. + const routes = isStandaloneSessionHref(target) ? [opened] : [{ ...only, params }, opened] + return { target, state: { ...state, index: routes.length - 1, routes } } } function decodedPathnameOf(href: string): string { @@ -122,7 +125,11 @@ export function useShellOpenTarget(): boolean { } return } - router.push(target) + if (isStandaloneSessionHref(target)) { + router.replace(target) + } else { + router.push(target) + } } const open = (target: string): void => { if (!isThisHost(client, target)) { diff --git a/mobile/src/session/mobile-session-route.test.ts b/mobile/src/session/mobile-session-route.test.ts index 7cbd6402b54..8481b760991 100644 --- a/mobile/src/session/mobile-session-route.test.ts +++ b/mobile/src/session/mobile-session-route.test.ts @@ -67,4 +67,35 @@ describe('mobile session route', () => { payload: target }) }) + + it('mounts the host, then puts a standalone session in place of its list', () => { + const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + const target = mobileSessionRouteTarget({ + hostId: 'host/one', + worktreeId: 'repo::/tmp/wt', + standalone: true + }) + + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + target + ) + harness.setState(mountedHostState('host/one')) + + expect(harness.navigation.dispatch.mock.calls).toEqual([ + [ + { + type: 'REPLACE', + target: '/h', + source: 'host-index', + payload: { + name: '[hostId]/session/[worktreeId]', + params: { hostId: 'host/one', worktreeId: 'repo::/tmp/wt', standalone: '1' } + } + } + ] + ]) + }) }) diff --git a/mobile/src/session/mobile-session-route.ts b/mobile/src/session/mobile-session-route.ts index ae2dfc26253..da6e1b5099f 100644 --- a/mobile/src/session/mobile-session-route.ts +++ b/mobile/src/session/mobile-session-route.ts @@ -1,5 +1,9 @@ import type { ExecutionHostId } from '../../../src/shared/execution-host' -import { HOST_SESSION_ROUTE, type HostStackRouteTarget } from '../navigation/host-stack-navigation' +import { + HOST_SESSION_ROUTE, + STANDALONE_SESSION_PARAM, + type HostStackRouteTarget +} from '../navigation/host-stack-navigation' export type MobileSessionRouteParams = { hostId: string @@ -8,6 +12,8 @@ export type MobileSessionRouteParams = { name?: string /** The server a `runtime:` workspace runs on; absent for the desktop's own workspaces. */ executionHost?: ExecutionHostId + /** Opens in place of the host's list, so Back returns to the opener. */ + standalone?: boolean } // Leave identities raw because the navigator owns parameter encoding. @@ -16,7 +22,8 @@ export function mobileSessionRouteTarget({ worktreeId, name, paneKey, - executionHost + executionHost, + standalone }: MobileSessionRouteParams): HostStackRouteTarget { return { name: HOST_SESSION_ROUTE, @@ -25,7 +32,8 @@ export function mobileSessionRouteTarget({ worktreeId, ...(name ? { name } : {}), ...(paneKey ? { paneKey } : {}), - ...(executionHost ? { executionHost } : {}) + ...(executionHost ? { executionHost } : {}), + ...(standalone ? { [STANDALONE_SESSION_PARAM]: '1' } : {}) } } } diff --git a/mobile/src/session/use-mobile-session-foundation.ts b/mobile/src/session/use-mobile-session-foundation.ts index cdc21bbabb0..9f54449fcda 100644 --- a/mobile/src/session/use-mobile-session-foundation.ts +++ b/mobile/src/session/use-mobile-session-foundation.ts @@ -2,6 +2,7 @@ import { useState } from 'react' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useLocalSearchParams } from 'expo-router' import { useRouteHandoff } from '../navigation/route-handoff' +import { STANDALONE_SESSION_PARAM } from '../navigation/host-stack-navigation' import { HOST_DOCK_MIN_WIDTH } from '../storage/preferences' import { useForceReconnect } from '../transport/connection/host-client-hooks' import { useWorkspaceClient } from '../worktree/use-workspace-client' @@ -24,13 +25,15 @@ export function useMobileSessionFoundation() { worktreeId, name: routeWorktreeName, created, - warning: createdWarning + warning: createdWarning, + [STANDALONE_SESSION_PARAM]: standaloneParam } = useLocalSearchParams<{ hostId: string worktreeId: string name?: string created?: string warning?: string + [STANDALONE_SESSION_PARAM]?: string }>() const isFolderWorkspaceRoute = worktreeId.startsWith('folder:') const isFloatingWorkspaceRoute = isFloatingWorkspaceWorktreeId(worktreeId) @@ -102,6 +105,7 @@ export function useMobileSessionFoundation() { isFolderWorkspaceRoute, isFloatingWorkspaceRoute, router, + standalone: standaloneParam === '1', insets, client, clientId,