fix(mobile): match main's observable behavior in the host adapters

An adapter body has to be the inline call it replaced, so each place where a
carried adapter answered differently from main is corrected here:

- Linear: per-method timeouts and error strings as main sent them, no result
  check on selectWorkspace or updateIssue, "Sub-issue not found" and
  "Failed to create sub-issue" restored.
- browser.tabCreate: the host's error message, main's 30s budget, and a
  tolerated missing page id.
- terminal.clearBuffer: an ok:false reply keeps main's success toast.
- Quick commands: only the load retries a logical cutover, and an empty error
  message falls back to main's per-operation copy.
- Project rows: the pull-request mutations no longer require a repository slug;
  a row without one forwards prRepo: null, as main did.
- worktree.listRetiredNames: a refused read reads as an empty registry again.
- repo.hooks: the create sheet keeps its state on a refused read, while task
  create still fails on one.

The paced-Escape route ref moves from render into a layout effect so the
changed-code React Doctor gate passes; a timer cannot observe the ref between
commit and layout, so the guard's timing is unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-08 23:11:34 -04:00
parent 222134ebea
commit 538bfd2bdd
23 changed files with 225 additions and 120 deletions
@@ -35,9 +35,9 @@ export function useNewWorkspaceSetupScript(args: {
}
let stale = false
void operations
.readRepoHooks(selectedRepo.id)
.readRepoHooksIfAvailable(selectedRepo.id)
.then((result) => {
if (stale) {
if (stale || !result) {
return
}
const command = result.hooks?.scripts?.setup?.trim() || null
@@ -31,7 +31,7 @@ export type HostSessionTabOperations = {
workspaceId: string,
commandId: string
): Promise<HostSessionQuickCommandLaunchResult>
createBrowser(workspaceId: string, url: string): Promise<{ browserPageId: string }>
createBrowser(workspaceId: string, url: string): Promise<{ browserPageId?: string }>
activate(workspaceId: string, tabId: string, leafId?: string): Promise<SessionTabsResult>
close(workspaceId: string, tabId: string): Promise<HostSessionTabCloseResult>
}
@@ -64,6 +64,8 @@ export type HostSessionTerminalOperations = {
enter: boolean,
clientId: string | null
): Promise<boolean>
/** Answers only for a terminal this same instance subscribed: the reply is dropped otherwise.
* A caller that routes query replies must route `subscribe` through the same instance too. */
sendQueryReply(
terminalId: string,
bytes: string,
@@ -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 =
'5042c3622cbf32166e5a2af5a534ff0305ee9446e8fd607a4baf7ec3e485b7ba'
'd0aada4091de4551fcb5edabad4aa84249799d2fd3244c0f7493eadb4fe3ba58'
const HEAD_NATIVE_REGISTRATION_SHA256 =
'cab85e4e4a3f43289ba93ddea9ccce57aea83e0bf14fd1620a965aad0c1cb49e'
const HEAD_NATIVE_REMOVAL_SHA256 =
@@ -79,7 +79,7 @@ const HEAD_TIMER_CREATION_SHA256 =
'1a31b625e2174c3db77272249843196d2b6b06ab1e654a96d8f7858e3082e66b'
const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f34234541a2065ec3d1a8cd116'
const HEAD_RUNTIME_STRING_SHA256 =
'd5cf66745d014a3848798f7fe388d2d3370e657451dd8353e19e972b07eec324'
'f7e7d7a99d506589d1b620b86f39bbce97223ab7ad4143b1f06a6a8d8ab81f5b'
const HEAD_HOST_JSX_SHA256 = '390405926b1695fa3a33686f0bc192b432f5468d8576499d7cafbb4922defbb5'
const HEAD_LEAF_JSX_SHA256 = 'd5f1ef0db57c63eb3e4ee7c98e8483bc21882a151ce0ca24e42c7d1234e1dace'
const HEAD_STYLE_REFERENCE_SHA256 =
@@ -517,7 +517,7 @@ describe('mobile session route extraction parity', () => {
it('preserves runtime strings, styles, and the expanded JSX tree', () => {
const strings = readRuntimeStrings()
expect(strings).toHaveLength(523)
expect(strings).toHaveLength(522)
expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256)
const jsx = readJsxFacts(readDefinitions())
expect(jsx.host).toHaveLength(124)
@@ -1,7 +1,4 @@
import {
parseNormalizedTerminalQuickCommands,
type TerminalQuickCommandMutation
} from '../terminal/quick-commands'
import { parseNormalizedTerminalQuickCommands } from '../terminal/quick-commands'
import type { RpcClient } from '../transport/rpc-client'
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type {
@@ -19,36 +16,31 @@ export function nativeHostSessionQuickCommandOperations(
return {
async snapshot(workspaceId, signal) {
return quickCommandSnapshot(
await quickCommandRequest(client, 'settings.getTerminalQuickCommands', undefined, signal),
await loadWithCutoverRetry(client, signal),
workspaceId,
'Failed to load quick commands'
)
},
async mutate(workspaceId, mutation) {
return quickCommandSnapshot(
await quickCommandRequest(client, 'settings.updateTerminalQuickCommands', {
mutation
}),
workspaceId,
'Failed to save quick command'
)
// Why no cutover retry here: a quick-command mutation is not idempotent, so a replay
// after a logical cutover could apply the same edit twice.
const response = await client.sendRequest('settings.updateTerminalQuickCommands', {
mutation
})
if (!response.ok) {
throw new Error(response.error.message || 'Failed to save quick command')
}
return quickCommandSnapshot(response.result, workspaceId, 'Failed to save quick command')
}
}
}
async function quickCommandRequest(
client: RpcClient,
method: 'settings.getTerminalQuickCommands' | 'settings.updateTerminalQuickCommands',
params?: { mutation: TerminalQuickCommandMutation },
signal?: AbortSignal
) {
async function loadWithCutoverRetry(client: RpcClient, signal?: AbortSignal) {
for (let retry = 0; ; retry += 1) {
try {
const response = params
? await client.sendRequest(method, params)
: await client.sendRequest(method)
const response = await client.sendRequest('settings.getTerminalQuickCommands')
if (!response.ok) {
throw new Error(response.error.message || 'quick_commands_failed')
throw new Error(response.error.message || 'Failed to load quick commands')
}
return response.result
} catch (error) {
@@ -74,7 +74,10 @@ describe('native host session tab operations', () => {
worktree: 'id:workspace-1',
url: 'https://example.com',
activate: true
}
},
// The caller carried this budget before the seam existed; a browser create that parks on
// reconnect leaves the composer spinning with no error.
{ timeoutMs: 30_000 }
],
[
'session.tabs.activate',
@@ -83,19 +83,18 @@ export function nativeHostSessionTabOperations(client: RpcClient): HostSessionTa
)
},
async createBrowser(workspaceId, url) {
const response = await client.sendRequest('browser.tabCreate', {
worktree: `id:${workspaceId}`,
url,
activate: true
})
const response = await client.sendRequest(
'browser.tabCreate',
{ worktree: `id:${workspaceId}`, url, activate: true },
{ timeoutMs: 30_000 }
)
if (!response.ok) {
throw new Error('browser_create_failed')
throw new Error(response.error.message)
}
// A host that answers without a page id still created the tab; the caller only loses the
// focus hint, so this is not a create failure.
const result = (response as RpcSuccess).result as { browserPageId?: unknown }
if (typeof result.browserPageId !== 'string') {
throw new Error('browser_create_failed')
}
return { browserPageId: result.browserPageId }
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,
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, type MutableRefObject } from 'react'
import type { HostSessionNativeChatOperations } from './host-session-native-chat-operations'
import { mobileNativeChatOperationTarget } from './mobile-native-chat-operation-target'
import { openMobileNativeChatSendBudget } from './mobile-native-chat-send'
@@ -30,7 +30,11 @@ export function useMobileNativeChatStop(args: {
* never happen. */
const dropSecondEscapeRef = useRef<(() => void) | null>(null)
const activeRouteRef = useRef({ operations, enabled, streamIdentity })
activeRouteRef.current = { operations, enabled, streamIdentity }
// Layout, not render or a passive Effect: the paced Escape fires from a timer, and only a
// synchronous post-commit write guarantees it never reads a route the render discarded.
useLayoutEffect(() => {
activeRouteRef.current = { operations, enabled, streamIdentity }
}, [operations, enabled, streamIdentity])
const cancelSecondEscape = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
@@ -224,9 +224,9 @@ export function useMobileSessionTerminalInput(scope: MobileSessionFileActionsMod
}
getTerminalRef(target.handle)?.clear()
try {
if (!(await sessionOperations.terminal.clear(target.handle))) {
throw new Error('terminal_clear_failed')
}
// Why the result is ignored: a host that answers `ok: false` still cleared the local
// buffer above, and only a transport failure told the user the clear did not happen.
await sessionOperations.terminal.clear(target.handle)
showToast('Terminal cleared')
} catch {
showToast("Couldn't clear terminal", 1500)
@@ -45,13 +45,19 @@ describe('mobile GitHub Project host routing boundary', () => {
it('pins Project-row PR actions to the row repository identity', () => {
// Every Project-row action resolves its target from the row plus the active Project host.
const targets = [...compositionSource.matchAll(/projectRowMutationTarget\(([^)]*)\)/g)]
const targets = [
...compositionSource.matchAll(/projectRow(?:Mutation|PullRequest)Target\(([^)]*)\)/g)
]
expect(targets.length).toBeGreaterThan(10)
for (const target of targets) {
expect(target[1].replace(/\s+/g, ' ').trim()).toBe('row, activeGitHubProjectHost')
}
// The target type carries the host that the PR mutations forward as prRepo.
expect(projectMutationAdapter).toContain('prRepo: slugPayload(target)')
// The target type carries the host that the PR mutations forward as prRepo, and a row with
// no slug forwards null rather than being refused.
expect(projectMutationAdapter).toContain('prRepo: prRepoPayload(target)')
expect(projectMutationAdapter).toMatch(
/function prRepoPayload\(target: HostTaskProjectItemTarget\) \{\s*return target\.owner && target\.repo/
)
for (const method of [
'fetchResolveReviewThread',
'fetchAddPRReviewCommentReply',
@@ -9,6 +9,7 @@ import type { TaskItem } from './mobile-tasks-project-workspace-types'
export {
projectRowIdentityTarget,
projectRowMutationTarget,
projectRowPullRequestTarget,
projectRowSlugTarget
} from './mobile-tasks-project-row-targets'
@@ -24,6 +24,28 @@ export function projectRowMutationTarget(
: null
}
/**
* For the pull-request mutations the host addresses by number, carrying the slug only as
* optional `prRepo` decoration: reviewers, check reruns, merges and file-viewed state. The slug
* is not required, because a row without one still names a pull request the host can act on.
*/
export function projectRowPullRequestTarget(
row: GitHubProjectRow,
host: string
): HostTaskProjectItemTarget | null {
const slug = splitRepositorySlug(row.content.repository)
const type = projectRowType(row)
return type && row.content.number
? {
owner: slug?.owner ?? '',
repo: slug?.repo ?? '',
host,
number: row.content.number,
type
}
: null
}
/**
* For mutations the host addresses by repository slug and comment id — the `*BySlug` comment
* edits. They read no issue number and no issue/PR kind, so requiring those refused rows
@@ -25,11 +25,11 @@ 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 = 'e7c47c8624d0c51100d1704cd7865a022d1ea372ad659c1f9d2581afa9e0de6a'
const SCREEN_HOOKS = 'b5d85ab1cd2996a6986604caf497c8a577dc118b095c49d555c576d98f48148f'
const DIFF_HOOKS = '93c7189b32bed8456cc51814fffa8ce80cf62011ef968a9d53ddec2b9686f58f'
const STATEMENTS = '268d9cebe985c14b35052128ce985e81306d4507aae49a6301c165dc65d93337'
const STATEMENTS = '1d059c8359400f2aedb16c94a7dba0c8a21b5a06edb45e2b0b7ff0317250d564'
const DECLARATIONS = 'cff54172af17a877789be1479c2eb6ca97d83c3e31dd831cd59395962f2b4c4a'
const SEMANTICS = '69cfc4f59322d1050b47f7028171ef5c9cbda6c3e51878676ecf9be68122affa'
const SEMANTICS = 'f767906884b93537f2c6369d6d0bd2d4cb39b4314c31cca9d8f9e5e9b78a75ee'
const STYLES = '1db6af69c791d9963928541ad5310942fcbda6d984b422c90b6eb92b6816579a'
const RENDER_TREE = 'a959c6712c70024127a429a47ae7689c629393752fa99a1ac6ba311d0bf13ca6'
@@ -58,7 +58,7 @@ describe('Mobile Tasks refactor parity', () => {
it('preserves RPC calls, runtime strings, and JSX host signatures', () => {
const semantics = readMobileTasksSemanticSource()
expect(semantics.split('\n')).toHaveLength(3_276)
expect(semantics.split('\n')).toHaveLength(3_278)
expect(hash(semantics)).toBe(SEMANTICS)
})
@@ -14,14 +14,25 @@ export function nativeHostTaskItemMutationOperations(
target.provider === 'github'
? await setGitHubClosed(client, target, closed)
: await setGitLabClosed(client, target, closed)
assertMutation(response, 'Failed to update task status')
// Provider-keyed, because each caller reported its own provider's wording before the seam.
assertMutation(
response,
target.provider === 'github'
? 'Failed to update GitHub status'
: 'Failed to update GitLab item'
)
},
async updateMetadata(target, updates) {
const response =
target.provider === 'github'
? await updateGitHubMetadata(client, target, updates)
: await updateGitLabMetadata(client, target, updates)
assertMutation(response, 'Failed to update task')
assertMutation(
response,
target.provider === 'github'
? 'Failed to update GitHub issue'
: 'Failed to update GitLab item'
)
}
}
}
@@ -1,5 +1,10 @@
import type { HostTaskLinearOperations } from './host-task-linear-operations'
import type { RpcRequestSender } from '../transport/rpc-client'
import type { SendRequestOptions } from '../transport/rpc-client'
/** The interactive Linear writes carry their own budget; the reads that only feed a picker do
* not, so a slow host degrades the picker instead of the whole screen. */
const INTERACTIVE: SendRequestOptions = { timeoutMs: 30_000 }
export function nativeHostTaskLinearOperations(client: RpcRequestSender): HostTaskLinearOperations {
return {
@@ -16,26 +21,23 @@ export function nativeHostTaskLinearOperations(client: RpcRequestSender): HostTa
workspaceId: target.workspaceId
}),
async selectWorkspace(workspaceId) {
assertMutation(
await request(client, 'linear.selectWorkspace', { workspaceId }),
'Failed to select workspace'
)
// Why no result check: the picker already switched, and it reloads the Linear context next.
// A rejected switch surfaces through that reload, not as a second error on the same tap.
await client.sendRequest('linear.selectWorkspace', { workspaceId })
},
async updateState(target, stateId) {
assertMutation(
await request(client, 'linear.updateIssue', {
id: target.issueId,
workspaceId: target.workspaceId,
updates: { stateId }
}),
'Failed to update Linear issue'
)
await request(client, 'linear.updateIssue', {
id: target.issueId,
workspaceId: target.workspaceId,
updates: { stateId }
})
},
async addComment(target, body) {
const result = await request<{ ok?: boolean; id?: string; error?: string }>(
client,
'linear.addIssueComment',
{ issueId: target.issueId, workspaceId: target.workspaceId, body }
{ issueId: target.issueId, workspaceId: target.workspaceId, body },
INTERACTIVE
)
assertMutation(result, 'Failed to add comment')
return result.id
@@ -43,21 +45,32 @@ export function nativeHostTaskLinearOperations(client: RpcRequestSender): HostTa
async loadIssue(target) {
const issue = await request<Awaited<
ReturnType<HostTaskLinearOperations['loadIssue']>
> | null>(client, 'linear.getIssue', { id: target.issueId, workspaceId: target.workspaceId })
> | null>(
client,
'linear.getIssue',
{ id: target.issueId, workspaceId: target.workspaceId },
INTERACTIVE
)
if (!issue) {
throw new Error('Linear issue not found')
throw new Error('Sub-issue not found')
}
return issue
},
async createSubIssue(target, title) {
return createdIssue(
await request(client, 'linear.createIssue', {
teamId: target.teamId,
title,
workspaceId: target.workspaceId,
parentIssueId: target.issueId,
projectId: target.projectId ?? null
})
await request(
client,
'linear.createIssue',
{
teamId: target.teamId,
title,
workspaceId: target.workspaceId,
parentIssueId: target.issueId,
projectId: target.projectId ?? null
},
INTERACTIVE
),
'Failed to create sub-issue'
)
},
async createIssue(payload) {
@@ -67,7 +80,8 @@ export function nativeHostTaskLinearOperations(client: RpcRequestSender): HostTa
title: payload.title,
description: payload.description,
workspaceId: payload.team.workspaceId
})
}),
'Failed to create Linear issue'
)
}
}
@@ -76,9 +90,12 @@ export function nativeHostTaskLinearOperations(client: RpcRequestSender): HostTa
async function request<T = unknown>(
client: RpcRequestSender,
method: string,
payload?: object
payload?: object,
options?: SendRequestOptions
): Promise<T> {
const response = await client.sendRequest(method, payload, { timeoutMs: 30_000 })
const response = options
? await client.sendRequest(method, payload, options)
: await client.sendRequest(method, payload)
if (!response.ok) {
throw new Error(response.error?.message ?? 'Task request failed')
}
@@ -94,7 +111,7 @@ function assertMutation(
}
}
function createdIssue(result: unknown) {
function createdIssue(result: unknown, fallback: string) {
const issue = result as {
ok?: boolean
id?: string
@@ -104,7 +121,7 @@ function createdIssue(result: unknown) {
error?: string
}
if (issue.ok === false || !issue.id || !issue.identifier) {
throw new Error(issue.error ?? 'Failed to create Linear issue')
throw new Error(issue.error ?? fallback)
}
return {
id: issue.id,
@@ -55,7 +55,11 @@ export function nativeHostTaskProjectFileOperations(
function repoPayload(target: HostTaskProjectItemTarget, repoId: string) {
return {
repo: `id:${repoId}`,
prRepo: { owner: target.owner, repo: target.repo, host: target.host }
// Null when the row carries no slug: that is what the screens sent before this seam.
prRepo:
target.owner && target.repo
? { owner: target.owner, repo: target.repo, host: target.host }
: null
}
}
@@ -86,10 +86,11 @@ export function nativeHostTaskProjectMutationOperations(
await fetchResolveReviewThread(client, repoId, {
threadId,
resolve,
// Why: `prRepo` is fork/GHES decoration the host treats as optional, and a draft row
// Why: a draft row
// has no slug — send it only when one resolved rather than an empty pair.
prRepo: target.owner && target.repo ? slugPayload(target) : null
})
prRepo: prRepoPayload(target)
}),
'Failed to resolve thread'
)
},
async replyReviewComment(target, repoId, payload) {
@@ -97,8 +98,9 @@ export function nativeHostTaskProjectMutationOperations(
await fetchAddPRReviewCommentReply(client, repoId, {
prNumber: target.number,
...payload,
prRepo: slugPayload(target)
})
prRepo: prRepoPayload(target)
}),
'Failed to reply'
)
},
async addConversationComment(target, repoId, body) {
@@ -106,9 +108,10 @@ export function nativeHostTaskProjectMutationOperations(
await fetchAddIssueComment(client, repoId, {
prNumber: target.number,
body,
prRepo: slugPayload(target),
prRepo: prRepoPayload(target),
type: target.type
})
}),
'Failed to reply'
)
},
async requestReviewers(target, repoId, reviewers) {
@@ -116,8 +119,9 @@ export function nativeHostTaskProjectMutationOperations(
await fetchRequestPRReviewers(client, repoId, {
prNumber: target.number,
reviewers,
prRepo: slugPayload(target)
})
prRepo: prRepoPayload(target)
}),
'Failed to request reviewers'
)
},
async rerunChecks(target, repoId, payload) {
@@ -125,10 +129,11 @@ export function nativeHostTaskProjectMutationOperations(
await fetchRerunPRChecks(
client,
repoId,
{ prNumber: target.number, ...payload, prRepo: slugPayload(target) },
{ prNumber: target.number, ...payload, prRepo: prRepoPayload(target) },
// A CI rerun and a merge both routinely outrun the 30s default.
{ timeoutMs: PROJECT_PR_MUTATION_TIMEOUT_MS }
)
),
'Failed to rerun checks'
)
},
async merge(target, repoId, method) {
@@ -136,14 +141,23 @@ export function nativeHostTaskProjectMutationOperations(
await fetchMergePR(
client,
repoId,
{ prNumber: target.number, method, prRepo: slugPayload(target) },
{ prNumber: target.number, method, prRepo: prRepoPayload(target) },
{ timeoutMs: PROJECT_PR_MUTATION_TIMEOUT_MS }
)
),
'Failed to merge pull request'
)
}
}
}
/** Fork/GHES decoration the host treats as optional. A row with no repository slug sent `null`
* before this seam existed, so refusing the whole call here would lose a working path. */
function prRepoPayload(target: HostTaskProjectItemTarget) {
return target.owner && target.repo
? { owner: target.owner, repo: target.repo, host: target.host }
: null
}
function slugPayload(target: HostTaskProjectItemTarget) {
return {
owner: target.owner,
@@ -153,35 +167,52 @@ function slugPayload(target: HostTaskProjectItemTarget) {
}
}
/** The wording each caller reported for a refused mutation before these calls moved behind the
* seam. A host that refuses without a message must still name the action that failed. */
const PROJECT_MUTATION_FALLBACKS: Record<string, string> = {
'github.project.updateIssueBySlug': 'Failed to update GitHub item',
'github.project.updatePullRequestBySlug': 'Failed to update GitHub item',
'github.project.addIssueCommentBySlug': 'Failed to add comment',
'github.project.updateIssueCommentBySlug': 'Failed to edit comment',
'github.project.deleteIssueCommentBySlug': 'Failed to delete comment',
'github.project.updateItemField': 'Failed to update project field',
'github.project.clearItemField': 'Failed to update project field',
'github.project.updateIssueTypeBySlug': 'Failed to update issue type'
}
async function projectMutation<T extends object = object>(
client: RpcRequestSender,
method: string,
payload: object
): Promise<T> {
const fallback = PROJECT_MUTATION_FALLBACKS[method] ?? 'GitHub Project request failed'
const response = (await client.sendRequest(method, payload, { timeoutMs: 30_000 })) as {
ok: boolean
result?: { ok?: boolean; error?: string | { 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) {
const error = response.result.error
throw new Error(
typeof error === 'string' ? error : (error?.message ?? 'GitHub Project request failed')
)
throw new Error(typeof error === 'string' ? error : (error?.message ?? fallback))
}
return (response.result ?? {}) as T
}
function requirePrMutation(result: GitHubPrMutationOutcome): void {
function requirePrMutation(result: GitHubPrMutationOutcome, fallback: string): void {
if (!result.ok) {
throw new Error(result.error)
// The wrapper mints `Request failed: <method>` when the host says nothing; the caller's own
// wording is what the user read before these calls moved behind the seam.
throw new Error(result.error.startsWith('Request failed: ') ? fallback : result.error)
}
}
function prMutationComment(result: GitHubPrMutationOutcome): DetailComment | undefined {
requirePrMutation(result)
function prMutationComment(
result: GitHubPrMutationOutcome,
fallback: string
): DetailComment | undefined {
requirePrMutation(result, fallback)
return (result as { comment?: DetailComment }).comment
}
@@ -7,7 +7,10 @@ import type {
HostedReviewMergeMethod,
TaskItem
} from './mobile-tasks-legacy-foundation'
import { projectRowMutationTarget, taskItemMutationTarget } from './mobile-tasks-mutation-targets'
import {
projectRowPullRequestTarget,
taskItemMutationTarget
} from './mobile-tasks-mutation-targets'
export function useMobileTasksProjectFileMergeActions(model: ProjectReviewCheckActionsModel) {
const {
@@ -45,7 +48,7 @@ export function useMobileTasksProjectFileMergeActions(model: ProjectReviewCheckA
return
}
const repo = findProjectRowRepo(row)
const target = projectRowMutationTarget(row, activeGitHubProjectHost)
const target = projectRowPullRequestTarget(row, activeGitHubProjectHost)
if (
!taskOperations ||
row.itemType !== 'PULL_REQUEST' ||
@@ -90,7 +93,7 @@ export function useMobileTasksProjectFileMergeActions(model: ProjectReviewCheckA
const addProjectGitHubFileReviewComment = useCallback(
async (row: GitHubProjectRow, file: GitHubDetailFile, line: number): Promise<void> => {
const repo = findProjectRowRepo(row)
const target = projectRowMutationTarget(row, activeGitHubProjectHost)
const target = projectRowPullRequestTarget(row, activeGitHubProjectHost)
if (
!taskOperations ||
projectMutating ||
@@ -157,7 +160,7 @@ export function useMobileTasksProjectFileMergeActions(model: ProjectReviewCheckA
const mergeProjectGitHubPullRequest = useCallback(
async (row: GitHubProjectRow, method: HostedReviewMergeMethod): Promise<void> => {
const repo = findProjectRowRepo(row)
const target = projectRowMutationTarget(row, activeGitHubProjectHost)
const target = projectRowPullRequestTarget(row, activeGitHubProjectHost)
if (
!taskOperations ||
projectMutating ||
@@ -6,7 +6,7 @@ import {
type GitHubProjectRow,
splitReviewerList
} from './mobile-tasks-legacy-foundation'
import { projectRowMutationTarget } from './mobile-tasks-mutation-targets'
import { projectRowPullRequestTarget } from './mobile-tasks-mutation-targets'
export function useMobileTasksProjectReviewCheckActions(model: ProjectMetadataActionsModel) {
const {
@@ -25,7 +25,7 @@ export function useMobileTasksProjectReviewCheckActions(model: ProjectMetadataAc
const requestProjectGitHubReviewers = useCallback(
async (row: GitHubProjectRow, logins?: string[]): Promise<void> => {
const repo = findProjectRowRepo(row)
const target = projectRowMutationTarget(row, activeGitHubProjectHost)
const target = projectRowPullRequestTarget(row, activeGitHubProjectHost)
if (
!taskOperations ||
projectMutating ||
@@ -92,7 +92,7 @@ export function useMobileTasksProjectReviewCheckActions(model: ProjectMetadataAc
const refreshProjectGitHubChecks = useCallback(
async (row: GitHubProjectRow): Promise<void> => {
const repo = findProjectRowRepo(row)
const target = projectRowMutationTarget(row, activeGitHubProjectHost)
const target = projectRowPullRequestTarget(row, activeGitHubProjectHost)
if (
!taskOperations ||
projectMutating ||
@@ -125,7 +125,7 @@ export function useMobileTasksProjectReviewCheckActions(model: ProjectMetadataAc
const rerunProjectGitHubChecks = useCallback(
async (row: GitHubProjectRow, failedOnly: boolean): Promise<void> => {
const repo = findProjectRowRepo(row)
const target = projectRowMutationTarget(row, activeGitHubProjectHost)
const target = projectRowPullRequestTarget(row, activeGitHubProjectHost)
if (
!taskOperations ||
projectMutating ||
@@ -157,7 +157,7 @@ export function useMobileTasksProjectReviewCheckActions(model: ProjectMetadataAc
const toggleProjectGitHubFileViewed = useCallback(
async (row: GitHubProjectRow, file: GitHubDetailFile): Promise<void> => {
const repo = findProjectRowRepo(row)
const target = projectRowMutationTarget(row, activeGitHubProjectHost)
const target = projectRowPullRequestTarget(row, activeGitHubProjectHost)
if (
!taskOperations ||
projectMutating ||
@@ -33,7 +33,7 @@ export type HostWorkspaceCatalogOperations = {
sleepWorkspace(workspaceId: string): Promise<void>
notifyForeground(): void
subscribeChanges(listener: (event: HostWorkspaceChange) => void): () => void
// Why: a hosted page reads `connected` from the shell's relayed snapshot, so it can issue its
// first catalog request a beat before that socket serves one. A direct socket omits this.
// Why: a provider that relays connection state can report `connected` a beat before its
// socket serves a catalog request. A direct socket omits this, so the flag is optional.
readonly connectionStateIsRelayed?: boolean
}
@@ -89,6 +89,9 @@ export type HostWorkspaceCreationOperations = {
connectSsh(targetId: string): Promise<SshConnectionState>
detectAgents(connectionId: string | null): Promise<string[]>
readRepoHooks(repoId: string): Promise<NewWorkspaceRepoHooks>
/** Null when the host refuses the read. The create sheet keeps whatever it already showed
* rather than replacing it with a "no setup script" answer it cannot stand behind. */
readRepoHooksIfAvailable(repoId: string): Promise<NewWorkspaceRepoHooks | null>
readRuntimeCapabilities(): Promise<NewWorktreeRuntimeCapabilities>
listSparsePresets(repoId: string): Promise<SparsePreset[]>
saveSparsePreset(
@@ -23,6 +23,7 @@ type ReadOperations = Pick<
| 'connectSsh'
| 'detectAgents'
| 'readRepoHooks'
| 'readRepoHooksIfAvailable'
| 'readRuntimeCapabilities'
>
@@ -37,10 +38,12 @@ export function nativeHostWorkspaceCreationReadOperations(
return result.repos
},
async readRetiredWorktreeNames(repoId) {
const result = await successfulResult<unknown>(
client.sendRequest('worktree.listRetiredNames', { repo: `id:${repoId}` })
)
return readRetiredNameRegistryForRepo(result, repoId)
// Why no ok check: a refused read must read as an empty registry, not as a failure. The
// caller holds its previous answer on a failure, which would keep offering a spent name.
const response = await client.sendRequest('worktree.listRetiredNames', {
repo: `id:${repoId}`
})
return readRetiredNameRegistryForRepo((response as { result?: unknown }).result, repoId)
},
async readRuntimeSettings() {
const result = await successfulResult<{ settings: NewWorkspaceRuntimeSettings }>(
@@ -90,6 +93,10 @@ export function nativeHostWorkspaceCreationReadOperations(
client.sendRequest('repo.hooks', { repo: `id:${repoId}` })
)
},
async readRepoHooksIfAvailable(repoId) {
const response = await client.sendRequest('repo.hooks', { repo: `id:${repoId}` })
return response.ok ? ((response as RpcSuccess).result as NewWorkspaceRepoHooks) : null
},
readRuntimeCapabilities() {
return readNewWorktreeRuntimeCapabilities(client)
}
@@ -9,9 +9,9 @@ export type RpcWorkspaceCreationOperations = Omit<
'createBlankWorkspace' | 'createWorkspaceFromSource'
>
/** Every workspace-creation call that is a plain desktop request. The native app passes its socket
* and the hosted page passes a bridge-backed sender, so neither side owns a second copy. Creation
* itself is excluded: it needs connection state for its retry, which a sender cannot report. */
/** Every workspace-creation call that is a plain desktop request, written against a bare request
* sender so any non-socket provider can supply one without a second copy. Creation itself is
* excluded: it needs connection state for its retry, which a sender cannot report. */
export function rpcWorkspaceCreationOperations(
client: RpcRequestSender
): RpcWorkspaceCreationOperations {