diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 442066ca962..8bf782c67c7 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, + PanelsTopLeft } 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' @@ -166,7 +168,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) @@ -188,7 +189,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(() => @@ -338,7 +343,6 @@ export function HostScreen({ useEffect(() => { setHostName('') setError('') - setCompatVerdict({ kind: 'ok' }) setRepoColorsByName(new Map()) setRepoIconsByName(new Map()) repoMetadataFetchedAtRef.current = 0 @@ -520,61 +524,6 @@ export function HostScreen({ [client, connState, hostId] ) - // Why: read desktop's protocol version from status.get on every connect - // and re-evaluate compatibility. If the desktop declares this mobile - // build too old (or vice versa via the local minimum), the host detail - // screen swaps to a hard-block screen instead of the worktree list. - // Today's compat constants are wide-open so this never blocks; the wire - // format is in place to flip a switch in a future release. - useEffect(() => { - if (connState !== 'connected' || !client) { - // Why: drop the prior host's capabilities while disconnected/switching so - // a capability-gated action (e.g. Agent Session History) can't linger for - // a host that doesn't support it. - 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: deterministic breadcrumb so support can confirm a block - // actually 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: rare path — sendRequest can throw on transport tear-down. - // Treat as transient; verdict stays at previous value. - } - })() - return () => { - cancelled = true - } - }, [connState, client]) - useFocusEffect( useCallback(() => { // Why: opening the host is a strong user signal — reset a backed-off or @@ -766,6 +715,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 }) @@ -1043,6 +998,24 @@ export function HostScreen({ /> + {floatingWorkspaceEnabled ? ( + + + + ) : null} + + {floatingWorkspaceEnabled ? ( + + + + ) : null} + setShowSearch((s) => !s)}> {showSearch ? ( diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index eaa04a7436a..7ffb45fc405 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -72,6 +72,7 @@ import { panelRouteDescriptor } from '../../../../src/session/session-panel-host' import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context' +import { isFloatingWorkspaceWorktreeId } from '../../../../src/session/floating-workspace' import { SessionDockColumn } from '../../../../src/session/SessionDockColumn' import { MobileSessionHeaderIconButton } from '../../../../src/session/MobileSessionHeaderIconButton' import { MobileSessionHeaderMoreActionsSheet } from '../../../../src/session/MobileSessionHeaderMoreActionsSheet' @@ -813,6 +814,9 @@ export default function SessionScreen() { warning?: string }>() const isFolderWorkspaceRoute = worktreeId.startsWith('folder:') + // Why: the desktop Floating Workspace sentinel has no backing repo/worktree, + // so repo-backed surfaces (files, source control, PR, agent history) hide. + const isFloatingWorkspaceRoute = isFloatingWorkspaceWorktreeId(worktreeId) const router = useRouter() const insets = useSafeAreaInsets() // Why: shared client per host owned by RpcClientProvider. See @@ -850,7 +854,9 @@ export default function SessionScreen() { // hub are loaded inside MobileSourceControlPanel — 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 @@ -2070,7 +2076,7 @@ export default function SessionScreen() { ) const loadDiffComments = useCallback(async (): Promise => { - if (!client || connState !== 'connected' || !worktreeId) { + if (!client || connState !== 'connected' || !worktreeId || isFloatingWorkspaceRoute) { setDiffComments([]) return } @@ -2084,7 +2090,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 => { @@ -3415,13 +3421,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(() => { @@ -4556,9 +4564,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 }) @@ -4599,13 +4607,15 @@ export default function SessionScreen() { - handlePanelTap('files')} - /> - {!isFolderWorkspaceRoute && ( + {!isFloatingWorkspaceRoute && ( + handlePanelTap('files')} + /> + )} + {!isFolderWorkspaceRoute && !isFloatingWorkspaceRoute && ( { - 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..daf2b4c4f36 --- /dev/null +++ b/mobile/src/session/floating-workspace.ts @@ -0,0 +1,16 @@ +// Mirrors FLOATING_TERMINAL_WORKTREE_ID in src/shared/constants.ts — the +// desktop Floating Workspace's synthetic worktree id (no backing repo or +// worktree record; terminals always run on the host's 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/use-live-worktree-name.test.ts b/mobile/src/session/use-live-worktree-name.test.ts index 97927c6b7d9..3a6e4955f12 100644 --- a/mobile/src/session/use-live-worktree-name.test.ts +++ b/mobile/src/session/use-live-worktree-name.test.ts @@ -166,4 +166,34 @@ 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') + }) }) diff --git a/mobile/src/session/use-live-worktree-name.ts b/mobile/src/session/use-live-worktree-name.ts index bd21ec8f156..cc7c2f77e09 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,6 +17,9 @@ type Params = { } export function useLiveWorktreeName({ client, connState, routeName, worktreeId }: Params): string { + // 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 [worktreeName, setWorktreeName] = useState(() => routeName?.trim() ?? '') useEffect(() => { @@ -24,7 +28,7 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId } useFocusEffect( useCallback(() => { - if (!client || connState !== 'connected') { + if (isFloatingWorkspace || !client || connState !== 'connected') { return } let stale = false @@ -128,8 +132,11 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId } stopFallbackPoll() unsubscribe() } - }, [client, connState, worktreeId]) + }, [client, connState, worktreeId, isFloatingWorkspace]) ) + if (isFloatingWorkspace) { + return worktreeName || FLOATING_WORKSPACE_TITLE + } return worktreeName } diff --git a/mobile/src/transport/host-status-gates.ts b/mobile/src/transport/host-status-gates.ts new file mode 100644 index 00000000000..ea182a0eeda --- /dev/null +++ b/mobile/src/transport/host-status-gates.ts @@ -0,0 +1,93 @@ +import { useEffect, useRef, 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 +} + +// Why: read desktop's protocol version from status.get on every connect and +// re-evaluate compatibility. If the desktop declares this mobile build too old +// (or vice versa via the local minimum), the host detail screen swaps to a +// hard-block screen instead of the worktree list. Today's compat constants are +// wide-open so this never blocks; the wire format is in place to flip a switch +// in a future release. +export function useHostStatusGates(args: { + hostId: string | undefined + client: RpcClient | null + connState: ConnectionState +}): HostStatusGates { + const { hostId, client, connState } = args + const [hostCapabilities, setHostCapabilities] = useState([]) + const [floatingWorkspaceEnabled, setFloatingWorkspaceEnabled] = useState(false) + const [compatVerdict, setCompatVerdict] = useState({ kind: 'ok' }) + const clientRef = useRef(null) + + useEffect(() => { + clientRef.current = client + }, [client]) + + // Why: switching hosts must clear the previous host's verdict so a stale + // block screen can't linger for the next host. + useEffect(() => { + setCompatVerdict({ kind: 'ok' }) + }, [hostId]) + + useEffect(() => { + if (connState !== 'connected' || !client) { + // Why: drop the prior host's capabilities while disconnected/switching so + // a capability-gated action (e.g. Agent Session History) can't linger for + // a host that doesn't support it. + setHostCapabilities([]) + setFloatingWorkspaceEnabled(false) + 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([]) + setFloatingWorkspaceEnabled(false) + return + } + const status = (response as RpcSuccess).result as DesktopStatus & { + capabilities?: string[] + } + setHostCapabilities(status.capabilities ?? []) + setFloatingWorkspaceEnabled(status.floatingWorkspaceEnabled === true) + const verdict = evaluateCompat({ + desktopProtocolVersion: status.protocolVersion, + desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion + }) + setCompatVerdict(verdict) + if (verdict.kind === 'blocked') { + // Why: deterministic breadcrumb so support can confirm a block + // actually 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: rare path — sendRequest can throw on transport tear-down. + // Treat as transient; verdict stays at previous value. + } + })() + return () => { + cancelled = true + } + }, [connState, client]) + + return { hostCapabilities, floatingWorkspaceEnabled, 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/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 44bb1117c43..c5bfb3303d3 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1627,6 +1627,19 @@ 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('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 60160117a6a..cc0cb230919 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -881,6 +881,7 @@ type RuntimeStore = { agentDefaultArgs?: GlobalSettings['agentDefaultArgs'] agentDefaultEnv?: GlobalSettings['agentDefaultEnv'] terminalWindowsShell?: GlobalSettings['terminalWindowsShell'] + floatingTerminalEnabled?: GlobalSettings['floatingTerminalEnabled'] agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled'] defaultTaskSource?: GlobalSettings['defaultTaskSource'] defaultTaskViewPreset?: GlobalSettings['defaultTaskViewPreset'] @@ -3134,6 +3135,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 } diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 953dae57909..ba03d3d57c0 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