mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
Access the Floating Workspace from mobile (#8405)
Surface the desktop Floating Workspace (the global, repo-less scratchpad of terminal tabs under the synthetic `global-floating-terminal` id) on the mobile app so a Claude session left running there is reachable from a phone. Adds a terminal-icon button to the mobile host header (phone + tablet sidebar) that opens the existing Session screen for the floating id. The sentinel already had host-side RPC support (#5946: local runtime, homedir cwd, explicit-id fast paths in session.tabs.*); this wires up the mobile surface and gates it on a new `floatingWorkspaceEnabled` status flag so the entry hides on hosts that predate it or where the feature is disabled. The Session screen learns an `isFloatingWorkspaceRoute` flag (mirroring the existing `folder:` route pattern) that hides repo-backed surfaces — Files, Source Control, PR/checks, agent history — skips the diff-comment and GitHub probes, routes terminal URL taps to the phone browser, and limits the New Tab drawer to terminals + agents (browser/markdown creation resolves a real worktree host-side and stays desktop-only). useLiveWorktreeName short-circuits for the sentinel so it no longer polls worktree.show forever. Extracted the host status.get gating into a useHostStatusGates hook to keep the host screen under the max-lines ratchet.
This commit is contained in:
@@ -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<Map<string, RepoIcon>>(new Map())
|
||||
const [hostName, setHostName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [compatVerdict, setCompatVerdict] = useState<CompatVerdict>({ kind: 'ok' })
|
||||
const [lastKnownWorktrees, setLastKnownWorktrees] = useState<Worktree[]>(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<Worktree | null>(null)
|
||||
const [hostCapabilities, setHostCapabilities] = useState<string[]>([])
|
||||
const { hostCapabilities, floatingWorkspaceEnabled, compatVerdict } = useHostStatusGates({
|
||||
hostId,
|
||||
client,
|
||||
connState
|
||||
})
|
||||
const [confirmDelete, setConfirmDelete] = useState<Worktree | null>(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({
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{floatingWorkspaceEnabled ? (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.embeddedToolbarIconButton,
|
||||
connState !== 'connected' && styles.toolbarIconDisabled
|
||||
]}
|
||||
onPress={openFloatingWorkspace}
|
||||
disabled={connState !== 'connected'}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Floating Workspace"
|
||||
>
|
||||
<PanelsTopLeft
|
||||
size={16}
|
||||
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
style={[
|
||||
styles.embeddedToolbarIconButton,
|
||||
@@ -1137,6 +1110,21 @@ export function HostScreen({
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{floatingWorkspaceEnabled ? (
|
||||
<Pressable
|
||||
style={styles.searchToggle}
|
||||
onPress={openFloatingWorkspace}
|
||||
disabled={connState !== 'connected'}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Floating Workspace"
|
||||
>
|
||||
<PanelsTopLeft
|
||||
size={16}
|
||||
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<Pressable style={styles.searchToggle} onPress={() => setShowSearch((s) => !s)}>
|
||||
{showSearch ? (
|
||||
<X size={16} color={colors.textSecondary} />
|
||||
|
||||
@@ -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<void> => {
|
||||
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<void> => {
|
||||
@@ -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() {
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<MobileSessionHeaderIconButton
|
||||
active={activePanel === 'files'}
|
||||
accessibilityLabel="Open file explorer"
|
||||
icon={Folder}
|
||||
onPress={() => handlePanelTap('files')}
|
||||
/>
|
||||
{!isFolderWorkspaceRoute && (
|
||||
{!isFloatingWorkspaceRoute && (
|
||||
<MobileSessionHeaderIconButton
|
||||
active={activePanel === 'files'}
|
||||
accessibilityLabel="Open file explorer"
|
||||
icon={Folder}
|
||||
onPress={() => handlePanelTap('files')}
|
||||
/>
|
||||
)}
|
||||
{!isFolderWorkspaceRoute && !isFloatingWorkspaceRoute && (
|
||||
<MobileSessionHeaderIconButton
|
||||
active={activePanel === 'sourceControl'}
|
||||
accessibilityLabel="Open source control"
|
||||
@@ -5274,26 +5284,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)}
|
||||
/>
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<string[]>([])
|
||||
const [floatingWorkspaceEnabled, setFloatingWorkspaceEnabled] = useState(false)
|
||||
const [compatVerdict, setCompatVerdict] = useState<CompatVerdict>({ kind: 'ok' })
|
||||
const clientRef = useRef<RpcClient | null>(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 }
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user