mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +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. * 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:
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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