diff --git a/mobile/app/h/[hostId]/accounts.tsx b/mobile/app/h/[hostId]/accounts.tsx index cb14566df8b..7294a539103 100644 --- a/mobile/app/h/[hostId]/accounts.tsx +++ b/mobile/app/h/[hostId]/accounts.tsx @@ -43,7 +43,7 @@ export default function AccountsScreen() { // Why: shared client per host. See docs/mobile-shared-client-per-host.md. const { client, state: connState } = useHostClient(hostId) const operations = useMemo( - () => (client && hostId ? defaultHostAccountOperations(client, hostId) : null), + () => (client && hostId ? defaultHostAccountOperations(client) : null), [client, hostId] ) const [hostName, setHostName] = useState('') diff --git a/mobile/src/accounts/default-host-account-operations.ts b/mobile/src/accounts/default-host-account-operations.ts index 88e6e7684fa..1448a56ba70 100644 --- a/mobile/src/accounts/default-host-account-operations.ts +++ b/mobile/src/accounts/default-host-account-operations.ts @@ -2,9 +2,6 @@ import type { RpcClient } from '../transport/rpc-client' import type { HostAccountOperations } from './host-account-operations' import { nativeHostAccountOperations } from './native-host-account-operations' -export function defaultHostAccountOperations( - client: RpcClient, - hostId: string -): HostAccountOperations { - return nativeHostAccountOperations(client, hostId) +export function defaultHostAccountOperations(client: RpcClient): HostAccountOperations { + return nativeHostAccountOperations(client) } diff --git a/mobile/src/accounts/host-account-operations.ts b/mobile/src/accounts/host-account-operations.ts index 626b5481469..8d9922b0873 100644 --- a/mobile/src/accounts/host-account-operations.ts +++ b/mobile/src/accounts/host-account-operations.ts @@ -3,20 +3,13 @@ import type { ProviderKey, RateLimitRuntimeTarget } from '../components/account-usage-state' -import type { CodexResetCreditRequestResult } from '../components/codex-reset-credit' -import type { CodexResetCreditExpectedScope } from '../../../src/shared/codex-reset-credit-scope' export type HostAccountOperations = { - loadHostName(hostId: string): Promise snapshot(): Promise select( provider: ProviderKey, accountId: string | null, codexTarget?: RateLimitRuntimeTarget | null ): Promise - readCodexResetCreditCapability(): Promise - consumeCodexResetCredit( - expectedScope: CodexResetCreditExpectedScope - ): Promise subscribe(listener: (snapshot: AccountsSnapshot) => void, onInvalid?: () => void): () => void } diff --git a/mobile/src/accounts/native-host-account-operations.ts b/mobile/src/accounts/native-host-account-operations.ts index 5f5dd27a54a..7acd9d8b223 100644 --- a/mobile/src/accounts/native-host-account-operations.ts +++ b/mobile/src/accounts/native-host-account-operations.ts @@ -3,22 +3,12 @@ import { type ProviderKey, type RateLimitRuntimeTarget } from '../components/account-usage-state' -import { requestCodexResetCredit } from '../components/codex-reset-credit' -import { readCodexResetCreditCapability } from '../components/codex-reset-credit-capability' -import { nativeDeviceOperations } from '../device/native-device-operations' -import { loadHosts } from '../transport/host-store' import type { RpcClient } from '../transport/rpc-client' import type { RpcSuccess } from '../transport/types' import type { HostAccountOperations } from './host-account-operations' -export function nativeHostAccountOperations( - client: RpcClient, - hostId: string -): HostAccountOperations { +export function nativeHostAccountOperations(client: RpcClient): HostAccountOperations { return { - async loadHostName(hostId) { - return (await loadHosts()).find((host) => host.id === hostId)?.name ?? null - }, async snapshot() { const response = await client.sendRequest('accounts.list') requireSuccess(response) @@ -34,16 +24,6 @@ export function nativeHostAccountOperations( : { accountId } requireSuccess(await client.sendRequest(method, params)) }, - readCodexResetCreditCapability() { - return readCodexResetCreditCapability(client) - }, - consumeCodexResetCredit(expectedScope) { - return requestCodexResetCredit(client, { - hostId, - expectedScope, - createIdempotencyKey: () => nativeDeviceOperations.randomNonce() - }) - }, subscribe(listener, onInvalid) { return client.subscribe('accounts.subscribe', null, (payload) => { if (!payload || typeof payload !== 'object') { diff --git a/mobile/src/device/default-device-operations.ts b/mobile/src/device/default-device-operations.ts deleted file mode 100644 index 6940fb09285..00000000000 --- a/mobile/src/device/default-device-operations.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { DeviceOperations } from './device-operations' -import { nativeDeviceOperations } from './native-device-operations' - -export function defaultDeviceOperations(): DeviceOperations { - return nativeDeviceOperations -} diff --git a/mobile/src/device/device-operations.ts b/mobile/src/device/device-operations.ts deleted file mode 100644 index e179863d5ae..00000000000 --- a/mobile/src/device/device-operations.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** Device capabilities a screen reaches through its host binding instead of importing a native - * module directly, so the same screen runs against a non-native provider later. - * - * Deliberately narrow: only what a caller on this branch reads. Haptics, clipboard and external - * links join it when the screens that use them are routed. */ -export type DeviceOperations = { - randomNonce(): string -} diff --git a/mobile/src/device/native-device-operations.ts b/mobile/src/device/native-device-operations.ts deleted file mode 100644 index d791ea18e18..00000000000 --- a/mobile/src/device/native-device-operations.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as ExpoCrypto from 'expo-crypto' -import type { DeviceOperations } from './device-operations' - -export const nativeDeviceOperations: DeviceOperations = { - randomNonce() { - return ExpoCrypto.randomUUID() - } -} diff --git a/mobile/src/session/github-pr-mutations.ts b/mobile/src/session/github-pr-mutations.ts index e7327fc76b5..fad0d0ea22b 100644 --- a/mobile/src/session/github-pr-mutations.ts +++ b/mobile/src/session/github-pr-mutations.ts @@ -19,10 +19,14 @@ type RawResult = { ok: true; result: unknown } | { ok: false; error: string } async function sendRaw( client: RpcRequestSender, method: string, - params: Record + params: Record, + options?: { timeoutMs?: number } ): Promise { try { - const response = await client.sendRequest(method, params) + // Keep the two-argument call shape when no timeout is requested. + const response = options + ? await client.sendRequest(method, params, options) + : await client.sendRequest(method, params) if (!response.ok) { return { ok: false, error: response.error?.message || `Request failed: ${method}` } } @@ -166,7 +170,8 @@ export async function fetchUpdatePRState( export async function fetchRequestPRReviewers( client: RpcRequestSender, worktreeId: string, - args: { prNumber: number; reviewers: string[]; prRepo?: GitHubPrRepoSlug | null } + args: { prNumber: number; reviewers: string[]; prRepo?: GitHubPrRepoSlug | null }, + options?: { timeoutMs?: number } ): Promise { return sendGithubPrMutation( client, @@ -176,7 +181,8 @@ export async function fetchRequestPRReviewers( worktreeId, { prNumber: args.prNumber, reviewers: args.reviewers }, { prRepo: args.prRepo } - ) + ), + options ) } @@ -211,7 +217,8 @@ export async function fetchAddPRReviewCommentReply( path?: string line?: number prRepo?: GitHubPrRepoSlug | null - } + }, + options?: { timeoutMs?: number } ): Promise { const params: Record = { prNumber: args.prNumber, @@ -232,7 +239,8 @@ export async function fetchAddPRReviewCommentReply( 'github.addPRReviewCommentReply', buildGithubPrParams('github.addPRReviewCommentReply', worktreeId, params, { prRepo: args.prRepo - }) + }), + options ) } @@ -247,7 +255,8 @@ export async function fetchAddIssueComment( // Why: the host addresses the comment by this; an issue row sent as 'pr' targets the // wrong conversation. PR call sites omit it. type?: 'issue' | 'pr' - } + }, + options?: { timeoutMs?: number } ): Promise { const params: Record = { number: args.prNumber, @@ -257,7 +266,8 @@ export async function fetchAddIssueComment( return sendGithubPrMutation( client, 'github.addIssueComment', - buildGithubPrParams('github.addIssueComment', worktreeId, params, { prRepo: args.prRepo }) + buildGithubPrParams('github.addIssueComment', worktreeId, params, { prRepo: args.prRepo }), + options ) } @@ -267,7 +277,8 @@ export async function fetchAddIssueComment( export async function fetchResolveReviewThread( client: RpcRequestSender, worktreeId: string, - args: { threadId: string; resolve: boolean; prRepo?: GitHubPrRepoSlug | null } + args: { threadId: string; resolve: boolean; prRepo?: GitHubPrRepoSlug | null }, + options?: { timeoutMs?: number } ): Promise { const response = await sendRaw( client, @@ -277,7 +288,8 @@ export async function fetchResolveReviewThread( worktreeId, { threadId: args.threadId, resolve: args.resolve }, { prRepo: args.prRepo } - ) + ), + options ) if (!response.ok) { return { diff --git a/mobile/src/session/host-session-native-chat-operations.ts b/mobile/src/session/host-session-native-chat-operations.ts index fa37cb77a38..419ee0307b3 100644 --- a/mobile/src/session/host-session-native-chat-operations.ts +++ b/mobile/src/session/host-session-native-chat-operations.ts @@ -56,7 +56,6 @@ export type HostSessionNativeChatOperations = { resolvedLaunchDraft?: { text: string; createdAt: number }, typeCommand?: boolean ): Promise - prepareCommit(target: HostSessionNativeChatTarget, deadline?: number): Promise respond( target: HostSessionNativeChatTarget, text: string, @@ -75,8 +74,11 @@ export type HostSessionNativeChatOperations = { followedByText?: boolean ): Promise releaseImages?(target: HostSessionNativeChatTarget, references: readonly string[]): Promise + /** Drops the legacy full-inventory fallback's cached listing for a workspace. Without it a + * second visit inside one connection serves the first read's inventory, so a file created in + * between is missing from `@` autocomplete. */ + resetFileSearchCache(workspaceId: string): void /** Null when the host refused the search. An empty array is a real answer the caller may * cache; a refusal must not be cached, or the next keystroke would never retry. */ searchFiles(target: HostSessionNativeChatTarget, query: string): Promise - openFile(target: HostSessionNativeChatTarget, pathText: string): Promise } diff --git a/mobile/src/session/host-session-tab-operations.ts b/mobile/src/session/host-session-tab-operations.ts index bd46d807593..b55c24c89a1 100644 --- a/mobile/src/session/host-session-tab-operations.ts +++ b/mobile/src/session/host-session-tab-operations.ts @@ -1,32 +1,12 @@ import type { SessionTabsResult } from './mobile-session-route-types' -import type { HostSessionRuntimeCapabilities } from './host-session-runtime-capabilities' -import type { MobileNewTabAgentOption } from './mobile-new-tab-agent-options' export type HostSessionTabCloseResult = | { outcome: 'closed' } | { outcome: 'refused'; reason: string | null } -export type HostSessionQuickCommandLaunchResult = { - snapshot: SessionTabsResult - tabId: string - initialInput: { text: string; enter: false; successToast: string } | null -} - export type HostSessionTabOperations = { - streamFirstStartup?: boolean - runtimeCapabilities(): Promise - snapshot(workspaceId: string): Promise - subscribe( - workspaceId: string, - onSnapshot: (snapshot: SessionTabsResult) => void, - onError: () => void - ): () => void - agentOptions(workspaceId: string): Promise - createQuickCommand?( - workspaceId: string, - commandId: string - ): Promise createBrowser(workspaceId: string, url: string): Promise<{ browserPageId?: string }> - activate(workspaceId: string, tabId: string, leafId?: string): Promise close(workspaceId: string, tabId: string): Promise } + +export type { SessionTabsResult } diff --git a/mobile/src/session/mobile-native-chat-open-file.ts b/mobile/src/session/mobile-native-chat-open-file.ts index 685be5689db..3de6755b4b3 100644 --- a/mobile/src/session/mobile-native-chat-open-file.ts +++ b/mobile/src/session/mobile-native-chat-open-file.ts @@ -1,6 +1,4 @@ import { splitFilePathLineSuffix } from '../components/markdown-file-path-detection' -import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types' -import type { RpcClient } from '../transport/rpc-client' import { openMobileFileTap, type FileTapSessionTab, @@ -30,50 +28,3 @@ export function openMobileNativeChatFileTap( column }) } - -export async function resolveMobileNativeChatWorktreePath(args: { - client: RpcClient - worktreeId: string - pathText: string - terminal: string | null -}): Promise { - try { - const response = await args.client.sendRequest('files.resolveTerminalPath', { - worktree: `id:${args.worktreeId}`, - pathText: args.pathText, - ...(args.terminal ? { terminal: args.terminal } : {}) - }) - if (!response.ok) { - return null - } - const resolved = response.result as RuntimeTerminalPathResolution - if (!resolved.exists || resolved.isDirectory) { - return null - } - return resolved.openTarget?.kind === 'worktree-file' - ? resolved.openTarget.relativePath - : (resolved.relativePath ?? null) - } catch { - return null - } -} - -export async function openMobileNativeChatFile(args: { - client: RpcClient - worktreeId: string - pathText: string - terminal: string | null -}): Promise { - const relativePath = await resolveMobileNativeChatWorktreePath(args) - if (!relativePath) { - return - } - try { - await args.client.sendRequest('files.open', { - worktree: `id:${args.worktreeId}`, - relativePath - }) - } catch { - // Best-effort open from transcript content. - } -} diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index d18b374fdd0..dbf204dcd96 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -70,7 +70,7 @@ const HEAD_CALLBACK_BODY_SHA256 = 'e66e6436cdb9a66e870c06fdfc140106502fbeddd8db4 const HEAD_EFFECT_SHA256 = 'd9ebfaabc1e79773cdada7ab370b20459ed972f1f8edce1652199f4d0391cd13' const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581' const HEAD_NESTED_FUNCTION_SHA256 = - '1dc163bbb2c1b4c9f8ff46514d6d5dcfc987c01a98d92a5c90058a868f55e089' + 'd0aada4091de4551fcb5edabad4aa84249799d2fd3244c0f7493eadb4fe3ba58' const HEAD_NATIVE_REGISTRATION_SHA256 = 'cab85e4e4a3f43289ba93ddea9ccce57aea83e0bf14fd1620a965aad0c1cb49e' const HEAD_NATIVE_REMOVAL_SHA256 = diff --git a/mobile/src/session/native-host-session-native-chat-operations.ts b/mobile/src/session/native-host-session-native-chat-operations.ts index 27b5e8d9af9..9828df79fb1 100644 --- a/mobile/src/session/native-host-session-native-chat-operations.ts +++ b/mobile/src/session/native-host-session-native-chat-operations.ts @@ -2,8 +2,6 @@ import type { RpcClient } from '../transport/rpc-client' import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe' import { isFloatingWorkspaceWorktreeId } from './floating-workspace' import { isMobileNativeChatTranscriptReadable } from './mobile-native-chat-eligibility' -import { openMobileNativeChatFile } from './mobile-native-chat-open-file' -import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input' import { sendMobileNativeChatMessageWithOutcome, typeMobileNativeChatCommandWithOutcome, @@ -80,17 +78,6 @@ export function nativeHostSessionNativeChatOperations( } return sendNative(target, text, true, client, deadline, clearInputFirst, resolvedLaunchDraft) }, - prepareCommit(target, deadline) { - if (!target.terminalId) { - return Promise.resolve(false) - } - return healMobileNativeChatStaleInput({ - client, - terminal: target.terminalId, - deviceToken: target.clientId, - deadline - }) - }, respond(target, text, enter, deadline) { return sendNative(target, text, enter, client, deadline) }, @@ -99,6 +86,11 @@ export function nativeHostSessionNativeChatOperations( // had parked on its input line. return sendNative(target, escape(), false, client, deadline) }, + resetFileSearchCache(workspaceId) { + searchSupported = null + legacyPathsByWorkspace.delete(workspaceId) + legacyLoadByWorkspace.delete(workspaceId) + }, async searchFiles(target, query) { if (searchSupported !== false) { const response = await client.sendRequest('files.searchPaths', { @@ -141,14 +133,6 @@ export function nativeHostSessionNativeChatOperations( legacyPathsByWorkspace.set(target.workspaceId, paths) } return rankSuggestions(legacyPaths, query, FILE_RESULT_LIMIT) - }, - openFile(target, pathText) { - return openMobileNativeChatFile({ - client, - worktreeId: target.workspaceId, - pathText, - terminal: target.terminalId - }) } } } diff --git a/mobile/src/session/native-host-session-tab-operations.test.ts b/mobile/src/session/native-host-session-tab-operations.test.ts index ef10918eec2..5161e000d85 100644 --- a/mobile/src/session/native-host-session-tab-operations.test.ts +++ b/mobile/src/session/native-host-session-tab-operations.test.ts @@ -1,59 +1,25 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' -import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { nativeHostSessionTabOperations } from './native-host-session-tab-operations' describe('native host session tab operations', () => { - it('projects status into the reviewed Session feature gates', async () => { - const sendRequest = vi.fn().mockResolvedValue({ - ok: true, - result: { - capabilities: [ - 'browser.screencast.v1', - 'aiVault.v1', - 'terminal.quick-commands.v1', - 'terminal.query-reply-input.v1', - 'secret.unreviewed.v1' - ] - } - }) - const operations = nativeHostSessionTabOperations({ - sendRequest - } as unknown as RpcClient) - - await expect(operations.runtimeCapabilities()).resolves.toEqual({ - browserScreencastSupported: true, - agentHistorySupported: true, - quickCommandsSupported: true, - terminalQueryReplyInputSupported: true - }) - expect(sendRequest).toHaveBeenCalledWith('status.get') - }) - - it('maps named lifecycle operations to the existing caller-local RPCs', async () => { + it('maps the two named lifecycle operations to the caller-local RPCs', async () => { const sendRequest = vi .fn() - .mockResolvedValueOnce({ ok: true, result: sessionSnapshot(1) }) .mockResolvedValueOnce({ ok: true, result: { browserPageId: 'browser-1' } }) - .mockResolvedValueOnce({ ok: true, result: sessionSnapshot(3) }) .mockResolvedValueOnce({ ok: true, result: { closed: true } }) const operations = nativeHostSessionTabOperations({ sendRequest } as unknown as RpcClient) - await expect(operations.snapshot('workspace-1')).resolves.toEqual(sessionSnapshot(1)) await expect(operations.createBrowser('workspace-1', 'https://example.com')).resolves.toEqual({ browserPageId: 'browser-1' }) - await expect(operations.activate('workspace-1', 'tab-1', 'leaf-1')).resolves.toEqual( - sessionSnapshot(3) - ) await expect(operations.close('workspace-1', 'tab-1')).resolves.toEqual({ outcome: 'closed' }) expect(sendRequest.mock.calls).toEqual([ - ['session.tabs.list', { worktree: 'id:workspace-1' }], [ 'browser.tabCreate', { @@ -65,104 +31,30 @@ describe('native host session tab operations', () => { // reconnect leaves the composer spinning with no error. { timeoutMs: 30_000 } ], - [ - 'session.tabs.activate', - { - worktree: 'id:workspace-1', - tabId: 'tab-1', - leafId: 'leaf-1', - notifyClients: false, - navigation: 'caller', - intent: 'user' - } - ], ['session.tabs.close', { worktree: 'id:workspace-1', tabId: 'tab-1', reason: 'user' }] ]) }) - it('forwards only snapshot events and preserves subscription cleanup', () => { - const unsubscribe = vi.fn() - let listener: ((event: unknown) => void) | null = null - const subscribe = vi.fn((_method, _params, onData) => { - listener = onData - return unsubscribe - }) - const onSnapshot = vi.fn() + it('treats a create that answers without a page id as a create, not a failure', async () => { const operations = nativeHostSessionTabOperations({ - subscribe + sendRequest: vi.fn().mockResolvedValue({ ok: true, result: {} }) } as unknown as RpcClient) - const cleanup = operations.subscribe('workspace-1', onSnapshot, vi.fn()) - listener?.({ type: 'ready' }) - listener?.({ type: 'updated', ...sessionSnapshot(3) }) - cleanup() - - expect(subscribe).toHaveBeenCalledWith( - 'session.tabs.subscribe', - { worktree: 'id:workspace-1' }, - expect.any(Function) + await expect(operations.createBrowser('workspace-1', 'https://example.com')).resolves.toEqual( + {} ) - expect(onSnapshot).toHaveBeenCalledWith({ - type: 'updated', - ...sessionSnapshot(3) - }) - expect(unsubscribe).toHaveBeenCalledOnce() }) - // Why: a host-side subscription cleanup ends the stream; without degrading, the tab - // list freezes on its last snapshot and never refetches. - it('degrades the stream on end and error rather than dropping them silently', () => { - let listener: ((event: unknown) => void) | null = null - const subscribe = vi.fn((_method, _params, onData) => { - listener = onData - return vi.fn() - }) - const onSnapshot = vi.fn() - const onError = vi.fn() - const operations = nativeHostSessionTabOperations({ subscribe } as unknown as RpcClient) - - operations.subscribe('workspace-1', onSnapshot, onError) - listener?.({ type: 'end' }) - listener?.({ type: 'error' }) - - expect(onSnapshot).not.toHaveBeenCalled() - expect(onError).toHaveBeenCalledTimes(2) - }) - - // Why: a relay-to-direct cutover rejects the in-flight request; activation is idempotent, - // so one retry keeps the host's active tab in step with the tab the user just tapped. - it('retries activation once after a logical client cutover', async () => { - const sendRequest = vi - .fn() - .mockRejectedValueOnce(new LogicalClientCutoverError('cutover')) - .mockResolvedValueOnce({ ok: true, result: sessionSnapshot(4) }) - const operations = nativeHostSessionTabOperations({ sendRequest } as unknown as RpcClient) - - await expect(operations.activate('workspace-1', 'tab-1')).resolves.toEqual(sessionSnapshot(4)) - expect(sendRequest).toHaveBeenCalledTimes(2) - }) - - it('loads enabled agent choices', async () => { - const sendRequest = vi.fn(async (method) => { - if (method === 'settings.get') { - return { - ok: true, - result: { settings: { defaultTuiAgent: 'codex', disabledTuiAgents: [] } } - } - } - if (method === 'preflight.detectAgents') { - return { ok: true, result: ['claude', 'codex'] } - } - return { ok: true, result: sessionSnapshot(4) } - }) + it('reports the host message when a browser create is refused', async () => { const operations = nativeHostSessionTabOperations({ - sendRequest + sendRequest: vi + .fn() + .mockResolvedValue({ ok: false, error: { code: 'busy', message: 'Browser busy' } }) } as unknown as RpcClient) - await expect(operations.agentOptions('global-floating-terminal')).resolves.toEqual([ - { agent: 'codex', label: 'Codex' }, - { agent: 'claude', label: 'Claude' } - ]) + await expect(operations.createBrowser('workspace-1', 'https://example.com')).rejects.toThrow( + 'Browser busy' + ) }) it('keeps refused closes visible to the shared screen', async () => { @@ -179,14 +71,3 @@ describe('native host session tab operations', () => { }) }) }) - -function sessionSnapshot(snapshotVersion: number) { - return { - worktree: 'workspace-1', - publicationEpoch: 'epoch-1', - snapshotVersion, - tabs: [], - activeTabId: null, - activeTabType: null - } -} diff --git a/mobile/src/session/native-host-session-tab-operations.ts b/mobile/src/session/native-host-session-tab-operations.ts index 0b7337bfc76..7aa079aed60 100644 --- a/mobile/src/session/native-host-session-tab-operations.ts +++ b/mobile/src/session/native-host-session-tab-operations.ts @@ -1,52 +1,9 @@ import type { RpcClient } from '../transport/rpc-client' import type { RpcSuccess } from '../transport/types' import type { HostSessionTabOperations } from './host-session-tab-operations' -import { projectHostSessionRuntimeCapabilities } from './host-session-runtime-capabilities' -import type { SessionTabsResult } from './mobile-session-route-types' -import { loadMobileNewTabAgentOptions } from './mobile-new-tab-agent-loader' -import { activateMobileSessionTab } from './mobile-session-tab-activation' export function nativeHostSessionTabOperations(client: RpcClient): HostSessionTabOperations { return { - async runtimeCapabilities() { - const response = await client.sendRequest('status.get') - if (!response.ok) { - throw new Error('session_capabilities_failed') - } - const result = (response as RpcSuccess).result as { capabilities?: unknown } - const capabilities = Array.isArray(result.capabilities) - ? result.capabilities.filter((value): value is string => typeof value === 'string') - : [] - return projectHostSessionRuntimeCapabilities(capabilities) - }, - async snapshot(workspaceId) { - return successfulSnapshot( - await client.sendRequest('session.tabs.list', { - worktree: `id:${workspaceId}` - }) - ) - }, - subscribe(workspaceId, onSnapshot, onError) { - return client.subscribe( - 'session.tabs.subscribe', - { worktree: `id:${workspaceId}` }, - (payload) => { - const event = payload as { type?: string } & SessionTabsResult - if (event.type === 'snapshot' || event.type === 'updated') { - onSnapshot(event) - return - } - // Why: a host-side subscription cleanup ends the stream; without degrading here the - // tab list freezes on its last snapshot and never refetches. - if (event.type === 'end' || event.type === 'error') { - onError() - } - } - ) - }, - agentOptions(workspaceId) { - return loadMobileNewTabAgentOptions({ client, worktreeId: workspaceId }) - }, async createBrowser(workspaceId, url) { const response = await client.sendRequest( 'browser.tabCreate', @@ -61,20 +18,6 @@ export function nativeHostSessionTabOperations(client: RpcClient): HostSessionTa const result = (response as RpcSuccess).result as { browserPageId?: unknown } return typeof result.browserPageId === 'string' ? { browserPageId: result.browserPageId } : {} }, - async activate(workspaceId, tabId, leafId) { - // Why: a relay-to-direct cutover rejects the in-flight request; activation is idempotent, - // so retrying once keeps the host's active tab in step with the UI the user just switched. - return successfulSnapshot( - await activateMobileSessionTab(client, { - worktree: `id:${workspaceId}`, - tabId, - ...(leafId ? { leafId } : {}), - notifyClients: false, - navigation: 'caller', - intent: 'user' - }) - ) - }, async close(workspaceId, tabId) { const response = await client.sendRequest('session.tabs.close', { worktree: `id:${workspaceId}`, @@ -98,10 +41,3 @@ export function nativeHostSessionTabOperations(client: RpcClient): HostSessionTa } } } - -function successfulSnapshot(response: Awaited>) { - if (!response.ok) { - throw new Error('session_snapshot_failed') - } - return (response as RpcSuccess).result as SessionTabsResult -} diff --git a/mobile/src/session/native-host-session-terminal-operations.ts b/mobile/src/session/native-host-session-terminal-operations.ts index 835f8156b8c..677a3861eaf 100644 --- a/mobile/src/session/native-host-session-terminal-operations.ts +++ b/mobile/src/session/native-host-session-terminal-operations.ts @@ -49,11 +49,11 @@ export function nativeHostSessionTerminalOperations( () => false ) }, - clear(terminalId) { - return client.sendRequest('terminal.clearBuffer', { terminal: terminalId }).then( - (response) => response.ok, - () => false - ) + async clear(terminalId) { + // Rejection is deliberately not caught: the caller distinguishes a clear that never + // reached the host from one the host refused, and only the former is a failure. + const response = await client.sendRequest('terminal.clearBuffer', { terminal: terminalId }) + return response.ok }, rename(terminalId, title) { return client.sendRequest('terminal.rename', { terminal: terminalId, title }).then( diff --git a/mobile/src/session/use-mobile-native-chat-file-search.ts b/mobile/src/session/use-mobile-native-chat-file-search.ts index e3d55feb95c..5ff1ad325bf 100644 --- a/mobile/src/session/use-mobile-native-chat-file-search.ts +++ b/mobile/src/session/use-mobile-native-chat-file-search.ts @@ -26,6 +26,7 @@ export function useMobileNativeChatFileSearch(args: { useEffect(() => { sequenceRef.current++ queryCacheRef.current.clear() + operations?.resetFileSearchCache(worktreeId) setNativeChatFilePaths([]) return () => { if (timerRef.current) { diff --git a/mobile/src/session/use-mobile-session-terminal-input.ts b/mobile/src/session/use-mobile-session-terminal-input.ts index 4a5a50dd9fc..10473104bc8 100644 --- a/mobile/src/session/use-mobile-session-terminal-input.ts +++ b/mobile/src/session/use-mobile-session-terminal-input.ts @@ -223,9 +223,12 @@ export function useMobileSessionTerminalInput(scope: MobileSessionFileActionsMod return } getTerminalRef(target.handle)?.clear() - if (await sessionOperations.terminal.clear(target.handle)) { + try { + // The refusal envelope is not a failure here: the local buffer above is already cleared, + // and only a clear that never reached the host is reported. + await sessionOperations.terminal.clear(target.handle) showToast('Terminal cleared') - } else { + } catch { showToast("Couldn't clear terminal", 1500) } } diff --git a/mobile/src/tasks/host-task-read-operations.ts b/mobile/src/tasks/host-task-read-operations.ts index fcc2affa2cf..55d10e06290 100644 --- a/mobile/src/tasks/host-task-read-operations.ts +++ b/mobile/src/tasks/host-task-read-operations.ts @@ -9,6 +9,10 @@ import type { export type { HostTaskBootstrap, HostTaskLinearStatus, HostTaskLinearTeams, HostTaskRepository } export type HostTaskReadOperations = { + /** Split from `bootstrap` so the caller can commit its supported state before the settings + * fan-out runs. Folding the two together moves that commit behind four more requests, and a + * transport failure in any of them then leaves the screen with nothing rendered at all. */ + tasksSupported(): Promise bootstrap(): Promise listRepositories(): Promise /** Split from the team read so a caller can commit the workspace list before asking for diff --git a/mobile/src/tasks/host-task-runtime-payloads.ts b/mobile/src/tasks/host-task-runtime-payloads.ts index d832b200aca..aa23ff1580d 100644 --- a/mobile/src/tasks/host-task-runtime-payloads.ts +++ b/mobile/src/tasks/host-task-runtime-payloads.ts @@ -14,7 +14,6 @@ export type HostTaskLinearStatus = { } export type HostTaskBootstrap = { - supported: boolean settings: RuntimeTaskSettings taskResumeState: TaskResumeState trustedOrcaHooks: PersistedTrustedOrcaHooks diff --git a/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts b/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts index 20acdb373c0..22a3fcdc358 100644 --- a/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts +++ b/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts @@ -25,9 +25,9 @@ const hash = (parts: string[] | string): string => * plus the row-target locals the adapters take in place of inline slug/number checks. Diff hooks, * declarations and styles are untouched. */ -const SCREEN_HOOKS = '9b2ac231e63169d3896ce30a3053262d30bc8f085456d75cbade522420205b02' +const SCREEN_HOOKS = '28f62d2fbd86d171bf162d26c6bd0763e4fb8c3f58895d7cd1a00633c1465f32' const DIFF_HOOKS = '93c7189b32bed8456cc51814fffa8ce80cf62011ef968a9d53ddec2b9686f58f' -const STATEMENTS = 'dd86f1ff89bf0bdbc3fbf159349d972a50d2dfa2607484848a34c4312b36ae60' +const STATEMENTS = 'd3f99f5ccf5decedadeef99e7633b8ce832b39e9a49675c43fbc0954b2a2e483' const DECLARATIONS = 'cff54172af17a877789be1479c2eb6ca97d83c3e31dd831cd59395962f2b4c4a' const SEMANTICS = 'f767906884b93537f2c6369d6d0bd2d4cb39b4314c31cca9d8f9e5e9b78a75ee' const STYLES = '1db6af69c791d9963928541ad5310942fcbda6d984b422c90b6eb92b6816579a' diff --git a/mobile/src/tasks/native-host-task-detail-operations.ts b/mobile/src/tasks/native-host-task-detail-operations.ts index 2a4481e5c6c..181dab48941 100644 --- a/mobile/src/tasks/native-host-task-detail-operations.ts +++ b/mobile/src/tasks/native-host-task-detail-operations.ts @@ -93,12 +93,11 @@ export function nativeHostTaskDetailOperations(client: RpcRequestSender): HostTa } } +/** Tolerates a refusal envelope only. A transport rejection still fails the detail load, so a + * timed-out comment read cannot render as an issue that simply has no comments. */ async function optionalComments(request: Promise): Promise { - try { - return await successfulResult(request) - } catch { - return [] - } + const response = (await request) as { ok: boolean; result?: unknown } + return response.ok ? ((response.result as DetailComment[]) ?? []) : [] } async function successfulResult(request: Promise): Promise { diff --git a/mobile/src/tasks/native-host-task-item-review-operations.ts b/mobile/src/tasks/native-host-task-item-review-operations.ts index 6e8a028b032..232be325b1c 100644 --- a/mobile/src/tasks/native-host-task-item-review-operations.ts +++ b/mobile/src/tasks/native-host-task-item-review-operations.ts @@ -87,7 +87,7 @@ export function nativeHostTaskItemReviewOperations( }, { timeoutMs: 60_000 } ) - assertMutation(response, 'Failed to merge review') + assertMutation(response, 'Failed to merge') } } } diff --git a/mobile/src/tasks/native-host-task-list-operations.ts b/mobile/src/tasks/native-host-task-list-operations.ts index 532f4efdb84..eeb27edf44e 100644 --- a/mobile/src/tasks/native-host-task-list-operations.ts +++ b/mobile/src/tasks/native-host-task-list-operations.ts @@ -6,16 +6,12 @@ export function nativeHostTaskListOperations(client: RpcRequestSender): HostTask return { async listGitHub(payload) { return successfulResult( - client.sendRequest( - 'github.listWorkItems', - { - repo: `id:${payload.repoId}`, - limit: payload.limit, - query: payload.query, - before: payload.before - }, - { timeoutMs: 30_000 } - ) + client.sendRequest('github.listWorkItems', { + repo: `id:${payload.repoId}`, + limit: payload.limit, + query: payload.query, + before: payload.before + }) ) }, async countGitHub(payload) { diff --git a/mobile/src/tasks/native-host-task-project-mutation-operations.ts b/mobile/src/tasks/native-host-task-project-mutation-operations.ts index 6cebae93c2a..9a015509024 100644 --- a/mobile/src/tasks/native-host-task-project-mutation-operations.ts +++ b/mobile/src/tasks/native-host-task-project-mutation-operations.ts @@ -15,6 +15,10 @@ import { } from '../session/github-pr-mutations' const PROJECT_PR_MUTATION_TIMEOUT_MS = 60_000 +/** Every project mutation carried a connect deadline before this seam existed. Without one the + * transport parks the request through the whole reconnect backoff, roughly six minutes, with + * the row's mutation UI disabled and no error. */ +const PROJECT_MUTATION_TIMEOUT_MS = 30_000 export function nativeHostTaskProjectMutationOperations( client: RpcRequestSender @@ -83,44 +87,64 @@ export function nativeHostTaskProjectMutationOperations( }, async resolveReviewThread(target, repoId, threadId, resolve) { requirePrMutation( - await fetchResolveReviewThread(client, repoId, { - threadId, - resolve, - // Why: a draft row - // has no slug — send it only when one resolved rather than an empty pair. - prRepo: prRepoPayload(target) - }), + await fetchResolveReviewThread( + client, + repoId, + { + threadId, + resolve, + // Why: a draft row + // has no slug — send it only when one resolved rather than an empty pair. + prRepo: prRepoPayload(target) + }, + { timeoutMs: PROJECT_MUTATION_TIMEOUT_MS } + ), resolve ? 'Failed to resolve thread' : 'Failed to reopen thread' ) }, async replyReviewComment(target, repoId, payload) { return prMutationComment( - await fetchAddPRReviewCommentReply(client, repoId, { - prNumber: target.number, - ...payload, - prRepo: prRepoPayload(target) - }), + await fetchAddPRReviewCommentReply( + client, + repoId, + { + prNumber: target.number, + ...payload, + prRepo: prRepoPayload(target) + }, + { timeoutMs: PROJECT_MUTATION_TIMEOUT_MS } + ), 'Failed to reply' ) }, async addConversationComment(target, repoId, body) { return prMutationComment( - await fetchAddIssueComment(client, repoId, { - prNumber: target.number, - body, - prRepo: prRepoPayload(target), - type: target.type - }), + await fetchAddIssueComment( + client, + repoId, + { + prNumber: target.number, + body, + prRepo: prRepoPayload(target), + type: target.type + }, + { timeoutMs: PROJECT_MUTATION_TIMEOUT_MS } + ), 'Failed to reply' ) }, async requestReviewers(target, repoId, reviewers) { requirePrMutation( - await fetchRequestPRReviewers(client, repoId, { - prNumber: target.number, - reviewers, - prRepo: prRepoPayload(target) - }), + await fetchRequestPRReviewers( + client, + repoId, + { + prNumber: target.number, + reviewers, + prRepo: prRepoPayload(target) + }, + { timeoutMs: PROJECT_MUTATION_TIMEOUT_MS } + ), 'Failed to request reviewers' ) }, diff --git a/mobile/src/tasks/native-host-task-project-read-operations.ts b/mobile/src/tasks/native-host-task-project-read-operations.ts index 502102802c0..cc7842612da 100644 --- a/mobile/src/tasks/native-host-task-project-read-operations.ts +++ b/mobile/src/tasks/native-host-task-project-read-operations.ts @@ -70,7 +70,8 @@ export function nativeHostTaskProjectReadOperations( }, async listItemLabels(payload) { const result = await projectResult<{ labels?: string[] }>( - client.sendRequest('github.project.listLabelsBySlug', payload, { timeoutMs: 30_000 }) + client.sendRequest('github.project.listLabelsBySlug', payload, { timeoutMs: 30_000 }), + 'Failed to load labels' ) return result.labels ?? [] }, @@ -80,7 +81,8 @@ export function nativeHostTaskProjectReadOperations( }>( client.sendRequest('github.project.listAssignableUsersBySlug', payload, { timeoutMs: 30_000 - }) + }), + 'Failed to load assignees' ) return result.users ?? [] }, @@ -90,24 +92,30 @@ export function nativeHostTaskProjectReadOperations( }>( client.sendRequest('github.project.listIssueTypesBySlug', payload, { timeoutMs: 30_000 - }) + }), + 'Failed to load issue types' ) return result.types ?? [] } } } -export async function projectResult(request: Promise): Promise { +/** `fallback` is the wording the calling screen reported before these reads moved behind the + * seam; a host that refuses without a message must still name what failed to load. */ +export async function projectResult( + request: Promise, + fallback = 'GitHub Project request failed' +): Promise { const response = (await request) as { ok: boolean result?: { ok?: boolean; error?: { message?: string } } error?: { message?: string } } if (!response.ok) { - throw new Error(response.error?.message ?? 'GitHub Project request failed') + throw new Error(response.error?.message ?? fallback) } if (response.result?.ok === false) { - throw new Error(response.result.error?.message ?? 'GitHub Project request failed') + throw new Error(response.result.error?.message ?? fallback) } return response.result as T } diff --git a/mobile/src/tasks/native-host-task-provider-write-operations.ts b/mobile/src/tasks/native-host-task-provider-write-operations.ts index be5741b80c6..eaac26ae5b3 100644 --- a/mobile/src/tasks/native-host-task-provider-write-operations.ts +++ b/mobile/src/tasks/native-host-task-provider-write-operations.ts @@ -24,7 +24,10 @@ export function nativeHostTaskProviderWriteOperations( error?: string } if (result.ok === false) { - throw new Error(result.error ?? `Failed to create ${payload.provider} issue`) + throw new Error( + result.error ?? + `Failed to create ${payload.provider === 'github' ? 'GitHub' : 'GitLab'} issue` + ) } return { ...(typeof result.number === 'number' ? { number: result.number } : {}), diff --git a/mobile/src/tasks/native-host-task-read-operations.ts b/mobile/src/tasks/native-host-task-read-operations.ts index 756c364fdb2..c13791b1b79 100644 --- a/mobile/src/tasks/native-host-task-read-operations.ts +++ b/mobile/src/tasks/native-host-task-read-operations.ts @@ -13,6 +13,7 @@ import type { RpcResponse, RpcSuccess } from '../transport/types' export function nativeHostTaskReadOperations(client: RpcRequestSender): HostTaskReadOperations { return { + tasksSupported: () => tasksSupported(client), bootstrap: () => bootstrap(client), listRepositories: () => listRepositories(client), linearStatus: () => linearStatus(client), @@ -21,13 +22,14 @@ export function nativeHostTaskReadOperations(client: RpcRequestSender): HostTask } } +async function tasksSupported(client: RpcRequestSender): Promise { + const response = await client.sendRequest('status.get') + requireSuccess(response) + const status = response.result as { capabilities?: string[] } + return status.capabilities?.includes(MOBILE_TASKS_CAPABILITY) === true +} + async function bootstrap(client: RpcRequestSender): Promise { - const statusResponse = await client.sendRequest('status.get') - requireSuccess(statusResponse) - const status = statusResponse.result as { capabilities?: string[] } - if (!status.capabilities?.includes(MOBILE_TASKS_CAPABILITY)) { - return emptyBootstrap(false) - } const [settingsResponse, uiResponse, preflightResponse, linearStatusResponse] = await Promise.all( [ client.sendRequest('settings.get'), @@ -47,7 +49,6 @@ async function bootstrap(client: RpcRequestSender): Promise { }>(uiResponse) const preflight = successResult<{ glab?: { installed?: boolean } }>(preflightResponse) return { - supported: true, settings: settingsEnvelope?.settings ?? {}, taskResumeState: uiEnvelope?.ui?.taskResumeState ?? {}, trustedOrcaHooks: uiEnvelope?.ui?.trustedOrcaHooks ?? {}, @@ -93,17 +94,6 @@ async function resolveGitHubRepoSlug( return response.result as GitHubOwnerRepo | null } -function emptyBootstrap(supported: boolean): HostTaskBootstrap { - return { - supported, - settings: {}, - taskResumeState: {}, - trustedOrcaHooks: {}, - gitLabInstalled: false, - linearStatus: normalizeLinearStatus(null) - } -} - function successResult(response: RpcResponse): T | null { return response.ok ? (response.result as T) : null } diff --git a/mobile/src/tasks/use-mobile-tasks-hydration-phases.test.tsx b/mobile/src/tasks/use-mobile-tasks-hydration-phases.test.tsx new file mode 100644 index 00000000000..ada6472906e --- /dev/null +++ b/mobile/src/tasks/use-mobile-tasks-hydration-phases.test.tsx @@ -0,0 +1,93 @@ +import { createElement, useEffect } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' + +// The hook reaches the Tasks barrels, which pull React Native in. Only the helpers this path +// runs before the settings fan-out matter, and the fan-out fails first in every case here. +vi.mock('./mobile-tasks-dependencies', () => ({ + filterAvailableTaskProviders: (providers: unknown) => providers, + isHostedTaskRepo: () => true, + normalizeVisibleTaskProviders: () => ['github'], + reconcileRepoSelection: () => new Set(), + resolveVisibleTaskProvider: () => 'github', + useEffect +})) +vi.mock('./mobile-tasks-legacy-foundation', () => ({ + EMPTY_GITHUB_PROJECT_SETTINGS: {}, + getTaskPresetQuery: () => '', + githubKindFromQuery: () => 'issues', + isTaskProvider: () => true, + normalizeGitHubPreset: () => 'issues', + normalizeLinearFilter: () => 'assigned', + scopeGitHubTaskSearch: (query: string) => query +})) + +const { useMobileTasksRuntimeHydration } = await import('./use-mobile-tasks-runtime-hydration') + +const client = { id: 'client-1' } + +/** 74 model fields reach this hook and only a handful steer this path, so anything unnamed + * answers with a spy. */ +function taskModel(taskOperations: unknown, recorded: Record>) { + const fixed: Record = { + client, + connState: 'connected', + taskOperations, + provider: 'github', + repoList: { state: { status: 'idle' }, repos: [], loading: false }, + repos: [], + requestedTaskSource: undefined, + visibleProviders: ['github'], + defaultLinearTeamSelectionRef: { current: null }, + defaultRepoSelectionRef: { current: null }, + repoSelectionHydratedRef: { current: false }, + taskResumeRef: { current: {} } + } + return new Proxy(fixed, { + get(target, property: string) { + if (property in target) { + return target[property] + } + recorded[property] ??= vi.fn() + return recorded[property] + }, + has: () => true + }) +} + +describe('tasks runtime hydration phases', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('renders a supported screen behind the error when the settings fan-out fails', async () => { + const recorded: Record> = {} + const taskOperations = { + read: { + tasksSupported: vi.fn().mockResolvedValue(true), + // The second phase is what a post-connect timeout on `preflight.check` rejects. + bootstrap: vi.fn().mockRejectedValue(new Error('Request timed out: preflight.check')) + } + } + + function Probe(): null { + useMobileTasksRuntimeHydration(taskModel(taskOperations, recorded) as never) + return null + } + await act(async () => { + renderer = create(createElement(Probe)) + }) + await act(async () => { + await Promise.resolve() + }) + + // Committed between the two phases. Folding them behind one await leaves this at 'unknown', + // which the list surface renders as a bare spinner with no path back. + expect(recorded.setTasksSupportState).toHaveBeenCalledWith({ kind: 'supported', client }) + expect(recorded.setError).toHaveBeenCalledWith('Request timed out: preflight.check') + expect(taskOperations.read.bootstrap).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx b/mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx index 0be77b7a36f..69f7c96f345 100644 --- a/mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx +++ b/mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx @@ -185,11 +185,11 @@ export function useMobileTasksRuntimeHydration(model: ClientSettingsActionsModel resetWorkspaceCreateState() const hydrateTaskState = async (): Promise => { - const bootstrap = await taskOperations.read.bootstrap() + const supported = await taskOperations.read.tasksSupported() if (stale) { return } - if (!bootstrap.supported) { + if (!supported) { // Why: Tasks is additive RPC surface, so old desktop builds can still // pair but must not receive the newer task-specific method calls. setTasksSupportState({ kind: 'unsupported', client }) @@ -236,8 +236,15 @@ export function useMobileTasksRuntimeHydration(model: ClientSettingsActionsModel setTaskStateHydrated(false) return } + // Committed before the settings fan-out: a transport failure there must still leave the + // Tasks chrome rendered behind an error banner. The effect deps do not change on a + // post-connect timeout, so an uncommitted screen would never retry. setTasksSupportState({ kind: 'supported', client }) setError('') + const bootstrap = await taskOperations.read.bootstrap() + if (stale) { + return + } const settings = bootstrap.settings setRuntimeTaskSettings(settings) setTrustedOrcaHooks(bootstrap.trustedOrcaHooks) diff --git a/mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx b/mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx index 1de2e6b63e1..e01e14985aa 100644 --- a/mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx +++ b/mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx @@ -72,8 +72,11 @@ export function useMobileTasksWorkspaceSshState(model: WorkspaceSparseActionsMod return } const state = await taskWorkspaceCreationOperations.readSshState(repo.connectionId) - setWorkspaceSshState(state) - if (state.status !== 'connected') { + // An unregistered target answers with no state at all; leave whatever the badge shows. + if (state) { + setWorkspaceSshState(state) + } + if (state?.status !== 'connected') { throw new Error(`Connect ${repo.displayName} before creating a workspace.`) } }, diff --git a/mobile/src/transport/screen-rpc-ratchet.test.ts b/mobile/src/transport/screen-rpc-ratchet.test.ts index 51c684d12e3..af5cbab11f4 100644 --- a/mobile/src/transport/screen-rpc-ratchet.test.ts +++ b/mobile/src/transport/screen-rpc-ratchet.test.ts @@ -19,8 +19,6 @@ const MOBILE_ROOT = join(__dirname, '../..') const ADAPTER_FILES = new Set([ 'src/accounts/default-host-account-operations.ts', 'src/accounts/native-host-account-operations.ts', - 'src/device/default-device-operations.ts', - 'src/device/native-device-operations.ts', 'src/files/default-host-file-explorer-operations.ts', 'src/files/native-host-file-explorer-operations.ts', 'src/session/default-host-session-browser-operations.ts', @@ -106,14 +104,13 @@ const INLINE_RPC_BY_FILE: Record = { 'src/notifications/mobile-notifications.ts': 4, 'src/session/ai-vault-resume-launch.ts': 3, 'src/session/ai-vault-resume-preparation.ts': 2, - 'src/session/github-pr-mutations.ts': 3, + 'src/session/github-pr-mutations.ts': 4, 'src/session/github-pr-rpc.ts': 9, 'src/session/mobile-clipboard-image.ts': 7, 'src/session/mobile-diff-review-loaders.ts': 5, 'src/session/mobile-image-attachment.ts': 2, 'src/session/mobile-native-chat-image-attachment.ts': 1, 'src/session/mobile-native-chat-image-send.ts': 2, - 'src/session/mobile-native-chat-open-file.ts': 2, 'src/session/mobile-native-chat-send.ts': 3, 'src/session/mobile-native-chat-session-option-persistence.ts': 1, 'src/session/mobile-native-chat-stale-input.ts': 1, diff --git a/mobile/src/worktree/host-workspace-creation-operations.ts b/mobile/src/worktree/host-workspace-creation-operations.ts index 4ca39de323e..1780c009d70 100644 --- a/mobile/src/worktree/host-workspace-creation-operations.ts +++ b/mobile/src/worktree/host-workspace-creation-operations.ts @@ -85,7 +85,8 @@ export type HostWorkspaceCreationOperations = { readTrustedHooks(): Promise isGitLabCliInstalled(): Promise isLinearConnected(): Promise - readSshState(targetId: string): Promise + /** Null when the host knows nothing about the target, which is not the same as disconnected. */ + readSshState(targetId: string): Promise connectSsh(targetId: string): Promise detectAgents(connectionId: string | null): Promise readRepoHooks(repoId: string): Promise diff --git a/mobile/src/worktree/host-workspace-host-context.ts b/mobile/src/worktree/host-workspace-host-context.ts deleted file mode 100644 index f17f7ae843d..00000000000 --- a/mobile/src/worktree/host-workspace-host-context.ts +++ /dev/null @@ -1,32 +0,0 @@ -export type HostWorkspaceSshTarget = { id: string; label: string } - -/** Inputs the workspace list needs to name and badge rows from non-local execution hosts. */ -export type HostWorkspaceHostContext = { - sshTargets: readonly HostWorkspaceSshTarget[] - hostSettingOverrides: unknown - platform: NodeJS.Platform | null -} - -export function readHostWorkspaceSshTargets(result: unknown): HostWorkspaceSshTarget[] { - const targets = (result as { targets?: unknown } | null)?.targets - if (!Array.isArray(targets)) { - return [] - } - return targets.filter( - (target): target is HostWorkspaceSshTarget => - typeof target === 'object' && - target !== null && - typeof (target as HostWorkspaceSshTarget).id === 'string' && - typeof (target as HostWorkspaceSshTarget).label === 'string' - ) -} - -export function readHostWorkspacePlatform(result: unknown): NodeJS.Platform | null { - const platform = (result as { platform?: unknown } | null)?.platform - return typeof platform === 'string' && platform ? (platform as NodeJS.Platform) : null -} - -export function readHostWorkspaceSettingOverrides(result: unknown): unknown { - return (result as { settings?: { hostSettingOverrides?: unknown } } | null)?.settings - ?.hostSettingOverrides -} diff --git a/mobile/src/worktree/native-host-workspace-creation-read-operations.ts b/mobile/src/worktree/native-host-workspace-creation-read-operations.ts index b8f94743d82..2a47386dbd2 100644 --- a/mobile/src/worktree/native-host-workspace-creation-read-operations.ts +++ b/mobile/src/worktree/native-host-workspace-creation-read-operations.ts @@ -75,7 +75,7 @@ export function nativeHostWorkspaceCreationReadOperations( const result = await successfulResult<{ state?: SshConnectionState | null }>( client.sendRequest('ssh.getState', { targetId }) ) - return result.state ?? disconnectedSshState(targetId) + return result.state ?? null }, async connectSsh(targetId) { const result = await successfulResult<{ state?: SshConnectionState | null }>(