fix(mobile): restore the state commit between the two Tasks hydration phases

read.bootstrap() folded status.get and the settings fan-out behind one await,
so the supported commit ran only after all five requests succeeded. A
post-connect timeout on any of the four left tasksSupportState at 'unknown',
which the list surface renders as a bare spinner, and the effect deps do not
change on that failure so it never retried.

The probe is now read.tasksSupported() and the fan-out is read.bootstrap(),
with the caller committing supported and clearing the error between them, as
it did before the seam existed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-09 02:41:01 -04:00
parent cac634631b
commit d07fe48b4f
35 changed files with 262 additions and 472 deletions
+1 -1
View File
@@ -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<string>('')
@@ -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)
}
@@ -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<string | null>
snapshot(): Promise<AccountsSnapshot>
select(
provider: ProviderKey,
accountId: string | null,
codexTarget?: RateLimitRuntimeTarget | null
): Promise<void>
readCodexResetCreditCapability(): Promise<boolean>
consumeCodexResetCredit(
expectedScope: CodexResetCreditExpectedScope
): Promise<CodexResetCreditRequestResult>
subscribe(listener: (snapshot: AccountsSnapshot) => void, onInvalid?: () => void): () => void
}
@@ -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') {
@@ -1,6 +0,0 @@
import type { DeviceOperations } from './device-operations'
import { nativeDeviceOperations } from './native-device-operations'
export function defaultDeviceOperations(): DeviceOperations {
return nativeDeviceOperations
}
-8
View File
@@ -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
}
@@ -1,8 +0,0 @@
import * as ExpoCrypto from 'expo-crypto'
import type { DeviceOperations } from './device-operations'
export const nativeDeviceOperations: DeviceOperations = {
randomNonce() {
return ExpoCrypto.randomUUID()
}
}
+22 -10
View File
@@ -19,10 +19,14 @@ type RawResult = { ok: true; result: unknown } | { ok: false; error: string }
async function sendRaw(
client: RpcRequestSender,
method: string,
params: Record<string, unknown>
params: Record<string, unknown>,
options?: { timeoutMs?: number }
): Promise<RawResult> {
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<GitHubPrMutationOutcome> {
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<GitHubPrMutationOutcome> {
const params: Record<string, unknown> = {
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<GitHubPrMutationOutcome> {
const params: Record<string, unknown> = {
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<GitHubPrMutationOutcome> {
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 {
@@ -56,7 +56,6 @@ export type HostSessionNativeChatOperations = {
resolvedLaunchDraft?: { text: string; createdAt: number },
typeCommand?: boolean
): Promise<MobileNativeChatSendOutcome>
prepareCommit(target: HostSessionNativeChatTarget, deadline?: number): Promise<boolean>
respond(
target: HostSessionNativeChatTarget,
text: string,
@@ -75,8 +74,11 @@ export type HostSessionNativeChatOperations = {
followedByText?: boolean
): Promise<boolean>
releaseImages?(target: HostSessionNativeChatTarget, references: readonly string[]): Promise<void>
/** 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<string[] | null>
openFile(target: HostSessionNativeChatTarget, pathText: string): Promise<void>
}
@@ -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<HostSessionRuntimeCapabilities>
snapshot(workspaceId: string): Promise<SessionTabsResult>
subscribe(
workspaceId: string,
onSnapshot: (snapshot: SessionTabsResult) => void,
onError: () => void
): () => void
agentOptions(workspaceId: string): Promise<MobileNewTabAgentOption[]>
createQuickCommand?(
workspaceId: string,
commandId: string
): Promise<HostSessionQuickCommandLaunchResult>
createBrowser(workspaceId: string, url: string): Promise<{ browserPageId?: string }>
activate(workspaceId: string, tabId: string, leafId?: string): Promise<SessionTabsResult>
close(workspaceId: string, tabId: string): Promise<HostSessionTabCloseResult>
}
export type { SessionTabsResult }
@@ -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<T extends FileTapSessionTab>(
column
})
}
export async function resolveMobileNativeChatWorktreePath(args: {
client: RpcClient
worktreeId: string
pathText: string
terminal: string | null
}): Promise<string | null> {
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<void> {
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.
}
}
@@ -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 =
@@ -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
})
}
}
}
@@ -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<RpcClient['sendRequest']>().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<RpcClient['sendRequest']>()
.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<RpcClient['sendRequest']>()
.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<RpcClient['sendRequest']>(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
}
}
@@ -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<ReturnType<RpcClient['sendRequest']>>) {
if (!response.ok) {
throw new Error('session_snapshot_failed')
}
return (response as RpcSuccess).result as SessionTabsResult
}
@@ -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(
@@ -26,6 +26,7 @@ export function useMobileNativeChatFileSearch(args: {
useEffect(() => {
sequenceRef.current++
queryCacheRef.current.clear()
operations?.resetFileSearchCache(worktreeId)
setNativeChatFilePaths([])
return () => {
if (timerRef.current) {
@@ -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)
}
}
@@ -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<boolean>
bootstrap(): Promise<HostTaskBootstrap>
listRepositories(): Promise<HostTaskRepository[]>
/** Split from the team read so a caller can commit the workspace list before asking for
@@ -14,7 +14,6 @@ export type HostTaskLinearStatus = {
}
export type HostTaskBootstrap = {
supported: boolean
settings: RuntimeTaskSettings
taskResumeState: TaskResumeState
trustedOrcaHooks: PersistedTrustedOrcaHooks
@@ -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'
@@ -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<unknown>): Promise<DetailComment[]> {
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<T>(request: Promise<unknown>): Promise<T> {
@@ -87,7 +87,7 @@ export function nativeHostTaskItemReviewOperations(
},
{ timeoutMs: 60_000 }
)
assertMutation(response, 'Failed to merge review')
assertMutation(response, 'Failed to merge')
}
}
}
@@ -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) {
@@ -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'
)
},
@@ -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<T>(request: Promise<unknown>): Promise<T> {
/** `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<T>(
request: Promise<unknown>,
fallback = 'GitHub Project request failed'
): Promise<T> {
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
}
@@ -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 } : {}),
@@ -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<boolean> {
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<HostTaskBootstrap> {
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<HostTaskBootstrap> {
}>(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<T>(response: RpcResponse): T | null {
return response.ok ? (response.result as T) : null
}
@@ -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<string>(),
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<string, ReturnType<typeof vi.fn>>) {
const fixed: Record<string, unknown> = {
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<string, ReturnType<typeof vi.fn>> = {}
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)
})
})
@@ -185,11 +185,11 @@ export function useMobileTasksRuntimeHydration(model: ClientSettingsActionsModel
resetWorkspaceCreateState()
const hydrateTaskState = async (): Promise<void> => {
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)
@@ -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.`)
}
},
@@ -19,8 +19,6 @@ const MOBILE_ROOT = join(__dirname, '../..')
const ADAPTER_FILES = new Set<string>([
'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<string, number> = {
'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,
@@ -85,7 +85,8 @@ export type HostWorkspaceCreationOperations = {
readTrustedHooks(): Promise<PersistedTrustedOrcaHooks>
isGitLabCliInstalled(): Promise<boolean>
isLinearConnected(): Promise<boolean>
readSshState(targetId: string): Promise<SshConnectionState>
/** Null when the host knows nothing about the target, which is not the same as disconnected. */
readSshState(targetId: string): Promise<SshConnectionState | null>
connectSsh(targetId: string): Promise<SshConnectionState>
detectAgents(connectionId: string | null): Promise<string[]>
readRepoHooks(repoId: string): Promise<NewWorkspaceRepoHooks>
@@ -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
}
@@ -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 }>(