diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 126744a984e..b406840ffb2 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -26,7 +26,8 @@ import { Filter, Check, UserCircle, - PanelLeftClose + PanelLeftClose, + SquareTerminal } from 'lucide-react-native' import type { RpcClient } from '../../../src/transport/rpc-client' import { loadHosts, updateLastConnected } from '../../../src/transport/host-store' @@ -57,6 +58,7 @@ import type { RepoIcon } from '../../../../src/shared/repo-icon' import { PickerModal } from '../../../src/components/PickerModal' import { ActionSheetContent } from '../../../src/components/ActionSheetModal' import { buildWorktreeNavigationActions } from '../../../src/agent-history/worktree-navigation-actions' +import { floatingWorkspaceSessionPath } from '../../../src/session/floating-workspace' import { ConfirmModal } from '../../../src/components/ConfirmModal' import { BottomDrawer } from '../../../src/components/BottomDrawer' import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen' @@ -68,7 +70,7 @@ import { setCachedRepos } from '../../../src/cache/repo-cache' import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme' import { useResponsiveLayout } from '../../../src/layout/responsive-layout' import { leaveHostRoute } from '../../../src/host-route-exit' -import { evaluateCompat, type CompatVerdict } from '../../../src/transport/protocol-compat' +import { useHostStatusGates } from '../../../src/transport/host-status-gates' import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences' import { createInitialHostRouteActionState, @@ -97,7 +99,7 @@ import { WORKSPACE_GROUP_OPTIONS as GROUP_OPTIONS, WORKSPACE_SORT_OPTIONS as SORT_OPTIONS } from '../../../src/worktree/workspace-list-picker-options' -import type { DesktopStatus, RepoSummary } from '../../../src/worktree/host-worktree-rpc-types' +import type { RepoSummary } from '../../../src/worktree/host-worktree-rpc-types' import type { WorkspaceStatusDefinition } from '../../../../src/shared/types' import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from '../../../src/worktree/mobile-workspace-statuses' @@ -155,7 +157,6 @@ export function HostScreen({ const [repoIconsByName, setRepoIconsByName] = useState>(new Map()) const [hostName, setHostName] = useState('') const [error, setError] = useState('') - const [compatVerdict, setCompatVerdict] = useState({ kind: 'ok' }) const [lastKnownWorktrees, setLastKnownWorktrees] = useState(initialCache ?? []) const [search, setSearch] = useState('') const [showSearch, setShowSearch] = useState(false) @@ -175,7 +176,11 @@ export function HostScreen({ const [showGroupPicker, setShowGroupPicker] = useState(false) const [showFilterModal, setShowFilterModal] = useState(false) const [actionTarget, setActionTarget] = useState(null) - const [hostCapabilities, setHostCapabilities] = useState([]) + const { hostCapabilities, floatingWorkspaceEnabled, compatVerdict } = useHostStatusGates({ + hostId, + client, + connState + }) const [confirmDelete, setConfirmDelete] = useState(null) const [confirmRemoveHost, setConfirmRemoveHost] = useState(false) const [routeActionState, setRouteActionState] = useState(() => @@ -315,7 +320,6 @@ export function HostScreen({ useEffect(() => { setHostName('') setError('') - setCompatVerdict({ kind: 'ok' }) setRepoColorsByName(new Map()) setRepoIconsByName(new Map()) repoMetadataFetchedAtRef.current = 0 @@ -483,52 +487,6 @@ export function HostScreen({ [client, connState, hostId] ) - // Why: re-evaluate protocol compat on connect; today's constants are wide-open so this never blocks yet. - useEffect(() => { - if (connState !== 'connected' || !client) { - // Why: drop capabilities while disconnected/switching so a capability-gated action can't linger for a new host. - setHostCapabilities([]) - return - } - let cancelled = false - const requestClient = client - void (async () => { - try { - const response = await requestClient.sendRequest('status.get') - if (cancelled || clientRef.current !== requestClient) { - return - } - if (!response.ok) { - setHostCapabilities([]) - return - } - const status = (response as RpcSuccess).result as DesktopStatus & { - capabilities?: string[] - } - setHostCapabilities(status.capabilities ?? []) - const verdict = evaluateCompat({ - desktopProtocolVersion: status.protocolVersion, - desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion - }) - setCompatVerdict(verdict) - if (verdict.kind === 'blocked') { - // Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints. - console.warn('[protocol-compat] blocked', { - reason: verdict.reason, - desktopVersion: verdict.desktopVersion, - requiredMobileVersion: verdict.requiredMobileVersion, - requiredDesktopVersion: verdict.requiredDesktopVersion - }) - } - } catch { - // Why: sendRequest can throw on transport tear-down; treat as transient, keep the prior verdict. - } - })() - return () => { - cancelled = true - } - }, [connState, client]) - useFocusEffect( useCallback(() => { // Why: focus nudges reconnect and probes a possibly half-open socket; empty deps fire per focus, not per state flip (which defeats backoff). @@ -705,6 +663,12 @@ export function HostScreen({ [client, connState, hostId, navigateFromHostList] ) + const openFloatingWorkspace = useCallback(() => { + // Why: no worktree.activate here — the floating sentinel has no worktree + // record; session.tabs.list hydrates its host-owned tabs on open. + navigateFromHostList(floatingWorkspaceSessionPath(hostId)) + }, [hostId, navigateFromHostList]) + const handleSortChange = useCallback( (value: MobileSortMode) => { persistViewSettings({ sortMode: value }) @@ -870,6 +834,24 @@ export function HostScreen({ ) })()} + {!embedded && floatingWorkspaceEnabled ? ( + + + + ) : null} {embedded && onHideSidebar ? ( + {floatingWorkspaceEnabled ? ( + + + + ) : null} + () const isFolderWorkspaceRoute = worktreeId.startsWith('folder:') // Synthetic ids have no repo scope. + // Why: the floating sentinel has no repo/worktree, so repo-backed surfaces hide. + const isFloatingWorkspaceRoute = isFloatingWorkspaceWorktreeId(worktreeId) const router = useRouter() const insets = useSafeAreaInsets() // Why: shared client per host owned by RpcClientProvider (docs/mobile-shared-client-per-host.md). @@ -833,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) { @@ -847,7 +849,9 @@ export default function SessionScreen() { // GitHub remote probe gates the PR dock icon so non-GitHub providers can't open the hosted-review surface; skip the unused identity RPCs. const { isGithubRepo: prIsGithubRepo, repoLoaded: prRepoContextLoaded } = useMobilePrBranchContext({ - client, + // Why: a null client parks the hook in its not-ready state — the floating + // sentinel has no repo to probe. + client: isFloatingWorkspaceRoute ? null : client, connState, worktreeId, includeBranchIdentity: false @@ -1933,7 +1937,7 @@ export default function SessionScreen() { ) const loadDiffComments = useCallback(async (): Promise => { - if (!client || connState !== 'connected' || !worktreeId) { + if (!client || connState !== 'connected' || !worktreeId || isFloatingWorkspaceRoute) { setDiffComments([]) return } @@ -1947,7 +1951,7 @@ export default function SessionScreen() { worktree?: { diffComments?: unknown } } setDiffComments(normalizeMobileDiffComments(result.worktree?.diffComments, worktreeId)) - }, [client, connState, worktreeId]) + }, [client, connState, worktreeId, isFloatingWorkspaceRoute]) const persistDiffComments = useCallback( async (comments: readonly DiffComment[]): Promise => { @@ -2621,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', { @@ -2644,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 @@ -2672,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') { @@ -3231,13 +3244,15 @@ export default function SessionScreen() { if (handle !== activeHandleRef.current) { return } - if (terminalLinkOpenMode === 'phone-browser') { + // Why: browser.tabCreate resolves a real worktree, which the floating + // sentinel doesn't have — open taps in the phone browser instead. + if (terminalLinkOpenMode === 'phone-browser' || isFloatingWorkspaceRoute) { void Linking.openURL(url).catch(() => {}) return } void handleCreateBrowserRef.current?.(url) }, - [terminalLinkOpenMode] + [terminalLinkOpenMode, isFloatingWorkspaceRoute] ) const toggleLiveInput = useCallback(() => { @@ -3575,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) @@ -3586,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([ @@ -3683,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) { @@ -4380,9 +4367,9 @@ export default function SessionScreen() { router.push(`/h/${hostId}/agent-history/${encodeURIComponent(worktreeId)}?${params.toString()}`) } const showAgentSessionHistoryAction = - !isFolderWorkspaceRoute && agentSessionHistorySupported === true + !isFolderWorkspaceRoute && !isFloatingWorkspaceRoute && agentSessionHistorySupported === true const showChecksAction = shouldShowSessionHeaderChecksAction({ - isFolderWorkspaceRoute, + isFolderWorkspaceRoute: isFolderWorkspaceRoute || isFloatingWorkspaceRoute, repoContextLoaded: prRepoContextLoaded, hostedChecksSupported: prIsGithubRepo }) @@ -4423,13 +4410,15 @@ export default function SessionScreen() { - handlePanelTap('files')} - /> - {!isFolderWorkspaceRoute && ( + {!isFloatingWorkspaceRoute && ( + handlePanelTap('files')} + /> + )} + {!isFolderWorkspaceRoute && !isFloatingWorkspaceRoute && ( setShowQuickCommands(false)} client={client} - repoId={isFolderWorkspaceRoute ? null : getRepoIdFromMobileWorktreeId(worktreeId) || null} + repoId={ + isFolderWorkspaceRoute || isFloatingWorkspaceRoute + ? null + : getRepoIdFromMobileWorktreeId(worktreeId) || null + } repoName={worktreeName || null} onLaunch={launchQuickCommand} /> @@ -5093,26 +5086,33 @@ export default function SessionScreen() { void handleCreateTerminal() } }, - { - label: 'Browser', - icon: Globe, - onPress: () => { - setShowCreateTabDrawer(false) - if (browserScreencastSupported !== true) { - showToast('Desktop update required for mobile browser streaming', 1600) - return - } - setShowCreateBrowserModal(true) - } - }, - { - label: 'Markdown Note', - icon: FileText, - onPress: () => { - setShowCreateTabDrawer(false) - void handleCreateMarkdownNote() - } - } + // Why: browser/markdown creation resolve a real worktree on the host + // (browser.tabCreate, files.createFile); the floating sentinel is + // terminal-only over RPC, so those options hide there. + ...(isFloatingWorkspaceRoute + ? [] + : [ + { + label: 'Browser', + icon: Globe, + onPress: () => { + setShowCreateTabDrawer(false) + if (browserScreencastSupported !== true) { + showToast('Desktop update required for mobile browser streaming', 1600) + return + } + setShowCreateBrowserModal(true) + } + }, + { + label: 'Markdown Note', + icon: FileText, + onPress: () => { + setShowCreateTabDrawer(false) + void handleCreateMarkdownNote() + } + } + ]) ]} onClose={() => setShowCreateTabDrawer(false)} /> diff --git a/mobile/src/session/floating-workspace.test.ts b/mobile/src/session/floating-workspace.test.ts new file mode 100644 index 00000000000..8f29646b49b --- /dev/null +++ b/mobile/src/session/floating-workspace.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { + FLOATING_WORKSPACE_WORKTREE_ID, + floatingWorkspaceSessionPath, + isFloatingWorkspaceWorktreeId +} from './floating-workspace' + +describe('floating workspace routing', () => { + it('matches only the desktop sentinel id', () => { + expect(isFloatingWorkspaceWorktreeId('global-floating-terminal')).toBe(true) + expect(isFloatingWorkspaceWorktreeId('repo-1::/worktree')).toBe(false) + expect(isFloatingWorkspaceWorktreeId('folder:group-1')).toBe(false) + expect(isFloatingWorkspaceWorktreeId(undefined)).toBe(false) + expect(isFloatingWorkspaceWorktreeId(null)).toBe(false) + }) + + it('builds the session route with an explicit title seed', () => { + expect(floatingWorkspaceSessionPath('host-1')).toBe( + `/h/host-1/session/${FLOATING_WORKSPACE_WORKTREE_ID}?name=Floating%20Workspace` + ) + }) +}) diff --git a/mobile/src/session/floating-workspace.ts b/mobile/src/session/floating-workspace.ts new file mode 100644 index 00000000000..acbc805ea33 --- /dev/null +++ b/mobile/src/session/floating-workspace.ts @@ -0,0 +1,15 @@ +// Mirrors FLOATING_TERMINAL_WORKTREE_ID in src/shared/constants.ts — the desktop +// Floating Workspace's synthetic id (no backing repo/worktree; always local runtime). +export const FLOATING_WORKSPACE_WORKTREE_ID = 'global-floating-terminal' + +export const FLOATING_WORKSPACE_TITLE = 'Floating Workspace' + +export function isFloatingWorkspaceWorktreeId(worktreeId: string | null | undefined): boolean { + return worktreeId === FLOATING_WORKSPACE_WORKTREE_ID +} + +// Route target for the host-header entry; the ?name param seeds the session +// screen title before tabs load. +export function floatingWorkspaceSessionPath(hostId: string | undefined): string { + return `/h/${hostId}/session/${FLOATING_WORKSPACE_WORKTREE_ID}?name=${encodeURIComponent(FLOATING_WORKSPACE_TITLE)}` +} 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/session/use-live-worktree-name.test.ts b/mobile/src/session/use-live-worktree-name.test.ts index 97927c6b7d9..8ac565c7684 100644 --- a/mobile/src/session/use-live-worktree-name.test.ts +++ b/mobile/src/session/use-live-worktree-name.test.ts @@ -166,4 +166,86 @@ describe('useLiveWorktreeName request volume', () => { expect(unsubscribeStream).toHaveBeenCalledTimes(1) }) + + it('never calls worktree.show for the floating workspace sentinel', async () => { + let name = '' + function FloatingHarness(): null { + name = useLiveWorktreeName({ + client, + connState: 'connected', + routeName: undefined, + worktreeId: 'global-floating-terminal' + }) + return null + } + + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(FloatingHarness)) + await Promise.resolve() + }) + } finally { + restoreConsoleError() + } + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000) + }) + + expect(sendRequest).not.toHaveBeenCalled() + expect(subscribe).not.toHaveBeenCalled() + expect(name).toBe('Floating Workspace') + }) + + it('does not render stale names while switching through the floating route', async () => { + const firstNameByWorktree = new Map() + let renderer: ReactTestRenderer | null = null + + function RouteHarness(props: { routeName?: string; worktreeId: string }): null { + const name = useLiveWorktreeName({ + client, + connState: 'connected', + routeName: props.routeName, + worktreeId: props.worktreeId + }) + if (!firstNameByWorktree.has(props.worktreeId)) { + firstNameByWorktree.set(props.worktreeId, name) + } + return null + } + + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create( + createElement(RouteHarness, { + routeName: 'Previous workspace', + worktreeId: 'repo-1::/worktree' + }) + ) + await Promise.resolve() + }) + await act(async () => { + renderer?.update( + createElement(RouteHarness, { + worktreeId: 'global-floating-terminal' + }) + ) + }) + await act(async () => { + renderer?.update( + createElement(RouteHarness, { + routeName: 'Next workspace', + worktreeId: 'repo-2::/worktree' + }) + ) + }) + } finally { + restoreConsoleError() + act(() => renderer?.unmount()) + } + + expect(firstNameByWorktree.get('global-floating-terminal')).toBe('Floating Workspace') + expect(firstNameByWorktree.get('repo-2::/worktree')).toBe('Next workspace') + }) }) diff --git a/mobile/src/session/use-live-worktree-name.ts b/mobile/src/session/use-live-worktree-name.ts index bd21ec8f156..53ef1cf1bbf 100644 --- a/mobile/src/session/use-live-worktree-name.ts +++ b/mobile/src/session/use-live-worktree-name.ts @@ -5,6 +5,7 @@ import { getRepoIdFromWorktreeId } from '../../../src/shared/worktree-id' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState, RpcSuccess } from '../transport/types' import { getLiveWorktreeDisplayName, type WorktreeDisplayNameSource } from './worktree-display-name' +import { FLOATING_WORKSPACE_TITLE, isFloatingWorkspaceWorktreeId } from './floating-workspace' const WORKTREE_NAME_FALLBACK_POLL_MS = 3000 @@ -16,15 +17,26 @@ type Params = { } export function useLiveWorktreeName({ client, connState, routeName, worktreeId }: Params): string { - const [worktreeName, setWorktreeName] = useState(() => routeName?.trim() ?? '') + // Why: the floating sentinel has no worktree record, so worktree.show would + // fail forever and keep the 3s fallback poll alive; its title is fixed. + const isFloatingWorkspace = isFloatingWorkspaceWorktreeId(worktreeId) + const routeNameHint = routeName?.trim() ?? '' + const [worktreeName, setWorktreeName] = useState(() => ({ + worktreeId, + name: routeNameHint + })) useEffect(() => { - setWorktreeName(routeName?.trim() ?? '') - }, [routeName, worktreeId]) + setWorktreeName((current) => + current.worktreeId === worktreeId && current.name === routeNameHint + ? current + : { worktreeId, name: routeNameHint } + ) + }, [routeNameHint, worktreeId]) useFocusEffect( useCallback(() => { - if (!client || connState !== 'connected') { + if (isFloatingWorkspace || !client || connState !== 'connected') { return } let stale = false @@ -58,7 +70,11 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId } ? getLiveWorktreeDisplayName([result.worktree], worktreeId) : null if (liveName) { - setWorktreeName((current) => (current === liveName ? current : liveName)) + setWorktreeName((current) => + current.worktreeId === worktreeId && current.name === liveName + ? current + : { worktreeId, name: liveName } + ) } hasSuccessfulRefresh = true if (eventStreamReady) { @@ -128,8 +144,11 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId } stopFallbackPoll() unsubscribe() } - }, [client, connState, worktreeId]) + }, [client, connState, worktreeId, isFloatingWorkspace]) ) - return worktreeName + if (isFloatingWorkspace) { + return FLOATING_WORKSPACE_TITLE + } + return worktreeName.worktreeId === worktreeId ? worktreeName.name : routeNameHint } diff --git a/mobile/src/session/use-mobile-native-chat-readability.test.ts b/mobile/src/session/use-mobile-native-chat-readability.test.ts index 46feaf79217..41d08c3c32c 100644 --- a/mobile/src/session/use-mobile-native-chat-readability.test.ts +++ b/mobile/src/session/use-mobile-native-chat-readability.test.ts @@ -2,6 +2,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' +import { FLOATING_WORKSPACE_WORKTREE_ID } from './floating-workspace' import { useMobileNativeChatReadability } from './use-mobile-native-chat-readability' describe('useMobileNativeChatReadability', () => { @@ -18,15 +19,19 @@ describe('useMobileNativeChatReadability', () => { renderer = null }) - async function mount(connectionId: string | null): Promise { + async function mount( + connectionId: string | null, + worktreeId = 'repo::/worktree' + ): Promise> { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { repos: [{ id: 'repo', connectionId }] } + }) const client = { - sendRequest: vi.fn().mockResolvedValue({ - ok: true, - result: { repos: [{ id: 'repo', connectionId }] } - }) + sendRequest } as unknown as RpcClient function Harness(): null { - readable = useMobileNativeChatReadability(client, 'repo::/worktree') + readable = useMobileNativeChatReadability(client, worktreeId) return null } const original = console.error @@ -44,6 +49,7 @@ describe('useMobileNativeChatReadability', () => { } finally { consoleSpy.mockRestore() } + return sendRequest } it('admits local and runtime-owned transcript hosts', async () => { @@ -61,6 +67,13 @@ describe('useMobileNativeChatReadability', () => { expect(readable).toBe(false) }) + it('treats the host-local floating workspace as readable without listing repos', async () => { + const sendRequest = await mount(null, FLOATING_WORKSPACE_WORKTREE_ID) + + expect(readable).toBe(true) + expect(sendRequest).not.toHaveBeenCalled() + }) + it('fails closed immediately while a reused route resolves its new worktree', async () => { let resolveNext: (response: unknown) => void = () => {} const client = { diff --git a/mobile/src/session/use-mobile-native-chat-readability.ts b/mobile/src/session/use-mobile-native-chat-readability.ts index e952335fe23..e3729022d38 100644 --- a/mobile/src/session/use-mobile-native-chat-readability.ts +++ b/mobile/src/session/use-mobile-native-chat-readability.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import type { RpcClient } from '../transport/rpc-client' +import { isFloatingWorkspaceWorktreeId } from './floating-workspace' import { isMobileNativeChatTranscriptReadable } from './mobile-native-chat-eligibility' import { getRepoIdFromMobileWorktreeId } from './mobile-session-route-helpers' @@ -10,12 +11,17 @@ export function useMobileNativeChatReadability( client: RpcClient | null, worktreeId: string ): boolean { + const isFloatingWorkspace = isFloatingWorkspaceWorktreeId(worktreeId) const [state, setState] = useState({ client: null, worktreeId: '', readable: false }) useEffect(() => { + // Why: the floating workspace always runs on the paired host and has no repo connection to resolve. + if (isFloatingWorkspace) { + return + } let active = true if (!client) { setState({ client, worktreeId, readable: false }) @@ -46,7 +52,10 @@ export function useMobileNativeChatReadability( return () => { active = false } - }, [client, worktreeId]) + }, [client, isFloatingWorkspace, worktreeId]) + if (isFloatingWorkspace) { + return true + } // Why: route reuse renders before its new effect resolves; never expose the // previous repo's readability under a different client/worktree key. return state.client === client && state.worktreeId === worktreeId ? state.readable : false diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index 121d0e42661..eacb68ab0b9 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -128,6 +128,90 @@ beforeEach(() => { }) describe('useHostClient', () => { + it('rebinds when Expo reuses a screen between two connected cached hosts', async () => { + const host2 = { ...HOST, id: 'host-2', name: 'Host 2' } + const client1 = makeFakeClient('connected') + const client2 = makeFakeClient('connected') + connectMock.mockReturnValueOnce(client1).mockReturnValueOnce(client2) + loadHostsMock.mockResolvedValue([HOST, host2]) + + let selectedHostId = HOST.id + let selectedClient: RpcClient | null = null + let selectedState: ConnectionState = 'disconnected' + let renderer: ReactTestRenderer | null = null + function Probe(): null { + const selected = useHostClient(selectedHostId) + selectedClient = selected.client + selectedState = selected.state + useHostClient(host2.id) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + expect(selectedClient).toBe(client1) + expect(selectedState).toBe('connected') + + selectedHostId = host2.id + client2.emitState('disconnected') + await act(async () => { + renderer?.update(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + + expect(selectedClient).toBe(client2) + expect(selectedState).toBe('disconnected') + expect(connectMock).toHaveBeenCalledTimes(2) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('stays disconnected while a reused screen resolves an uncached host', async () => { + const client = makeFakeClient('connected') + connectMock.mockReturnValue(client) + loadHostsMock.mockResolvedValueOnce([HOST]).mockReturnValueOnce(new Promise(() => {})) + + let selectedHostId = HOST.id + let renderTick = 0 + const stateByRenderTick = new Map() + let renderer: ReactTestRenderer | null = null + function Probe(): null { + stateByRenderTick.set(renderTick, useHostClient(selectedHostId).state) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + expect(stateByRenderTick.get(0)).toBe('connected') + + selectedHostId = 'missing-host' + renderTick = 1 + await act(async () => { + renderer?.update(createElement(RpcClientProvider, null, createElement(Probe))) + }) + expect(stateByRenderTick.get(1)).toBe('disconnected') + + renderTick = 2 + await act(async () => { + renderer?.update(createElement(RpcClientProvider, null, createElement(Probe))) + }) + expect(stateByRenderTick.get(2)).toBe('disconnected') + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + it('drops the closed client when the host entry is removed', async () => { const fake = makeFakeClient('connected') connectMock.mockReturnValue(fake) diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index 80ec7f11c1f..eec5ba40469 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -344,13 +344,16 @@ export function useHostClient(hostId: string | undefined): { hostId ? ctx.getState(hostId) : 'disconnected' ) const clientRef = useRef(null) + const clientHostIdRef = useRef(hostId) useEffect(() => { if (!hostId) { clientRef.current = null + clientHostIdRef.current = undefined setState('disconnected') return } + clientHostIdRef.current = hostId let cancelled = false // Subscribe before acquire so any state change during open is captured. const unsub = ctx.subscribeHostState(hostId, (next) => { @@ -370,28 +373,29 @@ export function useHostClient(hostId: string | undefined): { } }) const initial = ctx.acquire(hostId) + clientRef.current = initial + setState(ctx.getState(hostId)) if (initial) { - clientRef.current = initial - setState(ctx.getState(hostId)) + // Why: two cached hosts can both be connected, so equal state values cannot reveal the replacement client. + force((n) => n + 1) } return () => { cancelled = true unsub() ctx.release(hostId) clientRef.current = null + clientHostIdRef.current = undefined } }, [ctx, hostId]) - return { client: clientRef.current, state } + // Why: Expo can reuse the screen before effects bind the next host; never expose the prior host's client or state in that render. + const bound = clientHostIdRef.current === hostId + const boundState = bound ? state : hostId ? ctx.getState(hostId) : 'disconnected' + return { client: bound ? clientRef.current : null, state: boundState } } // Why: refcounting prevents a double-open when a host-detail screen shares one of these hosts. -export function useAllHostClients(hostIds: string[]): Array<{ - hostId: string - client: RpcClient - state: ConnectionState - path: MobileConnectionPath -}> { +export function useAllHostClients(hostIds: string[]) { const ctx = useRpcClientContext() // Stable key so we don't tear down on every render of the array. const key = useMemo(() => [...hostIds].sort().join(','), [hostIds]) 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..ce5974b1f84 --- /dev/null +++ b/mobile/src/transport/host-status-gates.test.ts @@ -0,0 +1,179 @@ +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 and ignores its late response while the client is replaced', async () => { + let resolveOldStatus: ((response: unknown) => void) | null = null + const pendingOldStatus = new Promise((resolve) => { + resolveOldStatus = resolve + }) + const oldSendRequest = vi.fn().mockReturnValue(pendingOldStatus) + const oldClient = { sendRequest: oldSendRequest } as unknown as RpcClient + const newSendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { + capabilities: ['terminal.quick-commands.v1'], + floatingWorkspaceEnabled: true + } + }) + const newClient = { sendRequest: newSendRequest } as unknown as RpcClient + let gates: HostStatusGates | null = null + const firstRenderByHost = new Map() + let renderer: ReactTestRenderer | null = null + + function Probe({ hostId, client }: { hostId: string; client: RpcClient }): null { + gates = useHostStatusGates({ hostId, client, connState: 'connected' }) + if (!firstRenderByHost.has(hostId)) { + firstRenderByHost.set(hostId, gates) + } + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(Probe, { hostId: 'host-1', client: oldClient })) + }) + + await act(async () => { + renderer?.update(createElement(Probe, { hostId: 'host-2', client: newClient })) + await Promise.resolve() + }) + expect(firstRenderByHost.get('host-2')).toMatchObject({ + hostCapabilities: [], + floatingWorkspaceEnabled: false, + compatVerdict: { kind: 'ok' } + }) + expect(gates).toMatchObject({ + hostCapabilities: ['terminal.quick-commands.v1'], + floatingWorkspaceEnabled: true + }) + + await act(async () => { + resolveOldStatus?.({ + ok: true, + result: { + capabilities: ['browser.screencast.v1'], + floatingWorkspaceEnabled: true + } + }) + await pendingOldStatus + }) + expect(gates).toMatchObject({ + hostCapabilities: ['terminal.quick-commands.v1'], + floatingWorkspaceEnabled: true + }) + expect(oldSendRequest).toHaveBeenCalledOnce() + expect(newSendRequest).toHaveBeenCalledOnce() + } finally { + restore() + renderer?.unmount() + } + }) + + it('loads gates from the connected host', 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 + }) + + expect(sendRequest).toHaveBeenCalledOnce() + } finally { + restore() + renderer?.unmount() + } + }) + + it('fails closed while the same client reconnects', async () => { + let resolveReconnect: ((response: unknown) => void) | null = null + const pendingReconnect = new Promise((resolve) => { + resolveReconnect = resolve + }) + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { capabilities: ['browser.screencast.v1'], floatingWorkspaceEnabled: true } + }) + .mockReturnValueOnce(pendingReconnect) + const client = { sendRequest } as unknown as RpcClient + let gates: HostStatusGates | null = null + let renderer: ReactTestRenderer | null = null + + function Probe({ connState }: { connState: 'connected' | 'disconnected' }): null { + gates = useHostStatusGates({ hostId: 'host-1', client, connState }) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(Probe, { connState: 'connected' })) + await Promise.resolve() + }) + expect(gates?.floatingWorkspaceEnabled).toBe(true) + + await act(async () => { + renderer?.update(createElement(Probe, { connState: 'disconnected' })) + }) + await act(async () => { + renderer?.update(createElement(Probe, { connState: 'connected' })) + }) + expect(gates).toMatchObject({ + hostCapabilities: [], + floatingWorkspaceEnabled: false + }) + + await act(async () => { + resolveReconnect?.({ + ok: true, + result: { capabilities: ['terminal.quick-commands.v1'], floatingWorkspaceEnabled: true } + }) + await pendingReconnect + }) + expect(gates).toMatchObject({ + hostCapabilities: ['terminal.quick-commands.v1'], + floatingWorkspaceEnabled: true + }) + } finally { + restore() + renderer?.unmount() + } + }) +}) diff --git a/mobile/src/transport/host-status-gates.ts b/mobile/src/transport/host-status-gates.ts new file mode 100644 index 00000000000..7a02cc87abb --- /dev/null +++ b/mobile/src/transport/host-status-gates.ts @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'react' +import type { RpcClient } from './rpc-client' +import type { ConnectionState, RpcSuccess } from './types' +import { evaluateCompat, type CompatVerdict } from './protocol-compat' +import type { DesktopStatus } from '../worktree/host-worktree-rpc-types' + +export type HostStatusGates = { + hostCapabilities: string[] + floatingWorkspaceEnabled: boolean + compatVerdict: CompatVerdict +} + +type LoadedHostStatusGates = HostStatusGates & { + hostId: string | undefined + client: RpcClient +} + +const EMPTY_HOST_CAPABILITIES: string[] = [] + +// Reads status.get on connect for capabilities, protocol-compat verdict, and the +// floating-workspace flag. Compat constants are wide-open today so this never blocks yet. +export function useHostStatusGates(args: { + hostId: string | undefined + client: RpcClient | null + connState: ConnectionState +}): HostStatusGates { + const { hostId, client, connState } = args + const [loaded, setLoaded] = useState(null) + + useEffect(() => { + if (connState !== 'connected' || !client) { + // Why: reconnecting the same host/client must revalidate gates instead of reviving its prior status response. + setLoaded(null) + return + } + let cancelled = false + const requestClient = client + void (async () => { + try { + const response = await requestClient.sendRequest('status.get') + if (cancelled) { + return + } + if (!response.ok) { + return + } + const status = (response as RpcSuccess).result as DesktopStatus & { + capabilities?: string[] + } + const verdict = evaluateCompat({ + desktopProtocolVersion: status.protocolVersion, + desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion + }) + setLoaded({ + hostId, + client: requestClient, + hostCapabilities: status.capabilities ?? [], + floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true, + compatVerdict: verdict + }) + if (verdict.kind === 'blocked') { + // Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints. + console.warn('[protocol-compat] blocked', { + reason: verdict.reason, + desktopVersion: verdict.desktopVersion, + requiredMobileVersion: verdict.requiredMobileVersion, + requiredDesktopVersion: verdict.requiredDesktopVersion + }) + } + } catch { + // Why: sendRequest can throw on transport tear-down; the fail-closed return below keeps gated actions hidden. + } + })() + return () => { + cancelled = true + } + }, [client, connState, hostId]) + + // Why: effects run after render, so key loaded gates by host and client to fail closed during route reuse. + if ( + connState !== 'connected' || + !client || + !loaded || + loaded.hostId !== hostId || + loaded.client !== client + ) { + return { + hostCapabilities: EMPTY_HOST_CAPABILITIES, + floatingWorkspaceEnabled: false, + compatVerdict: { kind: 'ok' } + } + } + return { + hostCapabilities: loaded.hostCapabilities, + floatingWorkspaceEnabled: loaded.floatingWorkspaceEnabled, + compatVerdict: loaded.compatVerdict + } +} diff --git a/mobile/src/worktree/host-worktree-rpc-types.ts b/mobile/src/worktree/host-worktree-rpc-types.ts index 9d0d1a4f66d..037e4dcfa87 100644 --- a/mobile/src/worktree/host-worktree-rpc-types.ts +++ b/mobile/src/worktree/host-worktree-rpc-types.ts @@ -5,6 +5,9 @@ import type { ExecutionHostId } from '../../../src/shared/execution-host' export type DesktopStatus = { protocolVersion?: number minCompatibleMobileVersion?: number + // Why: absent on hosts that predate the mobile Floating Workspace entry; + // treat absence as unsupported and hide the entry. + floatingWorkspaceEnabled?: boolean } export type RepoSummary = { diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 5d147a9eebd..bafc64347c7 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -3623,6 +3623,23 @@ describe('registerPtyHandlers', () => { deletePtyOwnership('remote-pty') }) + it('routes runtime exact liveness without enumerating provider sessions', () => { + const provider = getLocalPtyProvider() + const hasPty = vi.spyOn(provider, 'hasPty').mockImplementation((id) => id === 'live-pty') + const listProcesses = vi.spyOn(provider, 'listProcesses') + const runtime = { setPtyController: vi.fn() } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + hasPty: (ptyId: string) => boolean | null + } + + expect(controller.hasPty('live-pty')).toBe(true) + expect(controller.hasPty('missing-pty')).toBe(false) + expect(hasPty).toHaveBeenCalledTimes(2) + expect(listProcesses).not.toHaveBeenCalled() + }) + it('returns unavailable runtime confirmation for unsupported or missing providers', async () => { registerSshPtyProvider('ssh-1', {} as never) setPtyOwnership('unsupported-pty', 'ssh-1') diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 4f2e0fa1b7c..407ae52503f 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -3331,6 +3331,13 @@ export function registerPtyHandlers( /* best effort: renderer clear still handles local PTYs */ } }, + hasPty: (ptyId) => { + try { + return getProviderForPty(ptyId).hasPty?.(ptyId) ?? null + } catch { + return null + } + }, listProcesses: async () => { const providerSessions = await Promise.all([ localProvider.listProcesses(), diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 55a513ffc13..cda6f380a4a 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1717,6 +1717,86 @@ describe('OrcaRuntimeService', () => { expect(runtime.getStatus().terminalWindowsShell).toBe('wsl.exe') }) + it('reports floating workspace availability from settings on status', () => { + expect(createRuntime().getStatus().floatingWorkspaceEnabled).toBe(true) + + const disabledRuntime = new OrcaRuntimeService({ + ...store, + getSettings: () => ({ + ...store.getSettings(), + floatingTerminalEnabled: false + }) + } as never) + expect(disabledRuntime.getStatus().floatingWorkspaceEnabled).toBe(false) + }) + + it('polls floating tabs with targeted PTY liveness and no repo/provider inventory', async () => { + const getRepos = vi.fn(store.getRepos) + const listProcesses = vi.fn().mockResolvedValue([]) + const floatingPtyId = `${FLOATING_TERMINAL_WORKTREE_ID}@@pty-1` + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeRepoId: null, + activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: 'floating-tab' }, + tabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + id: 'floating-tab', + ptyId: floatingPtyId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + title: 'Floating Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'floating-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: floatingPtyId }) + } + }) + ) + const runtime = new OrcaRuntimeService({ ...runtimeStore, getRepos } as never) + const ptyController = { + livePtyIds: new Set([floatingPtyId]), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + hasPty(this: { livePtyIds: Set }, ptyId: string) { + return this.livePtyIds.has(ptyId) + }, + listProcesses + } + const hasPty = vi.spyOn(ptyController, 'hasPty') + runtime.setPtyController(ptyController) + + const tabs = await runtime.listMobileSessionTabs(`id:${FLOATING_TERMINAL_WORKTREE_ID}`) + const terminals = await runtime.listTerminals(`id:${FLOATING_TERMINAL_WORKTREE_ID}`) + await runtime.listMobileSessionTabs(`id:${FLOATING_TERMINAL_WORKTREE_ID}`) + await runtime.listTerminals(`id:${FLOATING_TERMINAL_WORKTREE_ID}`) + + expect(tabs.tabs).toEqual([ + expect.objectContaining({ + type: 'terminal', + parentTabId: 'floating-tab', + status: 'ready', + terminal: expect.any(String) + }) + ]) + expect(terminals.terminals).toEqual([ + expect.objectContaining({ + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + connected: true + }) + ]) + expect(hasPty).toHaveBeenCalledTimes(4) + expect(hasPty).toHaveBeenCalledWith(floatingPtyId) + expect(listProcesses).not.toHaveBeenCalled() + 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 8fc9500c500..4a6bcefaa8e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -902,6 +902,7 @@ type RuntimeStore = { agentDefaultArgs?: GlobalSettings['agentDefaultArgs'] agentDefaultEnv?: GlobalSettings['agentDefaultEnv'] terminalWindowsShell?: GlobalSettings['terminalWindowsShell'] + floatingTerminalEnabled?: GlobalSettings['floatingTerminalEnabled'] agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled'] defaultTaskSource?: GlobalSettings['defaultTaskSource'] defaultTaskViewPreset?: GlobalSettings['defaultTaskViewPreset'] @@ -1293,6 +1294,8 @@ type RuntimePtyController = { hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean + // Why: exact-id mobile polls should not enumerate every local and SSH PTY. + hasPty?(ptyId: string): boolean | null listProcesses?(): Promise serializeBuffer?( ptyId: string, @@ -3213,6 +3216,7 @@ export class OrcaRuntimeService { capabilities, hostPlatform: process.platform, terminalWindowsShell: this.store?.getSettings?.().terminalWindowsShell ?? null, + floatingWorkspaceEnabled: this.store?.getSettings?.().floatingTerminalEnabled !== false, protocolVersion: RUNTIME_PROTOCOL_VERSION, minCompatibleMobileVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION } @@ -3558,7 +3562,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) @@ -4599,12 +4603,19 @@ export class OrcaRuntimeService { } } - private async refreshMobileSessionPtyRecords(): Promise { - if (!this.ptyController?.listProcesses) { + private async refreshMobileSessionPtyRecords( + targetWorktreeId: string | null = null + ): Promise { + if (!this.ptyController?.listProcesses && !this.ptyController?.hasPty) { 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( @@ -4617,7 +4628,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 @@ -21983,6 +21994,12 @@ export class OrcaRuntimeService { resolvedWorktrees: ResolvedWorktree[], targetWorktreeId: string | null = null ): Promise | null> { + if (targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + const targetedLiveness = this.refreshFloatingWorkspacePtyLiveness() + if (targetedLiveness !== null) { + return targetedLiveness + } + } if (!this.ptyController?.listProcesses) { return null } @@ -22027,6 +22044,87 @@ export class OrcaRuntimeService { return livePtyIds } + private refreshFloatingWorkspacePtyLiveness(): Set | null { + const controller = this.ptyController + if (!controller?.hasPty) { + return null + } + const knownPtyIds = new Set() + const persistedBindingByPtyId = new Map() + for (const pty of this.ptysById.values()) { + if (pty.worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + knownPtyIds.add(pty.ptyId) + } + } + for (const leaf of this.leaves.values()) { + if (leaf.worktreeId === FLOATING_TERMINAL_WORKTREE_ID && leaf.ptyId) { + knownPtyIds.add(leaf.ptyId) + } + } + const snapshot = this.mobileSessionTabsByWorktree.get(FLOATING_TERMINAL_WORKTREE_ID) + for (const tab of snapshot?.tabs ?? []) { + if (tab.type !== 'terminal') { + continue + } + if (tab.ptyId) { + knownPtyIds.add(tab.ptyId) + persistedBindingByPtyId.set(tab.ptyId, { + tabId: tab.parentTabId, + paneKey: this.getMobileTerminalPaneKey(tab) + }) + } + for (const [leafId, ptyId] of Object.entries(tab.parentLayout?.ptyIdsByLeafId ?? {})) { + knownPtyIds.add(ptyId) + persistedBindingByPtyId.set(ptyId, { + tabId: tab.parentTabId, + paneKey: isTerminalLeafId(leafId) + ? makePaneKey(tab.parentTabId, leafId) + : `${tab.parentTabId}:${/^pane:(\d+)$/.exec(leafId)?.[1] ?? leafId}` + }) + } + } + + const liveness = new Map() + try { + for (const ptyId of knownPtyIds) { + const live = controller.hasPty(ptyId) + if (live === null) { + return null + } + liveness.set(ptyId, live) + } + } catch { + return null + } + + const livePtyIds = new Set() + for (const [ptyId, live] of liveness) { + let pty = this.ptysById.get(ptyId) + if (live) { + livePtyIds.add(ptyId) + const binding = persistedBindingByPtyId.get(ptyId) + if (!pty && binding) { + // Why: a live daemon PTY restored from disk needs its pane identity before mobile can issue a safe handle. + pty = this.recordPtyWorktree(ptyId, FLOATING_TERMINAL_WORKTREE_ID, { + connected: true, + tabId: binding.tabId, + paneKey: binding.paneKey + }) + } + if (pty) { + pty.connected = true + pty.disconnectedAt = null + this.refreshPtyForegroundAgent(ptyId) + } + } else if (pty && !this.leafExistsForPty(ptyId)) { + pty.connected = false + pty.disconnectedAt ??= Date.now() + } + } + this.pruneDisconnectedPtyRecords() + return livePtyIds + } + private pruneDisconnectedPtyTranscript(pty: RuntimePtyWorktreeRecord): void { if (pty.connected) { return diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index a22a9b96c96..2223996ef06 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -77,6 +77,9 @@ export type RuntimeStatus = { // Why: legacy or saved WebSocket pairings may not carry scope metadata, so // the server stamps the authenticated token scope here for status.get only. deviceScope?: DeviceScope + // Why: mobile gates its Floating Workspace entry on this; absent on older + // hosts, false when the user disabled the feature in desktop settings. + floatingWorkspaceEnabled?: boolean // COMPAT(runtimeStatusMobileAliases): added 2026-05-15 for mobile builds // that still read these names; new desktop/CLI code uses the fields above. protocolVersion?: number