Access the Floating Workspace from mobile (#8405) (#9523)

* 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.

* Harden mobile Floating Workspace routing

* Fix mobile host gate reuse race

* Harden floating mobile session polling

* fix(mobile): harden floating workspace route reuse

* fix(mobile): skip floating workspace repo lookup

* fix(mobile): clarify floating workspace header action
This commit is contained in:
Brennan Benson
2026-07-20 21:05:40 -07:00
committed by GitHub
parent 6c9be228ad
commit e3c8d96638
21 changed files with 1037 additions and 168 deletions
+55 -55
View File
@@ -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<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)
@@ -175,7 +176,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(() =>
@@ -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 ? (
<Pressable
style={[
styles.floatingWorkspaceHeaderButton,
connState !== 'connected' && styles.toolbarIconDisabled
]}
onPress={openFloatingWorkspace}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel="Floating Workspace"
hitSlop={8}
>
<SquareTerminal
size={18}
color={connState === 'connected' ? colors.textPrimary : colors.textMuted}
/>
</Pressable>
) : null}
{embedded && onHideSidebar ? (
<Pressable
style={styles.sidebarCollapseButton}
@@ -976,6 +958,24 @@ export function HostScreen({
/>
</Pressable>
{floatingWorkspaceEnabled ? (
<Pressable
style={[
styles.embeddedToolbarIconButton,
connState !== 'connected' && styles.toolbarIconDisabled
]}
onPress={openFloatingWorkspace}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel="Floating Workspace"
>
<SquareTerminal
size={18}
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
/>
</Pressable>
) : null}
<Pressable
style={[
styles.embeddedToolbarIconButton,
@@ -1554,12 +1554,12 @@ const styles = StyleSheet.create({
toolbarSpacer: {
flex: 1
},
toolbarIconButton: {
floatingWorkspaceHeaderButton: {
width: 32,
height: 28,
height: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button
marginLeft: spacing.xs
},
embeddedToolbarIconButton: {
flex: 1,
+84 -84
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'
@@ -171,11 +172,8 @@ import {
getMobileSessionTabTitle,
resolveMobileTerminalTabAgentId
} from '../../../../src/session/mobile-terminal-tab-agent'
import {
buildMobileNewTabAgentOptions,
type MobileNewTabAgentOption,
type MobileNewTabAgentSettings
} from '../../../../src/session/mobile-new-tab-agent-options'
import type { MobileNewTabAgentOption } from '../../../../src/session/mobile-new-tab-agent-options'
import { loadMobileNewTabAgentOptions } from '../../../../src/session/mobile-new-tab-agent-loader'
import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment'
import { useMobileAttachmentInputLeaseGate } from '../../../../src/session/use-mobile-attachment-input-lease-gate'
import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste'
@@ -816,6 +814,8 @@ export default function SessionScreen() {
warning?: string
}>()
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<ActivePanel>(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<void> => {
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<void> => {
@@ -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<string | null> => {
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() {
</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"
@@ -5075,7 +5064,11 @@ export default function SessionScreen() {
visible={showQuickCommands && quickCommandsSupported === true}
onClose={() => setShowQuickCommands(false)}
client={client}
repoId={isFolderWorkspaceRoute ? null : getRepoIdFromMobileWorktreeId(worktreeId) || null}
repoId={
isFolderWorkspaceRoute || isFloatingWorkspaceRoute
? null
: getRepoIdFromMobileWorktreeId(worktreeId) || null
}
repoName={worktreeName || null}
onLaunch={launchQuickCommand}
/>
@@ -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)}
/>
@@ -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`
)
})
})
+15
View File
@@ -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)}`
}
@@ -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<unknown>
): RpcClient & { sendRequest: ReturnType<typeof vi.fn> } {
return {
sendRequest: vi.fn(handler),
subscribe: vi.fn(() => () => {})
} as unknown as RpcClient & { sendRequest: ReturnType<typeof vi.fn> }
}
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'
])
})
})
@@ -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<MobileNewTabAgentOption[]> {
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')
}
@@ -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(
@@ -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<string, string>()
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')
})
})
+26 -7
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,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
}
@@ -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<void> {
async function mount(
connectionId: string | null,
worktreeId = 'repo::/worktree'
): Promise<ReturnType<typeof vi.fn>> {
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 = {
@@ -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<ReadabilityState>({
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
@@ -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<never>(() => {}))
let selectedHostId = HOST.id
let renderTick = 0
const stateByRenderTick = new Map<number, ConnectionState>()
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)
+13 -9
View File
@@ -344,13 +344,16 @@ export function useHostClient(hostId: string | undefined): {
hostId ? ctx.getState(hostId) : 'disconnected'
)
const clientRef = useRef<RpcClient | null>(null)
const clientHostIdRef = useRef<string | undefined>(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])
@@ -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<string, HostStatusGates>()
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()
}
})
})
+98
View File
@@ -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<LoadedHostStatusGates | null>(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
}
}
@@ -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 = {
+17
View File
@@ -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')
+7
View File
@@ -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(),
+80
View File
@@ -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<string> }, 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)
+104 -6
View File
@@ -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<boolean>
clearBuffer?(ptyId: string): Promise<void>
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<PtyProcessInfo[]>
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<void> {
if (!this.ptyController?.listProcesses) {
private async refreshMobileSessionPtyRecords(
targetWorktreeId: string | null = null
): Promise<void> {
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<Set<string> | 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<string> | null {
const controller = this.ptyController
if (!controller?.hasPty) {
return null
}
const knownPtyIds = new Set<string>()
const persistedBindingByPtyId = new Map<string, { tabId: string; paneKey: string }>()
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<string, boolean>()
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<string>()
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
+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