Harden mobile Floating Workspace routing

This commit is contained in:
Brennan Benson
2026-07-20 11:55:33 -07:00
parent 6c063f7952
commit d4dc99fa06
8 changed files with 267 additions and 56 deletions
+34 -50
View File
@@ -172,11 +172,8 @@ import {
getMobileSessionTabTitle,
resolveMobileTerminalTabAgentId
} from '../../../../src/session/mobile-terminal-tab-agent'
import {
buildMobileNewTabAgentOptions,
type MobileNewTabAgentOption,
type MobileNewTabAgentSettings
} from '../../../../src/session/mobile-new-tab-agent-options'
import type { MobileNewTabAgentOption } from '../../../../src/session/mobile-new-tab-agent-options'
import { loadMobileNewTabAgentOptions } from '../../../../src/session/mobile-new-tab-agent-loader'
import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment'
import { useMobileAttachmentInputLeaseGate } from '../../../../src/session/use-mobile-attachment-input-lease-gate'
import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste'
@@ -836,11 +833,13 @@ export default function SessionScreen() {
const { isWideLayout } = useResponsiveLayout()
const [activePanel, setActivePanel] = useState<ActivePanel>(null)
const [sessionContentRowWidth, setSessionContentRowWidth] = useState(0)
const canDockPanel = canDockSessionPanel({
isWideLayout,
availableWidth: sessionContentRowWidth,
dockWidth: HOST_DOCK_MIN_WIDTH
})
const canDockPanel =
!isFloatingWorkspaceRoute &&
canDockSessionPanel({
isWideLayout,
availableWidth: sessionContentRowWidth,
dockWidth: HOST_DOCK_MIN_WIDTH
})
// Why: if rotation/split-screen makes the docked row too narrow, clear activePanel so it doesn't survive into overlay/push mode.
useEffect(() => {
if (!canDockPanel && activePanel !== null) {
@@ -2626,7 +2625,7 @@ export default function SessionScreen() {
showToast('Open Orca on the host to wake sleeping agents.', 3000)
}
}
if (client && created !== '1') {
if (client && created !== '1' && !isFloatingWorkspaceRoute) {
// Why: hydrate host-owned tabs without pulling other paired clients (esp. desktop) into this worktree.
void client
.sendRequest('worktree.activate', {
@@ -2649,7 +2648,7 @@ export default function SessionScreen() {
}
addTimer(() => void fetchTerminals({ allowEmptyLoaded: false }), 750)
addTimer(() => void fetchTerminals({ allowEmptyLoaded: true }), 1500)
if (client && created === '1') {
if (client && created === '1' && !isFloatingWorkspaceRoute) {
addTimer(() => {
if (activeHandleRef.current) {
return
@@ -2677,7 +2676,16 @@ export default function SessionScreen() {
clearTimeout(t)
}
}
}, [client, connState, created, fetchSessionTabs, fetchTerminals, showToast, worktreeId])
}, [
client,
connState,
created,
fetchSessionTabs,
fetchTerminals,
isFloatingWorkspaceRoute,
showToast,
worktreeId
])
useEffect(() => {
if (!client || connState !== 'connected') {
@@ -3582,7 +3590,8 @@ export default function SessionScreen() {
}, [])
const getActiveWorktreeConnectionId = useCallback(async (): Promise<string | null> => {
if (!client) {
// Why: the floating workspace always runs on the paired host itself, never an SSH repo target.
if (!client || isFloatingWorkspaceRoute) {
return null
}
const repoId = getRepoIdFromMobileWorktreeId(worktreeId)
@@ -3593,7 +3602,7 @@ export default function SessionScreen() {
const repos =
((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos ?? []
return repos.find((repo) => repo.id === repoId)?.connectionId?.trim() || null
}, [client, worktreeId])
}, [client, isFloatingWorkspaceRoute, worktreeId])
const refreshCanPaste = useCallback(() => {
void Promise.all([
@@ -3690,43 +3699,14 @@ export default function SessionScreen() {
setCreateTabAgentOptions([])
void (async () => {
const [settingsResponse, repoResponse] = await Promise.all([
client.sendRequest('settings.get'),
client.sendRequest('repo.list')
])
if (!settingsResponse.ok) {
throw new Error((settingsResponse as RpcFailure).error.message)
}
const settings = (
(settingsResponse as RpcSuccess).result as {
settings?: MobileNewTabAgentSettings
}
).settings
if (!repoResponse.ok) {
throw new Error((repoResponse as RpcFailure).error.message)
}
const repoId = getRepoIdFromMobileWorktreeId(worktreeId)
if (!repoId) {
throw new Error('worktree_repo_missing')
}
const repos =
((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos ?? []
const repo = repos.find((candidate) => candidate.id === repoId)
if (!repo) {
throw new Error('worktree_repo_not_found')
}
const connectionId = repo.connectionId?.trim() || null
const detectedResponse = connectionId
? await client.sendRequest('preflight.detectRemoteAgents', { connectionId })
: await client.sendRequest('preflight.detectAgents')
if (!detectedResponse.ok) {
throw new Error((detectedResponse as RpcFailure).error.message)
}
const options = await loadMobileNewTabAgentOptions({
client,
worktreeId
})
if (stale) {
return
}
const detectedAgentIds = (detectedResponse as RpcSuccess).result as unknown[]
setCreateTabAgentOptions(buildMobileNewTabAgentOptions(settings, detectedAgentIds))
setCreateTabAgentOptions(options)
setCreateTabAgentLoadState('loaded')
})().catch(() => {
if (!stale) {
@@ -5084,7 +5064,11 @@ export default function SessionScreen() {
visible={showQuickCommands && quickCommandsSupported === true}
onClose={() => setShowQuickCommands(false)}
client={client}
repoId={isFolderWorkspaceRoute ? null : getRepoIdFromMobileWorktreeId(worktreeId) || null}
repoId={
isFolderWorkspaceRoute || isFloatingWorkspaceRoute
? null
: getRepoIdFromMobileWorktreeId(worktreeId) || null
}
repoName={worktreeName || null}
onLaunch={launchQuickCommand}
/>
@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { FLOATING_WORKSPACE_WORKTREE_ID } from './floating-workspace'
import { loadMobileNewTabAgentOptions } from './mobile-new-tab-agent-loader'
function createClient(
handler: (method: string, params?: unknown) => Promise<unknown>
): RpcClient & { sendRequest: ReturnType<typeof vi.fn> } {
return {
sendRequest: vi.fn(handler),
subscribe: vi.fn(() => () => {})
} as unknown as RpcClient & { sendRequest: ReturnType<typeof vi.fn> }
}
describe('mobile new-tab agent loading', () => {
it('detects agents locally for the floating workspace without listing repos', async () => {
const client = createClient(async (method) => {
if (method === 'settings.get') {
return {
ok: true,
result: { settings: { defaultTuiAgent: 'codex', disabledTuiAgents: [] } }
}
}
if (method === 'preflight.detectAgents') {
return { ok: true, result: ['claude', 'codex'] }
}
throw new Error(`unexpected request: ${method}`)
})
await expect(
loadMobileNewTabAgentOptions({
client,
worktreeId: FLOATING_WORKSPACE_WORKTREE_ID
})
).resolves.toEqual([
{ agent: 'codex', label: 'Codex' },
{ agent: 'claude', label: 'Claude' }
])
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'preflight.detectAgents',
'settings.get'
])
})
it('detects agents through the worktree repo connection for SSH sessions', async () => {
const client = createClient(async (method, params) => {
if (method === 'settings.get') {
return { ok: true, result: { settings: {} } }
}
if (method === 'repo.list') {
return { ok: true, result: { repos: [{ id: 'repo-1', connectionId: 'ssh-1' }] } }
}
if (method === 'preflight.detectRemoteAgents') {
expect(params).toEqual({ connectionId: 'ssh-1' })
return { ok: true, result: ['claude'] }
}
throw new Error(`unexpected request: ${method}`)
})
await expect(
loadMobileNewTabAgentOptions({
client,
worktreeId: 'repo-1::/remote/worktree'
})
).resolves.toEqual([{ agent: 'claude', label: 'Claude' }])
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'repo.list',
'settings.get',
'preflight.detectRemoteAgents'
])
})
})
@@ -0,0 +1,62 @@
import type { RpcClient } from '../transport/rpc-client'
import type { RpcFailure, RpcSuccess } from '../transport/types'
import { isFloatingWorkspaceWorktreeId } from './floating-workspace'
import { getRepoIdFromMobileWorktreeId } from './mobile-session-route-helpers'
import {
buildMobileNewTabAgentOptions,
type MobileNewTabAgentOption,
type MobileNewTabAgentSettings
} from './mobile-new-tab-agent-options'
type RuntimeRepoSummary = {
id: string
connectionId?: string | null
}
export async function loadMobileNewTabAgentOptions(args: {
client: RpcClient
worktreeId: string
}): Promise<MobileNewTabAgentOption[]> {
const { client, worktreeId } = args
// Why: the floating workspace runs on the paired host, so it has no repo connection to resolve.
const detectedAgentsRequest = isFloatingWorkspaceWorktreeId(worktreeId)
? client.sendRequest('preflight.detectAgents')
: loadWorkspaceDetectedAgents(client, worktreeId)
const [settingsResponse, detectedResponse] = await Promise.all([
client.sendRequest('settings.get'),
detectedAgentsRequest
])
if (!settingsResponse.ok) {
throw new Error((settingsResponse as RpcFailure).error.message)
}
if (!detectedResponse.ok) {
throw new Error((detectedResponse as RpcFailure).error.message)
}
const settings = (
(settingsResponse as RpcSuccess).result as {
settings?: MobileNewTabAgentSettings
}
).settings
return buildMobileNewTabAgentOptions(
settings,
(detectedResponse as RpcSuccess).result as unknown[]
)
}
async function loadWorkspaceDetectedAgents(client: RpcClient, worktreeId: string) {
const repoResponse = await client.sendRequest('repo.list')
if (!repoResponse.ok) {
throw new Error((repoResponse as RpcFailure).error.message)
}
const repoId = getRepoIdFromMobileWorktreeId(worktreeId)
const repos =
((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos ?? []
const repo = repos.find((candidate) => candidate.id === repoId)
if (!repo) {
throw new Error('worktree_repo_not_found')
}
const connectionId = repo.connectionId?.trim() || null
return connectionId
? client.sendRequest('preflight.detectRemoteAgents', { connectionId })
: client.sendRequest('preflight.detectAgents')
}
@@ -36,6 +36,8 @@ describe('mobile session startup', () => {
)
expect(startupEffect).toContain("void client\n .sendRequest('worktree.activate'")
expect(startupEffect).toContain("if (client && created !== '1' && !isFloatingWorkspaceRoute)")
expect(startupEffect).toContain("if (client && created === '1' && !isFloatingWorkspaceRoute)")
expect(startupEffect).toContain('notifyClients: false')
expect(startupEffect).not.toContain("await client\n .sendRequest('worktree.activate'")
expect(startupEffect.indexOf("sendRequest('worktree.activate'")).toBeLessThan(
@@ -0,0 +1,61 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from './rpc-client'
import { useHostStatusGates, type HostStatusGates } from './host-status-gates'
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => spy.mockRestore()
}
describe('useHostStatusGates', () => {
it('clears every prior-host gate before a reused client can be replaced', async () => {
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: {
capabilities: ['browser.screencast.v1'],
floatingWorkspaceEnabled: true
}
})
const client = { sendRequest } as unknown as RpcClient
let gates: HostStatusGates | null = null
let renderer: ReactTestRenderer | null = null
function Probe({ hostId }: { hostId: string }): null {
gates = useHostStatusGates({ hostId, client, connState: 'connected' })
return null
}
const restore = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(Probe, { hostId: 'host-1' }))
await Promise.resolve()
})
expect(gates).toMatchObject({
hostCapabilities: ['browser.screencast.v1'],
floatingWorkspaceEnabled: true
})
await act(async () => {
renderer?.update(createElement(Probe, { hostId: 'host-2' }))
})
expect(gates).toMatchObject({
hostCapabilities: [],
floatingWorkspaceEnabled: false,
compatVerdict: { kind: 'ok' }
})
expect(sendRequest).toHaveBeenCalledOnce()
} finally {
restore()
renderer?.unmount()
}
})
})
+3 -1
View File
@@ -27,8 +27,10 @@ export function useHostStatusGates(args: {
clientRef.current = client
}, [client])
// Why: clear the prior host's verdict on switch so a stale block screen can't linger.
// Why: Expo can render the next host once with the prior client, so hide every host-scoped gate until its client answers.
useEffect(() => {
setHostCapabilities([])
setFloatingWorkspaceEnabled(false)
setCompatVerdict({ kind: 'ok' })
}, [hostId])
+21
View File
@@ -1725,6 +1725,27 @@ describe('OrcaRuntimeService', () => {
expect(disabledRuntime.getStatus().floatingWorkspaceEnabled).toBe(false)
})
it('lists floating session tabs without resolving repo worktrees', async () => {
const getRepos = vi.fn(store.getRepos)
const listProcesses = vi.fn().mockResolvedValue([])
const runtime = new OrcaRuntimeService({
...store,
getRepo: () => undefined,
getRepos
} as never)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses
})
await runtime.listMobileSessionTabs(`id:${FLOATING_TERMINAL_WORKTREE_ID}`)
expect(listProcesses).toHaveBeenCalledOnce()
expect(getRepos).not.toHaveBeenCalled()
})
it('advertises browser screencast only when a renderer window is available', () => {
const runtime = createRuntime()
electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never)
+12 -5
View File
@@ -3518,7 +3518,7 @@ export class OrcaRuntimeService {
const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector)
if (explicitWorktreeId) {
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId)
await this.refreshMobileSessionPtyRecords()
await this.refreshMobileSessionPtyRecords(explicitWorktreeId)
return this.getMobileSessionTabsForWorktree(explicitWorktreeId)
}
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
@@ -4559,12 +4559,19 @@ export class OrcaRuntimeService {
}
}
private async refreshMobileSessionPtyRecords(): Promise<void> {
private async refreshMobileSessionPtyRecords(
targetWorktreeId: string | null = null
): Promise<void> {
if (!this.ptyController?.listProcesses) {
return
}
const resolvedWorktrees = await this.listResolvedWorktrees()
await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees)
// Why: floating PTY identity is explicit, so polling must not resolve every Git/SSH worktree.
const isFloatingWorkspace = targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID
const resolvedWorktrees = isFloatingWorkspace ? [] : await this.listResolvedWorktrees()
await this.refreshPtyWorktreeRecordsFromController(
resolvedWorktrees,
isFloatingWorkspace ? targetWorktreeId : null
)
}
async activateMobileSessionTab(
@@ -4577,7 +4584,7 @@ export class OrcaRuntimeService {
const worktreeId =
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
await this.refreshMobileSessionPtyRecords()
await this.refreshMobileSessionPtyRecords(worktreeId)
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
const directTab = snapshot?.tabs.find((candidate) => candidate.id === tabId)
const tab = leafId