Merge remote-tracking branch 'origin/feat/mobile-remote-tab-actions' into feat/mobile-server-ssh-repos

# Conflicts:
#	mobile/src/components/NewWorktreeModal.test.tsx
This commit is contained in:
Jinwoo-H
2026-10-11 22:38:21 -04:00
10 changed files with 221 additions and 172 deletions
@@ -11,6 +11,7 @@ const asyncStorage = vi.hoisted(() => ({
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
AppState: { currentState: 'active' },
FlatList: 'FlatList',
Image: 'Image',
Keyboard: { dismiss: vi.fn() },
@@ -45,8 +46,12 @@ vi.mock('./TaskProviderLogo', () => ({ TaskProviderLogo: 'TaskProviderLogo' }))
import { setCachedRepos } from '../cache/repo-cache'
import { recordHostDescriptor } from '../transport/host-descriptor-store'
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
import { getLocalExecutionHostLabel } from '../../../src/shared/execution-host'
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
import { MOBILE_RELAY_HOSTS_LIST_METHOD } from '../../../src/shared/mobile-relay-hosts-contract'
import { NewWorktreeModal } from './NewWorktreeModal'
import { NewWorktreeModalController } from './NewWorktreeModalController'
const LOCAL_HOST_LABEL = getLocalExecutionHostLabel('darwin')
@@ -548,6 +553,58 @@ describe('NewWorktreeModal project targets', () => {
expect(server).not.toHaveBeenCalledWith('worktree.create', expect.anything(), expect.anything())
})
it("offers the desktop's servers, by name, in a sheet opened before the list has them", async () => {
const serverRepo = { ...repos[0]!, id: 'server-repo', path: '/srv/orca' }
// One desktop: its own repos, its server list, and a server's repos relayed through it.
const desktop = new FakeSession('connected')
desktop.sendRequest.mockImplementation(async (method: string, ...rest: unknown[]) => {
const options = rest[1]
const relayed =
typeof options === 'object' && options !== null && 'executionHost' in options
? options.executionHost
: undefined
const result =
method === MOBILE_RELAY_HOSTS_LIST_METHOD
? {
hosts: [
{ hostId: 'runtime:vm', label: 'scratch-vm', health: 'available', relay: 'ready' }
]
}
: method === 'repo.list'
? { repos: relayed === 'runtime:vm' ? [serverRepo] : repos }
: {}
return { id: 'reply', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
})
const listCalls = () =>
desktop.sendRequest.mock.calls.filter(([method]) => method === MOBILE_RELAY_HOSTS_LIST_METHOD)
// The host list's own names: no server rows have loaded behind a sheet opened from Home.
const sheet = (routeVisible: boolean) =>
createElement(NewWorktreeModalController, {
routeVisible,
client: desktop,
hostCapabilities: [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY],
hostId: 'host-1',
hostLabelById: new Map(),
openExternalUrl: () => {},
onCreated: () => {},
onRouteVisibleChange: () => {}
})
await act(async () => {
renderer = create(sheet(false))
})
await flushUpdates()
// Closed, it asks nothing of the desktop.
expect(listCalls()).toHaveLength(0)
await act(async () => renderer.update(sheet(true)))
await flushUpdates()
expect(pickerItems(renderer, 'Run on').map((item) => [item.label, item.detail])).toEqual([
[expect.any(String), '/src/orca'],
['scratch-vm', '/srv/orca']
])
})
it("lists the desktop's repos at once while a server has not answered", async () => {
const desktop = vi.fn((method: string) =>
method === 'repo.list'
@@ -1,5 +1,9 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from 'react'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
import type { ExecutionHostId } from '../../../src/shared/execution-host'
import { reachableServerClients } from '../transport/execution-host-scoped-rpc-client'
import { useRelayHosts } from '../worktree/relay-hosts-feed'
import { withServerHosts } from '../worktree/server-workspaces'
import { NewWorktreeModal } from './NewWorktreeModal'
import type { NewWorktreeModalProps } from './new-worktree-modal-types'
@@ -7,7 +11,14 @@ export type NewWorktreeModalControllerHandle = {
open: () => void
}
type Props = Omit<NewWorktreeModalProps, 'visible' | 'onClose'> & {
type Props = Omit<
NewWorktreeModalProps,
'visible' | 'onClose' | 'serverClients' | 'hostLabelById'
> & {
/** The desktop's capabilities, which say whether its servers are reachable through it. */
hostCapabilities: readonly string[]
/** The desktop's own host names; the sheet adds its servers' from the feed. */
hostLabelById: ReadonlyMap<ExecutionHostId, string>
routeVisible: boolean
onVisibleChange?: (visible: boolean) => void
onRouteVisibleChange: (visible: boolean) => void
@@ -18,7 +29,7 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
{
routeVisible,
client,
serverClients,
hostCapabilities,
hostId,
hostLabelById,
existingWorktreePaths,
@@ -32,6 +43,16 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
) {
const [manualVisible, setManualVisible] = useState(false)
const visible = routeVisible || manualVisible
// Why the feed, not the list's server rows: the list stops refreshing behind the sheet.
const servers = useRelayHosts(visible ? client : null)
const serverClients = useMemo(
() => reachableServerClients(client, hostCapabilities, servers ?? []),
[client, hostCapabilities, servers]
)
const serverLabelById = useMemo(
() => withServerHosts(hostLabelById, servers ?? [], (host) => host.label),
[hostLabelById, servers]
)
useImperativeHandle(
ref,
@@ -58,7 +79,7 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
client={client}
serverClients={serverClients}
hostId={hostId}
hostLabelById={hostLabelById}
hostLabelById={serverLabelById}
existingWorktreePaths={existingWorktreePaths}
existingWorktrees={existingWorktrees}
openExternalUrl={openExternalUrl}
@@ -0,0 +1,83 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('react-native', () => ({
AppState: { currentState: 'active' },
Linking: { openURL: vi.fn() },
Platform: { OS: 'ios', select: (options: { ios?: unknown }) => options.ios },
Pressable: 'Pressable',
StyleSheet: { create: <T,>(styles: T) => styles },
Text: 'Text',
View: 'View'
}))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('lucide-react-native', () => ({ Check: 'Check', Moon: 'Moon' }))
vi.mock('../agent-history/worktree-navigation-actions', () => ({
buildWorktreeNavigationActions: () => []
}))
vi.mock('../components/ActionSheetModal', () => ({ ActionSheetContent: 'ActionSheetContent' }))
vi.mock('../components/BottomDrawer', () => ({ BottomDrawer: 'BottomDrawer' }))
vi.mock('../components/ConfirmModal', () => ({ ConfirmModal: 'ConfirmModal' }))
vi.mock('../components/NewWorktreeModalController', () => ({
NewWorktreeModalController: 'NewWorktreeModalController'
}))
vi.mock('../components/PickerModal', () => ({ PickerModal: 'PickerModal' }))
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
import type { Worktree } from '../worktree/workspace-list-types'
import { HostScreenOverlays } from './host-screen-overlays'
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
/** The long-press sheet's action labels for `row`. */
async function actionLabels(row: Partial<Worktree>): Promise<string[]> {
const controller = {
actions: {},
catalog: {},
client: new FakeSession('connected'),
connState: 'connected',
existingWorktreePaths: [],
hostCapabilities: [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY],
hostId: 'host-1',
sectionsResult: { uniqueRepos: [] },
settings: { activeFilterCount: 0 },
showNewWorktree: false,
state: {
actionTarget: { worktreeId: 'wt', repo: 'orca', branch: 'main', ...row },
confirmDelete: null,
filters: { filterRepoIds: new Set() },
hostLabelById: new Map(),
pinnedIds: new Set()
}
}
await act(async () => {
renderer = create(
createElement(HostScreenOverlays, {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the action sheet reads only these members; the drawers and pickers that read the rest are mocked.
controller: controller as unknown as Parameters<typeof HostScreenOverlays>[0]['controller']
})
)
})
const sheet = renderer!.root.find((node) => String(node.type) === 'ActionSheetContent')
return sheet.props.actions.map((action: { label: string }) => action.label)
}
describe('the workspace long-press sheet', () => {
it("offers Sleep for the desktop's own workspace", async () => {
expect(await actionLabels({})).toContain('Sleep')
})
it("does not offer Sleep for a server's workspace, which the desktop would sleep by its own id", async () => {
expect(await actionLabels({ worktreeId: 'runtime:vm-wt', hostId: 'runtime:vm' })).toEqual([
'Pin',
'Delete'
])
})
})
+15 -21
View File
@@ -1,4 +1,3 @@
import { useState } from 'react'
import { openExternalLink } from '../platform/external-link'
import { Pressable, Text, View } from 'react-native'
import { Check, Moon } from 'lucide-react-native'
@@ -19,7 +18,6 @@ import {
} from '../worktree/workspace-list-picker-options'
import { isWorktreePinned } from '../worktree/workspace-list-sections'
import { hostScreenStyles as styles } from './host-screen-styles'
import { useNewWorkspaceServers } from './use-host-display-worktrees'
import type { HostScreenController } from './use-host-screen-controller'
export function HostScreenOverlays({ controller }: { controller: HostScreenController }) {
@@ -31,7 +29,6 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
existingWorktreePaths,
hostCapabilities,
hostId,
hostLabels,
settings,
showNewWorktree,
state
@@ -44,13 +41,6 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
desktopCapabilities: hostCapabilities,
executionHost: actionTarget?.hostId
})
const [newWorktreeVisible, setNewWorktreeVisible] = useState(false)
const newWorkspaceServers = useNewWorkspaceServers({
client,
hostCapabilities,
hostLabelById: hostLabels.hostLabelById,
open: newWorktreeVisible
})
return (
<>
@@ -189,14 +179,19 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
navigate: actions.navigateFromHostList,
onDone: () => state.setActionTarget(null)
}),
{
label: 'Sleep',
icon: Moon,
onPress: () => {
actions.putWorktreeToSleep(actionTarget)
state.setActionTarget(null)
}
},
// Server sleep is not offered yet; the desktop would sleep its own id.
...(actionServer.server
? []
: [
{
label: 'Sleep',
icon: Moon,
onPress: () => {
actions.putWorktreeToSleep(actionTarget)
state.setActionTarget(null)
}
}
]),
{
label: isWorktreePinned(actionTarget, state.pinnedIds) ? 'Unpin' : 'Pin',
onPress: () => {
@@ -231,9 +226,9 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
ref={state.newWorktreeModalRef}
routeVisible={showNewWorktree}
client={client}
serverClients={newWorkspaceServers.serverClients}
hostCapabilities={hostCapabilities}
hostId={hostId}
hostLabelById={newWorkspaceServers.hostLabelById}
hostLabelById={state.hostLabelById}
existingWorktreePaths={existingWorktreePaths}
existingWorktrees={state.worktrees}
// The seam, not react-native's `Linking`: this screen is in the tasks page closure, and
@@ -241,7 +236,6 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
openExternalUrl={openExternalLink}
onVisibleChange={(visible) => {
state.newWorktreeModalVisibleRef.current = visible
setNewWorktreeVisible(visible)
}}
onCreated={(worktreeId, worktreeName, warning, executionHost) => {
void catalog.fetchWorktrees({ allowDuringModal: true })
@@ -26,7 +26,7 @@ import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
import type { RpcResponse } from '../transport/types'
import { readVisibleHostIds } from '../worktree/visible-host-rows'
import type { Worktree } from '../worktree/workspace-list-types'
import { useHostDisplayWorktrees, useHostLabelSources } from './use-host-display-worktrees'
import { useHostDisplayWorktrees } from './use-host-display-worktrees'
import { useHostScreenState } from './use-host-screen-state'
import { useHostWorktreeActions } from './use-host-worktree-actions'
import { useHostWorktreeCatalog } from './use-host-worktree-catalog'
@@ -149,7 +149,7 @@ async function mountScreen(first: FakeSession, hostCapabilities: string[]): Prom
})
held.state = state
held.fetch = catalog.fetchWorktrees
held.rows = useHostDisplayWorktrees('connected', state, useHostLabelSources('host-1', state))
held.rows = useHostDisplayWorktrees('connected', state, 'host-1')
return null
}
await act(async () => {
@@ -13,7 +13,7 @@ vi.mock('../cache/repo-cache', () => ({ setCachedRepos: () => {} }))
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
import type { RpcResponse } from '../transport/types'
import type { Worktree } from '../worktree/workspace-list-types'
import { useHostDisplayWorktrees, useHostLabelSources } from './use-host-display-worktrees'
import { useHostDisplayWorktrees } from './use-host-display-worktrees'
import { useHostRepoMetadata } from './use-host-repo-metadata'
import { useHostScreenState } from './use-host-screen-state'
@@ -70,7 +70,7 @@ async function displayedRows(args: {
state.clientRef.current = client
setWorktrees = state.setWorktrees
seen.refresh = useHostRepoMetadata({ client, connState: 'connected', hostId: 'host-1', state })
seen.rows = useHostDisplayWorktrees('connected', state, useHostLabelSources('host-1', state))
seen.rows = useHostDisplayWorktrees('connected', state, 'host-1')
return null
}
await act(async () => {
@@ -1,65 +0,0 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
vi.mock('react-native', () => ({ AppState: { currentState: 'active' } }))
vi.mock('../transport/host-descriptor-store', () => ({ useHostDescriptor: () => null }))
import type { ExecutionHostId } from '../../../src/shared/execution-host'
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
import { MOBILE_RELAY_HOSTS_LIST_METHOD } from '../../../src/shared/mobile-relay-hosts-contract'
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
import { useNewWorkspaceServers } from './use-host-display-worktrees'
const RELAYS = [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY]
// The host list's own labels: what a sheet opened from Home sees before the list loads a server.
const DESKTOP_LABELS: ReadonlyMap<ExecutionHostId, string> = new Map()
/** A desktop that lists one ready server. */
function desktop(): FakeSession {
const session = new FakeSession('connected')
session.sendRequest.mockImplementation(async (method: string) => ({
id: 'reply',
ok: true,
result:
method === MOBILE_RELAY_HOSTS_LIST_METHOD
? { hosts: [{ hostId: 'runtime:vm', label: 'vm', health: 'available', relay: 'ready' }] }
: {},
_meta: { runtimeId: 'runtime-1' }
}))
return session
}
function listCalls(session: FakeSession): number {
return session.sendRequest.mock.calls.filter(
([method]) => method === MOBILE_RELAY_HOSTS_LIST_METHOD
).length
}
describe('the servers a new workspace can run on', () => {
it('are offered, by name, when the sheet opens before the host list has loaded them', async () => {
const client = desktop()
const seen: { value: ReturnType<typeof useNewWorkspaceServers> | null } = { value: null }
function Sheet({ open }: { open: boolean }): null {
seen.value = useNewWorkspaceServers({
client,
hostCapabilities: RELAYS,
hostLabelById: DESKTOP_LABELS,
open
})
return null
}
const rendered: { tree: ReturnType<typeof create> | null } = { tree: null }
await act(async () => {
rendered.tree = create(createElement(Sheet, { open: false }))
})
// Closed, it asks nothing of the desktop.
expect(listCalls(client)).toBe(0)
expect([...(seen.value?.serverClients.keys() ?? [])]).toEqual([])
await act(async () => {
rendered.tree?.update(createElement(Sheet, { open: true }))
})
expect([...(seen.value?.serverClients.keys() ?? [])]).toEqual(['runtime:vm'])
expect(seen.value?.hostLabelById.get('runtime:vm')).toBe('vm')
})
})
@@ -1,24 +1,18 @@
import { useMemo } from 'react'
import type { ExecutionHostId } from '../../../src/shared/execution-host'
import { reachableServerClients } from '../transport/execution-host-scoped-rpc-client'
import { useHostDescriptor } from '../transport/host-descriptor-store'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import { useRelayHosts } from '../worktree/relay-hosts-feed'
import { serverHostHealth } from '../worktree/server-workspaces'
import { serverHostHealth, withServerHosts } from '../worktree/server-workspaces'
import { filterVisibleHostRows } from '../worktree/visible-host-rows'
import {
applyWorktreeHostContextLabels,
type HostLabelSources
} from '../worktree/worktree-host-context-labels'
import { applyWorktreeHostContextLabels } from '../worktree/worktree-host-context-labels'
import { applyWorktreeRowDisplayState } from '../worktree/worktree-host-row-identity'
import type { HostScreenState } from './use-host-screen-state'
/** What names each host on this screen: the row badges and the new-workspace run targets. */
export function useHostLabelSources(
hostId: string | undefined,
state: HostScreenState
): HostLabelSources {
/** The rows the host list renders: the live or last-known catalog with display state and host badges. */
export function useHostDisplayWorktrees(
connState: ConnectionState,
state: HostScreenState,
hostId: string | undefined
) {
const { serverWorkspaces } = state
// Why: a server's label and health come with its rows, alongside the desktop's SSH ones.
const hostLabelById = useMemo(
@@ -32,84 +26,35 @@ export function useHostLabelSources(
const descriptorPlatform = useHostDescriptor(hostId)?.platform ?? null
// Why: metadata reads the platform only for a mixed desktop catalog; server rows mix it too.
const hostPlatform = state.hostPlatform ?? descriptorPlatform
return useMemo(
() => ({
repoHostIdByRepoId: state.repoHostIdByRepoId,
hostLabelById,
hostPlatform,
hostHealthById
}),
[state.repoHostIdByRepoId, hostLabelById, hostPlatform, hostHealthById]
)
}
/**
* The servers the new-workspace sheet offers, and their names, read from the desktop's server-list
* feed while it is open: the list's own server rows stop refreshing behind the sheet.
*/
export function useNewWorkspaceServers(args: {
client: RpcClient | null
hostCapabilities: readonly string[]
hostLabelById: ReadonlyMap<ExecutionHostId, string>
open: boolean
}): {
serverClients: ReadonlyMap<ExecutionHostId, RpcClient>
hostLabelById: ReadonlyMap<ExecutionHostId, string>
} {
const { client, hostCapabilities, hostLabelById, open } = args
const hosts = useRelayHosts(open ? client : null)
return useMemo(() => {
const listed = hosts ?? []
return {
serverClients: reachableServerClients(client, hostCapabilities, listed),
hostLabelById: withServerHosts(hostLabelById, listed, (host) => host.label)
}
}, [client, hostCapabilities, hostLabelById, hosts])
}
/** The rows the host list renders: the live or last-known catalog with display state and host badges. */
export function useHostDisplayWorktrees(
connState: ConnectionState,
state: HostScreenState,
labels: HostLabelSources
) {
return useMemo(() => {
// Why: live `worktrees` is authoritative only while connected; under the amber
// mount default, connecting/handshaking must keep the pre-reconnect list too.
const base = connState === 'connected' ? state.worktrees : state.lastKnownWorktrees
const rows = filterVisibleHostRows(
[...base, ...state.serverWorkspaces.worktrees],
[...base, ...serverWorkspaces.worktrees],
state.visibleHostIds,
state.repoHostIdByRepoId
)
return applyWorktreeHostContextLabels(
applyWorktreeRowDisplayState(rows, state.sleptIds, state.optimisticActiveWorktreeIdentity),
labels
{
repoHostIdByRepoId: state.repoHostIdByRepoId,
hostLabelById,
hostPlatform,
hostHealthById
}
)
}, [
connState,
state.worktrees,
state.lastKnownWorktrees,
state.serverWorkspaces.worktrees,
serverWorkspaces.worktrees,
state.visibleHostIds,
state.sleptIds,
state.optimisticActiveWorktreeIdentity,
state.repoHostIdByRepoId,
labels
hostLabelById,
hostPlatform,
hostHealthById
])
}
function withServerHosts<Value>(
desktop: ReadonlyMap<ExecutionHostId, Value>,
hosts: HostScreenState['serverWorkspaces']['hosts'],
read: (host: HostScreenState['serverWorkspaces']['hosts'][number]) => Value
): ReadonlyMap<ExecutionHostId, Value> {
if (hosts.length === 0) {
return desktop
}
const merged = new Map(desktop)
for (const host of hosts) {
merged.set(host.hostId, read(host))
}
return merged
}
@@ -15,7 +15,7 @@ import {
useRelayRecoveryStatus
} from '../transport/client-context-connection-metrics'
import { useWorkspaceSections } from '../worktree/use-workspace-sections'
import { useHostDisplayWorktrees, useHostLabelSources } from './use-host-display-worktrees'
import { useHostDisplayWorktrees } from './use-host-display-worktrees'
import { useHostRepoMetadata } from './use-host-repo-metadata'
import { useHostScreenIdentity } from './use-host-screen-identity'
import { useHostScreenState } from './use-host-screen-state'
@@ -103,8 +103,7 @@ export function useHostScreenController({
}
const showNewWorktree = resolvedRouteActionState.showNewWorktree
const hostLabels = useHostLabelSources(hostId, state)
const displayWorktrees = useHostDisplayWorktrees(connState, state, hostLabels)
const displayWorktrees = useHostDisplayWorktrees(connState, state, hostId)
const sectionsResult = useWorkspaceSections({
displayWorktrees,
sortMode: state.sortMode,
@@ -136,7 +135,6 @@ export function useHostScreenController({
hostCapabilities,
hostDisplay,
hostId,
hostLabels,
insets,
isReadOnly: connState === 'auth-failed',
isWideLayout,
+16
View File
@@ -19,6 +19,22 @@ export function serverHostHealth(host: MobileRelayHost): ExecutionHostHealth {
return host.relay === 'update-needed' ? 'blocked' : host.health
}
/** `desktop`'s per-host values with each server's read from its feed entry. */
export function withServerHosts<Value>(
desktop: ReadonlyMap<ExecutionHostId, Value>,
hosts: readonly MobileRelayHost[],
read: (host: MobileRelayHost) => Value
): ReadonlyMap<ExecutionHostId, Value> {
if (hosts.length === 0) {
return desktop
}
const merged = new Map(desktop)
for (const host of hosts) {
merged.set(host.hostId, read(host))
}
return merged
}
function serverRowSchema(hostId: ExecutionHostId) {
return z.custom<Worktree>(
(row) =>