mirror of
https://github.com/stablyai/orca.git
synced 2026-10-04 00:02:21 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 }>(
|
||||
|
||||
Reference in New Issue
Block a user