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)