diff --git a/mobile/src/cache/new-tab-agent-inventory-cache.ts b/mobile/src/cache/new-tab-agent-inventory-cache.ts new file mode 100644 index 00000000000..de94efeef47 --- /dev/null +++ b/mobile/src/cache/new-tab-agent-inventory-cache.ts @@ -0,0 +1,39 @@ +import type { MobileNewTabAgentInventory } from '../session/mobile-new-tab-agent-loader' + +// Why: installed agents change rarely, so the New Tab drawer reuses the last settled detection for +// this paired host and host-qualified workspace, as desktop's agent inventory does. + +const cache = new Map() + +const MAX_ENTRIES = 50 + +function cacheKey(hostId: string, workspaceKey: string): string { + return `${hostId}\u0000${workspaceKey}` +} + +export function setCachedNewTabAgentInventory( + hostId: string, + workspaceKey: string, + inventory: MobileNewTabAgentInventory +): void { + const key = cacheKey(hostId, workspaceKey) + cache.delete(key) + cache.set(key, inventory) + if (cache.size > MAX_ENTRIES) { + const oldest = cache.keys().next().value + if (oldest) { + cache.delete(oldest) + } + } +} + +export function getCachedNewTabAgentInventory( + hostId: string, + workspaceKey: string +): MobileNewTabAgentInventory | null { + return cache.get(cacheKey(hostId, workspaceKey)) ?? null +} + +export function deleteCachedNewTabAgentInventory(hostId: string, workspaceKey: string): void { + cache.delete(cacheKey(hostId, workspaceKey)) +} diff --git a/mobile/src/session/mobile-new-tab-agent-loader.test.ts b/mobile/src/session/mobile-new-tab-agent-loader.test.ts index fb23847a20f..904982f7c2f 100644 --- a/mobile/src/session/mobile-new-tab-agent-loader.test.ts +++ b/mobile/src/session/mobile-new-tab-agent-loader.test.ts @@ -7,6 +7,12 @@ import { MobileWorkspaceOnOtherRuntimeError } from './mobile-new-tab-agent-loader' +async function loadOptions( + args: Parameters[0] +): Promise { + return (await loadMobileNewTabAgentOptions(args)).options +} + function createClient( handler: (method: string, params?: unknown) => Promise ): RpcClient & { sendRequest: ReturnType } { @@ -29,7 +35,7 @@ describe('mobile new-tab agent loading', () => { throw new Error(`unexpected request: ${method}`) }) await expect( - loadMobileNewTabAgentOptions({ + loadOptions({ client, worktreeId: 'folder:fw-1', hostRefusesOtherRuntime: true @@ -155,9 +161,9 @@ describe('mobile new-tab agent loading', () => { ] ])('resolves an older host folder catalog: %s', async (_label, args, method) => { const client = folderClient(args) - await expect( - loadMobileNewTabAgentOptions({ client, worktreeId: 'folder:fw-1' }) - ).resolves.toEqual([{ agent: 'claude', label: 'Claude' }]) + await expect(loadOptions({ client, worktreeId: 'folder:fw-1' })).resolves.toEqual([ + { agent: 'claude', label: 'Claude' } + ]) expect(client.sendRequest.mock.calls.at(-1)?.[0]).toBe(method) }) @@ -199,9 +205,7 @@ describe('mobile new-tab agent loading', () => { ['other runtime', { executionHostId: 'runtime:other', connectionId: 'server-private-ssh' }] ])('does not probe the wrong host for a %s folder', async (_label, args) => { const client = folderClient(args) - await expect( - loadMobileNewTabAgentOptions({ client, worktreeId: 'folder:fw-1' }) - ).rejects.toThrow() + await expect(loadOptions({ client, worktreeId: 'folder:fw-1' })).rejects.toThrow() expect(client.sendRequest.mock.calls.some(([method]) => method.startsWith('preflight.'))).toBe( false ) @@ -214,9 +218,9 @@ describe('mobile new-tab agent loading', () => { } return { ok: false, error: { code: 'method_not_found', message: 'Unknown method' } } }) - await expect( - loadMobileNewTabAgentOptions({ client, worktreeId: 'folder:fw-1' }) - ).rejects.toThrow('Unknown method') + await expect(loadOptions({ client, worktreeId: 'folder:fw-1' })).rejects.toThrow( + 'Unknown method' + ) expect(client.sendRequest.mock.calls.some(([method]) => method.startsWith('preflight.'))).toBe( false ) @@ -232,9 +236,7 @@ describe('mobile new-tab agent loading', () => { } return base.sendRequest(method, params) }) - await expect( - loadMobileNewTabAgentOptions({ client, worktreeId: 'folder:fw-1' }) - ).rejects.toThrow() + await expect(loadOptions({ client, worktreeId: 'folder:fw-1' })).rejects.toThrow() expect( client.sendRequest.mock.calls.some(([method]) => method.startsWith('preflight.')) ).toBe(false) @@ -252,7 +254,7 @@ describe('mobile new-tab agent loading', () => { throw disconnected }) await expect( - loadMobileNewTabAgentOptions({ + loadOptions({ client, worktreeId: 'folder:fw-1', hostRefusesOtherRuntime: modern @@ -273,7 +275,7 @@ describe('mobile new-tab agent loading', () => { : { ok: false, error: { code: 'runtime_error', message: 'SSH host not served' } } ) await expect( - loadMobileNewTabAgentOptions({ + loadOptions({ client, worktreeId: 'folder:fw-1', hostRefusesOtherRuntime: true @@ -287,7 +289,7 @@ describe('mobile new-tab agent loading', () => { it('preserves a modern host folder ownership refusal', async () => { await expect( - loadMobileNewTabAgentOptions({ + loadOptions({ client: folderClient({ refused: true }), worktreeId: 'folder:fw-1', hostRefusesOtherRuntime: true @@ -310,7 +312,7 @@ describe('mobile new-tab agent loading', () => { }) await expect( - loadMobileNewTabAgentOptions({ + loadOptions({ client, worktreeId: FLOATING_WORKSPACE_WORKTREE_ID }) @@ -340,7 +342,7 @@ describe('mobile new-tab agent loading', () => { }) await expect( - loadMobileNewTabAgentOptions({ + loadOptions({ client, worktreeId: 'repo-1::/remote/worktree' }) @@ -369,7 +371,7 @@ describe('mobile new-tab agent loading', () => { }) await expect( - loadMobileNewTabAgentOptions({ client, worktreeId: 'repo-1::/srv/worktree' }) + loadOptions({ client, worktreeId: 'repo-1::/srv/worktree' }) ).rejects.toBeInstanceOf(MobileWorkspaceOnOtherRuntimeError) // Neither the paired host nor its SSH targets are probed for a workspace it does not own. expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ @@ -409,7 +411,7 @@ describe('mobile new-tab agent loading', () => { error: { code: 'runtime_error', message: WORKSPACE_ON_OTHER_RUNTIME } })) await expect( - loadMobileNewTabAgentOptions({ + loadOptions({ client: refused, worktreeId: 'repo-1::/srv/worktree', hostRefusesOtherRuntime: true @@ -418,7 +420,7 @@ describe('mobile new-tab agent loading', () => { const answered = sharedRepoIdClient(() => ({ ok: true, result: ['claude'] })) await expect( - loadMobileNewTabAgentOptions({ + loadOptions({ client: answered, worktreeId: 'repo-1::/srv/worktree', hostRefusesOtherRuntime: true @@ -434,11 +436,44 @@ describe('mobile new-tab agent loading', () => { it('#13752: an older host cannot decide a shared repo id, so nothing is probed', async () => { const client = sharedRepoIdClient(() => ({ ok: true, result: ['codex'] })) await expect( - loadMobileNewTabAgentOptions({ client, worktreeId: 'repo-1::/srv/worktree' }) + loadOptions({ client, worktreeId: 'repo-1::/srv/worktree' }) ).rejects.toBeInstanceOf(MobileWorkspaceOnOtherRuntimeError) expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ 'repo.list', 'settings.get' ]) }) + + it('refreshes like desktop, and detects only where the host predates refresh for phones', async () => { + const client = createClient(async (method) => { + if (method === 'settings.get') { + return { ok: true, result: { settings: {} } } + } + if (method === 'preflight.refreshAgents') { + return { + ok: false, + error: { + code: 'forbidden', + message: "Method 'preflight.refreshAgents' is not available to mobile clients" + } + } + } + if (method === 'preflight.detectAgents') { + return { ok: true, result: ['claude'] } + } + throw new Error(`unexpected request: ${method}`) + }) + await expect( + loadOptions({ + client, + worktreeId: FLOATING_WORKSPACE_WORKTREE_ID, + refresh: true + }) + ).resolves.toEqual([{ agent: 'claude', label: 'Claude' }]) + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'preflight.refreshAgents', + 'settings.get', + 'preflight.detectAgents' + ]) + }) }) diff --git a/mobile/src/session/mobile-new-tab-agent-loader.ts b/mobile/src/session/mobile-new-tab-agent-loader.ts index 38dfafef7a1..8a513339f29 100644 --- a/mobile/src/session/mobile-new-tab-agent-loader.ts +++ b/mobile/src/session/mobile-new-tab-agent-loader.ts @@ -17,8 +17,10 @@ import { type MobileRuntimeRepoSummary, newTabRepoListRead, preflightDetectAgentsRead, - preflightDetectRemoteAgentsRead + preflightDetectRemoteAgentsRead, + preflightRefreshAgentsRead } from './mobile-session-read-operations' +import { isMobileMethodUnavailableError } from '../transport/mobile-method-unavailable' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse } from '../transport/types' import { isFloatingWorkspaceWorktreeId } from './floating-workspace' @@ -59,6 +61,8 @@ type MobileAgentLaunchContextArgs = { worktreeId: string /** Whether the host refuses, rather than answers for, a workspace another runtime owns. */ hostRefusesOtherRuntime?: boolean + /** Re-reads the host's login-shell PATH before probing, as desktop's refresh does. */ + refresh?: boolean } export async function loadMobileAgentLaunchContext( @@ -67,7 +71,7 @@ export async function loadMobileAgentLaunchContext( const { client, worktreeId } = args // Started before the settings read, not inside the array: the detection request goes on the wire // first, and the recorded sender order is what says so. - const detectedAgentsRequest = loadDetectedAgents(client, worktreeId, args.hostRefusesOtherRuntime) + const detectedAgentsRequest = loadDetectedAgents(client, worktreeId, args) const [settingsResponse, detectedAgents] = await Promise.all([ newTabSettingsRead.request(client), detectedAgentsRequest @@ -79,15 +83,35 @@ export async function loadMobileAgentLaunchContext( return { settings: readSettings(), detectedAgents: detected, repo: detectedAgents.repo } } +/** The drawer's options and the detection they came from, which a later opening can reuse. */ +export type MobileNewTabAgentInventory = Pick & { + options: MobileNewTabAgentOption[] +} + export async function loadMobileNewTabAgentOptions( - args: MobileAgentLaunchContextArgs -): Promise { + args: MobileAgentLaunchContextArgs & { settled?: MobileNewTabAgentInventory | null } +): Promise { + // Why settings every time: desktop reads them live, while a settled detection stands until refreshed. + if (args.settled) { + const reply = await newTabSettingsRead.request(args.client) + return newTabAgentInventory(newTabSettingsRead.interpret(reply)(), args.settled.detectedAgents) + } const context = await loadMobileAgentLaunchContext(args) - return buildMobileNewTabAgentOptions( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - context.settings as MobileNewTabAgentSettings | undefined, - context.detectedAgents - ) + return newTabAgentInventory(context.settings, context.detectedAgents) +} + +function newTabAgentInventory( + settings: unknown, + detectedAgents: unknown[] +): MobileNewTabAgentInventory { + return { + options: buildMobileNewTabAgentOptions( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + settings as MobileNewTabAgentSettings | undefined, + detectedAgents + ), + detectedAgents + } } /** The reply and the operation that reads it: two methods detect agents and each reads its own. */ @@ -100,18 +124,14 @@ type DetectedAgentsReply = { async function loadDetectedAgents( client: RpcClient, worktreeId: string, - hostRefusesOtherRuntime = false + host: Pick ): Promise { // Why: the floating workspace runs on the paired host, so it has no repo connection to resolve. if (isFloatingWorkspaceWorktreeId(worktreeId)) { - return { - reply: await preflightDetectAgentsRead.request(client), - interpret: preflightDetectAgentsRead.interpret, - repo: null - } + return { ...(await probeHostAgents(client, undefined, host.refresh)), repo: null } } if (worktreeId.startsWith('folder:')) { - return loadFolderDetectedAgents(client, worktreeId, hostRefusesOtherRuntime) + return loadFolderDetectedAgents(client, worktreeId, host) } const repoResponse = await newTabRepoListRead.request(client) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. @@ -127,14 +147,10 @@ async function loadDetectedAgents( if (runtimeOwned.includes(true)) { // Why: rows on several hosts can share a repo id, and then only a host that refuses another // runtime's workspace may decide; the first row's connection could name this host's SSH target. - if (!hostRefusesOtherRuntime || !runtimeOwned.includes(false)) { + if (!host.hostRefusesOtherRuntime || !runtimeOwned.includes(false)) { throw new MobileWorkspaceOnOtherRuntimeError() } - return { - reply: await preflightDetectAgentsRead.request(client, { worktreeId }), - interpret: preflightDetectAgentsRead.interpret, - repo - } + return { ...(await probeHostAgents(client, { worktreeId }, host.refresh)), repo } } const connectionId = repo.connectionId?.trim() || null return connectionId @@ -146,12 +162,34 @@ async function loadDetectedAgents( : { // Why the workspace: the host resolves its project runtime (a WSL distro on Windows). An // older host discards the params and answers with its own default, as it always has. - reply: await preflightDetectAgentsRead.request(client, { worktreeId }), - interpret: preflightDetectAgentsRead.interpret, + ...(await probeHostAgents(client, { worktreeId }, host.refresh)), repo } } +/** Desktop's `refreshRuntimeDetectedAgents` call: refresh, and detect only where the host lacks it. */ +async function probeHostAgents( + client: RpcClient, + params: { worktreeId: string } | undefined, + refresh: boolean | undefined +): Promise> { + if (refresh) { + const reply = await (params + ? preflightRefreshAgentsRead.request(client, params) + : preflightRefreshAgentsRead.request(client)) + // Why only this refusal: retrying a disconnect or a host failure doubles the work for nothing. + if (reply.ok || !isMobileMethodUnavailableError(reply.error.code, reply.error.message)) { + return { reply, interpret: preflightRefreshAgentsRead.interpret } + } + } + return { + reply: await (params + ? preflightDetectAgentsRead.request(client, params) + : preflightDetectAgentsRead.request(client)), + interpret: preflightDetectAgentsRead.interpret + } +} + function interpretDetectedAgents(detected: DetectedAgentsReply): unknown[] { try { return detected.interpret(detected.reply) @@ -167,14 +205,13 @@ function interpretDetectedAgents(detected: DetectedAgentsReply): unknown[] { async function loadFolderDetectedAgents( client: RpcClient, worktreeId: string, - hostRefusesOtherRuntime: boolean + { + hostRefusesOtherRuntime, + refresh + }: Pick ): Promise { if (hostRefusesOtherRuntime) { - return { - reply: await preflightDetectAgentsRead.request(client, { worktreeId }), - interpret: preflightDetectAgentsRead.interpret, - repo: null - } + return { ...(await probeHostAgents(client, { worktreeId }, refresh)), repo: null } } // Older hosts ignore worktreeId during detection, so resolve their folder catalog first. const [folders, groups, repos] = await Promise.all([ @@ -227,9 +264,5 @@ async function loadFolderDetectedAgents( interpret: preflightDetectRemoteAgentsRead.interpret, repo: null } - : { - reply: await preflightDetectAgentsRead.request(client, { worktreeId }), - interpret: preflightDetectAgentsRead.interpret, - repo: null - } + : { ...(await probeHostAgents(client, { worktreeId }, refresh)), repo: null } } diff --git a/mobile/src/session/mobile-session-read-operations.ts b/mobile/src/session/mobile-session-read-operations.ts index 2fac18b2ac0..477dec5749b 100644 --- a/mobile/src/session/mobile-session-read-operations.ts +++ b/mobile/src/session/mobile-session-read-operations.ts @@ -3,6 +3,7 @@ import { rpcResultVariant } from '../transport/rpc-operation-result-reader' import { detectedAgentsSchema, markdownTabDocumentSchema, + refreshedAgentsSchema, runtimeRepoListSchema, sessionForwardedReplySchema, sessionTerminalInventorySchema, @@ -90,6 +91,16 @@ export const preflightDetectAgentsRead = bindDeferredRpcOperation( }) ) +export const preflightRefreshAgentsRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'preflight.refresh-agents', + method: 'preflight.refreshAgents', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcResultVariant('refreshed-agents', refreshedAgentsSchema) + }) +) + export const preflightDetectRemoteAgentsRead = bindDeferredRpcOperation( defineRpcOperation({ name: 'preflight.detect-remote-agents', diff --git a/mobile/src/session/session-read-reply-schema.ts b/mobile/src/session/session-read-reply-schema.ts index 20f3e25ceac..fb03fe4ce91 100644 --- a/mobile/src/session/session-read-reply-schema.ts +++ b/mobile/src/session/session-read-reply-schema.ts @@ -44,6 +44,11 @@ export const runtimeRepoListSchema = z */ export const detectedAgentsSchema = z.array(z.unknown()) +/** `preflight.refreshAgents` wraps the same list with PATH diagnostics the drawer never reads. */ +export const refreshedAgentsSchema = z + .looseObject({ agents: detectedAgentsSchema }) + .transform((reply) => reply.agents) + /** * The workspace file paths native chat suggests, from either the search or the legacy inventory. * diff --git a/mobile/src/session/use-mobile-new-tab-agent-options.test.tsx b/mobile/src/session/use-mobile-new-tab-agent-options.test.tsx new file mode 100644 index 00000000000..6856607e9ef --- /dev/null +++ b/mobile/src/session/use-mobile-new-tab-agent-options.test.tsx @@ -0,0 +1,158 @@ +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' +import { + PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY, + WORKSPACE_ON_OTHER_RUNTIME +} from '../../../src/shared/protocol-version' +import type { ExecutionHostId } from '../../../src/shared/execution-host' +import { WorkspaceExecutionHostContext } from '../navigation/workspace-execution-host' +import { useMobileNewTabAgentOptions } from './use-mobile-new-tab-agent-options' + +const claude: RpcResponse = { id: '1', ok: true, result: ['claude'] } +const codex: RpcResponse = { id: '1', ok: true, result: ['codex'] } + +type FakeHost = { + agents: () => RpcResponse | Promise + settings?: unknown +} + +function createClient(host: FakeHost): RpcClient & { sendRequest: ReturnType } { + const sendRequest = vi.fn(async (method: string) => { + if (method === 'settings.get') { + return { ok: true, result: { settings: host.settings ?? {} } } + } + if (method === 'preflight.detectAgents') { + return host.agents() + } + if (method === 'preflight.refreshAgents') { + const reply = await host.agents() + return reply.ok ? { ...reply, result: { agents: reply.result } } : reply + } + throw new Error(`unexpected request: ${method}`) + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test double implements the two members the loader calls. + return { sendRequest, subscribe: vi.fn(() => () => {}) } as unknown as RpcClient & { + sendRequest: ReturnType + } +} + +function probes(client: { sendRequest: ReturnType }): unknown[] { + return client.sendRequest.mock.calls + .map(([method]) => method) + .filter((method) => method !== 'settings.get') +} + +function mountDrawer(hostId: string) { + const setLoadState = vi.fn() + const setOptions = vi.fn() + function Probe({ client, open }: { client: RpcClient; open: boolean }) { + useMobileNewTabAgentOptions({ + client, + connState: 'connected', + hostId, + hostCapabilities: [PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY], + worktreeId: 'folder:fw-1', + open, + setLoadState, + setOptions + }) + return null + } + let renderer: ReactTestRenderer | undefined + const render = async ( + client: RpcClient, + open: boolean, + executionHost?: ExecutionHostId + ): Promise => { + const tree = ( + + + + ) + await act(async () => { + if (renderer) { + renderer.update(tree) + } else { + renderer = create(tree) + } + }) + } + const states = (): unknown[] => setLoadState.mock.calls.map(([state]) => state) + const reset = (): void => { + setLoadState.mockClear() + setOptions.mockClear() + } + return { render, states, reset, setOptions, unmount: () => renderer?.unmount() } +} + +describe('useMobileNewTabAgentOptions', () => { + it('#21856: reopening the drawer shows the last agents at once and does not detect again', async () => { + const drawer = mountDrawer('host-reopen') + const host: FakeHost = { agents: () => ({ id: '1', ok: true, result: ['claude', 'codex'] }) } + const client = createClient(host) + await drawer.render(client, true) + expect(drawer.states()).toEqual(['loading', 'loaded']) + + await drawer.render(client, false) + host.settings = { disabledTuiAgents: ['codex'] } + drawer.reset() + await drawer.render(client, true) + + expect(drawer.states()).toEqual(['loaded', 'loaded']) + expect(drawer.setOptions.mock.calls[0]?.[0]).toEqual([ + { agent: 'claude', label: 'Claude' }, + { agent: 'codex', label: 'Codex' } + ]) + // Settings stay live, as on desktop; only the settled detection is reused. + expect(drawer.setOptions.mock.lastCall?.[0]).toEqual([{ agent: 'claude', label: 'Claude' }]) + expect(probes(client)).toEqual(['preflight.detectAgents']) + drawer.unmount() + }) + + it("does not offer one relayed server's agents for another's workspace with the same id", async () => { + const drawer = mountDrawer('host-relay') + const answered = createClient({ agents: () => claude }) + await drawer.render(answered, true, 'runtime:vm-a') + await drawer.render(answered, false, 'runtime:vm-a') + drawer.reset() + + await drawer.render( + createClient({ agents: () => new Promise(() => {}) }), + true, + 'runtime:vm-b' + ) + + expect(drawer.states()).toEqual(['loading']) + expect(drawer.setOptions.mock.calls).toEqual([[[]]]) + drawer.unmount() + }) + + it("refreshes the host's agents on a new screen, and shows a refusal over the cached ones", async () => { + const first = mountDrawer('host-refusal') + const host: FakeHost = { agents: () => claude } + const client = createClient(host) + await first.render(client, true) + first.unmount() + host.agents = () => ({ + id: '1', + ok: false, + error: { code: 'runtime_error', message: WORKSPACE_ON_OTHER_RUNTIME }, + _meta: { runtimeId: 'r' } + }) + + const drawer = mountDrawer('host-refusal') + await drawer.render(client, true) + + expect(probes(client)).toEqual(['preflight.detectAgents', 'preflight.refreshAgents']) + expect(drawer.states()).toEqual(['loaded', 'other-runtime']) + expect(drawer.setOptions.mock.lastCall?.[0]).toEqual([]) + host.agents = () => codex + await drawer.render(client, false) + drawer.reset() + await drawer.render(client, true) + expect(drawer.states()[0]).toBe('loading') + drawer.unmount() + }) +}) diff --git a/mobile/src/session/use-mobile-new-tab-agent-options.ts b/mobile/src/session/use-mobile-new-tab-agent-options.ts new file mode 100644 index 00000000000..f8d459d0442 --- /dev/null +++ b/mobile/src/session/use-mobile-new-tab-agent-options.ts @@ -0,0 +1,96 @@ +import { useEffect, useRef } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' +import { composeWorktreeHostIdentity } from '../../../src/shared/worktree/host-qualified-identity' +import { + deleteCachedNewTabAgentInventory, + getCachedNewTabAgentInventory, + setCachedNewTabAgentInventory +} from '../cache/new-tab-agent-inventory-cache' +import { useWorkspaceExecutionHost } from '../navigation/workspace-execution-host' +import { + hostRefusesOtherRuntimeWorkspace, + loadMobileNewTabAgentOptions, + MobileWorkspaceOnOtherRuntimeError +} from './mobile-new-tab-agent-loader' +import type { MobileNewTabAgentOption } from './mobile-new-tab-agent-options' +import type { MobileNewTabAgentLoadState } from './mobile-session-route-types' + +/** Loads the agents the New Tab drawer and the diff-notes picker offer while either is open. */ +export function useMobileNewTabAgentOptions(args: { + client: RpcClient | null + connState: ConnectionState + hostId: string + hostCapabilities: readonly string[] | null | undefined + worktreeId: string + open: boolean + setLoadState: (state: MobileNewTabAgentLoadState) => void + setOptions: (options: MobileNewTabAgentOption[]) => void +}): void { + const { client, connState, hostId, worktreeId, open, setLoadState, setOptions } = args + const hostRefusesOtherRuntime = hostRefusesOtherRuntimeWorkspace(args.hostCapabilities) + // Why the execution host: servers relayed by one paired desktop can share a workspace id. + const workspaceKey = composeWorktreeHostIdentity(useWorkspaceExecutionHost(), worktreeId) + const observedTargetsRef = useRef(new Set()) + useEffect(() => { + if (!open || !client || connState !== 'connected') { + setLoadState('idle') + setOptions([]) + return + } + + let stale = false + const cached = getCachedNewTabAgentInventory(hostId, workspaceKey) + // Why mirrored, not imported: useDetectedAgents' rule reads desktop's store. A screen reuses a + // settled detection, and the first open on a new screen refreshes a known list or detects again. + const observedTargets = observedTargetsRef.current + const target = `${hostId}\u0000${workspaceKey}\u0000${hostRefusesOtherRuntime}` + const observed = observedTargets.has(target) + setLoadState(cached ? 'loaded' : 'loading') + setOptions(cached?.options ?? []) + + void (async () => { + const inventory = await loadMobileNewTabAgentOptions({ + client, + worktreeId, + hostRefusesOtherRuntime, + settled: observed ? cached : null, + refresh: !observed && Boolean(cached?.detectedAgents.length) + }) + if (stale) { + return + } + observedTargets.add(target) + setCachedNewTabAgentInventory(hostId, workspaceKey, inventory) + setOptions(inventory.options) + setLoadState('loaded') + })().catch((error: unknown) => { + if (stale) { + return + } + const otherRuntime = error instanceof MobileWorkspaceOnOtherRuntimeError + if (otherRuntime) { + deleteCachedNewTabAgentInventory(hostId, workspaceKey) + } + // Why keep the cached list otherwise: a failed refresh does not uninstall the agents it showed. + if (otherRuntime || !cached) { + setOptions([]) + setLoadState(otherRuntime ? 'other-runtime' : 'error') + } + }) + + return () => { + stale = true + } + }, [ + client, + connState, + hostId, + hostRefusesOtherRuntime, + open, + setLoadState, + setOptions, + workspaceKey, + worktreeId + ]) +} diff --git a/mobile/src/session/use-mobile-session-attachments.ts b/mobile/src/session/use-mobile-session-attachments.ts index 6d88ae44ed8..a1586dd111a 100644 --- a/mobile/src/session/use-mobile-session-attachments.ts +++ b/mobile/src/session/use-mobile-session-attachments.ts @@ -2,11 +2,7 @@ import { useEffect } from 'react' import { AppState, type AppStateStatus } from 'react-native' import { useClipboardReader } from '../platform/clipboard' import { triggerSelection, triggerError } from '../platform/haptics' -import { - hostRefusesOtherRuntimeWorkspace, - loadMobileNewTabAgentOptions, - MobileWorkspaceOnOtherRuntimeError -} from './mobile-new-tab-agent-loader' +import { useMobileNewTabAgentOptions } from './use-mobile-new-tab-agent-options' import { useMobileSessionImageAttachments } from './use-mobile-session-image-attachments' import { useMobileNativeChatStructuredComposer } from './use-mobile-native-chat-structured-composer' import { resolveMobileNativeChatSessionId } from './mobile-native-chat-eligibility' @@ -144,55 +140,16 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti } }, [clipboardContents, selectModeActive, setCanPaste]) - const hostRefusesOtherRuntime = hostRefusesOtherRuntimeWorkspace(hostCapabilities) - useEffect(() => { - const shouldLoadAgentOptions = showCreateTabDrawer || pendingDiffNotesDelivery !== null - if (!shouldLoadAgentOptions) { - setCreateTabAgentLoadState('idle') - setCreateTabAgentOptions([]) - return - } - if (!client || connState !== 'connected') { - setCreateTabAgentLoadState('idle') - setCreateTabAgentOptions([]) - return - } - - let stale = false - setCreateTabAgentLoadState('loading') - setCreateTabAgentOptions([]) - - void (async () => { - const options = await loadMobileNewTabAgentOptions({ - client, - worktreeId, - hostRefusesOtherRuntime - }) - if (stale) { - return - } - setCreateTabAgentOptions(options) - setCreateTabAgentLoadState('loaded') - })().catch((error: unknown) => { - if (!stale) { - setCreateTabAgentOptions([]) - setCreateTabAgentLoadState( - error instanceof MobileWorkspaceOnOtherRuntimeError ? 'other-runtime' : 'error' - ) - } - }) - - return () => { - stale = true - } - }, [ + useMobileNewTabAgentOptions({ client, connState, - hostRefusesOtherRuntime, - pendingDiffNotesDelivery, - showCreateTabDrawer, - worktreeId - ]) + hostId, + hostCapabilities, + worktreeId, + open: showCreateTabDrawer || pendingDiffNotesDelivery !== null, + setLoadState: setCreateTabAgentLoadState, + setOptions: setCreateTabAgentOptions + }) return { handlePaste, flushPendingLiveInputBeforeAttachmentSend, diff --git a/mobile/src/test-support/rpc-recording/adapters/new-tab-agent-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/new-tab-agent-mount-adapters.ts index 7dec78b53e3..18714f85c2b 100644 --- a/mobile/src/test-support/rpc-recording/adapters/new-tab-agent-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/new-tab-agent-mount-adapters.ts @@ -12,7 +12,9 @@ export function newTabAgentMountAdapters( ).loadMobileNewTabAgentOptions return { action: (_name, args) => - load({ client, worktreeId: String(args.workspace ?? 'repo-1::/folder') }), + load({ client, worktreeId: String(args.workspace ?? 'repo-1::/folder') }).then( + ({ options }) => options + ), state: () => ({}), dispose: () => {} } diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts index 7587fd5260c..f1ddfc4e5b3 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts @@ -134,6 +134,7 @@ export const EXECUTION_HOST_METHODS = [ 'preflight.check', 'preflight.detectAgents', 'preflight.detectRemoteAgents', + 'preflight.refreshAgents', 'hostedReview.create', 'hostedReview.createStacked', 'hostedReview.forBranch',