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:
Brennan Benson
2026-07-19 23:36:51 -07:00
parent 624c8d4120
commit 0ea9c08b7f
11 changed files with 291 additions and 97 deletions
+49 -61
View File
@@ -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} />
+51 -34
View File
@@ -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`
)
})
})
+16
View File
@@ -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')
})
})
+9 -2
View File
@@ -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
}
+93
View File
@@ -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 = {
+13
View File
@@ -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)
+2
View File
@@ -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
}
+3
View File
@@ -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