From ddcdc2eeadd2e0e1333ef1a94da6c83219225db2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:01:47 -0700 Subject: [PATCH] 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. --- mobile/src/components/MobilePRSidebar.tsx | 5 +- .../session/MobileDiffReviewRouteScreen.tsx | 3 +- .../mobile-agent-launch-availability.test.ts | 46 +++++++++++++++++++ .../mobile-agent-launch-availability.ts | 25 ++++++++-- .../session/mobile-diff-review-retry.test.tsx | 1 + .../use-mobile-diff-review-controller.test.ts | 1 + .../use-mobile-diff-review-controller.ts | 9 ++-- mobile/src/session/use-mobile-pr-ai-triage.ts | 11 +++-- .../use-mobile-commit-failure-recovery.ts | 5 +- 9 files changed, 89 insertions(+), 17 deletions(-) create mode 100644 mobile/src/session/mobile-agent-launch-availability.test.ts diff --git a/mobile/src/components/MobilePRSidebar.tsx b/mobile/src/components/MobilePRSidebar.tsx index 6585fb16e1f..2850f1a0ba6 100644 --- a/mobile/src/components/MobilePRSidebar.tsx +++ b/mobile/src/components/MobilePRSidebar.tsx @@ -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. diff --git a/mobile/src/session/MobileDiffReviewRouteScreen.tsx b/mobile/src/session/MobileDiffReviewRouteScreen.tsx index 2d1962aec12..58c19b19da6 100644 --- a/mobile/src/session/MobileDiffReviewRouteScreen.tsx +++ b/mobile/src/session/MobileDiffReviewRouteScreen.tsx @@ -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, diff --git a/mobile/src/session/mobile-agent-launch-availability.test.ts b/mobile/src/session/mobile-agent-launch-availability.test.ts new file mode 100644 index 00000000000..b1775f13311 --- /dev/null +++ b/mobile/src/session/mobile-agent-launch-availability.test.ts @@ -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') + }) +}) diff --git a/mobile/src/session/mobile-agent-launch-availability.ts b/mobile/src/session/mobile-agent-launch-availability.ts index a97d3e036bb..81871fb7ab9 100644 --- a/mobile/src/session/mobile-agent-launch-availability.ts +++ b/mobile/src/session/mobile-agent-launch-availability.ts @@ -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' } diff --git a/mobile/src/session/mobile-diff-review-retry.test.tsx b/mobile/src/session/mobile-diff-review-retry.test.tsx index c0e1356c00e..c7d8f3fb868 100644 --- a/mobile/src/session/mobile-diff-review-retry.test.tsx +++ b/mobile/src/session/mobile-diff-review-retry.test.tsx @@ -57,6 +57,7 @@ function ReviewWhileUnreachable({ connState, hostCapabilities: [], hostStatusPending: false, + hostStatusReadable: true, hostId: 'host-1', worktreeId: 'wt-1', name: 'review', diff --git a/mobile/src/session/use-mobile-diff-review-controller.test.ts b/mobile/src/session/use-mobile-diff-review-controller.test.ts index bceecc84606..31db5abf911 100644 --- a/mobile/src/session/use-mobile-diff-review-controller.test.ts +++ b/mobile/src/session/use-mobile-diff-review-controller.test.ts @@ -45,6 +45,7 @@ describe('useMobileDiffReviewController', () => { connState, hostCapabilities: [], hostStatusPending: false, + hostStatusReadable: true, hostId: 'host-1', worktreeId: 'wt-1', name: 'review', diff --git a/mobile/src/session/use-mobile-diff-review-controller.ts b/mobile/src/session/use-mobile-diff-review-controller.ts index f3760be63b1..4ca0a9f1760 100644 --- a/mobile/src/session/use-mobile-diff-review-controller.ts +++ b/mobile/src/session/use-mobile-diff-review-controller.ts @@ -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, diff --git a/mobile/src/session/use-mobile-pr-ai-triage.ts b/mobile/src/session/use-mobile-pr-ai-triage.ts index 73979b3a06c..e88b18fd956 100644 --- a/mobile/src/session/use-mobile-pr-ai-triage.ts +++ b/mobile/src/session/use-mobile-pr-ai-triage.ts @@ -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(null) const [error, setError] = useState(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 => { diff --git a/mobile/src/source-control/use-mobile-commit-failure-recovery.ts b/mobile/src/source-control/use-mobile-commit-failure-recovery.ts index 2c1806f81e6..42e4ede98be 100644 --- a/mobile/src/source-control/use-mobile-commit-failure-recovery.ts +++ b/mobile/src/source-control/use-mobile-commit-failure-recovery.ts @@ -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(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)