mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
Harden mobile Floating Workspace routing
This commit is contained in:
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user