fix(mobile): return to Home's agent lists in one Back from an agent's session

This commit is contained in:
Kelvin Amoaba
2026-11-12 11:31:06 +00:00
parent d0a4484754
commit bef24d841b
10 changed files with 106 additions and 17 deletions
@@ -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<string, string> = {
...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 !== '')
),
@@ -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]
@@ -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(
@@ -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,
+30 -6
View File
@@ -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({
@@ -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
@@ -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<typeof Navigator.useContext>['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)) {
@@ -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' }
}
}
]
])
})
})
+11 -3
View File
@@ -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' } : {})
}
}
}
@@ -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,