fix(mobile): ask to update Orca only when the host answered without agent launch

An unread or failed status read settles with no capabilities, which the AI
buttons read as an old host and showed "Update Orca on your computer". The
update copy now needs a status the host actually returned; an unread one keeps
the buttons disabled without blaming the desktop's version.
This commit is contained in:
Brennan Benson
2026-09-25 12:01:47 -07:00
parent c31ad0248b
commit ddcdc2eead
9 changed files with 89 additions and 17 deletions
+3 -2
View File
@@ -90,13 +90,14 @@ export function MobilePRSidebar({
prRepo,
refetch
})
const { hostCapabilities, statusPending } = useHostProtocolGates()
const { hostCapabilities, statusPending, statusReadable } = useHostProtocolGates()
const triage = useMobilePrAiTriage({
client,
connState,
worktreeId,
hostCapabilities,
hostStatusPending: statusPending
hostStatusPending: statusPending,
hostStatusReadable: statusReadable
})
// Keyed on the PR payload identity so overrides re-fetch with each PR refetch
// instead of staying a stale one-shot snapshot for the whole session.
@@ -52,12 +52,13 @@ export function MobileDiffReviewRouteScreen() {
)
}, [hostId, name, router, worktreeId])
const { hostCapabilities, statusPending } = useHostProtocolGates()
const { hostCapabilities, statusPending, statusReadable } = useHostProtocolGates()
const controller = useMobileDiffReviewController({
client,
connState,
hostCapabilities,
hostStatusPending: statusPending,
hostStatusReadable: statusReadable,
hostId,
worktreeId,
name,
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { resolveMobileAgentLaunchAvailability } from './mobile-agent-launch-availability'
const LAUNCH = ['agent.launch.v2', 'agent.launch.replay.v1', 'agent.launch.replay-required.v1']
describe('resolveMobileAgentLaunchAvailability', () => {
it('is available when the host advertises the replay-required launch', () => {
expect(
resolveMobileAgentLaunchAvailability({
hostCapabilities: LAUNCH,
statusPending: false,
statusReadable: true
})
).toBe('available')
})
it('asks for an update only when the host answered without the capability', () => {
expect(
resolveMobileAgentLaunchAvailability({
hostCapabilities: ['agent.launch.v2'],
statusPending: false,
statusReadable: true
})
).toBe('update-required')
})
it('does not blame the host version when its status could not be read', () => {
expect(
resolveMobileAgentLaunchAvailability({
hostCapabilities: [],
statusPending: false,
statusReadable: false
})
).toBe('unverified')
})
it('waits while the status is being read', () => {
expect(
resolveMobileAgentLaunchAvailability({
hostCapabilities: [],
statusPending: true,
statusReadable: false
})
).toBe('checking')
})
})
@@ -3,15 +3,30 @@ import { supportsMobileExistingAgentLaunch } from './mobile-existing-agent-launc
/**
* Whether an AI button that starts an agent with a prompt can be used on this host. There is no
* older-host fallback: the path before `agent.launch` typed the prompt into a bare shell.
* `unverified`: the host's status could not be read, so whether it is too old is unknown.
*/
export type MobileAgentLaunchAvailability = 'checking' | 'available' | 'update-required'
export type MobileAgentLaunchAvailability =
| 'checking'
| 'available'
| 'update-required'
| 'unverified'
/** The host status gates this reads (`useHostProtocolGates`). */
export type MobileAgentLaunchHostStatus = {
hostCapabilities: readonly string[]
statusPending: boolean
statusReadable: boolean
}
export function resolveMobileAgentLaunchAvailability(
hostCapabilities: readonly string[],
hostStatusPending: boolean
host: MobileAgentLaunchHostStatus
): MobileAgentLaunchAvailability {
if (supportsMobileExistingAgentLaunch(hostCapabilities)) {
if (supportsMobileExistingAgentLaunch(host.hostCapabilities)) {
return 'available'
}
return hostStatusPending ? 'checking' : 'update-required'
if (host.statusPending) {
return 'checking'
}
// Only a host that answered without the capability is too old to update.
return host.statusReadable ? 'update-required' : 'unverified'
}
@@ -57,6 +57,7 @@ function ReviewWhileUnreachable({
connState,
hostCapabilities: [],
hostStatusPending: false,
hostStatusReadable: true,
hostId: 'host-1',
worktreeId: 'wt-1',
name: 'review',
@@ -45,6 +45,7 @@ describe('useMobileDiffReviewController', () => {
connState,
hostCapabilities: [],
hostStatusPending: false,
hostStatusReadable: true,
hostId: 'host-1',
worktreeId: 'wt-1',
name: 'review',
@@ -35,6 +35,7 @@ type ControllerInput = {
connState: ConnectionState
hostCapabilities: readonly string[]
hostStatusPending: boolean
hostStatusReadable: boolean
hostId: string
worktreeId: string
name: string
@@ -51,6 +52,7 @@ export function useMobileDiffReviewController(input: ControllerInput) {
connState,
hostCapabilities,
hostStatusPending,
hostStatusReadable,
hostId,
worktreeId,
name,
@@ -264,10 +266,11 @@ export function useMobileDiffReviewController(input: ControllerInput) {
return {
...interactions,
...prSidebar,
agentLaunchAvailability: resolveMobileAgentLaunchAvailability(
agentLaunchAvailability: resolveMobileAgentLaunchAvailability({
hostCapabilities,
hostStatusPending
),
statusPending: hostStatusPending,
statusReadable: hostStatusReadable
}),
// Exposed so the screen can thread the RPC client + worktree into the PR
// sidebar's lazy check-detail fetches (U5) and mutation actions (U6).
client,
@@ -25,10 +25,12 @@ type Input = {
worktreeId: string
hostCapabilities: readonly string[]
hostStatusPending: boolean
hostStatusReadable: boolean
}
export function useMobilePrAiTriage(input: Input) {
const { client, connState, worktreeId, hostCapabilities, hostStatusPending } = input
const { client, connState, worktreeId, hostCapabilities, hostStatusPending, hostStatusReadable } =
input
const [busyKey, setBusyKey] = useState<PrAiTriageKey | null>(null)
const [error, setError] = useState<string | null>(null)
// The agent started without its prompt; kept so the user can paste it in themselves.
@@ -36,10 +38,11 @@ export function useMobilePrAiTriage(input: Input) {
// Synchronous lock: setBusyKey commits async, so a fast double-tap could pass the
// busyKey check twice before either render. The ref flips immediately and dedupes.
const inFlightRef = useRef(false)
const availability: MobileAgentLaunchAvailability = resolveMobileAgentLaunchAvailability(
const availability: MobileAgentLaunchAvailability = resolveMobileAgentLaunchAvailability({
hostCapabilities,
hostStatusPending
)
statusPending: hostStatusPending,
statusReadable: hostStatusReadable
})
const launch = useCallback(
async (key: PrAiTriageKey, buildPrompt: () => string): Promise<boolean> => {
@@ -20,7 +20,8 @@ type Params = {
}
export function useMobileCommitFailureRecovery({ client, connState, worktreeId, failure }: Params) {
const { hostCapabilities, statusPending } = useHostProtocolGates()
const hostStatus = useHostProtocolGates()
const { hostCapabilities } = hostStatus
const [launching, setLaunching] = useState(false)
const [launchError, setLaunchError] = useState<string | null>(null)
// The agent started without its prompt; kept so the user can paste it in themselves. Keyed by the
@@ -31,7 +32,7 @@ export function useMobileCommitFailureRecovery({ client, connState, worktreeId,
} | null>(null)
const undeliveredPrompt = undelivered?.failure === failure ? undelivered.prompt : null
const summary = useMemo(() => (failure ? summarizeCommitFailure(failure.error) : null), [failure])
const availability = resolveMobileAgentLaunchAvailability(hostCapabilities, statusPending)
const availability = resolveMobileAgentLaunchAvailability(hostStatus)
useEffect(() => {
setLaunchError(null)