fix(mobile): New Tab drawer reopens with its agents and refreshes them when desktop does (#31367)

This commit is contained in:
Kelvin Amoaba
2026-10-21 18:26:42 +00:00
committed by GitHub
parent 8f8c0abfc7
commit cdb032e83f
10 changed files with 447 additions and 110 deletions
+39
View File
@@ -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<string, MobileNewTabAgentInventory>()
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))
}
@@ -7,6 +7,12 @@ import {
MobileWorkspaceOnOtherRuntimeError
} from './mobile-new-tab-agent-loader'
async function loadOptions(
args: Parameters<typeof loadMobileNewTabAgentOptions>[0]
): Promise<unknown[]> {
return (await loadMobileNewTabAgentOptions(args)).options
}
function createClient(
handler: (method: string, params?: unknown) => Promise<unknown>
): RpcClient & { sendRequest: ReturnType<typeof vi.fn> } {
@@ -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'
])
})
})
@@ -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<MobileAgentLaunchContext, 'detectedAgents'> & {
options: MobileNewTabAgentOption[]
}
export async function loadMobileNewTabAgentOptions(
args: MobileAgentLaunchContextArgs
): Promise<MobileNewTabAgentOption[]> {
args: MobileAgentLaunchContextArgs & { settled?: MobileNewTabAgentInventory | null }
): Promise<MobileNewTabAgentInventory> {
// 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<MobileAgentLaunchContextArgs, 'hostRefusesOtherRuntime' | 'refresh'>
): Promise<DetectedAgentsReply> {
// 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<Omit<DetectedAgentsReply, 'repo'>> {
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<MobileAgentLaunchContextArgs, 'hostRefusesOtherRuntime' | 'refresh'>
): Promise<DetectedAgentsReply> {
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 }
}
@@ -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',
@@ -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.
*
@@ -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<RpcResponse>
settings?: unknown
}
function createClient(host: FakeHost): RpcClient & { sendRequest: ReturnType<typeof vi.fn> } {
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<typeof vi.fn>
}
}
function probes(client: { sendRequest: ReturnType<typeof vi.fn> }): 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<void> => {
const tree = (
<WorkspaceExecutionHostContext.Provider value={executionHost}>
<Probe client={client} open={open} />
</WorkspaceExecutionHostContext.Provider>
)
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<RpcResponse>(() => {}) }),
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()
})
})
@@ -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<string>())
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
])
}
@@ -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,
@@ -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: () => {}
}
@@ -134,6 +134,7 @@ export const EXECUTION_HOST_METHODS = [
'preflight.check',
'preflight.detectAgents',
'preflight.detectRemoteAgents',
'preflight.refreshAgents',
'hostedReview.create',
'hostedReview.createStacked',
'hostedReview.forBranch',