fix(mobile): choose host for new workspace (#11647)

* fix(mobile): choose host for new workspace

* fix(mobile): close stale workspace host picker

* fix(mobile): disambiguate workspace host choices

* fix(mobile): keep host endpoint paths private

* fix(mobile): redact invalid host endpoints

* fix(mobile): handle opaque host endpoints

* fix(mobile): announce host picker options

* fix(mobile): harden workspace host picker

* fix(mobile): preserve host through workspace creation
This commit is contained in:
Brennan Benson 2026-08-06 11:15:36 -07:00 committed by GitHub
parent 92f759bb51
commit f6d0bde6fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 508 additions and 82 deletions

View File

@ -67,6 +67,7 @@ import { leaveHostRoute } from '../../../src/host-route-exit'
import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences'
import {
createInitialHostRouteActionState,
hostNewWorktreeSessionRoute,
resolveHostRouteActionState,
setHostRouteNewWorktreeVisible
} from '../../../src/host-route-action-state'
@ -1403,10 +1404,7 @@ export function HostScreen({
}}
onCreated={(worktreeId, worktreeName) => {
void fetchWorktrees({ allowDuringModal: true })
const params = new URLSearchParams({ name: worktreeName, created: '1' })
navigateFromHostList(
`/h/${hostId}/session/${encodeURIComponent(worktreeId)}?${params.toString()}`
)
navigateFromHostList(hostNewWorktreeSessionRoute(hostId, worktreeId, worktreeName))
}}
onRouteVisibleChange={setShowNewWorktreeVisible}
/>

View File

@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
import { View, Text, StyleSheet, Pressable, FlatList, Alert } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter, useFocusEffect } from 'expo-router'
import { QrCode, Settings, ChevronRight, Terminal, Plus, ListTodo } from 'lucide-react-native'
import { QrCode, Settings, ChevronRight, Terminal, ListTodo } from 'lucide-react-native'
import { ClaudeIcon, OpenAIIcon } from '../src/components/AgentIcons'
import {
type AccountsSnapshot,
@ -41,6 +41,7 @@ import type { ConnectionState, HostCatalogEntry, HostProfile } from '../src/tran
import { triggerMediumImpact } from '../src/platform/haptics'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { MobileHostCard } from '../src/components/MobileHostCard'
import { MobileHomeQuickActions } from '../src/components/MobileHomeQuickActions'
import { TaskProviderLogo } from '../src/components/TaskProviderLogo'
import { ActionSheetModal } from '../src/components/ActionSheetModal'
import { getHostListActionSheetActions } from '../src/host-list-action-sheet-actions'
@ -71,15 +72,8 @@ import {
type HomeResumeCard
} from '../src/worktree/home-resume-card'
import { hostRouteWithNotice } from '../src/host-route-notice'
function endpointLabel(endpoint: string): string {
try {
const url = new URL(endpoint)
return `${url.hostname}${url.port ? `:${url.port}` : ''}`
} catch {
return endpoint
}
}
import { hostNewWorktreeRoute } from '../src/host-route-action-state'
import { hostEndpointLabel } from '../src/transport/host-endpoint-label'
type HomeTaskSettings = {
visibleTaskProviders?: unknown
@ -572,10 +566,11 @@ export default function HomeScreen() {
return items
}, [sortedHosts, hostStates, accountsByHost])
const primaryConnectedHost = useMemo(
() => sortedHosts.find((host) => hostStates[host.id] === 'connected') ?? null,
const connectedHosts = useMemo(
() => sortedHosts.filter((host) => hostStates[host.id] === 'connected'),
[sortedHosts, hostStates]
)
const primaryConnectedHost = connectedHosts[0] ?? null
const primaryTaskProviders = primaryConnectedHost
? (taskProvidersByHost[primaryConnectedHost.id] ?? ['github'])
: []
@ -847,36 +842,11 @@ export default function HomeScreen() {
{renderTaskHomeCard()}
{/* ─── Quick actions ─── */}
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Quick Actions</Text>
<View style={styles.quickActions}>
<Pressable
style={({ pressed }) => [styles.quickAction, pressed && styles.hostCardPressed]}
onPress={() => router.push('/pair-scan')}
>
<View style={styles.quickActionIcon}>
<QrCode size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>Pair Desktop</Text>
</Pressable>
<Pressable
disabled={!primaryConnectedHost}
style={({ pressed }) => [
styles.quickAction,
!primaryConnectedHost && styles.cardDisabled,
pressed && styles.hostCardPressed
]}
onPress={() => {
if (primaryConnectedHost) {
router.push(`/h/${primaryConnectedHost.id}?action=newWorktree`)
}
}}
>
<View style={styles.quickActionIcon}>
<Plus size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>New Workspace</Text>
</Pressable>
</View>
<MobileHomeQuickActions
connectedHosts={connectedHosts}
onPairDesktop={() => router.push('/pair-scan')}
onCreateWorkspace={(hostId) => router.push(hostNewWorktreeRoute(hostId))}
/>
{/* ─── Account usage ─── */}
{accountsHosts.length > 0 ? (
@ -964,7 +934,7 @@ export default function HomeScreen() {
<ActionSheetModal
visible={actionTarget != null}
title={actionTarget?.name}
message={actionTarget ? endpointLabel(actionTarget.endpoint) : undefined}
message={actionTarget ? hostEndpointLabel(actionTarget.endpoint) : undefined}
actions={getHostListActionSheetActions({
host: actionTarget,
state: actionTarget
@ -1185,6 +1155,9 @@ const styles = StyleSheet.create({
paddingRight: spacing.md,
paddingVertical: 12
},
cardDisabled: {
opacity: 0.45
},
taskHomeIcon: {
width: 46,
height: 46,
@ -1278,40 +1251,6 @@ const styles = StyleSheet.create({
marginTop: 4
},
/* ─── Quick actions ─── */
quickActions: {
flexDirection: 'row',
gap: spacing.sm
},
quickAction: {
flex: 1,
flexDirection: 'row',
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.card,
paddingVertical: 10,
paddingHorizontal: 12,
alignItems: 'center',
gap: 10
},
cardDisabled: {
opacity: 0.45
},
quickActionIcon: {
width: 28,
height: 28,
borderRadius: 9,
backgroundColor: 'rgba(255,255,255,0.04)',
alignItems: 'center',
justifyContent: 'center'
},
quickActionLabel: {
fontSize: 12,
fontWeight: '600',
color: colors.textSecondary
},
/* ─── Empty state ─── */
emptyContainer: {
flex: 1

View File

@ -0,0 +1,223 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HostProfile } from '../transport/types'
import { MobileHomeQuickActions } from './MobileHomeQuickActions'
vi.mock('react-native', () => ({
Pressable: 'Pressable',
StyleSheet: { create: (styles: unknown) => styles },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
Plus: 'Plus',
QrCode: 'QrCode'
}))
vi.mock('./PickerModal', async () => {
const React = await import('react')
return {
PickerModal: (props: unknown) => React.createElement('PickerModal', props)
}
})
function host(id: string, name: string, endpoint: string): HostProfile {
return {
id,
name,
endpoint,
deviceToken: `token-${id}`,
publicKeyB64: `key-${id}`,
lastConnected: 1
}
}
describe('MobileHomeQuickActions', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.restoreAllMocks()
})
async function renderQuickActions(connectedHosts: HostProfile[]) {
const onPairDesktop = vi.fn()
const onCreateWorkspace = vi.fn()
const quickActions = (hosts: HostProfile[]) =>
createElement(MobileHomeQuickActions, {
connectedHosts: hosts,
onPairDesktop,
onCreateWorkspace
})
const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) {
throw new Error(String(args[0]))
}
})
await act(async () => {
renderer = create(quickActions(connectedHosts))
})
consoleError.mockRestore()
return {
onCreateWorkspace,
rerender: async (hosts: HostProfile[]) => {
await act(async () => renderer!.update(quickActions(hosts)))
}
}
}
function newWorkspaceButton() {
return renderer!.root.findAllByType('Pressable')[1]
}
function picker() {
return renderer!.root.findByType('PickerModal')
}
it('disables workspace creation without a connected host', async () => {
await renderQuickActions([])
expect(newWorkspaceButton().props.disabled).toBe(true)
expect(newWorkspaceButton().props.accessibilityState).toEqual({ disabled: true })
expect(picker().props.visible).toBe(false)
})
it('opens the only connected host directly', async () => {
const desk = host('desk', 'Desk', 'ws://192.168.1.2:6768')
const callbacks = await renderQuickActions([desk])
act(() => newWorkspaceButton().props.onPress())
expect(callbacks.onCreateWorkspace).toHaveBeenCalledWith('desk')
expect(picker().props.visible).toBe(false)
})
it('asks which host to use when multiple are connected', async () => {
const callbacks = await renderQuickActions([
host('desk', 'Desk', 'ws://192.168.1.2:6768'),
host('laptop', 'Laptop', 'wss://relay.example.com/mobile')
])
act(() => newWorkspaceButton().props.onPress())
expect(callbacks.onCreateWorkspace).not.toHaveBeenCalled()
expect(picker().props.visible).toBe(true)
expect(picker().props.title).toBe('Create Workspace On')
expect(picker().props.options).toEqual([
{ value: 'desk', label: 'Desk', subtitle: '192.168.1.2:6768' },
{ value: 'laptop', label: 'Laptop', subtitle: 'relay.example.com' }
])
act(() => picker().props.onSelect('laptop'))
expect(callbacks.onCreateWorkspace).not.toHaveBeenCalled()
expect(picker().props.visible).toBe(false)
act(() => picker().props.onAfterClose())
expect(callbacks.onCreateWorkspace).toHaveBeenCalledWith('laptop')
})
it('disambiguates path-routed hosts without exposing endpoint paths', async () => {
await renderQuickActions([
host('desk-a', 'Desk', 'wss://gateway.example.com/v1/connect/bearer-secret-a'),
host('desk-b', 'Desk', 'wss://gateway.example.com/v1/connect/bearer-secret-b')
])
act(() => newWorkspaceButton().props.onPress())
expect(picker().props.options).toEqual([
{
value: 'desk-a',
label: 'Desk',
subtitle: 'gateway.example.com · desk-a'
},
{
value: 'desk-b',
label: 'Desk',
subtitle: 'gateway.example.com · desk-b'
}
])
expect(JSON.stringify(picker().props.options)).not.toContain('bearer-secret')
})
it('does not expose malformed legacy endpoint details', async () => {
await renderQuickActions([
host('desk-a', 'Desk', 'gateway.example.com/v1/connect/bearer-secret?token=query-secret'),
host('desk-b', 'Desk', 'localhost:6768/private-secret')
])
act(() => newWorkspaceButton().props.onPress())
expect(picker().props.options).toEqual([
{ value: 'desk-a', label: 'Desk', subtitle: 'Unknown endpoint · desk-a' },
{ value: 'desk-b', label: 'Desk', subtitle: 'Unknown endpoint · desk-b' }
])
expect(JSON.stringify(picker().props.options)).not.toContain('secret')
})
it('closes a stale picker when fewer than two hosts remain connected', async () => {
const desk = host('desk', 'Desk', 'ws://192.168.1.2:6768')
const laptop = host('laptop', 'Laptop', 'wss://relay.example.com/mobile')
const callbacks = await renderQuickActions([desk, laptop])
act(() => newWorkspaceButton().props.onPress())
expect(picker().props.visible).toBe(true)
await callbacks.rerender([desk])
expect(picker().props.visible).toBe(false)
act(() => picker().props.onAfterClose())
await callbacks.rerender([desk, laptop])
expect(picker().props.visible).toBe(false)
})
it('does not reopen a stale picker if its old host set returns while closing', async () => {
const desk = host('desk', 'Desk', 'ws://192.168.1.2:6768')
const laptop = host('laptop', 'Laptop', 'wss://relay.example.com/mobile')
const callbacks = await renderQuickActions([desk, laptop])
act(() => newWorkspaceButton().props.onPress())
await callbacks.rerender([desk])
await callbacks.rerender([desk, laptop])
expect(picker().props.visible).toBe(false)
act(() => picker().props.onAfterClose())
expect(callbacks.onCreateWorkspace).not.toHaveBeenCalled()
})
it('keeps a selected host through an unrelated topology change while closing', async () => {
const desk = host('desk', 'Desk', 'ws://192.168.1.2:6768')
const laptop = host('laptop', 'Laptop', 'wss://relay.example.com/mobile')
const callbacks = await renderQuickActions([desk, laptop])
act(() => newWorkspaceButton().props.onPress())
act(() => picker().props.onSelect('laptop'))
await callbacks.rerender([
desk,
laptop,
host('server', 'Server', 'wss://ssh.example.com/mobile')
])
act(() => picker().props.onAfterClose())
expect(callbacks.onCreateWorkspace).toHaveBeenCalledWith('laptop')
})
it('drops a selection that disconnects while the picker is closing', async () => {
const desk = host('desk', 'Desk', 'ws://192.168.1.2:6768')
const laptop = host('laptop', 'Laptop', 'wss://relay.example.com/mobile')
const callbacks = await renderQuickActions([desk, laptop])
act(() => newWorkspaceButton().props.onPress())
act(() => picker().props.onSelect('laptop'))
await callbacks.rerender([desk])
act(() => picker().props.onAfterClose())
expect(callbacks.onCreateWorkspace).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,159 @@
import { useRef, useState } from 'react'
import { Plus, QrCode } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import type { HostProfile } from '../transport/types'
import { hostEndpointLabel } from '../transport/host-endpoint-label'
import { colors, radii, spacing } from '../theme/mobile-theme'
import { PickerModal } from './PickerModal'
type Props = {
connectedHosts: HostProfile[]
onPairDesktop: () => void
onCreateWorkspace: (hostId: string) => void
}
function hostPickerOptions(hosts: HostProfile[]) {
const entries = hosts.map((host) => ({
host,
endpointLabel: hostEndpointLabel(host.endpoint)
}))
const endpointCounts = new Map<string, number>()
for (const entry of entries) {
const endpointKey = JSON.stringify([entry.host.name, entry.endpointLabel])
endpointCounts.set(endpointKey, (endpointCounts.get(endpointKey) ?? 0) + 1)
}
return entries.map((entry) => {
const endpointKey = JSON.stringify([entry.host.name, entry.endpointLabel])
const endpointCollides = (endpointCounts.get(endpointKey) ?? 0) > 1
const subtitle = endpointCollides
? `${entry.endpointLabel} · ${entry.host.id}`
: entry.endpointLabel
return { value: entry.host.id, label: entry.host.name, subtitle }
})
}
export function MobileHomeQuickActions(props: Props) {
const [hostPickerForHostSet, setHostPickerForHostSet] = useState<string | null>(null)
const pendingHostIdRef = useRef<string | null>(null)
const canCreateWorkspace = props.connectedHosts.length > 0
const hostSetKey = JSON.stringify(props.connectedHosts.map((host) => host.id))
const hostPickerVisible = hostPickerForHostSet === hostSetKey
if (hostPickerForHostSet !== null && !hostPickerVisible) {
setHostPickerForHostSet(null)
}
function handleCreateWorkspace() {
if (props.connectedHosts.length === 1) {
props.onCreateWorkspace(props.connectedHosts[0].id)
return
}
if (props.connectedHosts.length > 1) {
setHostPickerForHostSet(hostSetKey)
}
}
function handleHostSelect(hostId: string) {
pendingHostIdRef.current = hostId
setHostPickerForHostSet(null)
}
function handleHostPickerClosed() {
setHostPickerForHostSet(null)
const hostId = pendingHostIdRef.current
pendingHostIdRef.current = null
if (hostId && props.connectedHosts.some((host) => host.id === hostId)) {
props.onCreateWorkspace(hostId)
}
}
return (
<>
<Text style={styles.sectionHeading}>Quick Actions</Text>
<View style={styles.quickActions}>
<Pressable
accessibilityRole="button"
style={({ pressed }) => [styles.quickAction, pressed && styles.quickActionPressed]}
onPress={props.onPairDesktop}
>
<View style={styles.quickActionIcon}>
<QrCode size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>Pair Desktop</Text>
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: !canCreateWorkspace }}
disabled={!canCreateWorkspace}
style={({ pressed }) => [
styles.quickAction,
!canCreateWorkspace && styles.quickActionDisabled,
pressed && styles.quickActionPressed
]}
onPress={handleCreateWorkspace}
>
<View style={styles.quickActionIcon}>
<Plus size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>New Workspace</Text>
</Pressable>
</View>
<PickerModal
visible={hostPickerVisible}
title="Create Workspace On"
options={hostPickerOptions(props.connectedHosts)}
selected=""
onSelect={handleHostSelect}
onClose={() => setHostPickerForHostSet(null)}
onAfterClose={handleHostPickerClosed}
/>
</>
)
}
const styles = StyleSheet.create({
sectionHeading: {
marginTop: spacing.xl,
marginBottom: spacing.sm,
paddingHorizontal: spacing.xs,
color: colors.textMuted,
fontSize: 11,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.6
},
quickActions: {
flexDirection: 'row',
gap: spacing.sm
},
quickAction: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.card,
backgroundColor: colors.bgPanel
},
quickActionPressed: {
backgroundColor: colors.bgRaised
},
quickActionDisabled: {
opacity: 0.45
},
quickActionIcon: {
width: 28,
height: 28,
borderRadius: 9,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised
},
quickActionLabel: {
color: colors.textSecondary,
fontSize: 12,
fontWeight: '600'
}
})

View File

@ -0,0 +1,66 @@
import { createElement, type ReactNode } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { PickerModal } from './PickerModal'
vi.mock('react-native', () => ({
Pressable: 'Pressable',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({ Check: 'Check' }))
vi.mock('./BottomDrawer', async () => {
const React = await import('react')
return {
BottomDrawer: ({ children }: { children?: ReactNode }) =>
React.createElement('BottomDrawer', null, children)
}
})
describe('PickerModal accessibility', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) {
throw new Error(String(args[0]))
}
})
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.restoreAllMocks()
})
it('announces option rows as actionable with their selection and disabled state', async () => {
await act(async () => {
renderer = create(
createElement(PickerModal, {
visible: true,
title: 'Create Workspace On',
options: [
{ value: 'desk', label: 'Desk' },
{ value: 'laptop', label: 'Laptop', disabled: true }
],
selected: 'desk',
onSelect: vi.fn(),
onClose: vi.fn()
})
)
})
const rows = renderer!.root.findAllByType('Pressable')
expect(rows.map((row) => row.props.accessible)).toEqual([true, true])
expect(rows.map((row) => row.props.accessibilityRole)).toEqual(['button', 'button'])
expect(rows.map((row) => row.props.accessibilityState)).toEqual([
{ disabled: false, selected: true },
{ disabled: true, selected: false }
])
})
})

View File

@ -20,6 +20,7 @@ type Props<T extends string = string> = {
onSelect: (value: T) => void
onLongSelect?: (value: T) => void
onClose: () => void
onAfterClose?: () => void
zIndex?: number
}
@ -36,10 +37,11 @@ export function PickerModal<T extends string = string>({
onSelect,
onLongSelect,
onClose,
onAfterClose,
zIndex
}: Props<T>) {
return (
<BottomDrawer visible={visible} onClose={onClose} zIndex={zIndex}>
<BottomDrawer visible={visible} onClose={onClose} onAfterClose={onAfterClose} zIndex={zIndex}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
</View>
@ -72,6 +74,9 @@ function PickerModalContent<T extends string = string>({
<View key={opt.value}>
{i > 0 && <View style={styles.separator} />}
<Pressable
accessible
accessibilityRole="button"
accessibilityState={{ disabled: Boolean(opt.disabled), selected: isSelected }}
disabled={opt.disabled}
style={({ pressed }) => [
styles.row,

View File

@ -2,11 +2,23 @@ import { describe, expect, it } from 'vitest'
import {
createInitialHostRouteActionState,
hostNewWorktreeRoute,
hostNewWorktreeSessionRoute,
resolveHostRouteActionState,
setHostRouteNewWorktreeVisible
} from './host-route-action-state'
describe('host route action state', () => {
it('encodes opaque host ids in the new-worktree route segment', () => {
expect(hostNewWorktreeRoute('relay/one#50%')).toBe('/h/relay%2Fone%2350%25?action=newWorktree')
})
it('preserves opaque host and worktree ids after creation', () => {
expect(hostNewWorktreeSessionRoute('relay/one#50%', 'repo/one#20%', 'Relay workspace')).toBe(
'/h/relay%2Fone%2350%25/session/repo%2Fone%2320%25?name=Relay+workspace&created=1'
)
})
it('opens new worktree modal on an initial newWorktree action', () => {
expect(createInitialHostRouteActionState('newWorktree')).toEqual({
routeAction: 'newWorktree',

View File

@ -3,6 +3,19 @@ export type HostRouteActionState = {
showNewWorktree: boolean
}
export function hostNewWorktreeRoute(hostId: string): `/h/${string}?action=newWorktree` {
return `/h/${encodeURIComponent(hostId)}?action=newWorktree`
}
export function hostNewWorktreeSessionRoute(
hostId: string,
worktreeId: string,
worktreeName: string
): `/h/${string}/session/${string}?${string}` {
const params = new URLSearchParams({ name: worktreeName, created: '1' })
return `/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent(worktreeId)}?${params}`
}
export function createInitialHostRouteActionState(
routeAction: string | undefined
): HostRouteActionState {

View File

@ -0,0 +1,11 @@
export function hostEndpointLabel(endpoint: string): string {
try {
const url = new URL(endpoint)
if (!url.hostname) {
return 'Unknown endpoint'
}
return `${url.hostname}${url.port ? `:${url.port}` : ''}`
} catch {
return 'Unknown endpoint'
}
}