mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(mobile): New Tab drawer reopens with its agents and refreshes them when desktop does (#31367)
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user