Files
orca/mobile/src/session/use-mobile-diff-review-github.ts
T
Kelvin Amoaba a3efdcb21b feat(mobile): GitHub review threads, viewed sync and line comments on the diff screen (#37073)
* feat(mobile): GitHub threads, viewed sync and line comments on the diff screen

Committed files of a GitHub pull request now show its review threads under
their lines with Reply and Resolve, take their viewed state from GitHub and
sync the checkbox and Viewed · next file back through setPRFileViewed. A tap
or a long-press drag picks lines and opens a sheet that sends the note to
the workspace's agent through the existing send path, or posts it as a
review comment. Threads and GitHub comments appear only while the local
branch is at the PR head, the same contents desktop shows them on;
otherwise the file says to push. openDiffForFile takes an optional line for
a thread's Open in diff.

* test(mobile): use a type import for the line-mutations recording adapter

* fix(mobile): name the staged or unstaged side in the jump sheet's file labels
2026-11-01 18:38:23 +00:00

194 lines
6.7 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react'
import { translate } from '../i18n/mobile-i18n'
import type { ConnectionState } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { triggerError, triggerSuccess } from '../platform/haptics'
import { fetchAddPRReviewComment, fetchSetPRFileViewed } from './github-pr-review-line-mutations'
import {
applyMobileDiffReviewGitHubViewed,
buildMobileDiffReviewGitHub,
canCommentOnGitHub,
gitHubViewedFile,
type MobileDiffReviewGitHub
} from './mobile-diff-review-github'
import type {
MobileDiffReviewQueueFilter,
MobileDiffReviewQueueItem
} from './mobile-diff-review-queue'
import type { ReviewScreenState } from './mobile-diff-review-screen-model'
import { isPrSidebarDetailsPlaceholder, prSidebarDetailsNeedFetch } from './mobile-pr-sidebar-state'
import type { MobilePrSidebarController } from './use-mobile-pr-sidebar-controller'
type Input = {
client: RpcClient | null
connState: ConnectionState
worktreeId: string
filter: MobileDiffReviewQueueFilter
screenState: ReviewScreenState
prSidebar: MobilePrSidebarController
setActionError: (message: string | null) => void
}
/** The branch's GitHub pull request as the diff screen uses it: viewed state, threads and line comments. */
export function useMobileDiffReviewGitHub(input: Input) {
const { client, connState, worktreeId, filter, screenState, prSidebar, setActionError } = input
const {
prSidebarState,
prSidebarIsGithubRepo,
refetchPRSidebar,
ensurePrSidebarDetails,
patchPrFileViewed
} = prSidebar
const wantsPullRequest = filter === 'branch' && prSidebarIsGithubRepo
// Committed files are the PR's files, so load its details (threads, viewed state) with the screen.
const sidebarKind = prSidebarState.kind
const needsDetails =
prSidebarState.kind === 'ready' && prSidebarDetailsNeedFetch(prSidebarState.data.details)
useEffect(() => {
if (!wantsPullRequest) {
return
}
if (sidebarKind === 'hidden') {
void refetchPRSidebar({ includeDetails: true })
} else if (needsDetails) {
void ensurePrSidebarDetails()
}
}, [ensurePrSidebarDetails, needsDetails, refetchPRSidebar, sidebarKind, wantsPullRequest])
const localHeadOid =
screenState.kind === 'ready'
? (screenState.branchCompare?.summary.headOid ?? screenState.status.head ?? null)
: null
const data = prSidebarState.kind === 'ready' ? prSidebarState.data : null
const details =
data?.details && !isPrSidebarDetailsPlaceholder(data.details) ? data.details : null
const github = useMemo<MobileDiffReviewGitHub | null>(
() =>
wantsPullRequest && data && details
? buildMobileDiffReviewGitHub({
prNumber: data.pr.number,
prRepo: data.pr.prRepo ?? null,
details,
localHeadOid
})
: null,
[data, details, localHeadOid, wantsPullRequest]
)
// Like desktop, a file whose viewed write is still in flight takes no second tick until it settles.
const [pendingViewed, setPendingViewed] = useState<ReadonlySet<string>>(() => new Set())
const applyViewed = useCallback(
(queue: readonly MobileDiffReviewQueueItem[]) =>
applyMobileDiffReviewGitHubViewed(queue, github),
[github]
)
/**
* Marks a PR file viewed on GitHub. Null when this file's viewed state lives on the phone; false
* while the PR's details are still loading, so a tick is never saved where GitHub will replace it.
*/
const setRemoteViewed = useCallback(
(item: MobileDiffReviewQueueItem, nextViewed: boolean): Promise<boolean> | null => {
// A failed details load leaves a placeholder; only a load still in flight waits.
const detailsLoading =
prSidebarState.kind === 'loading' ||
(prSidebarState.kind === 'ready' && prSidebarState.data.details === null)
if (wantsPullRequest && item.scope === 'branch' && detailsLoading) {
return Promise.resolve(false)
}
const pullRequestId = github?.pullRequestId
const file = gitHubViewedFile(github, item)
if (!github || !pullRequestId || !file) {
return null
}
if (pendingViewed.has(item.filePath)) {
return Promise.resolve(false)
}
if (!client || connState !== 'connected') {
setActionError(
translate('session.useDiffReviewGitHub.waitingForDesktop', 'Waiting for desktop...')
)
return Promise.resolve(false)
}
const path = item.filePath
const previous = file.viewerViewedState
const setPending = (pending: boolean) =>
setPendingViewed((prev) => {
const next = new Set(prev)
if (pending) {
next.add(path)
} else {
next.delete(path)
}
return next
})
setPending(true)
patchPrFileViewed(pullRequestId, path, nextViewed ? 'VIEWED' : 'UNVIEWED')
return fetchSetPRFileViewed(client, worktreeId, {
pullRequestId,
path,
viewed: nextViewed,
prRepo: github.prRepo
})
.then((outcome) => {
if (outcome.ok) {
return true
}
if (previous) {
patchPrFileViewed(pullRequestId, path, previous)
}
triggerError()
setActionError(outcome.error)
return false
})
.finally(() => setPending(false))
},
[
client,
connState,
github,
patchPrFileViewed,
pendingViewed,
prSidebarState,
setActionError,
wantsPullRequest,
worktreeId
]
)
const commentOnGitHub = useCallback(
async (args: {
item: MobileDiffReviewQueueItem
startLine: number
line: number
body: string
}): Promise<{ ok: true } | { ok: false; error: string | null }> => {
if (!client || !github?.headSha || !canCommentOnGitHub(github, args.item, args)) {
return { ok: false, error: null }
}
const outcome = await fetchAddPRReviewComment(client, worktreeId, {
prNumber: github.prNumber,
commitId: github.headSha,
path: args.item.filePath,
line: args.line,
startLine: args.startLine,
body: args.body,
prRepo: github.prRepo
})
if (!outcome.ok) {
// The sheet is still open over the screen's banner, so the sheet shows this.
triggerError()
return { ok: false, error: outcome.error }
}
triggerSuccess()
// Show the new thread under its line.
void refetchPRSidebar({ includeDetails: true })
return { ok: true }
},
[client, github, refetchPRSidebar, worktreeId]
)
return { github, applyViewed, setRemoteViewed, commentOnGitHub }
}