diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index fcc395ef43a..9ed22a07af4 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -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(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 => { - 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} /> diff --git a/mobile/src/session/mobile-new-tab-agent-loader.test.ts b/mobile/src/session/mobile-new-tab-agent-loader.test.ts new file mode 100644 index 00000000000..b9f5d9e174e --- /dev/null +++ b/mobile/src/session/mobile-new-tab-agent-loader.test.ts @@ -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 +): RpcClient & { sendRequest: ReturnType } { + return { + sendRequest: vi.fn(handler), + subscribe: vi.fn(() => () => {}) + } as unknown as RpcClient & { sendRequest: ReturnType } +} + +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' + ]) + }) +}) diff --git a/mobile/src/session/mobile-new-tab-agent-loader.ts b/mobile/src/session/mobile-new-tab-agent-loader.ts new file mode 100644 index 00000000000..ea87e74ba0e --- /dev/null +++ b/mobile/src/session/mobile-new-tab-agent-loader.ts @@ -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 { + 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') +} diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts index c4083b1a545..3f29816f242 100644 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ b/mobile/src/session/mobile-session-startup-source.test.ts @@ -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( diff --git a/mobile/src/transport/host-status-gates.test.ts b/mobile/src/transport/host-status-gates.test.ts new file mode 100644 index 00000000000..4ce8add5fac --- /dev/null +++ b/mobile/src/transport/host-status-gates.test.ts @@ -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() + } + }) +}) diff --git a/mobile/src/transport/host-status-gates.ts b/mobile/src/transport/host-status-gates.ts index 04cba2957ed..29175225228 100644 --- a/mobile/src/transport/host-status-gates.ts +++ b/mobile/src/transport/host-status-gates.ts @@ -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]) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 5f0b47d4952..b706173ff05 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index d916cf290a6..daacf754ac5 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 { + private async refreshMobileSessionPtyRecords( + targetWorktreeId: string | null = null + ): Promise { 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