Post fixing replies after launching PR comment resolution agents

Orca now automatically replies to selected comments after launching an
agent to resolve them. Review-thread comments get nested replies; review
summaries and issue comments get top-level @-replies. Payloads are
snapshotted at queue time and posted only after prompt delivery
succeeds. Bounded concurrency (4) prevents SSH slowdown.
This commit is contained in:
Jinjing
2026-08-02 17:18:51 -07:00
parent f820f40502
commit 2b8adee369
20 changed files with 1535 additions and 153 deletions
@@ -153,6 +153,10 @@ import {
getCommentReplyTargetCandidates,
resolveCommentReplyTarget
} from '@/components/comment-reply-target-state'
import {
attachPRReviewReplyParent,
canPostPRReviewThreadReply
} from '@/components/right-sidebar/pr-comments-ai-launch-ack'
import { useAppStore } from '@/store'
import { useAllWorktrees } from '@/store/selectors'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
@@ -3253,8 +3257,10 @@ function ConversationTab({
)
return false
}
// Why: nest under review threads (path/threadId/discussion_r); never post a
// separate top-level conversation comment for those.
const result =
comment.path && item.type === 'pr'
item.type === 'pr' && canPostPRReviewThreadReply(comment)
? await addPRReviewCommentReplyForRepo({
repoPath: repoPath ?? '',
repoId: item.repoId,
@@ -3284,7 +3290,11 @@ function ConversationTab({
)
return false
}
onCommentAdded(result.comment)
onCommentAdded(
item.type === 'pr' && canPostPRReviewThreadReply(comment)
? attachPRReviewReplyParent(result.comment, comment)
: result.comment
)
setReplyingTo(null)
toast.success(translate('auto.components.GitHubItemDialog.10f4ff5be8', 'Reply posted.'))
return true
@@ -146,6 +146,10 @@ import {
} from '@/components/comment-code-context-state'
import { getPrCommentCodeContext } from '@/components/github/pr-comment-code-context'
import { resolveCommentReplyTarget } from '@/components/comment-reply-target-state'
import {
attachPRReviewReplyParent,
canPostPRReviewThreadReply
} from '@/components/right-sidebar/pr-comments-ai-launch-ack'
import { useAppStore } from '@/store'
import { useAllWorktrees } from '@/store/selectors'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
@@ -3397,8 +3401,10 @@ function ConversationTab({
)
return false
}
// Why: nest under review threads (path/threadId/discussion_r); never post a
// separate top-level conversation comment for those.
const result =
comment.path && item.type === 'pr'
item.type === 'pr' && canPostPRReviewThreadReply(comment)
? await addPRReviewCommentReplyForRepo({
repoPath: repoPath ?? '',
repoId: item.repoId,
@@ -3428,7 +3434,11 @@ function ConversationTab({
)
return false
}
onCommentAdded(result.comment)
onCommentAdded(
item.type === 'pr' && canPostPRReviewThreadReply(comment)
? attachPRReviewReplyParent(result.comment, comment)
: result.comment
)
setReplyingTo(null)
toast.success(translate('auto.components.PullRequestPage.11505c7a71', 'Reply posted.'))
return true
@@ -150,7 +150,7 @@ export function buildPRCommentsResolutionPrompt({
'- For outdated comments, inspect the current file and nearby code before editing. Apply the reviewer intent only if it still matches the current code.',
'- Keep changes minimal and coherent. If multiple selected comments conflict or require a larger design decision, stop and report the tradeoff instead of guessing.',
'- Preserve unrelated staged and unstaged work. Do not run destructive cleanup commands such as git reset --hard, git checkout ., git restore ., or git stash.',
'- Host thread resolution is handled by Orca after launch. Do not resolve or unresolve threads on the host, reply on the host, edit host comments, or use provider APIs/CLIs just to change review state.',
'- Host thread resolution and any fixing replies are handled by Orca after launch. Do not resolve or unresolve threads on the host, reply on the host, edit host comments, or use provider APIs/CLIs just to change review state.',
'- Do not push, create commits, or rewrite history.',
'- Run git diff --check before finishing. Run the most focused relevant tests, typecheck, or lint command you can reasonably identify; if validation is impractical, explain why.',
'',
@@ -79,6 +79,18 @@ import {
buildPRCommentsResolutionPrompt,
isResolvablePRCommentGroup
} from '../pr-comments-resolution-prompt'
import {
acknowledgePRCommentsAfterAiLaunch,
attachPRReviewReplyParent,
buildPRCommentConversationReplyBody,
canPostPRReviewThreadReply,
checksPanelReviewStableKey,
clearPendingPRCommentAiAck,
resolvePRReviewReplyThreadId,
setPendingPRCommentAiAck,
takePendingPRCommentAiAck
} from './pr-comments-ai-launch-ack'
import { parseGitHubIssueOrPRLink } from '../../../../shared/github-links'
import { startFixChecksAgent } from '@/lib/fix-checks-agent-launch'
import type {
HostedReviewCreationEligibility,
@@ -238,6 +250,13 @@ function buildChecksPanelEligibilityGitFingerprint(input: {
})
}
type ChecksCommentResolutionGithubTarget = {
repoPath: string
repoId: string
prNumber: number
prRepo: NonNullable<PRInfo['prRepo']>
}
type ChecksAgentComposerState = {
actionId: SourceControlLaunchActionId
title: string
@@ -249,6 +268,8 @@ type ChecksAgentComposerState = {
provider: ChecksPanelReview['provider']
selectedThreadIds: string[]
selectedGroups: PRCommentGroup[]
/** Snapshotted at queue time so post-launch reply does not depend on live React state. */
githubTarget?: ChecksCommentResolutionGithubTarget
}
}
type ChecksPanelReviewHeaderProps = {
@@ -509,6 +530,23 @@ export default function ChecksPanel(): React.JSX.Element {
const [agentComposerState, setAgentComposerState] = useState<ChecksAgentComposerState | null>(
null
)
// Why: submit-after-ready outlives dialog close; keep the payload until launch is accepted.
const pendingCommentResolutionRef = useRef<NonNullable<
ChecksAgentComposerState['commentResolution']
> | null>(null)
// Why: an accepted launch parks its payload here so panel churn cannot drop it while
// submit-after-ready is still running; nothing is posted until delivery succeeds.
const claimedCommentResolutionRef = useRef<NonNullable<
ChecksAgentComposerState['commentResolution']
> | null>(null)
const commentResolutionLaunchAcceptedRef = useRef(false)
// Why: a second launch while the first ack is still landing would double-post fixing replies.
const [commentResolutionAckBusy, setCommentResolutionAckBusy] = useState(false)
const commentResolutionAckBusyRef = useRef(false)
const setCommentResolutionAckBusyNow = useCallback((busy: boolean): void => {
commentResolutionAckBusyRef.current = busy
setCommentResolutionAckBusy(busy)
}, [])
const [hostedReviewCreationSnapshot, setHostedReviewCreationSnapshot] =
useState<HostedReviewCreationSnapshot | null>(null)
// Sticky record of the latest hard refresh error so Create can't flap back until a qualifying eligibility request clears it.
@@ -675,6 +713,11 @@ export default function ChecksPanel(): React.JSX.Element {
setCreatePrError(null)
setIsPublishingBranch(false)
setAgentComposerState(null)
pendingCommentResolutionRef.current = null
claimedCommentResolutionRef.current = null
commentResolutionLaunchAcceptedRef.current = false
setCommentResolutionAckBusyNow(false)
clearPendingPRCommentAiAck()
setHostedReviewCreationSnapshot(null)
setHardRefreshError(null)
setGitStatusSnapshot(null)
@@ -1378,11 +1421,18 @@ export default function ChecksPanel(): React.JSX.Element {
[]
)
useEffect(() => {
// Why: compare without headSha — PR head can move while the agent is still starting.
if (
agentComposerState?.commentResolution &&
agentComposerState.commentResolution.reviewContextKey !== stateRequestKey
checksPanelReviewStableKey(agentComposerState.commentResolution.reviewContextKey) !==
checksPanelReviewStableKey(stateRequestKey)
) {
setAgentComposerState(null)
if (!commentResolutionLaunchAcceptedRef.current) {
pendingCommentResolutionRef.current = null
claimedCommentResolutionRef.current = null
clearPendingPRCommentAiAck()
}
}
}, [agentComposerState?.commentResolution, stateRequestKey])
@@ -2751,21 +2801,27 @@ export default function ChecksPanel(): React.JSX.Element {
useEffect(() => {
if (!sourceControlAiActionsVisible) {
setAgentComposerState(null)
pendingCommentResolutionRef.current = null
claimedCommentResolutionRef.current = null
commentResolutionLaunchAcceptedRef.current = false
clearPendingPRCommentAiAck()
}
}, [sourceControlAiActionsVisible])
const resolveCommentsWithAIDisabledReason = commentsLoading
? 'Comments are still loading.'
: aiActionDisabledReason
? aiActionDisabledReason
: !activeReview
? 'Open a PR or MR before launching an AI action.'
: !repo
? 'Select a repository before launching an AI action.'
: activeReview.provider === 'github' && !prNumber
? 'Open a GitHub PR before resolving comments.'
: activeReview.provider === 'gitlab' && !activeGitLabReview
? 'Open a GitLab MR before resolving comments.'
: undefined
const resolveCommentsWithAIDisabledReason = commentResolutionAckBusy
? 'Still finishing the previous comment launch.'
: commentsLoading
? 'Comments are still loading.'
: aiActionDisabledReason
? aiActionDisabledReason
: !activeReview
? 'Open a PR or MR before launching an AI action.'
: !repo
? 'Select a repository before launching an AI action.'
: activeReview.provider === 'github' && !prNumber
? 'Open a GitHub PR before resolving comments.'
: activeReview.provider === 'gitlab' && !activeGitLabReview
? 'Open a GitLab MR before resolving comments.'
: undefined
const handleAddPRComment = useCallback(
async (body: string) => {
@@ -2862,7 +2918,8 @@ export default function ChecksPanel(): React.JSX.Element {
)
const handleReplyToComment = useCallback(
async (comment: PRComment, body: string) => {
async (comment: PRComment, body: string, options: { notifyOnFailure?: boolean } = {}) => {
const notifyOnFailure = options.notifyOnFailure !== false
if (!repo || !prNumber || !pr?.prRepo) {
return { ok: false as const, error: commentsDisabledReason ?? 'Commenting unavailable.' }
}
@@ -2873,28 +2930,49 @@ export default function ChecksPanel(): React.JSX.Element {
pr.prRepo,
pr.headSha
)
const canReplyToReviewThread =
Boolean(comment.threadId) && Number.isSafeInteger(comment.id) && comment.id > 0
const result = canReplyToReviewThread
// Why: review-thread replies nest under the parent on GitHub; conversation
// comments are top-level only. Prefer thread replies whenever path/threadId/url
// indicate a review comment.
const parentThreadId =
resolvePRReviewReplyThreadId({
parent: comment,
existingComments: commentsRef.current
}) ?? comment.threadId
const result = canPostPRReviewThreadReply(comment)
? await addPRReviewCommentReply(repo.path, prNumber, comment.id, body, {
repoId: repo.id,
prRepo: pr.prRepo,
threadId: comment.threadId,
threadId: parentThreadId,
path: comment.path,
line: comment.line
})
: await addPRConversationComment(repo.path, prNumber, `@${comment.author} ${body}`, {
repoId: repo.id,
prRepo: pr.prRepo
})
: await addPRConversationComment(
repo.path,
prNumber,
buildPRCommentConversationReplyBody(comment.author, body),
{
repoId: repo.id,
prRepo: pr.prRepo
}
)
if (!isCurrentAsyncResult(requestKey)) {
return result.ok ? { ok: true as const } : result
}
if (!result.ok) {
toast.error(result.error)
if (notifyOnFailure) {
toast.error(result.error)
}
return result
}
setComments((prev) => mergePRCommentIntoList(prev, result.comment))
// Why: keep review replies under the parent thread in the sidebar even when the
// host payload omits threadId/path (conversation posts stay standalone).
const mergedComment = canPostPRReviewThreadReply(comment)
? attachPRReviewReplyParent(result.comment, {
...comment,
threadId: parentThreadId
})
: result.comment
setComments((prev) => mergePRCommentIntoList(prev, mergedComment))
return { ok: true as const }
},
[
@@ -2916,6 +2994,12 @@ export default function ChecksPanel(): React.JSX.Element {
return
}
const conflictFiles = activeConflictReview.conflictSummary?.files ?? []
// Why: swapping the composer to another action never fires onOpenChange, so a queued
// comment-resolution ack would survive and post fixing replies on this launch instead.
pendingCommentResolutionRef.current = null
claimedCommentResolutionRef.current = null
commentResolutionLaunchAcceptedRef.current = false
clearPendingPRCommentAiAck()
setAgentComposerState({
actionId: 'resolveConflicts',
title: translate(
@@ -2947,6 +3031,11 @@ export default function ChecksPanel(): React.JSX.Element {
) {
return
}
// Why: re-entering while a launch/ack is still landing would post a second
// fixing reply on the same threads.
if (commentResolutionAckBusyRef.current) {
return
}
const selectedThreadIds = selectedGroups.flatMap((group) =>
group.kind === 'thread' && isResolvablePRCommentGroup(group) ? [group.threadId] : []
)
@@ -2959,6 +3048,45 @@ export default function ChecksPanel(): React.JSX.Element {
)
return
}
// Why: pr.prRepo can be missing while comments are still visible; fall back to the PR URL.
const githubTargetFromPr =
activeReview.provider === 'github' && prNumber && pr?.prRepo
? {
repoPath: repo.path,
repoId: repo.id,
prNumber,
prRepo: pr.prRepo
}
: undefined
const githubTargetFromUrl = ((): ChecksCommentResolutionGithubTarget | undefined => {
if (activeReview.provider !== 'github' || githubTargetFromPr) {
return undefined
}
const link = parseGitHubIssueOrPRLink(activeReview.url || pr?.url || '')
if (!link || link.type !== 'pr') {
return undefined
}
return {
repoPath: repo.path,
repoId: repo.id,
prNumber: link.number,
prRepo: {
owner: link.slug.owner,
repo: link.slug.repo,
host: link.slug.host
}
}
})()
const githubTarget = githubTargetFromPr ?? githubTargetFromUrl
const commentResolution = {
reviewContextKey: stateRequestKey,
provider: activeReview.provider,
selectedThreadIds,
selectedGroups,
githubTarget
}
claimedCommentResolutionRef.current = null
commentResolutionLaunchAcceptedRef.current = false
setAgentComposerState({
actionId: 'resolveComments',
title: translate(
@@ -2966,10 +3094,18 @@ export default function ChecksPanel(): React.JSX.Element {
'Resolve {{value0}} Comments With AI',
{ value0: activeReview.provider === 'gitlab' ? 'MR' : 'PR' }
),
description: translate(
'auto.components.right.sidebar.ChecksPanel.ed3f79c031',
'Review the prompt before starting an agent. Selected threads are marked resolved after launch.'
),
// Why: only GitHub with a resolved PR target posts fixing replies; other providers
// must not be promised a reply the ack never sends.
description:
githubTarget && activeReview.provider === 'github'
? translate(
'auto.components.right.sidebar.ChecksPanel.ed3f79c031',
'Review the prompt before starting an agent. After launch, Orca posts a fixing reply on each selected comment and resolves host threads when possible.'
)
: translate(
'auto.components.right.sidebar.ChecksPanel.abf59262fb',
'Review and edit the full command input before starting an agent.'
),
prompt: buildPRCommentsResolutionPrompt({
reviewKind: activeReview.provider === 'gitlab' ? 'MR' : 'PR',
reviewNumber: activeReview.number,
@@ -2979,18 +3115,19 @@ export default function ChecksPanel(): React.JSX.Element {
worktreePath: activeWorktreePath
}),
launchSource: 'task_page',
commentResolution: {
reviewContextKey: stateRequestKey,
provider: activeReview.provider,
selectedThreadIds,
selectedGroups
}
commentResolution
})
pendingCommentResolutionRef.current = commentResolution
// Why: module-level store survives dialog close / re-render races that clear the ref.
setPendingPRCommentAiAck(commentResolution)
},
[
activeReview,
activeWorktreeId,
activeWorktreePath,
pr?.prRepo,
pr?.url,
prNumber,
repo,
resolveCommentsWithAIDisabledReason,
sourceControlAiActionsVisible,
@@ -3021,53 +3158,134 @@ export default function ChecksPanel(): React.JSX.Element {
const resolveSelectedThreadsAfterLaunch = useCallback(
async (resolution: NonNullable<ChecksAgentComposerState['commentResolution']>) => {
clearSentCommentSelection(resolution.reviewContextKey)
let resolved = 0
let skipped = Math.max(
0,
resolution.selectedGroups.length - resolution.selectedThreadIds.length
)
let failed = 0
let attemptedThreadCount = 0
if (resolution.selectedThreadIds.length === 0) {
toast.success(
translate(
'auto.components.right.sidebar.ChecksPanel.3c3ad3a1d2',
'Started the agent. No selected comments can be marked resolved on the host.'
)
)
return
}
for (const threadId of resolution.selectedThreadIds) {
if (asyncResultKeyRef.current !== resolution.reviewContextKey) {
skipped += resolution.selectedThreadIds.length - attemptedThreadCount
break
// Why: ignore headSha churn; only abort resolve/UI refresh if the user left this PR/panel.
const launchStableKey = checksPanelReviewStableKey(resolution.reviewContextKey)
// Why: the host calls keep the snapshotted target, but every UI mutation must belong
// to the review the panel is showing now — otherwise replies land in another PR's list.
const isPanelStillOnLaunchReview = (): boolean =>
checksPanelReviewStableKey(asyncResultKeyRef.current) === launchStableKey
const githubTarget = resolution.githubTarget
const canReplyOnHost = resolution.provider === 'github' && githubTarget != null
// Why: only GitHub posts fixing replies today; a GitLab MR reaching replied=0 is expected, not an error.
let lastReplyError =
resolution.provider === 'github' && githubTarget == null
? translate(
'auto.components.right.sidebar.ChecksPanel.7e4b2a19c0',
'Could not resolve the GitHub PR to reply on.'
)
: undefined
const counts = await acknowledgePRCommentsAfterAiLaunch({
groups: resolution.selectedGroups,
deps: {
isStillCurrent: isPanelStillOnLaunchReview,
isThreadStillResolvable: (threadId) => {
const currentGroup = groupPRComments(commentsRef.current).find(
(group) => group.kind === 'thread' && group.threadId === threadId
)
return Boolean(currentGroup && isResolvablePRCommentGroup(currentGroup))
},
resolveThread: (threadId) => handleResolve(threadId, true, { notifyOnFailure: false }),
canReply: canReplyOnHost,
replyInThread: async (comment, body) => {
if (!githubTarget || !canPostPRReviewThreadReply(comment)) {
return false
}
try {
const parentThreadId =
resolvePRReviewReplyThreadId({
parent: comment,
existingComments: commentsRef.current
}) ?? comment.threadId
const result = await addPRReviewCommentReply(
githubTarget.repoPath,
githubTarget.prNumber,
comment.id,
body,
{
repoId: githubTarget.repoId,
prRepo: githubTarget.prRepo,
threadId: parentThreadId,
path: comment.path,
line: comment.line
}
)
if (result.ok) {
// Why: force threadId/path onto the optimistic row so the sidebar groups it
// under the parent immediately (API payload may omit them).
if (isPanelStillOnLaunchReview()) {
setComments((prev) =>
mergePRCommentIntoList(
prev,
attachPRReviewReplyParent(result.comment, {
...comment,
threadId: parentThreadId
})
)
)
}
return true
}
lastReplyError = result.error
console.warn('In-thread fixing reply failed:', result.error)
return false
} catch (err) {
lastReplyError = err instanceof Error ? err.message : String(err)
console.warn('Failed to post in-thread fixing reply for review comment:', err)
return false
}
},
// Why: CodeRabbit / review-summary / conversation comments have no nested-reply API.
replyAsConversation: async (comment, body) => {
if (!githubTarget) {
return false
}
try {
const result = await addPRConversationComment(
githubTarget.repoPath,
githubTarget.prNumber,
buildPRCommentConversationReplyBody(comment.author, body),
{
repoId: githubTarget.repoId,
prRepo: githubTarget.prRepo
}
)
if (result.ok) {
if (isPanelStillOnLaunchReview()) {
setComments((prev) => mergePRCommentIntoList(prev, result.comment))
}
return true
}
lastReplyError = result.error
console.warn('Conversation fixing reply failed:', result.error)
return false
} catch (err) {
lastReplyError = err instanceof Error ? err.message : String(err)
console.warn('Failed to post conversation fixing reply for review comment:', err)
return false
}
}
}
attemptedThreadCount += 1
const currentGroup = groupPRComments(commentsRef.current).find(
(group) => group.kind === 'thread' && group.threadId === threadId
)
if (!currentGroup || !isResolvablePRCommentGroup(currentGroup)) {
skipped += 1
continue
}
const ok = await handleResolve(threadId, true, { notifyOnFailure: false })
if (ok) {
resolved += 1
} else {
failed += 1
}
}
})
if (asyncResultKeyRef.current === resolution.reviewContextKey) {
if (isPanelStillOnLaunchReview()) {
await refreshCommentsAfterBulkResolve(resolution.provider)
}
if (failed > 0) {
// Why: surface the underlying API error when replies were possible but none landed.
const repliedNoneDespiteHostSupport =
canReplyOnHost && counts.replied === 0 && resolution.selectedGroups.length > 0
if (counts.failed > 0 || repliedNoneDespiteHostSupport || lastReplyError) {
toast.error(
translate(
'auto.components.right.sidebar.ChecksPanel.f273f2271c',
'Started the agent. Marked {{value0}} resolved, skipped {{value1}}, failed {{value2}}.',
{ value0: resolved, value1: skipped, value2: failed }
'Started the agent. Marked {{value0}} resolved, replied to {{value1}}, skipped {{value2}}, failed {{value3}}.{{value4}}',
{
value0: counts.resolved,
value1: counts.replied,
value2: counts.skipped,
value3: counts.failed,
value4: lastReplyError ? ` ${lastReplyError}` : ''
}
)
)
return
@@ -3075,14 +3293,100 @@ export default function ChecksPanel(): React.JSX.Element {
toast.success(
translate(
'auto.components.right.sidebar.ChecksPanel.aa95b81a3a',
'Started the agent. Marked {{value0}} resolved, skipped {{value1}}, failed {{value2}}.',
{ value0: resolved, value1: skipped, value2: failed }
'Started the agent. Marked {{value0}} resolved, replied to {{value1}}, skipped {{value2}}, failed {{value3}}.',
{
value0: counts.resolved,
value1: counts.replied,
value2: counts.skipped,
value3: counts.failed
}
)
)
},
[clearSentCommentSelection, handleResolve, refreshCommentsAfterBulkResolve]
[
addPRConversationComment,
addPRReviewCommentReply,
clearSentCommentSelection,
handleResolve,
refreshCommentsAfterBulkResolve
]
)
/**
* Tab created: park the payload so panel churn during submit-after-ready cannot drop it.
* Posts nothing — fixing replies and resolves are irreversible and wait for delivery.
*/
const claimPendingCommentResolutionForLaunch = useCallback((): void => {
const pendingResolution =
takePendingPRCommentAiAck<NonNullable<ChecksAgentComposerState['commentResolution']>>() ??
pendingCommentResolutionRef.current
pendingCommentResolutionRef.current = null
if (!pendingResolution) {
return
}
claimedCommentResolutionRef.current = pendingResolution
commentResolutionLaunchAcceptedRef.current = true
setCommentResolutionAckBusyNow(true)
}, [setCommentResolutionAckBusyNow])
/** Launch failed after the tab existed: hand the payload back for a retry, post nothing. */
const releaseClaimedCommentResolutionAfterFailedLaunch = useCallback((): void => {
const claimed = claimedCommentResolutionRef.current
claimedCommentResolutionRef.current = null
commentResolutionLaunchAcceptedRef.current = false
if (claimed) {
pendingCommentResolutionRef.current = claimed
setPendingPRCommentAiAck(claimed)
}
setCommentResolutionAckBusyNow(false)
}, [setCommentResolutionAckBusyNow])
/** Prompt reached the agent: only now may Orca write to the host. */
const consumeClaimedCommentResolutionAfterDelivery = useCallback((): void => {
const resolution =
claimedCommentResolutionRef.current ??
takePendingPRCommentAiAck<NonNullable<ChecksAgentComposerState['commentResolution']>>() ??
pendingCommentResolutionRef.current
claimedCommentResolutionRef.current = null
pendingCommentResolutionRef.current = null
commentResolutionLaunchAcceptedRef.current = false
if (!resolution) {
setCommentResolutionAckBusyNow(false)
return
}
setCommentResolutionAckBusyNow(true)
void resolveSelectedThreadsAfterLaunch(resolution)
.catch((err) => {
console.warn('Failed to resolve/reply on selected review comments after AI launch:', err)
toast.error(
translate(
'auto.components.right.sidebar.ChecksPanel.495b2f8c4b',
'Started the agent, but could not resolve or reply on the selected comments.'
)
)
})
.finally(() => setCommentResolutionAckBusyNow(false))
}, [resolveSelectedThreadsAfterLaunch, setCommentResolutionAckBusyNow])
// Why: auto-start can capture a stale callback; always call the latest consumer.
const consumeClaimedCommentResolutionAfterDeliveryRef = useRef(
consumeClaimedCommentResolutionAfterDelivery
)
consumeClaimedCommentResolutionAfterDeliveryRef.current =
consumeClaimedCommentResolutionAfterDelivery
const claimPendingCommentResolutionForLaunchRef = useRef(claimPendingCommentResolutionForLaunch)
claimPendingCommentResolutionForLaunchRef.current = claimPendingCommentResolutionForLaunch
const releaseClaimedCommentResolutionAfterFailedLaunchRef = useRef(
releaseClaimedCommentResolutionAfterFailedLaunch
)
releaseClaimedCommentResolutionAfterFailedLaunchRef.current =
releaseClaimedCommentResolutionAfterFailedLaunch
const handleLaunchAccepted = useCallback((): void => {
claimPendingCommentResolutionForLaunchRef.current()
}, [])
const handleLaunchAborted = useCallback((): void => {
releaseClaimedCommentResolutionAfterFailedLaunchRef.current()
}, [])
const handleFixChecksWithAI = useCallback(async (): Promise<void> => {
if (
!sourceControlAiActionsVisible ||
@@ -4161,6 +4465,14 @@ export default function ChecksPanel(): React.JSX.Element {
onOpenChange={(open) => {
if (!open) {
setAgentComposerState(null)
// Why: a launch in flight owns the payload (claimed ref). Any other close —
// cancel, or closing after a failed launch — must drop it so the next action
// (e.g. fix checks) does not post stale fixing replies.
if (!commentResolutionLaunchAcceptedRef.current) {
pendingCommentResolutionRef.current = null
claimedCommentResolutionRef.current = null
clearPendingPRCommentAiAck()
}
}
}}
actionId={agentComposerState?.actionId ?? 'fixChecks'}
@@ -4207,33 +4519,31 @@ export default function ChecksPanel(): React.JSX.Element {
: null
}
onSaveAgentDefault={saveLaunchActionDefault}
// Why: claims the ack payload when the tab exists; the host writes still wait for delivery.
onLaunchAccepted={handleLaunchAccepted}
onLaunchAborted={handleLaunchAborted}
onLaunched={() => {
const launchedState = agentComposerState
if (launchedState?.actionId === 'resolveComments' && launchedState.commentResolution) {
void resolveSelectedThreadsAfterLaunch(launchedState.commentResolution).catch((err) => {
console.warn('Failed to resolve selected review comments after AI launch:', err)
toast.error(
translate(
'auto.components.right.sidebar.ChecksPanel.495b2f8c4b',
'Started the agent, but could not mark the selected comments resolved.'
)
)
})
} else if (launchedState?.actionId === 'resolveConflicts') {
// Why: prompt delivery succeeded — the only point at which host replies/resolves may run.
consumeClaimedCommentResolutionAfterDeliveryRef.current()
if (agentComposerState?.actionId === 'resolveConflicts') {
toast.success(
translate(
'auto.components.right.sidebar.ChecksPanel.a0181a8d76',
'Started an AI agent for the conflicts.'
)
)
} else {
toast.success(
translate(
'auto.components.right.sidebar.ChecksPanel.2ef90c9819',
'Started an AI agent for the broken checks.'
)
)
return
}
if (agentComposerState?.actionId === 'resolveComments') {
// Why: resolve/reply toast is emitted by resolveSelectedThreadsAfterLaunch.
return
}
toast.success(
translate(
'auto.components.right.sidebar.ChecksPanel.2ef90c9819',
'Started an AI agent for the broken checks.'
)
)
}}
/>
</div>
@@ -39,6 +39,13 @@ export type SourceControlAgentActionDialogProps = {
recipe: SourceControlActionRecipe
) => void | Promise<void>
onOpenSettings?: () => void
/**
* Fires when the agent tab is created, before deferred prompt delivery finishes.
* Reversible bookkeeping only; irreversible host writes belong in onLaunched.
*/
onLaunchAccepted?: () => void
/** Fires when an accepted launch later failed to deliver its prompt. */
onLaunchAborted?: () => void
onLaunched?: () => void
startLabel?: string
onStart?: (args: {
@@ -2020,7 +2020,13 @@ function PRCommentGroupView({
// nested reply — can be replied to, not just the thread root.
const renderReplyComposer = (comment: PRComment): React.ReactNode =>
replyingCommentId === comment.id && onReply ? (
<div className={cn('px-3 pb-2', group.kind === 'thread' && 'pl-6')}>
<div
className={cn(
'px-3 pb-2',
// Why: nest the composer under the parent the same way GitHub nests thread replies.
group.kind === 'thread' && 'ml-3 border-l-2 border-border/50 pl-3'
)}
>
<RightPanelCommentComposer
placeholder={translate(
'auto.components.right.sidebar.checks.panel.content.ba20d1a896',
@@ -0,0 +1,75 @@
import type { PRComment } from '../../../../shared/types'
/** Posted when a selected review comment is sent to AI. */
export const PR_COMMENT_AI_FIXING_REPLY = 'Fixing. Will be in the next commit'
/**
* GitHub App logins carry a `[bot]` suffix that does not resolve as an @-mention
* (`@coderabbitai[bot]` renders literally; `@coderabbitai` reaches the bot).
*/
export function formatPRCommentMentionHandle(author: string | undefined): string {
return (author ?? '').replace(/\[bot\]$/i, '').trim()
}
/** Top-level conversation reply body, addressed to the comment author. */
export function buildPRCommentConversationReplyBody(
author: string | undefined,
body: string
): string {
const handle = formatPRCommentMentionHandle(author)
return handle ? `@${handle} ${body}` : body
}
const ACK_SNIPPET_MAX_LENGTH = 72
/** First readable line of a comment body, minus HTML comments and markdown markers. */
function summarizePRCommentBody(body: string): string {
const line = body
.replace(/<!--[\s\S]*?-->/g, ' ')
.split('\n')
.map((candidate) =>
candidate
.replace(/^[\s>#*\-_`]+/, '')
.replace(/\s+/g, ' ')
.trim()
)
.find((candidate) => candidate.length > 0)
if (!line) {
return ''
}
return line.length > ACK_SNIPPET_MAX_LENGTH
? `${line.slice(0, ACK_SNIPPET_MAX_LENGTH - 1).trimEnd()}…`
: line
}
/** Short "what this was" label so the batched reply names each item without quoting it whole. */
export function describePRCommentAckTarget(comment: PRComment): string {
const kind =
typeof comment.url === 'string' && comment.url.includes('pullrequestreview')
? 'review summary'
: comment.path
? `comment on ${comment.path}${comment.line == null ? '' : `:${comment.line}`}`
: 'comment'
const snippet = summarizePRCommentBody(comment.body)
return snippet ? `${kind} — ${snippet}` : kind
}
/**
* One conversation comment covering every selected comment that has no nested-reply
* endpoint, so a bulk send leaves a single readable ack instead of N "Fixing." posts.
*/
export function buildPRCommentBatchConversationReplyBody(comments: readonly PRComment[]): string {
if (comments.length === 0) {
return ''
}
const first = comments[0]!
if (comments.length === 1) {
return buildPRCommentConversationReplyBody(first.author, PR_COMMENT_AI_FIXING_REPLY)
}
const items = comments.map((comment) => {
const handle = formatPRCommentMentionHandle(comment.author)
const label = describePRCommentAckTarget(comment)
return handle ? `- @${handle}: ${label}` : `- ${label}`
})
return `Fixing:\n${items.join('\n')}\n\nWill be in the next commit.`
}
@@ -22,6 +22,10 @@ describe('pr-comment-presentation', () => {
expect(cards.group).toContain('shadow-xs')
expect(cards.avatar).toContain('border-border')
expect(cards.avatar).toContain('bg-background')
// Why: replies must read nested under the root (GitHub-style), not full-width siblings.
expect(cards.repliesContainer).toContain('ml-3')
expect(cards.repliesContainer).toContain('border-l-2')
expect(cards.commentRowReply).toContain('pl-3')
const focus = getPRCommentPresentationClasses('focus')
expect(focus.useCardLayout).toBe(true)
@@ -37,6 +41,8 @@ describe('pr-comment-presentation', () => {
expect(focus.commentHeaderReply).toContain('px-3 py-2')
expect(focus.commentHeaderMeta).toContain('pl-7')
expect(focus.commentHeaderMetaWithSelection).toContain('pl-[3.25rem]')
expect(focus.repliesContainer).toContain('ml-3')
expect(focus.repliesContainer).toContain('border-l-2')
})
it('restores block flow for span-rendered markdown paragraphs and headings', () => {
@@ -209,7 +209,8 @@ export function getPRCommentPresentationClasses(
groupStandalone: '',
groupThread: '',
commentRow: 'group/comment',
commentRowReply: `border-t ${COMMENT_CARD_DIVIDER} bg-muted/25 dark:bg-muted/10`,
// Why: nest replies under the root like GitHub threads (indent + left rail), not full-width siblings.
commentRowReply: `border-t ${COMMENT_CARD_DIVIDER} bg-muted/25 pl-3 dark:bg-muted/10`,
commentHeader: `flex flex-col gap-1 border-b ${COMMENT_CARD_DIVIDER} ${CARD_COMMENT_HEADER_PADDING}`,
commentHeaderReply: `flex min-w-0 items-center gap-2 ${CARD_COMMENT_HEADER_PADDING}`,
commentBody: `${CARD_COMMENT_BODY_PADDING} ${CARD_COMMENT_BODY_SIZE} text-foreground`,
@@ -224,7 +225,7 @@ export function getPRCommentPresentationClasses(
pathBadge: 'min-w-0 max-w-full truncate font-mono text-muted-foreground',
time: 'shrink-0 text-[11px] text-muted-foreground',
resolvedContainer: 'opacity-60',
repliesContainer: 'flex flex-col',
repliesContainer: 'ml-3 flex flex-col border-l-2 border-border/50',
resolvedSection: 'mt-1 border-t border-border pt-1',
resolvedSectionTrigger: RESOLVED_SECTION_TRIGGER,
resolvedSectionContent: 'flex flex-col gap-2 pb-1 pt-1',
@@ -0,0 +1,532 @@
import { describe, expect, it, vi } from 'vitest'
import type { PRComment } from '../../../../shared/types'
import type { PRCommentGroup } from '@/lib/pr-comment-groups'
import {
acknowledgePRCommentsAfterAiLaunch,
attachPRReviewReplyParent,
buildPRCommentConversationReplyBody,
canPostPRReviewThreadReply,
checksPanelReviewStableKey,
clearPendingPRCommentAiAck,
formatPRCommentMentionHandle,
getPRCommentGroupReplyTarget,
PR_COMMENT_ACK_MAX_CONCURRENCY,
PR_COMMENT_AI_FIXING_REPLY,
resolvePRReviewReplyThreadId,
setPendingPRCommentAiAck,
takePendingPRCommentAiAck
} from './pr-comments-ai-launch-ack'
function comment(overrides: Partial<PRComment> = {}): PRComment {
return {
id: 1,
author: 'alice',
authorAvatarUrl: '',
body: 'Please update this.',
createdAt: '2026-05-14T00:00:00Z',
url: 'https://github.com/acme/widgets/pull/42#discussion_r1',
...overrides
}
}
type PRCommentThreadGroup = Extract<PRCommentGroup, { kind: 'thread' }>
type PRCommentStandaloneGroup = Extract<PRCommentGroup, { kind: 'standalone' }>
function openThread(threadId: string, id = 10): PRCommentThreadGroup {
return {
kind: 'thread',
threadId,
root: comment({ id, threadId, path: 'src/a.ts', isResolved: false }),
replies: []
}
}
function standalone(id = 20): PRCommentStandaloneGroup {
return {
kind: 'standalone',
comment: comment({
id,
body: 'Overall looks good; one nit.',
url: 'https://github.com/x/y/pull/1#issuecomment-9'
})
}
}
function codeRabbitReviewSummary(id = 30): PRCommentStandaloneGroup {
return {
kind: 'standalone',
comment: comment({
id,
author: 'coderabbitai',
isBot: true,
body: '## Nitpick comments (2)\n\n...',
url: 'https://github.com/acme/widgets/pull/42#pullrequestreview-999'
})
}
}
describe('canPostPRReviewThreadReply', () => {
it('allows review-thread comments with threadId, path, or discussion_r url', () => {
expect(canPostPRReviewThreadReply(comment({ id: 1, threadId: 'T1' }))).toBe(true)
expect(canPostPRReviewThreadReply(comment({ id: 2, path: 'a.ts' }))).toBe(true)
expect(
canPostPRReviewThreadReply(
comment({ id: 3, url: 'https://github.com/acme/widgets/pull/42#discussion_r99' })
)
).toBe(true)
})
it('rejects conversation comments, review summaries, and invalid ids', () => {
expect(
canPostPRReviewThreadReply(
comment({ id: 3, url: 'https://github.com/acme/widgets/pull/42#issuecomment-9' })
)
).toBe(false)
expect(
canPostPRReviewThreadReply(
comment({
id: 4,
url: 'https://github.com/acme/widgets/pull/42#pullrequestreview-999'
})
)
).toBe(false)
expect(canPostPRReviewThreadReply(comment({ id: 0, threadId: 'T1' }))).toBe(false)
})
// Why: CodeRabbit review summaries can still carry path/threadId metadata; the
// pullrequestreview anchor must win or the replies endpoint 404s.
it('rejects review summaries even when thread metadata is present', () => {
expect(
canPostPRReviewThreadReply(
comment({
id: 5,
threadId: 'T1',
path: 'src/a.ts',
url: 'https://github.com/acme/widgets/pull/42#pullrequestreview-999'
})
)
).toBe(false)
})
})
describe('buildPRCommentConversationReplyBody', () => {
it('strips the [bot] login suffix so the mention resolves', () => {
expect(formatPRCommentMentionHandle('coderabbitai[bot]')).toBe('coderabbitai')
expect(
buildPRCommentConversationReplyBody('coderabbitai[bot]', PR_COMMENT_AI_FIXING_REPLY)
).toBe(`@coderabbitai ${PR_COMMENT_AI_FIXING_REPLY}`)
})
it('keeps human logins and drops the mention when the author is unknown', () => {
expect(buildPRCommentConversationReplyBody('alice', 'Fixing.')).toBe('@alice Fixing.')
expect(buildPRCommentConversationReplyBody(undefined, 'Fixing.')).toBe('Fixing.')
})
})
describe('getPRCommentGroupReplyTarget', () => {
it('uses the latest reply in a thread when present', () => {
const group: PRCommentGroup = {
kind: 'thread',
threadId: 'T1',
root: comment({ id: 10, threadId: 'T1', path: 'a.ts' }),
replies: [
comment({ id: 11, threadId: 'T1', path: 'a.ts', body: 'first' }),
comment({ id: 12, threadId: 'T1', path: 'a.ts', body: 'latest' })
]
}
expect(getPRCommentGroupReplyTarget(group).id).toBe(12)
})
it('falls back to the root when there are no replies', () => {
const group = openThread('T1', 10)
expect(getPRCommentGroupReplyTarget(group).id).toBe(10)
})
})
describe('attachPRReviewReplyParent', () => {
it('fills missing thread metadata from the parent', () => {
const parent = comment({
id: 10,
threadId: 'T1',
path: 'src/a.ts',
line: 4,
isResolved: false
})
const reply = comment({ id: 99, body: 'Fixing.' })
expect(attachPRReviewReplyParent(reply, parent)).toMatchObject({
id: 99,
body: 'Fixing.',
threadId: 'T1',
path: 'src/a.ts',
line: 4,
isResolved: false
})
})
it('does not overwrite explicit reply metadata', () => {
const parent = comment({ id: 10, threadId: 'T1', path: 'src/a.ts', line: 4 })
const reply = comment({ id: 99, threadId: 'T2', path: 'src/b.ts', line: 9 })
expect(attachPRReviewReplyParent(reply, parent)).toMatchObject({
threadId: 'T2',
path: 'src/b.ts',
line: 9
})
})
})
describe('resolvePRReviewReplyThreadId', () => {
it('prefers the parent threadId', () => {
expect(
resolvePRReviewReplyThreadId({
parent: comment({ id: 1, threadId: 'T1', path: 'a.ts' }),
existingComments: []
})
).toBe('T1')
})
it('recovers threadId from existing comments by id or path/line', () => {
const existing = [
comment({ id: 1, threadId: 'T_path', path: 'src/a.ts', line: 3 }),
comment({ id: 2, threadId: 'T_id', path: 'src/b.ts', line: 9 })
]
expect(
resolvePRReviewReplyThreadId({
parent: comment({ id: 2, path: 'src/b.ts', line: 9 }),
existingComments: existing
})
).toBe('T_id')
expect(
resolvePRReviewReplyThreadId({
parent: comment({ id: 50, path: 'src/a.ts', line: 3 }),
existingComments: existing
})
).toBe('T_path')
})
})
describe('checksPanelReviewStableKey', () => {
it('drops the trailing headSha segment', () => {
expect(checksPanelReviewStableKey('repo::main::owner/repo::12::abc123')).toBe(
'repo::main::owner/repo::12'
)
})
it('passes through a key with no separator', () => {
expect(checksPanelReviewStableKey('solo')).toBe('solo')
})
})
describe('acknowledgePRCommentsAfterAiLaunch', () => {
it('replies in-thread and resolves host-resolvable threads', async () => {
const resolveThread = vi.fn().mockResolvedValue(true)
const replyInThread = vi.fn().mockResolvedValue(true)
const replyAsConversation = vi.fn().mockResolvedValue(true)
const group = openThread('T1')
const result = await acknowledgePRCommentsAfterAiLaunch({
groups: [group, openThread('T2', 11)],
deps: {
isStillCurrent: () => true,
isThreadStillResolvable: () => true,
resolveThread,
canReply: true,
replyInThread,
replyAsConversation
}
})
expect(result).toEqual({ resolved: 2, replied: 2, skipped: 0, failed: 0 })
expect(replyInThread).toHaveBeenCalledTimes(2)
expect(replyInThread).toHaveBeenCalledWith(group.root, PR_COMMENT_AI_FIXING_REPLY)
expect(replyAsConversation).not.toHaveBeenCalled()
expect(resolveThread).toHaveBeenCalledTimes(2)
})
// Why: on GitHub canReply is true; a failed resolve still leaves the thread open and
// must count as failed or the success toast lies.
it('still replies in-thread when resolve fails, and counts the failed resolve', async () => {
const replyInThread = vi.fn().mockResolvedValue(true)
const group = openThread('T1')
const result = await acknowledgePRCommentsAfterAiLaunch({
groups: [group],
deps: {
isStillCurrent: () => true,
isThreadStillResolvable: () => true,
resolveThread: vi.fn().mockResolvedValue(false),
canReply: true,
replyInThread,
replyAsConversation: vi.fn()
}
})
expect(result).toEqual({ resolved: 0, replied: 1, skipped: 0, failed: 1 })
expect(replyInThread).toHaveBeenCalledWith(group.root, PR_COMMENT_AI_FIXING_REPLY)
})
it('posts a conversation @-reply for standalone conversation comments', async () => {
const replyAsConversation = vi.fn().mockResolvedValue(true)
const group = standalone(42)
const result = await acknowledgePRCommentsAfterAiLaunch({
groups: [group],
deps: {
isStillCurrent: () => true,
isThreadStillResolvable: () => true,
resolveThread: vi.fn(),
canReply: true,
replyInThread: vi.fn(),
replyAsConversation
}
})
expect(result).toEqual({ resolved: 0, replied: 1, skipped: 0, failed: 0 })
expect(replyAsConversation).toHaveBeenCalledWith(group.comment, PR_COMMENT_AI_FIXING_REPLY)
})
it('posts a conversation @-reply for CodeRabbit review summaries', async () => {
const replyInThread = vi.fn().mockResolvedValue(true)
const replyAsConversation = vi.fn().mockResolvedValue(true)
const group = codeRabbitReviewSummary(77)
const result = await acknowledgePRCommentsAfterAiLaunch({
groups: [group],
deps: {
isStillCurrent: () => true,
isThreadStillResolvable: () => true,
resolveThread: vi.fn(),
canReply: true,
replyInThread,
replyAsConversation
}
})
expect(result).toEqual({ resolved: 0, replied: 1, skipped: 0, failed: 0 })
expect(replyInThread).not.toHaveBeenCalled()
expect(replyAsConversation).toHaveBeenCalledWith(group.comment, PR_COMMENT_AI_FIXING_REPLY)
})
// Why: the reported bug — a selection of only unresolvable CodeRabbit summaries
// produced replied=0 because the ack ran the resolve path and nothing else.
it('replies to every unresolvable review summary in the selection', async () => {
const replyAsConversation = vi.fn().mockResolvedValue(true)
const resolveThread = vi.fn()
const result = await acknowledgePRCommentsAfterAiLaunch({
groups: [codeRabbitReviewSummary(30), codeRabbitReviewSummary(31)],
deps: {
isStillCurrent: () => true,
isThreadStillResolvable: () => false,
resolveThread,
canReply: true,
replyInThread: vi.fn(),
replyAsConversation
}
})
expect(result).toEqual({ resolved: 0, replied: 2, skipped: 0, failed: 0 })
expect(resolveThread).not.toHaveBeenCalled()
})
it('mixes in-thread and conversation replies in a single selection', async () => {
const replyInThread = vi.fn().mockResolvedValue(true)
const replyAsConversation = vi.fn().mockResolvedValue(true)
const thread = openThread('T1', 10)
const summary = codeRabbitReviewSummary(30)
const result = await acknowledgePRCommentsAfterAiLaunch({
groups: [thread, summary],
deps: {
isStillCurrent: () => true,
isThreadStillResolvable: () => true,
resolveThread: vi.fn().mockResolvedValue(true),
canReply: true,
replyInThread,
replyAsConversation
}
})
expect(result).toEqual({ resolved: 1, replied: 2, skipped: 0, failed: 0 })
expect(replyInThread).toHaveBeenCalledWith(thread.root, PR_COMMENT_AI_FIXING_REPLY)
expect(replyAsConversation).toHaveBeenCalledWith(summary.comment, PR_COMMENT_AI_FIXING_REPLY)
})
it('counts a failed conversation reply instead of silently reporting success', async () => {
const result = await acknowledgePRCommentsAfterAiLaunch({
groups: [codeRabbitReviewSummary(30)],
deps: {
isStillCurrent: () => true,
isThreadStillResolvable: () => false,
resolveThread: vi.fn(),
canReply: true,
replyInThread: vi.fn(),
replyAsConversation: vi.fn().mockResolvedValue(false)
}
})
expect(result).toEqual({ resolved: 0, replied: 0, skipped: 0, failed: 1 })
})
it('replies to the latest message in a multi-comment thread', async () => {
const replyInThread = vi.fn().mockResolvedValue(true)
const group: PRCommentGroup = {
kind: 'thread',
threadId: 'T1',
root: comment({ id: 10, threadId: 'T1', path: 'a.ts', isResolved: false }),
replies: [comment({ id: 11, threadId: 'T1', path: 'a.ts', body: 'prior reply' })]
}
const result = await acknowledgePRCommentsAfterAiLaunch({
groups: [group],
deps: {
isStillCurrent: () => true,
isThreadStillResolvable: () => false,
resolveThread: vi.fn(),
canReply: true,
replyInThread,
replyAsConversation: vi.fn()
}
})
expect(result).toEqual({ resolved: 0, replied: 1, skipped: 0, failed: 0 })
expect(replyInThread).toHaveBeenCalledWith(group.replies[0], PR_COMMENT_AI_FIXING_REPLY)
})
it('skips when reply is unavailable and resolve cannot run', async () => {
const result = await acknowledgePRCommentsAfterAiLaunch({
groups: [standalone()],
deps: {
isStillCurrent: () => true,
isThreadStillResolvable: () => true,
resolveThread: vi.fn(),
canReply: false,
replyInThread: vi.fn(),
replyAsConversation: vi.fn()
}
})
expect(result).toEqual({ resolved: 0, replied: 0, skipped: 1, failed: 0 })
})
it('counts a failure when resolve fails and reply is unavailable', async () => {
const result = await acknowledgePRCommentsAfterAiLaunch({
groups: [openThread('T1')],
deps: {
isStillCurrent: () => true,
isThreadStillResolvable: () => true,
resolveThread: vi.fn().mockResolvedValue(false),
canReply: false,
replyInThread: vi.fn(),
replyAsConversation: vi.fn()
}
})
expect(result).toEqual({ resolved: 0, replied: 0, skipped: 0, failed: 1 })
})
it('keeps a durable pending payload across take', () => {
clearPendingPRCommentAiAck()
setPendingPRCommentAiAck({ groups: 2 })
expect(takePendingPRCommentAiAck<{ groups: number }>()).toEqual({ groups: 2 })
expect(takePendingPRCommentAiAck()).toBeNull()
})
it('still posts all replies when the review context becomes stale mid-loop', async () => {
let live = true
const replyInThread = vi.fn().mockImplementation(async () => {
live = false
return true
})
const resolveThread = vi.fn().mockResolvedValue(true)
const result = await acknowledgePRCommentsAfterAiLaunch({
groups: [openThread('T1'), openThread('T2', 11), openThread('T3', 12)],
deps: {
isStillCurrent: () => live,
isThreadStillResolvable: () => true,
resolveThread,
canReply: true,
replyInThread,
replyAsConversation: vi.fn()
}
})
// Why: replies must not depend on checks-panel key stability; resolve stops after context
// goes stale, and each skipped resolve is reported rather than silently counted as success.
expect(replyInThread).toHaveBeenCalledTimes(3)
expect(resolveThread).toHaveBeenCalledTimes(0)
expect(result).toEqual({ resolved: 0, replied: 3, skipped: 3, failed: 0 })
})
it('counts a failed resolve on a group whose reply also failed', async () => {
const result = await acknowledgePRCommentsAfterAiLaunch({
groups: [openThread('T1')],
deps: {
isStillCurrent: () => true,
isThreadStillResolvable: () => true,
resolveThread: vi.fn().mockResolvedValue(false),
canReply: true,
replyInThread: vi.fn().mockResolvedValue(false),
replyAsConversation: vi.fn()
}
})
expect(result).toEqual({ resolved: 0, replied: 0, skipped: 0, failed: 2 })
})
it('reports a resolvable thread as skipped when the panel context moved', async () => {
const resolveThread = vi.fn()
const result = await acknowledgePRCommentsAfterAiLaunch({
groups: [openThread('T1')],
deps: {
isStillCurrent: () => false,
isThreadStillResolvable: () => true,
resolveThread,
canReply: true,
replyInThread: vi.fn().mockResolvedValue(true),
replyAsConversation: vi.fn()
}
})
expect(result).toEqual({ resolved: 0, replied: 1, skipped: 1, failed: 0 })
expect(resolveThread).not.toHaveBeenCalled()
})
it('runs groups with bounded concurrency, replying before resolving in each group', async () => {
const groups = Array.from({ length: 9 }, (_, index) => openThread(`T${index}`, 100 + index))
const order: string[] = []
let inFlight = 0
let peakInFlight = 0
const track = async <T>(label: string, value: T): Promise<T> => {
inFlight += 1
peakInFlight = Math.max(peakInFlight, inFlight)
order.push(label)
await Promise.resolve()
inFlight -= 1
return value
}
const result = await acknowledgePRCommentsAfterAiLaunch({
groups,
deps: {
isStillCurrent: () => true,
isThreadStillResolvable: () => true,
resolveThread: (threadId) => track(`resolve:${threadId}`, true),
canReply: true,
replyInThread: (comment) => track(`reply:${comment.threadId}`, true),
replyAsConversation: vi.fn()
}
})
expect(result).toEqual({ resolved: 9, replied: 9, skipped: 0, failed: 0 })
expect(peakInFlight).toBeGreaterThan(1)
expect(peakInFlight).toBeLessThanOrEqual(PR_COMMENT_ACK_MAX_CONCURRENCY)
for (const group of groups) {
expect(order.indexOf(`reply:${group.threadId}`)).toBeLessThan(
order.indexOf(`resolve:${group.threadId}`)
)
}
})
})
@@ -0,0 +1,271 @@
import type { PRComment } from '../../../../shared/types'
import { getPRCommentGroupRoot, type PRCommentGroup } from '@/lib/pr-comment-groups'
import { isResolvablePRCommentGroup } from '../pr-comments-resolution-prompt'
/** Posted when a selected review comment is sent to AI. */
export const PR_COMMENT_AI_FIXING_REPLY = 'Fixing. Will be in the next commit'
export type PRCommentAiLaunchAckCounts = {
resolved: number
replied: number
skipped: number
failed: number
}
export type PRCommentAiLaunchAckDeps = {
isStillCurrent: () => boolean
/** True when the thread is still open and host-resolvable in live comment state. */
isThreadStillResolvable: (threadId: string) => boolean
resolveThread: (threadId: string) => Promise<boolean>
/** Provider supports posting a reply (GitHub in the Checks panel today). */
canReply: boolean
/**
* Nested review-thread reply (pulls/.../comments/{id}/replies).
* Only call when canPostPRReviewThreadReply(comment) is true.
*/
replyInThread: (comment: PRComment, body: string) => Promise<boolean>
/**
* Top-level PR conversation comment (issues/.../comments), used for review
* summaries and other comments that have no nested-reply endpoint (e.g. CodeRabbit).
*/
replyAsConversation: (comment: PRComment, body: string) => Promise<boolean>
}
/**
* GitHub App logins carry a `[bot]` suffix that does not resolve as an @-mention
* (`@coderabbitai[bot]` renders literally; `@coderabbitai` reaches the bot).
*/
export function formatPRCommentMentionHandle(author: string | undefined): string {
return (author ?? '').replace(/\[bot\]$/i, '').trim()
}
/** Top-level conversation reply body, addressed to the comment author. */
export function buildPRCommentConversationReplyBody(
author: string | undefined,
body: string
): string {
const handle = formatPRCommentMentionHandle(author)
return handle ? `@${handle} ${body}` : body
}
/**
* True when GitHub can nest a reply under this comment (inline review-thread comment).
* Conversation comments and PR review summaries (CodeRabbit, etc.) cannot use that API.
*/
export function canPostPRReviewThreadReply(
comment: Pick<PRComment, 'id' | 'threadId' | 'path' | 'url'>
): boolean {
if (!Number.isSafeInteger(comment.id) || comment.id <= 0) {
return false
}
// Why: review-summary anchors use #pullrequestreview-N; that id is not a review
// comment id and will 404 on the replies endpoint.
if (typeof comment.url === 'string' && comment.url.includes('pullrequestreview')) {
return false
}
// Why: threadId is the GraphQL thread key; path marks review comments even when
// thread metadata is missing; #discussion_r is the REST anchor for review comments.
if (Boolean(comment.threadId) || Boolean(comment.path)) {
return true
}
return typeof comment.url === 'string' && comment.url.includes('discussion_r')
}
/**
* Prefer the latest message in a thread so the fixing reply sits after existing replies.
*/
export function getPRCommentGroupReplyTarget(group: PRCommentGroup): PRComment {
if (group.kind === 'thread' && group.replies.length > 0) {
return group.replies.at(-1)!
}
return getPRCommentGroupRoot(group)
}
/**
* Attach parent review-thread metadata so the sidebar groups the reply under the
* original comment immediately (GitHub REST replies often omit threadId/path).
*/
export function attachPRReviewReplyParent(
reply: PRComment,
parent: Pick<PRComment, 'threadId' | 'path' | 'line' | 'isResolved' | 'isOutdated' | 'startLine'>
): PRComment {
return {
...reply,
threadId: reply.threadId ?? parent.threadId,
path: reply.path ?? parent.path,
line: reply.line ?? parent.line,
startLine: reply.startLine ?? parent.startLine,
isResolved: reply.isResolved ?? parent.isResolved,
isOutdated: reply.isOutdated ?? parent.isOutdated
}
}
/**
* Resolve a threadId for a reply target from the live comment list when the
* target itself is missing one (path-only review comments).
*/
export function resolvePRReviewReplyThreadId(args: {
parent: Pick<PRComment, 'id' | 'threadId' | 'path' | 'line'>
existingComments: readonly PRComment[]
}): string | undefined {
if (args.parent.threadId) {
return args.parent.threadId
}
const byId = args.existingComments.find(
(comment) => comment.id === args.parent.id && Boolean(comment.threadId)
)
if (byId?.threadId) {
return byId.threadId
}
if (!args.parent.path) {
return undefined
}
const sibling = args.existingComments.find(
(comment) =>
Boolean(comment.threadId) &&
comment.path === args.parent.path &&
(args.parent.line == null || comment.line === args.parent.line)
)
return sibling?.threadId
}
/**
* Drop the trailing headSha segment from a checks-panel async key.
* Agent launch (submit-after-ready) can take long enough for PR head to move;
* ack must not abort just because headSha churned.
*/
export function checksPanelReviewStableKey(asyncResultKey: string): string {
const parts = asyncResultKey.split('::')
if (parts.length <= 1) {
return asyncResultKey
}
return parts.slice(0, -1).join('::')
}
/**
* Bulk ack fans out over the host API (`gh`), which is slow on SSH; the shared
* client already caps itself at 4 in-flight requests, so match that ceiling.
*/
export const PR_COMMENT_ACK_MAX_CONCURRENCY = 4
const EMPTY_ACK_COUNTS: PRCommentAiLaunchAckCounts = {
resolved: 0,
replied: 0,
skipped: 0,
failed: 0
}
/** Fixed-size worker pool; results stay in input order. */
async function mapWithBoundedConcurrency<T, R>(
items: readonly T[],
limit: number,
run: (item: T) => Promise<R>
): Promise<R[]> {
const results = Array.from<R>({ length: items.length })
let next = 0
const workers = Array.from({ length: Math.min(Math.max(limit, 1), items.length) }, async () => {
while (next < items.length) {
const index = next
next += 1
results[index] = await run(items[index]!)
}
})
await Promise.all(workers)
return results
}
/**
* After launching an agent for selected review feedback:
* post a fixing reply (nested when possible, else conversation @-reply), then
* resolve host threads when possible.
*
* Why: replies use a snapshotted GitHub target and must not abort just because
* the checks-panel async key churned mid-launch. isStillCurrent only gates
* host-thread resolve + UI refresh, not the reply posts themselves.
*
* Groups run through a bounded pool; reply-before-resolve stays serial *within*
* a group because the resolve decision reads live comment state the reply mutates.
*/
export async function acknowledgePRCommentsAfterAiLaunch(args: {
groups: readonly PRCommentGroup[]
deps: PRCommentAiLaunchAckDeps
}): Promise<PRCommentAiLaunchAckCounts> {
const perGroup = await mapWithBoundedConcurrency(
args.groups,
PR_COMMENT_ACK_MAX_CONCURRENCY,
(group) => acknowledgePRCommentGroup(group, args.deps)
)
return perGroup.reduce<PRCommentAiLaunchAckCounts>(
(totals, counts) => ({
resolved: totals.resolved + counts.resolved,
replied: totals.replied + counts.replied,
skipped: totals.skipped + counts.skipped,
failed: totals.failed + counts.failed
}),
EMPTY_ACK_COUNTS
)
}
async function acknowledgePRCommentGroup(
group: PRCommentGroup,
deps: PRCommentAiLaunchAckDeps
): Promise<PRCommentAiLaunchAckCounts> {
const counts = { ...EMPTY_ACK_COUNTS }
const replyTarget = getPRCommentGroupReplyTarget(group)
const threadId =
group.kind === 'thread' && isResolvablePRCommentGroup(group) ? group.threadId : null
if (deps.canReply) {
// Why: prefer nested review-thread replies; fall back to a top-level conversation
// comment for review summaries / issue comments (CodeRabbit, etc.) that have no
// replies endpoint.
const repliedOk = canPostPRReviewThreadReply(replyTarget)
? await deps.replyInThread(replyTarget, PR_COMMENT_AI_FIXING_REPLY)
: await deps.replyAsConversation(replyTarget, PR_COMMENT_AI_FIXING_REPLY)
if (repliedOk) {
counts.replied += 1
} else {
counts.failed += 1
}
} else if (threadId == null || !deps.isThreadStillResolvable(threadId)) {
counts.skipped += 1
}
if (threadId == null || !deps.isThreadStillResolvable(threadId)) {
return counts
}
// Why: resolve is UI/host-state sensitive; skip it if the panel context moved.
// Replies already used a snapshotted target and must not be gated the same way.
if (!deps.isStillCurrent()) {
counts.skipped += 1
return counts
}
if (await deps.resolveThread(threadId)) {
counts.resolved += 1
} else {
// Why: a resolve failure leaves the thread open on the host regardless of whether
// the reply landed; counting it only when !canReply made GitHub failures invisible.
counts.failed += 1
}
return counts
}
/** Durable pending ack payload so dialog close / re-renders cannot drop it before launch. */
let pendingAiCommentAck: unknown = null
export function setPendingPRCommentAiAck<T>(payload: T): void {
pendingAiCommentAck = payload
}
export function takePendingPRCommentAiAck<T>(): T | null {
const payload = pendingAiCommentAck
pendingAiCommentAck = null
return (payload as T | null) ?? null
}
export function clearPendingPRCommentAiAck(): void {
pendingAiCommentAck = null
}
@@ -71,6 +71,122 @@ describe('runSourceControlAgentActionStart', () => {
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('notifies onLaunchAccepted as soon as the agent tab is created, before prompt delivery', async () => {
let resolveDelivery: (value: {
delivered: boolean
failureNotified: boolean
}) => void = () => {}
const promptDeliveryResult = new Promise<{ delivered: boolean; failureNotified: boolean }>(
(resolve) => {
resolveDelivery = resolve
}
)
const onLaunchAccepted = vi.fn()
const onLaunchAborted = vi.fn()
mocks.launchAgentInNewTab.mockReturnValue({
tabId: 'tab-1',
startupPlan: {} as never,
pasteDraftAfterLaunch: true,
promptDeliveryResult
})
const launchPromise = runSourceControlAgentActionStart(
buildArgs({ onLaunchAccepted, onLaunchAborted })
)
await vi.waitFor(() => {
expect(onLaunchAccepted).toHaveBeenCalledTimes(1)
})
expect(mocks.onLaunched).not.toHaveBeenCalled()
resolveDelivery({ delivered: true, failureNotified: false })
await expect(launchPromise).resolves.toBe(true)
expect(mocks.onLaunched).toHaveBeenCalledTimes(1)
expect(onLaunchAborted).not.toHaveBeenCalled()
})
// Why: onLaunchAccepted only parks launch-scoped state; firing it twice or on a dead
// launch would leave a caller believing an agent is running.
it('fires onLaunchAccepted exactly once and only when a tab was created', async () => {
const onLaunchAccepted = vi.fn()
mocks.launchAgentInNewTab.mockReturnValue({
tabId: 'tab-1',
startupPlan: {} as never,
pasteDraftAfterLaunch: true,
promptDeliveryResult: Promise.resolve({ delivered: true, failureNotified: false })
})
await expect(runSourceControlAgentActionStart(buildArgs({ onLaunchAccepted }))).resolves.toBe(
true
)
expect(onLaunchAccepted).toHaveBeenCalledTimes(1)
vi.clearAllMocks()
mocks.launchAgentInNewTab.mockReturnValue(null)
await expect(runSourceControlAgentActionStart(buildArgs({ onLaunchAccepted }))).resolves.toBe(
false
)
expect(onLaunchAccepted).not.toHaveBeenCalled()
})
// Why: irreversible host writes (fixing replies, thread resolves) hang off onLaunched, so a
// launch whose prompt never landed must report the abort and never reach onLaunched.
it('aborts an accepted launch when deferred prompt delivery fails', async () => {
const onLaunchAccepted = vi.fn()
const onLaunchAborted = vi.fn()
mocks.launchAgentInNewTab.mockReturnValue({
tabId: 'tab-1',
startupPlan: {} as never,
pasteDraftAfterLaunch: true,
promptDeliveryResult: Promise.resolve({ delivered: false, failureNotified: true })
})
await expect(
runSourceControlAgentActionStart(buildArgs({ onLaunchAccepted, onLaunchAborted }))
).resolves.toBe(false)
expect(onLaunchAccepted).toHaveBeenCalledTimes(1)
expect(onLaunchAborted).toHaveBeenCalledTimes(1)
expect(mocks.onLaunched).not.toHaveBeenCalled()
})
it('aborts an accepted launch when promptDeliveryResult rejects', async () => {
const onLaunchAccepted = vi.fn()
const onLaunchAborted = vi.fn()
const originalConsole = console
vi.stubGlobal('console', { ...originalConsole, error: vi.fn() })
mocks.launchAgentInNewTab.mockReturnValue({
tabId: 'tab-1',
startupPlan: {} as never,
pasteDraftAfterLaunch: true,
promptDeliveryResult: Promise.reject(new Error('boom'))
})
try {
await expect(
runSourceControlAgentActionStart(buildArgs({ onLaunchAccepted, onLaunchAborted }))
).resolves.toBe(false)
expect(onLaunchAccepted).toHaveBeenCalledTimes(1)
expect(onLaunchAborted).toHaveBeenCalledTimes(1)
expect(mocks.onLaunched).not.toHaveBeenCalled()
} finally {
vi.stubGlobal('console', originalConsole)
}
})
it('does not report an abort when no tab was ever created', async () => {
const onLaunchAborted = vi.fn()
mocks.launchAgentInNewTab.mockReturnValue(null)
await expect(runSourceControlAgentActionStart(buildArgs({ onLaunchAborted }))).resolves.toBe(
false
)
expect(onLaunchAborted).not.toHaveBeenCalled()
})
it('keeps the source-control dialog open when deferred prompt delivery fails', async () => {
mocks.launchAgentInNewTab.mockReturnValue({
tabId: 'tab-1',
@@ -37,6 +37,15 @@ type RunSourceControlAgentActionStartArgs = {
actionId: SourceControlLaunchActionId,
recipe: SourceControlActionRecipe
) => void | Promise<void>
/**
* Fires as soon as the agent tab/session is created, before deferred
* submit-after-ready prompt delivery finishes. Reversible bookkeeping only —
* irreversible side effects (posting host replies, resolving threads) belong in
* onLaunched, which only fires once the prompt actually reached the agent.
*/
onLaunchAccepted?: () => void
/** Fires when a launch that already reported onLaunchAccepted failed to deliver its prompt. */
onLaunchAborted?: () => void
onLaunched?: () => void
onClose: () => void
}
@@ -58,17 +67,30 @@ export async function runSourceControlAgentActionStart({
launchSource,
onStart,
onSaveAgentDefault,
onLaunchAccepted,
onLaunchAborted,
onLaunched,
onClose
}: RunSourceControlAgentActionStartArgs): Promise<boolean> {
let launched = false
let launchFailureNotified = false
let launchAcceptedNotified = false
const notifyLaunchAccepted = (): void => {
if (launchAcceptedNotified) {
return
}
launchAcceptedNotified = true
onLaunchAccepted?.()
}
if (onStart) {
launched = await onStart({
agent: selectedAgent,
commandInput: trimmedCommandInput,
agentArgs
})
if (launched) {
notifyLaunchAccepted()
}
} else if (worktreeId) {
const result = launchAgentInNewTab({
agent: selectedAgent,
@@ -84,6 +106,11 @@ export async function runSourceControlAgentActionStart({
if (result?.tabId) {
focusTerminalTabSurface(result.tabId)
}
// Why: lets callers park launch-scoped state before submit-after-ready finishes
// (can take tens of seconds); host mutations still wait for delivery below.
if (launched) {
notifyLaunchAccepted()
}
if (result?.promptDeliveryResult) {
try {
const deliveryResult = await result.promptDeliveryResult
@@ -96,6 +123,9 @@ export async function runSourceControlAgentActionStart({
}
}
if (!launched) {
if (launchAcceptedNotified) {
onLaunchAborted?.()
}
if (!launchFailureNotified) {
toast.error(
translate(
@@ -37,6 +37,8 @@ export function useSourceControlAgentActionDialog({
launchSource,
savedAgentId,
onSaveAgentDefault,
onLaunchAccepted,
onLaunchAborted,
onLaunched,
onStart
}: SourceControlAgentActionDialogProps): UseSourceControlAgentActionDialogResult {
@@ -187,6 +189,8 @@ export function useSourceControlAgentActionDialog({
refreshDetectedAgents,
onStart,
onSaveAgentDefault,
onLaunchAccepted,
onLaunchAborted,
onLaunched,
onClose: closeDialog
})
@@ -247,33 +251,24 @@ export function useSourceControlAgentActionDialog({
detecting
})
const onSelectedAgentChange = useCallback(
(agent: TuiAgent | null) => {
setSelectedAgent(agent)
resetDeliveryPlan()
},
// Why: editing any launch field invalidates the previewed delivery plan.
const resetPlanAfter = useCallback(
<T>(apply: (value: T) => void) =>
(value: T): void => {
apply(value)
resetDeliveryPlan()
},
[resetDeliveryPlan]
)
const onAgentArgsChange = useCallback(
(value: string) => {
setAgentArgs(value)
resetDeliveryPlan()
},
[resetDeliveryPlan]
const onSelectedAgentChange = useMemo(() => resetPlanAfter(setSelectedAgent), [resetPlanAfter])
const onAgentArgsChange = useMemo(() => resetPlanAfter(setAgentArgs), [resetPlanAfter])
const onCommandTemplateChange = useMemo(
() => resetPlanAfter(setCommandTemplate),
[resetPlanAfter]
)
const onCommandTemplateChange = useCallback(
(value: string) => {
setCommandTemplate(value)
resetDeliveryPlan()
},
[resetDeliveryPlan]
)
const onSaveLaunchRecipeChange = useCallback(
(value: boolean) => {
setSaveLaunchRecipe(value)
resetDeliveryPlan()
},
[resetDeliveryPlan]
const onSaveLaunchRecipeChange = useMemo(
() => resetPlanAfter(setSaveLaunchRecipe),
[resetPlanAfter]
)
const agentScopeNote = useMemo(() => {
@@ -43,6 +43,8 @@ type UseSourceControlAgentActionStartArgs = {
actionId: SourceControlLaunchActionId,
recipe: SourceControlActionRecipe
) => void | Promise<void>
onLaunchAccepted?: () => void
onLaunchAborted?: () => void
onLaunched?: () => void
onClose: () => void
}
@@ -84,6 +86,8 @@ export function useSourceControlAgentActionStart({
refreshDetectedAgents,
onStart,
onSaveAgentDefault,
onLaunchAccepted,
onLaunchAborted,
onLaunched,
onClose
}: UseSourceControlAgentActionStartArgs): UseSourceControlAgentActionStartResult {
@@ -158,6 +162,8 @@ export function useSourceControlAgentActionStart({
launchSource,
onStart,
onSaveAgentDefault,
onLaunchAccepted,
onLaunchAborted,
onLaunched,
onClose: () => {
resetDeliveryPlan()
@@ -179,6 +185,8 @@ export function useSourceControlAgentActionStart({
launchSource,
launchPlatform,
onClose,
onLaunchAborted,
onLaunchAccepted,
onLaunched,
onSaveAgentDefault,
onStart,
+6 -5
View File
@@ -10142,10 +10142,10 @@
"653c105ecc": "More PR actions",
"f316a8ca2b": "No unresolved comments selected.",
"d00ebdc402": "Resolve {{value0}} Comments With AI",
"ed3f79c031": "Review the prompt before starting an agent. Selected threads are marked resolved after launch.",
"f273f2271c": "Started the agent. Marked {{value0}} resolved, skipped {{value1}}, failed {{value2}}.",
"aa95b81a3a": "Started the agent. Marked {{value0}} resolved, skipped {{value1}}, failed {{value2}}.",
"495b2f8c4b": "Started the agent, but could not mark the selected comments resolved.",
"ed3f79c031": "Review the prompt before starting an agent. After launch, Orca posts a fixing reply on each selected comment and resolves host threads when possible.",
"f273f2271c": "Started the agent. Marked {{value0}} resolved, replied to {{value1}}, skipped {{value2}}, failed {{value3}}.{{value4}}",
"aa95b81a3a": "Started the agent. Marked {{value0}} resolved, replied to {{value1}}, skipped {{value2}}, failed {{value3}}.",
"495b2f8c4b": "Started the agent, but could not resolve or reply on the selected comments.",
"3c3ad3a1d2": "Started the agent. No selected comments can be marked resolved on the host.",
"review": {
"auto_retry": "Orca will retry at {{time}}.",
@@ -10155,7 +10155,8 @@
"sync": {
"pending": "Syncing…",
"branch": "Sync Branch"
}
},
"7e4b2a19c0": "Could not resolve the GitHub PR to reply on."
},
"CreatePullRequestDialog": {
"2bc1b4345e": "Cancel",
+6 -5
View File
@@ -10069,10 +10069,10 @@
"653c105ecc": "Más acciones de PR",
"f316a8ca2b": "No hay comentarios sin resolver seleccionados.",
"d00ebdc402": "Resolver comentarios de {{value0}} con IA",
"ed3f79c031": "Revisa el prompt antes de iniciar un agente. Los hilos seleccionados se marcarán como resueltos después del inicio.",
"f273f2271c": "Agente iniciado. Marcados como resueltos: {{value0}}; omitidos: {{value1}}; con error: {{value2}}.",
"aa95b81a3a": "Agente iniciado. Marcados como resueltos: {{value0}}; omitidos: {{value1}}; con error: {{value2}}.",
"495b2f8c4b": "Agente iniciado, pero no se pudieron marcar los comentarios seleccionados como resueltos.",
"ed3f79c031": "Revisa el prompt antes de iniciar un agente. Tras el lanzamiento, Orca publica una respuesta de corrección en cada comentario seleccionado y resuelve los hilos en el host cuando es posible.",
"f273f2271c": "Agente iniciado. Marcados como resueltos: {{value0}}; respondidos: {{value1}}; omitidos: {{value2}}; con error: {{value3}}.{{value4}}",
"aa95b81a3a": "Agente iniciado. Marcados como resueltos: {{value0}}; respondidos: {{value1}}; omitidos: {{value2}}; con error: {{value3}}.",
"495b2f8c4b": "Agente iniciado, pero no se pudieron resolver ni responder los comentarios seleccionados.",
"3c3ad3a1d2": "Agente iniciado. No se puede marcar ningún comentario seleccionado como resuelto en el host.",
"review": {
"auto_retry": "Orca reintentará a las {{time}}.",
@@ -10082,7 +10082,8 @@
"sync": {
"pending": "Syncing…",
"branch": "Sync Branch"
}
},
"7e4b2a19c0": "No se pudo resolver el PR de GitHub para responder."
},
"CreatePullRequestDialog": {
"2bc1b4345e": "Cancelar",
+6 -5
View File
@@ -10069,10 +10069,10 @@
"653c105ecc": "その他の PR 操作",
"f316a8ca2b": "未解決のコメントが選択されていません。",
"d00ebdc402": "{{value0}} のコメントを AI で解決",
"ed3f79c031": "agent を開始する前にプロンプトを確認してください。選択したスレッドは開始後に解決済みにマークされます。",
"f273f2271c": "agent を開始しました。{{value0}} 件を解決済みにし、{{value1}} 件をスキップ、{{value2}} 件が失敗しました。",
"aa95b81a3a": "agent を開始しました。{{value0}} 件を解決済みにし、{{value1}} 件をスキップ、{{value2}} 件が失敗しました。",
"495b2f8c4b": "agent を開始しましたが、選択したコメントを解決済みにできませんでした。",
"ed3f79c031": "agent を開始する前にプロンプトを確認してください。開始後、Orca は選択した各コメントに修正の返信を投稿し、可能な場合はホスト上のスレッドを解決します。",
"f273f2271c": "agent を開始しました。{{value0}} 件を解決済み、{{value1}} 件に返信、{{value2}} 件をスキップ、{{value3}} 件が失敗しました。{{value4}}",
"aa95b81a3a": "agent を開始しました。{{value0}} 件を解決済み、{{value1}} 件に返信、{{value2}} 件をスキップ、{{value3}} 件が失敗しました。",
"495b2f8c4b": "agent を開始しましたが、選択したコメントの解決または返信ができませんでした。",
"3c3ad3a1d2": "agent を開始しました。選択したコメントにホスト上で解決済みにできるものはありません。",
"review": {
"auto_retry": "Orca は {{time}} に再試行します。",
@@ -10082,7 +10082,8 @@
"sync": {
"pending": "Syncing…",
"branch": "Sync Branch"
}
},
"7e4b2a19c0": "返信先の GitHub PR を特定できませんでした。"
},
"CreatePullRequestDialog": {
"2bc1b4345e": "キャンセル",
+6 -5
View File
@@ -10069,10 +10069,10 @@
"653c105ecc": "더 많은 PR 작업",
"f316a8ca2b": "선택한 해결되지 않은 댓글이 없습니다.",
"d00ebdc402": "AI로 {{value0}} 댓글 해결",
"ed3f79c031": "agent를 시작하기 전에 prompt를 검토하세요. 선택한 스레드는 시작 후 해결됨으로 표시됩니다.",
"f273f2271c": "agent를 시작했습니다. {{value0}}개 해결됨으로 표시, {{value1}}개 건너뜀, {{value2}}개 실패.",
"aa95b81a3a": "agent를 시작했습니다. {{value0}}개 해결됨으로 표시, {{value1}}개 건너뜀, {{value2}}개 실패.",
"495b2f8c4b": "agent를 시작했지만 선택한 댓글을 해결됨으로 표시할 수 없습니다.",
"ed3f79c031": "에이전트를 시작하기 전에 프롬프트를 확인하세요. 시작 후 Orca가 선택한 각 댓글에 수정 답글을 게시하고 가능한 경우 호스트 스레드를 해결합니다.",
"f273f2271c": "agent를 시작했습니다. {{value0}}개 해결됨, {{value1}}개 답글, {{value2}}개 건너뜀, {{value3}}개 실패.{{value4}}",
"aa95b81a3a": "agent를 시작했습니다. {{value0}}개 해결됨, {{value1}}개 답글, {{value2}}개 건너뜀, {{value3}}개 실패.",
"495b2f8c4b": "agent를 시작했지만 선택한 댓글을 해결하거나 답글할 수 없습니다.",
"3c3ad3a1d2": "agent를 시작했습니다. 호스트에서 해결됨으로 표시할 수 있는 선택된 댓글이 없습니다.",
"review": {
"auto_retry": "Orca가 {{time}}에 다시 시도합니다.",
@@ -10082,7 +10082,8 @@
"sync": {
"pending": "Syncing…",
"branch": "Sync Branch"
}
},
"7e4b2a19c0": "답글을 게시할 GitHub PR을 확인하지 못했습니다."
},
"CreatePullRequestDialog": {
"2bc1b4345e": "취소",
+6 -5
View File
@@ -10069,10 +10069,10 @@
"653c105ecc": "更多 PR 操作",
"f316a8ca2b": "未选择未解决的评论。",
"d00ebdc402": "使用 AI 解决 {{value0}} 评论",
"ed3f79c031": "启动智能体前请检查提示词。启动后,选中的线程会被标记为已解决。",
"f273f2271c": "已启动智能体。已标记 {{value0}} 个为已解决,跳过 {{value1}} 个,失败 {{value2}} 个。",
"aa95b81a3a": "已启动智能体。已标记 {{value0}} 个为已解决,跳过 {{value1}} 个,失败 {{value2}} 个。",
"495b2f8c4b": "已启动智能体,但无法将选中的评论标记为已解决。",
"ed3f79c031": "启动 agent 前请检查提示词。启动后,Orca 会在每条选中的评论下发布修复回复,并在可能时解决主机上的讨论串。",
"f273f2271c": "已启动智能体。已标记 {{value0}} 个为已解决,回复 {{value1}} 个,跳过 {{value2}} 个,失败 {{value3}} 个。{{value4}}",
"aa95b81a3a": "已启动智能体。已标记 {{value0}} 个为已解决,回复 {{value1}} 个,跳过 {{value2}} 个,失败 {{value3}} 个。",
"495b2f8c4b": "已启动智能体,但无法解决或回复选中的评论。",
"3c3ad3a1d2": "已启动智能体。选中的评论中没有可在托管平台上标记为已解决的评论。",
"review": {
"auto_retry": "Orca 将在 {{time}} 重试。",
@@ -10082,7 +10082,8 @@
"sync": {
"pending": "Syncing…",
"branch": "Sync Branch"
}
},
"7e4b2a19c0": "无法确定要回复的 GitHub PR。"
},
"CreatePullRequestDialog": {
"2bc1b4345e": "取消",