mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
fix(mobile): return to Home's agent lists in one Back from an agent's session
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
]
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user