feat(github): add PR comment reactions

This commit is contained in:
Jinwoo-H
2026-08-10 00:33:21 -04:00
parent 6e63bbbb52
commit f47267bb0c
24 changed files with 1009 additions and 41 deletions
+108
View File
@@ -178,6 +178,7 @@ import {
getPullRequestPushTarget,
mergePR,
resolveReviewThread,
setPRCommentReaction,
setPRAutoMerge,
updatePRState,
updatePRTitle,
@@ -4082,6 +4083,113 @@ describe('GitHub GraphQL rate-limit guard', () => {
)
})
it('returns GraphQL reaction subjects and viewer state for PR comments', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({
data: {
repository: {
pullRequest: {
comments: {
nodes: [
{
id: 'IC_1',
databaseId: 10,
author: { login: 'octo', avatarUrl: '', __typename: 'User' },
body: 'Issue comment',
createdAt: '2026-04-01T00:00:00Z',
url: 'https://example.test/issue-comment',
reactionGroups: [
{
content: 'THUMBS_UP',
viewerHasReacted: true,
reactors: { totalCount: 2 }
}
]
}
]
},
reviewThreads: {
nodes: [
{
id: 'PRRT_1',
isResolved: false,
line: 4,
startLine: null,
originalLine: 4,
originalStartLine: null,
comments: {
nodes: [
{
id: 'PRRC_1',
databaseId: 11,
author: { login: 'reviewer', avatarUrl: '', __typename: 'User' },
body: 'Inline comment',
createdAt: '2026-04-01T00:01:00Z',
url: 'https://example.test/review-comment',
path: 'src/app.ts',
reactionGroups: [
{
content: 'EYES',
viewerHasReacted: false,
reactors: { totalCount: 1 }
}
]
}
]
}
}
]
}
}
}
}
})
})
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({ stdout: '[]' })
const comments = await getPRComments('/repo-root', 7, {
prRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' }
})
expect(comments).toEqual([
expect.objectContaining({
id: 10,
reactionSubjectId: 'IC_1',
reactions: [{ content: '+1', count: 2, viewerHasReacted: true }]
}),
expect.objectContaining({
id: 11,
reactionSubjectId: 'PRRC_1',
reactions: [{ content: 'eyes', count: 1, viewerHasReacted: false }]
})
])
})
it('adds and removes PR comment reactions through GraphQL', async () => {
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValue({ stdout: '{}', stderr: '' })
await expect(setPRCommentReaction('/repo-root', 'IC_1', '+1', true)).resolves.toBe(true)
await expect(setPRCommentReaction('/repo-root', 'PRRC_1', 'eyes', false)).resolves.toBe(true)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
expect.arrayContaining(['subjectId=IC_1', 'content=THUMBS_UP']),
expect.any(Object)
)
expect(ghExecFileAsyncMock.mock.calls[0]?.[0].join(' ')).toContain('addReaction')
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
expect.arrayContaining(['subjectId=PRRC_1', 'content=EYES']),
expect.any(Object)
)
expect(ghExecFileAsyncMock.mock.calls[1]?.[0].join(' ')).toContain('removeReaction')
expect(noteRateLimitSpendMock).toHaveBeenCalledTimes(2)
})
it('uses explicit PR repo for merge and title mutations', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({
+70 -1
View File
@@ -12,6 +12,7 @@ import type {
PRCheckDetail,
PRCheckRunDetails,
GitHubCommentResult,
GitHubReactionContent,
GitHubPRReviewCommentInput,
PRComment,
GitHubViewer,
@@ -110,7 +111,11 @@ import {
mapPRState,
deriveCheckStatus
} from './mappers'
import { mapGraphQLReactionGroups, type GitHubGraphQLReactionGroup } from './comment-reactions'
import {
mapGraphQLReactionGroups,
toGraphQLReactionContent,
type GitHubGraphQLReactionGroup
} from './comment-reactions'
import {
getRateLimit,
noteRepositoryRateLimitSpend,
@@ -4117,6 +4122,7 @@ query($owner: String!, $repo: String!, $pr: Int!) {
originalStartLine
comments(first: 100) {
nodes {
id
databaseId
author { __typename login avatarUrl(size: 48) }
body
@@ -4125,6 +4131,7 @@ query($owner: String!, $repo: String!, $pr: Int!) {
path
reactionGroups {
content
viewerHasReacted
reactors {
totalCount
}
@@ -4135,6 +4142,7 @@ query($owner: String!, $repo: String!, $pr: Int!) {
}
comments(first: 100) {
nodes {
id
databaseId
author { __typename login avatarUrl(size: 48) }
body
@@ -4142,6 +4150,7 @@ query($owner: String!, $repo: String!, $pr: Int!) {
url
reactionGroups {
content
viewerHasReacted
reactors {
totalCount
}
@@ -4255,6 +4264,7 @@ export async function getPRComments(
originalStartLine: number | null
comments: {
nodes: {
id: string
databaseId: number
author: { __typename?: string; login: string; avatarUrl: string } | null
body: string
@@ -4266,6 +4276,7 @@ export async function getPRComments(
}
}
type GQLIssueComment = {
id: string
databaseId: number
author: { __typename?: string; login: string; avatarUrl: string } | null
body: string
@@ -4295,6 +4306,7 @@ export async function getPRComments(
createdAt: c.createdAt,
url: c.url,
isBot: c.author?.__typename === 'Bot',
reactionSubjectId: c.id,
reactions: mapGraphQLReactionGroups(c.reactionGroups)
})
)
@@ -4313,6 +4325,7 @@ export async function getPRComments(
createdAt: c.createdAt,
url: c.url,
isBot: c.author?.__typename === 'Bot',
reactionSubjectId: c.id,
reactions: mapGraphQLReactionGroups(c.reactionGroups),
path: c.path,
threadId: thread.id,
@@ -4393,6 +4406,62 @@ export async function getPRComments(
}
}
export async function setPRCommentReaction(
repoPath: string,
reactionSubjectId: string,
content: GitHubReactionContent,
reacted: boolean,
connectionId?: string | null,
prRepo?: GitHubApiRepository | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<boolean> {
const mutation = reacted ? 'addReaction' : 'removeReaction'
const query = `mutation($subjectId: ID!, $content: ReactionContent!) {
${mutation}(input: { subjectId: $subjectId, content: $content }) {
subject { id }
}
}`
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
prRepo,
connectionId,
localGitOptions
)
if (!ownerRepo) {
return false
}
const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions)
if (guard.blocked) {
console.warn(
`${mutation} skipped: GitHub GraphQL rate limit nearly exhausted (${guard.remaining}/${guard.limit})`
)
return false
}
await acquire()
try {
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
await ghExecFileAsync(
[
'api',
'graphql',
'-f',
`query=${query}`,
'-f',
`subjectId=${reactionSubjectId}`,
'-f',
`content=${toGraphQLReactionContent(content)}`
],
ghOptions
)
return true
} catch (err) {
console.warn(`${mutation} failed:`, err)
return false
} finally {
release()
}
}
/**
* Mark or unmark a PR file as viewed via GitHub's GraphQL API.
*/
+10 -5
View File
@@ -1,19 +1,19 @@
import { describe, expect, it } from 'vitest'
import { mapGraphQLReactionGroups } from './comment-reactions'
import { mapGraphQLReactionGroups, toGraphQLReactionContent } from './comment-reactions'
describe('mapGraphQLReactionGroups', () => {
it('normalizes GraphQL reaction groups into GitHub comment reactions', () => {
expect(
mapGraphQLReactionGroups([
{ content: 'EYES', reactors: { totalCount: 1 } },
{ content: 'THUMBS_UP', reactors: { totalCount: 2 } },
{ content: 'EYES', reactors: { totalCount: 1 }, viewerHasReacted: false },
{ content: 'THUMBS_UP', reactors: { totalCount: 2 }, viewerHasReacted: true },
{ content: 'HEART', reactors: { totalCount: 0 } },
{ content: 'UNKNOWN', reactors: { totalCount: 9 } }
])
).toEqual([
{ content: '+1', count: 2 },
{ content: 'eyes', count: 1 }
{ content: '+1', count: 2, viewerHasReacted: true },
{ content: 'eyes', count: 1, viewerHasReacted: false }
])
})
@@ -22,4 +22,9 @@ describe('mapGraphQLReactionGroups', () => {
undefined
)
})
it('maps REST reaction content to the GraphQL enum', () => {
expect(toGraphQLReactionContent('+1')).toBe('THUMBS_UP')
expect(toGraphQLReactionContent('hooray')).toBe('HOORAY')
})
})
+30 -4
View File
@@ -13,6 +13,7 @@ type GitHubGraphQLReactionContent =
export type GitHubGraphQLReactionGroup = {
content?: string | null
reactors?: { totalCount?: number | null } | null
viewerHasReacted?: boolean | null
}
const GRAPHQL_REACTION_CONTENT: Record<GitHubGraphQLReactionContent, GitHubReactionContent> = {
@@ -37,10 +38,27 @@ const REACTION_ORDER: GitHubReactionContent[] = [
'eyes'
]
const REACTION_CONTENT_TO_GRAPHQL: Record<GitHubReactionContent, GitHubGraphQLReactionContent> = {
'+1': 'THUMBS_UP',
'-1': 'THUMBS_DOWN',
laugh: 'LAUGH',
confused: 'CONFUSED',
heart: 'HEART',
hooray: 'HOORAY',
rocket: 'ROCKET',
eyes: 'EYES'
}
export function toGraphQLReactionContent(
content: GitHubReactionContent
): GitHubGraphQLReactionContent {
return REACTION_CONTENT_TO_GRAPHQL[content]
}
export function mapGraphQLReactionGroups(
groups?: GitHubGraphQLReactionGroup[] | null
): GitHubReaction[] | undefined {
const counts = new Map<GitHubReactionContent, number>()
const reactionsByContent = new Map<GitHubReactionContent, GitHubReaction>()
for (const group of groups ?? []) {
const content =
group.content && group.content in GRAPHQL_REACTION_CONTENT
@@ -50,12 +68,20 @@ export function mapGraphQLReactionGroups(
if (!content || count <= 0) {
continue
}
counts.set(content, (counts.get(content) ?? 0) + count)
const existing = reactionsByContent.get(content)
const viewerHasReacted = Boolean(existing?.viewerHasReacted || group.viewerHasReacted)
reactionsByContent.set(content, {
content,
count: (existing?.count ?? 0) + count,
...(existing?.viewerHasReacted !== undefined || group.viewerHasReacted != null
? { viewerHasReacted }
: {})
})
}
const reactions = REACTION_ORDER.flatMap((content) => {
const count = counts.get(content) ?? 0
return count > 0 ? [{ content, count }] : []
const reaction = reactionsByContent.get(content)
return reaction ? [reaction] : []
})
return reactions.length > 0 ? reactions : undefined
}
+21
View File
@@ -22,6 +22,7 @@ const {
getPRChecksMock,
getPRCheckDetailsMock,
getPRCommentsMock,
setPRCommentReactionMock,
resolveReviewThreadMock,
setPRFileViewedMock,
addPRReviewCommentMock,
@@ -64,6 +65,7 @@ const {
getPRChecksMock: vi.fn(),
getPRCheckDetailsMock: vi.fn(),
getPRCommentsMock: vi.fn(),
setPRCommentReactionMock: vi.fn(),
resolveReviewThreadMock: vi.fn(),
setPRFileViewedMock: vi.fn(),
addPRReviewCommentMock: vi.fn(),
@@ -123,6 +125,7 @@ vi.mock('../github/client', () => ({
getPRChecks: getPRChecksMock,
getPRCheckDetails: getPRCheckDetailsMock,
getPRComments: getPRCommentsMock,
setPRCommentReaction: setPRCommentReactionMock,
resolveReviewThread: resolveReviewThreadMock,
setPRFileViewed: setPRFileViewedMock,
addPRReviewComment: addPRReviewCommentMock,
@@ -212,6 +215,7 @@ describe('registerGitHubHandlers', () => {
getPRChecksMock.mockReset()
getPRCheckDetailsMock.mockReset()
getPRCommentsMock.mockReset()
setPRCommentReactionMock.mockReset()
resolveReviewThreadMock.mockReset()
setPRFileViewedMock.mockReset()
addPRReviewCommentMock.mockReset()
@@ -907,6 +911,7 @@ describe('registerGitHubHandlers', () => {
getPRChecksMock.mockResolvedValue([])
getPRCheckDetailsMock.mockResolvedValue(null)
getPRCommentsMock.mockResolvedValue([])
setPRCommentReactionMock.mockResolvedValue(true)
resolveReviewThreadMock.mockResolvedValue(true)
setPRFileViewedMock.mockResolvedValue(true)
addPRReviewCommentReplyMock.mockResolvedValue({ ok: true })
@@ -964,6 +969,13 @@ describe('registerGitHubHandlers', () => {
prRepo,
noCache: true
})
await handlers['gh:setPRCommentReaction'](null, {
repoPath: '/workspace/repo',
reactionSubjectId: ' IC_1 ',
content: '+1',
reacted: true,
prRepo
})
await handlers['gh:resolveReviewThread'](null, {
repoPath: '/workspace/repo',
threadId: 'thread-1',
@@ -1124,6 +1136,15 @@ describe('registerGitHubHandlers', () => {
null,
localGitOptions
)
expect(setPRCommentReactionMock).toHaveBeenCalledWith(
'/workspace/repo',
'IC_1',
'+1',
true,
null,
prRepo,
localGitOptions
)
expect(resolveReviewThreadMock).toHaveBeenCalledWith(
'/workspace/repo',
'thread-1',
+32
View File
@@ -13,6 +13,7 @@ import type {
GitHubPRRefreshCandidate,
GitHubPRRefreshEnqueueResult,
GitHubPRRefreshReason,
GitHubReactionContent,
PRRefreshOutcome,
GitHubPRFile
} from '../../shared/types'
@@ -39,6 +40,7 @@ import {
getPRChecks,
getPRCheckDetails,
getPRComments,
setPRCommentReaction,
resolveReviewThread,
setPRFileViewed,
addPRReviewComment,
@@ -667,6 +669,36 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
}
)
ipcMain.handle(
'gh:setPRCommentReaction',
(
_event,
args: {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
reactionSubjectId: string
content: GitHubReactionContent
reacted: boolean
prRepo?: GitHubOwnerRepo | null
}
) => {
const repo = assertRegisteredRepo(args, store)
if (!args.reactionSubjectId?.trim()) {
return false
}
return setPRCommentReaction(
repo.path,
args.reactionSubjectId.trim(),
args.content,
args.reacted,
repoConnectionId(repo),
args.prRepo ?? null,
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gh:resolveReviewThread',
async (
+21
View File
@@ -228,6 +228,7 @@ import type {
GitHubPullRequestStateUpdate,
GitHubPRFile,
GitHubPRReviewCommentInput,
GitHubReactionContent,
GitLabIssueUpdate,
GitLabMRInlineCommentInput,
GitLabProjectRef,
@@ -591,6 +592,7 @@ import {
getPRCheckDetails,
rerunPRChecks,
getPRComments,
setPRCommentReaction,
getIssue,
resolveReviewThread,
setPRFileViewed,
@@ -20147,6 +20149,25 @@ export class OrcaRuntimeService {
)
}
async setRepoPRCommentReaction(
repoSelector: string,
reactionSubjectId: string,
content: GitHubReactionContent,
reacted: boolean,
prRepo?: GitHubOwnerRepo | null
): Promise<Awaited<ReturnType<typeof setPRCommentReaction>>> {
const repo = await this.resolveRepoSelector(repoSelector)
return setPRCommentReaction(
repo.path,
reactionSubjectId,
content,
reacted,
repo.connectionId ?? null,
prRepo ?? null,
...this.getLocalGitExecutionOptionArgs(repo)
)
}
async getRepoPRFileContents(
repoSelector: string,
args: {
@@ -219,6 +219,31 @@ describe('github RPC methods', () => {
expect(response).toMatchObject({ ok: true, result: [] })
})
it('sets a PR comment reaction on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
setRepoPRCommentReaction: vi.fn().mockResolvedValue(true)
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('github.setPRCommentReaction', {
repo: 'repo-1',
reactionSubjectId: 'IC_1',
content: 'heart',
reacted: true,
prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }
})
)
expect(runtime.setRepoPRCommentReaction).toHaveBeenCalledWith('repo-1', 'IC_1', 'heart', true, {
owner: 'acme',
repo: 'widgets',
host: 'github.acme.test'
})
expect(response).toMatchObject({ ok: true, result: true })
})
it('fetches PR file contents on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
+19
View File
@@ -73,6 +73,13 @@ const PullRequest = RepoSelector.extend({
prRepo: SlugRepo.nullable().optional()
})
const PRCommentReaction = RepoSelector.extend({
reactionSubjectId: requiredString('Missing reaction subject ID'),
content: z.enum(['+1', '-1', 'laugh', 'confused', 'heart', 'hooray', 'rocket', 'eyes']),
reacted: z.boolean(),
prRepo: SlugRepo.nullable().optional()
})
const PullRequestChecks = PullRequest.extend({
headSha: OptionalString
})
@@ -459,6 +466,18 @@ export const GITHUB_METHODS: RpcMethod[] = [
noCache: params.noCache
})
}),
defineMethod({
name: 'github.setPRCommentReaction',
params: PRCommentReaction,
handler: async (params, { runtime }) =>
runtime.setRepoPRCommentReaction(
params.repo,
params.reactionSubjectId,
params.content,
params.reacted,
params.prRepo ?? null
)
}),
defineMethod({
name: 'github.prFileContents',
params: PullRequestFileContents,
+10
View File
@@ -226,6 +226,7 @@ import type {
MarkdownDocument,
FloatingTerminalCwdRequest,
GitHubIssueUpdate,
GitHubReactionContent,
GitHubPRRefreshCandidate,
GitHubPRRefreshEnqueueResult,
GitHubPRRefreshEvent,
@@ -1882,6 +1883,15 @@ export type PreloadApi = {
prRepo?: GitHubOwnerRepo | null
noCache?: boolean
}) => Promise<PRComment[]>
setPRCommentReaction: (args: {
repoPath: string
repoId?: string
sourceContext?: TaskSourceContext | null
reactionSubjectId: string
content: GitHubReactionContent
reacted: boolean
prRepo?: GitHubOwnerRepo | null
}) => Promise<boolean>
resolveReviewThread: (args: {
repoPath: string
repoId?: string
+11
View File
@@ -75,6 +75,7 @@ import type {
GitHubCommentResult,
GitHubCreateIssueResult,
GitHubOwnerRepo,
GitHubReactionContent,
GitHubWorkItem,
JiraProjectStatusOrder,
GitPushTarget,
@@ -1470,6 +1471,16 @@ const api = {
noCache?: boolean
}): Promise<unknown[]> => ipcRenderer.invoke('gh:prComments', args),
setPRCommentReaction: (args: {
repoPath: string
repoId?: string
sourceContext?: TaskSourceContext | null
reactionSubjectId: string
content: GitHubReactionContent
reacted: boolean
prRepo?: GitHubOwnerRepo | null
}): Promise<boolean> => ipcRenderer.invoke('gh:setPRCommentReaction', args),
resolveReviewThread: (args: {
repoPath: string
repoId?: string
@@ -1,6 +1,12 @@
import React from 'react'
import { SmilePlus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import type { GitHubReaction } from '../../../../shared/types'
import { cn } from '@/lib/utils'
import { GITHUB_REACTION_ORDER } from '@/lib/pr-comment-reactions'
import type { GitHubReaction, GitHubReactionContent } from '../../../../shared/types'
const REACTION_EMOJI: Record<GitHubReaction['content'], string> = {
'+1': '👍',
@@ -13,36 +19,160 @@ const REACTION_EMOJI: Record<GitHubReaction['content'], string> = {
eyes: '👀'
}
const REACTION_LABEL: Record<GitHubReactionContent, string> = {
'+1': 'thumbs up',
'-1': 'thumbs down',
laugh: 'laugh',
confused: 'confused',
heart: 'heart',
hooray: 'hooray',
rocket: 'rocket',
eyes: 'eyes'
}
export function CommentReactions({
reactions
reactions,
className,
onReactionChange
}: {
reactions?: GitHubReaction[]
className?: string
onReactionChange?: (content: GitHubReactionContent, reacted: boolean) => Promise<void> | void
}): React.JSX.Element | null {
const visibleReactions = (reactions ?? []).filter((reaction) => reaction.count > 0)
if (visibleReactions.length === 0) {
const [open, setOpen] = React.useState(false)
const [pendingContent, setPendingContent] = React.useState<GitHubReactionContent | null>(null)
if (visibleReactions.length === 0 && !onReactionChange) {
return null
}
const changeReaction = async (
content: GitHubReactionContent,
reacted: boolean
): Promise<void> => {
if (!onReactionChange || pendingContent) {
return
}
setPendingContent(content)
setOpen(false)
try {
await onReactionChange(content, reacted)
} finally {
setPendingContent(null)
}
}
const pickerLabel = translate(
'auto.components.github.CommentReactions.addReaction',
'Add reaction'
)
return (
<div className="mt-2 flex flex-wrap gap-1.5">
<div className={cn('mt-2 flex flex-wrap items-center gap-1.5', className)}>
{visibleReactions.map((reaction) => (
<span
key={reaction.content}
className="inline-flex h-6 items-center gap-1 rounded-full border border-border/60 bg-muted/35 px-2 text-[12px] leading-none text-foreground"
aria-label={translate(
'auto.components.GitHubItemDialog.a18f669c7a',
'{{value0}} {{value1}} reaction{{value2}}',
{
value0: reaction.count,
value1: reaction.content,
value2: reaction.count === 1 ? '' : 's'
}
<React.Fragment key={reaction.content}>
{onReactionChange ? (
<Button
type="button"
variant="outline"
size="xs"
disabled={pendingContent !== null}
className={cn(
'h-6 gap-1 rounded-full px-2 text-[12px] font-normal',
reaction.viewerHasReacted && 'border-ring bg-accent text-accent-foreground'
)}
aria-pressed={Boolean(reaction.viewerHasReacted)}
aria-label={translate(
'auto.components.GitHubItemDialog.a18f669c7a',
'{{value0}} {{value1}} reaction{{value2}}',
{
value0: reaction.count,
value1: REACTION_LABEL[reaction.content],
value2: reaction.count === 1 ? '' : 's'
}
)}
onClick={() => void changeReaction(reaction.content, !reaction.viewerHasReacted)}
>
<span aria-hidden="true">{REACTION_EMOJI[reaction.content]}</span>
<span className="tabular-nums">{reaction.count}</span>
</Button>
) : (
<span
className="inline-flex h-6 items-center gap-1 rounded-full border border-border/60 bg-muted/35 px-2 text-[12px] leading-none text-foreground"
aria-label={translate(
'auto.components.GitHubItemDialog.a18f669c7a',
'{{value0}} {{value1}} reaction{{value2}}',
{
value0: reaction.count,
value1: REACTION_LABEL[reaction.content],
value2: reaction.count === 1 ? '' : 's'
}
)}
>
<span aria-hidden="true">{REACTION_EMOJI[reaction.content]}</span>
<span className="tabular-nums">{reaction.count}</span>
</span>
)}
>
<span aria-hidden="true">{REACTION_EMOJI[reaction.content]}</span>
<span className="tabular-nums">{reaction.count}</span>
</span>
</React.Fragment>
))}
{onReactionChange ? (
<Popover open={open} onOpenChange={(nextOpen) => !pendingContent && setOpen(nextOpen)}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
disabled={pendingContent !== null}
className="text-muted-foreground hover:text-foreground"
aria-label={pickerLabel}
>
<SmilePlus className="size-3.5" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{pickerLabel}
</TooltipContent>
</Tooltip>
<PopoverContent align="start" side="top" sideOffset={6} className="w-auto p-1.5">
<div className="grid grid-cols-4 gap-1" aria-label={pickerLabel} role="group">
{GITHUB_REACTION_ORDER.map((content) => {
const reaction = reactions?.find((candidate) => candidate.content === content)
const reacted = Boolean(reaction?.viewerHasReacted)
return (
<Button
key={content}
type="button"
variant="ghost"
size="icon-sm"
disabled={pendingContent !== null}
className={cn('text-lg', reacted && 'bg-accent text-accent-foreground')}
aria-label={
reacted
? translate(
'auto.components.github.CommentReactions.removeNamedReaction',
'Remove {{value0}} reaction',
{ value0: REACTION_LABEL[content] }
)
: translate(
'auto.components.github.CommentReactions.addNamedReaction',
'Add {{value0}} reaction',
{ value0: REACTION_LABEL[content] }
)
}
aria-pressed={reacted}
onClick={() => void changeReaction(content, !reacted)}
>
<span aria-hidden="true">{REACTION_EMOJI[content]}</span>
</Button>
)
})}
</div>
</PopoverContent>
</Popover>
) : null}
</div>
)
}
@@ -25,6 +25,7 @@ import { useActiveWorktree, useRepoById } from '@/store/selectors'
import { useChecksPanelTerminalWorktree } from './use-checks-panel-terminal-worktree'
import { cn } from '@/lib/utils'
import { openHttpLink } from '@/lib/http-link-routing'
import { setReactionOnSubject } from '@/lib/pr-comment-reactions'
import { Button } from '@/components/ui/button'
import { DetachedHeadBadge } from '@/components/DetachedHeadBadge'
import {
@@ -64,6 +65,7 @@ import type {
PRCheckDetail,
PRCheckRunDetails,
PRComment,
GitHubReactionContent,
PRRefreshErrorType
} from '../../../../shared/types'
import { getConnectionId } from '@/lib/connection-context'
@@ -496,6 +498,7 @@ export default function ChecksPanel(): React.JSX.Element {
const fetchPRComments = useAppStore((s) => s.fetchPRComments)
const addPRConversationComment = useAppStore((s) => s.addPRConversationComment)
const addPRReviewCommentReply = useAppStore((s) => s.addPRReviewCommentReply)
const setPRCommentReaction = useAppStore((s) => s.setPRCommentReaction)
const resolveReviewThread = useAppStore((s) => s.resolveReviewThread)
const detectedAgentIds = useAppStore((s) => s.detectedAgentIds)
const remoteDetectedAgentIds = useAppStore((s) => {
@@ -2941,6 +2944,49 @@ export default function ChecksPanel(): React.JSX.Element {
[pr?.prRepo, confirm]
)
const handleSetReaction = useCallback(
async (comment: PRComment, content: GitHubReactionContent, reacted: boolean): Promise<void> => {
const reactionSubjectId = comment.reactionSubjectId
if (!repo || !prNumber || !pr?.prRepo || !reactionSubjectId) {
return
}
const requestKey = checksPanelAsyncResultKey(
prCacheKey,
branch,
prNumber,
pr.prRepo,
pr.headSha
)
const previousReactions = comment.reactions
setComments((current) => setReactionOnSubject(current, reactionSubjectId, content, reacted))
const ok = await setPRCommentReaction(
repo.path,
prNumber,
reactionSubjectId,
content,
reacted,
{ repoId: repo.id, prRepo: pr.prRepo }
)
if (!isCurrentAsyncResult(requestKey) || ok) {
return
}
setComments((current) =>
current.map((entry) =>
entry.reactionSubjectId === reactionSubjectId
? { ...entry, reactions: previousReactions }
: entry
)
)
toast.error(
translate(
'auto.components.right.sidebar.ChecksPanel.updateReactionFailed',
'Failed to update reaction.'
)
)
},
[branch, isCurrentAsyncResult, pr, prCacheKey, prNumber, repo, setPRCommentReaction]
)
const handleReplyToComment = useCallback(
async (comment: PRComment, body: string, options: { notifyOnFailure?: boolean } = {}) => {
const notifyOnFailure = options.notifyOnFailure !== false
@@ -4506,6 +4552,7 @@ export default function ChecksPanel(): React.JSX.Element {
onResolve={pr || activeGitLabReview ? handleResolve : undefined}
onEditComment={pr ? handleEditComment : undefined}
onDeleteComment={pr ? handleDeleteComment : undefined}
onSetReaction={canTargetPRComments ? handleSetReaction : undefined}
/>
<SourceControlAgentActionDialog
open={sourceControlAiActionsVisible && agentComposerState !== null}
@@ -55,6 +55,7 @@ import {
} from '@/components/ui/context-menu'
import { cn } from '@/lib/utils'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { CommentReactions } from '@/components/github/CommentReactions'
import { CheckJobLogTail } from './check-job-log-tail'
import {
filterPRCommentsByAudience,
@@ -86,6 +87,7 @@ import type {
PRCheckDetail,
PRCheckRunDetails,
PRComment,
GitHubReactionContent,
PRConflictSummary,
PRMergeableState
} from '../../../../shared/types'
@@ -1732,6 +1734,7 @@ function CommentRow({
onReply,
onEditComment,
onDeleteComment,
onSetReaction,
onQueueForAgent
}: {
comment: PRComment
@@ -1749,6 +1752,11 @@ function CommentRow({
onReply?: (comment: PRComment) => void
onEditComment?: (comment: PRComment, body: string) => Promise<boolean>
onDeleteComment?: (comment: PRComment) => void | Promise<void>
onSetReaction?: (
comment: PRComment,
content: GitHubReactionContent,
reacted: boolean
) => Promise<void>
onQueueForAgent?: () => void
}): React.JSX.Element {
const automated = isBotPRComment(comment, botAuthorOverrides)
@@ -2007,13 +2015,17 @@ function CommentRow({
</div>
</div>
) : (
<CommentMarkdown
content={comment.body}
className={cn(
isReply ? presentation.commentBodyReply : presentation.commentBody,
presentation.commentBodyMarkdown
)}
/>
<div className={isReply ? presentation.commentBodyReply : presentation.commentBody}>
<CommentMarkdown content={comment.body} className={presentation.commentBodyMarkdown} />
<CommentReactions
reactions={comment.reactions}
onReactionChange={
comment.reactionSubjectId && onSetReaction
? (content, reacted) => onSetReaction(comment, content, reacted)
: undefined
}
/>
</div>
)}
</div>
</div>
@@ -2036,6 +2048,7 @@ function PRCommentGroupView({
onReply,
onEditComment,
onDeleteComment,
onSetReaction,
onQueueForAgent
}: {
group: PRCommentGroup
@@ -2053,6 +2066,11 @@ function PRCommentGroupView({
onReply?: (comment: PRComment, body: string) => Promise<RightPanelCommentSubmitResult>
onEditComment?: (comment: PRComment, body: string) => Promise<boolean>
onDeleteComment?: (comment: PRComment) => void | Promise<void>
onSetReaction?: (
comment: PRComment,
content: GitHubReactionContent,
reacted: boolean
) => Promise<void>
onQueueForAgent?: () => void
}): React.JSX.Element {
// Reply targets a specific comment id so any comment in a thread — root or
@@ -2099,6 +2117,7 @@ function PRCommentGroupView({
onResolve,
onEditComment,
onDeleteComment,
onSetReaction,
onQueueForAgent
}
@@ -2181,7 +2200,8 @@ function ResolvedCommentGroupsSection({
onCancelReply,
onReply,
onEditComment,
onDeleteComment
onDeleteComment,
onSetReaction
}: {
groups: PRCommentGroup[]
botAuthorOverrides: ReadonlySet<string>
@@ -2195,6 +2215,11 @@ function ResolvedCommentGroupsSection({
onReply?: (comment: PRComment, body: string) => Promise<RightPanelCommentSubmitResult>
onEditComment?: (comment: PRComment, body: string) => Promise<boolean>
onDeleteComment?: (comment: PRComment) => void | Promise<void>
onSetReaction?: (
comment: PRComment,
content: GitHubReactionContent,
reacted: boolean
) => Promise<void>
}): React.JSX.Element | null {
if (groups.length === 0) {
return null
@@ -2230,6 +2255,7 @@ function ResolvedCommentGroupsSection({
onReply={onReply}
onEditComment={onEditComment}
onDeleteComment={onDeleteComment}
onSetReaction={onSetReaction}
/>
))}
</AccordionContent>
@@ -2296,7 +2322,8 @@ export function PRCommentsList({
onReply,
onResolve,
onEditComment,
onDeleteComment
onDeleteComment,
onSetReaction
}: {
comments: PRComment[]
commentsLoading: boolean
@@ -2313,6 +2340,11 @@ export function PRCommentsList({
onResolve?: (threadId: string, resolve: boolean) => boolean | Promise<boolean>
onEditComment?: (comment: PRComment, body: string) => Promise<boolean>
onDeleteComment?: (comment: PRComment) => void | Promise<void>
onSetReaction?: (
comment: PRComment,
content: GitHubReactionContent,
reacted: boolean
) => Promise<void>
}): React.JSX.Element {
const presentation = React.useMemo(() => getPRCommentPresentationClasses(), [])
const [commentFilter, setCommentFilter] = useState<PRCommentAudienceFilter>('all')
@@ -2435,6 +2467,7 @@ export function PRCommentsList({
onReply={onReply}
onEditComment={onEditComment}
onDeleteComment={onDeleteComment}
onSetReaction={onSetReaction}
onQueueForAgent={canQueue ? () => addGroupToSelection(groupId) : undefined}
/>
)
@@ -2765,6 +2798,7 @@ export function PRCommentsList({
onReply={onReply}
onEditComment={onEditComment}
onDeleteComment={onDeleteComment}
onSetReaction={onSetReaction}
/>
</>
)}
+7 -1
View File
@@ -2412,6 +2412,11 @@
"label": "Close as duplicate",
"description": "Duplicate of another issue"
}
},
"CommentReactions": {
"addReaction": "Add reaction",
"removeNamedReaction": "Remove {{value0}} reaction",
"addNamedReaction": "Add {{value0}} reaction"
}
},
"linear": {
@@ -10566,7 +10571,8 @@
"pending": "Syncing…",
"branch": "Sync Branch"
},
"7e4b2a19c0": "Could not resolve the GitHub PR to reply on."
"7e4b2a19c0": "Could not resolve the GitHub PR to reply on.",
"updateReactionFailed": "Failed to update reaction."
},
"CreatePullRequestDialog": {
"2bc1b4345e": "Cancel",
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import type { PRComment } from '../../../shared/types'
import { setCommentReaction, setReactionOnSubject } from './pr-comment-reactions'
function comment(overrides: Partial<PRComment> = {}): PRComment {
return {
id: 1,
author: 'octocat',
authorAvatarUrl: '',
body: 'Looks good',
createdAt: '2026-08-09T12:00:00Z',
url: 'https://github.com/acme/orca/pull/1#issuecomment-1',
...overrides
}
}
describe('setCommentReaction', () => {
it('adds a viewer reaction in GitHub order', () => {
expect(
setCommentReaction(
comment({ reactions: [{ content: 'heart', count: 2, viewerHasReacted: false }] }),
'+1',
true
).reactions
).toEqual([
{ content: '+1', count: 1, viewerHasReacted: true },
{ content: 'heart', count: 2, viewerHasReacted: false }
])
})
it('removes the final viewer reaction', () => {
expect(
setCommentReaction(
comment({ reactions: [{ content: 'rocket', count: 1, viewerHasReacted: true }] }),
'rocket',
false
).reactions
).toBeUndefined()
})
it('updates only the targeted comment', () => {
const target = comment({ reactionSubjectId: 'IC_1' })
const other = comment({ id: 2, reactionSubjectId: 'IC_2' })
const result = setReactionOnSubject([target, other], 'IC_1', 'eyes', true)
expect(result[0].reactions).toEqual([{ content: 'eyes', count: 1, viewerHasReacted: true }])
expect(result[1]).toBe(other)
})
})
@@ -0,0 +1,53 @@
import type { GitHubReaction, GitHubReactionContent, PRComment } from '../../../shared/types'
export const GITHUB_REACTION_ORDER: readonly GitHubReactionContent[] = [
'+1',
'-1',
'laugh',
'confused',
'heart',
'hooray',
'rocket',
'eyes'
]
export function setCommentReaction(
comment: PRComment,
content: GitHubReactionContent,
reacted: boolean
): PRComment {
const reactions = comment.reactions ?? []
const current = reactions.find((reaction) => reaction.content === content)
if (Boolean(current?.viewerHasReacted) === reacted) {
return comment
}
const nextCount = Math.max(0, (current?.count ?? 0) + (reacted ? 1 : -1))
const nextReaction: GitHubReaction = {
content,
count: nextCount,
viewerHasReacted: reacted
}
const nextReactions = reactions
.filter((reaction) => reaction.content !== content)
.concat(nextCount > 0 ? nextReaction : [])
.sort(
(left, right) =>
GITHUB_REACTION_ORDER.indexOf(left.content) - GITHUB_REACTION_ORDER.indexOf(right.content)
)
return { ...comment, reactions: nextReactions.length > 0 ? nextReactions : undefined }
}
export function setReactionOnSubject(
comments: readonly PRComment[],
reactionSubjectId: string,
content: GitHubReactionContent,
reacted: boolean
): PRComment[] {
return comments.map((comment) =>
comment.reactionSubjectId === reactionSubjectId
? setCommentReaction(comment, content, reacted)
: comment
)
}
@@ -45,6 +45,7 @@ const mockApi = {
prComments: vi.fn().mockResolvedValue([]),
addIssueComment: vi.fn(),
addPRReviewCommentReply: vi.fn(),
setPRCommentReaction: vi.fn(),
resolveReviewThread: vi.fn(),
listWorkItems: vi.fn(),
countWorkItems: vi.fn().mockResolvedValue(0),
@@ -1503,6 +1504,7 @@ describe('createGitHubSlice PR comment mutations', () => {
url: ''
}
})
mockApi.gh.setPRCommentReaction.mockResolvedValue(true)
})
it('deduplicates merged PR comments and preserves existing thread metadata', () => {
@@ -1666,6 +1668,131 @@ describe('createGitHubSlice PR comment mutations', () => {
).toMatchObject({ body: 'reply', threadId: 'PRRT_1', path: 'src/a.ts', line: 8 })
})
it('updates cached reactions through local GitHub IPC', async () => {
const store = createTestStore()
const repoPath = '/repo'
const repoId = 'repo-id'
const cacheKey = `${repoId}::pr-comments::acme/widgets::12`
store.setState({
repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }],
commentsCache: {
[cacheKey]: {
data: [
{
id: 10,
reactionSubjectId: 'IC_10',
author: 'reviewer',
authorAvatarUrl: '',
body: 'Nice',
createdAt: '2026-03-28T00:00:00Z',
url: ''
}
],
fetchedAt: 1
}
}
} as unknown as Partial<AppState>)
await store.getState().setPRCommentReaction(repoPath, 12, 'IC_10', 'heart', true, {
repoId,
prRepo: { owner: 'Acme', repo: 'Widgets' }
})
expect(mockApi.gh.setPRCommentReaction).toHaveBeenCalledWith({
repoPath,
repoId,
reactionSubjectId: 'IC_10',
content: 'heart',
reacted: true,
prRepo: { owner: 'Acme', repo: 'Widgets' },
sourceContext: undefined
})
expect(store.getState().commentsCache[cacheKey]?.data?.[0].reactions).toEqual([
{ content: 'heart', count: 1, viewerHasReacted: true }
])
})
it('rolls back an optimistic reaction when GitHub rejects it', async () => {
mockApi.gh.setPRCommentReaction.mockResolvedValueOnce(false)
const store = createTestStore()
const repoPath = '/repo'
const repoId = 'repo-id'
const cacheKey = `${repoId}::pr-comments::acme/widgets::12`
store.setState({
repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }],
commentsCache: {
[cacheKey]: {
data: [
{
id: 10,
reactionSubjectId: 'IC_10',
author: 'reviewer',
authorAvatarUrl: '',
body: 'Nice',
createdAt: '2026-03-28T00:00:00Z',
url: '',
reactions: [{ content: 'heart', count: 2, viewerHasReacted: false }]
}
],
fetchedAt: 1
}
}
} as unknown as Partial<AppState>)
const ok = await store.getState().setPRCommentReaction(repoPath, 12, 'IC_10', 'heart', true, {
repoId,
prRepo: { owner: 'Acme', repo: 'Widgets' }
})
expect(ok).toBe(false)
expect(store.getState().commentsCache[cacheKey]?.data?.[0].reactions).toEqual([
{ content: 'heart', count: 2, viewerHasReacted: false }
])
})
it('routes reaction mutations through the workspace runtime', async () => {
runtimeEnvironmentCall.mockResolvedValueOnce({
id: 'rpc-pr-reaction',
ok: true,
result: true,
_meta: { runtimeId: 'remote-runtime' }
})
const store = createTestStore()
const repoPath = '/repo'
const repoId = 'repo-id'
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'],
repos: [
{
id: repoId,
path: repoPath,
name: 'repo',
kind: 'git',
executionHostId: 'runtime:env-1'
}
]
} as unknown as Partial<AppState>)
await store.getState().setPRCommentReaction(repoPath, 12, 'PRRC_10', 'rocket', true, {
repoId,
prRepo: { owner: 'Acme', repo: 'Widgets' }
})
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'github.setPRCommentReaction',
params: {
repo: repoId,
reactionSubjectId: 'PRRC_10',
content: 'rocket',
reacted: true,
prRepo: { owner: 'Acme', repo: 'Widgets' }
},
timeoutMs: 30_000
})
expect(mockApi.gh.setPRCommentReaction).not.toHaveBeenCalled()
})
it('does not mutate the PR comments cache when GitHub omits the comment payload', async () => {
mockApi.gh.addIssueComment.mockResolvedValueOnce({ ok: true })
const store = createTestStore()
+115
View File
@@ -17,6 +17,7 @@ import type {
PRRefreshErrorType,
PRRefreshOutcome,
GitHubCommentResult,
GitHubReactionContent,
IssueInfo,
PRCheckDetail,
PRCheckRunDetails,
@@ -74,6 +75,7 @@ import {
type TaskSourceContext
} from '../../../../shared/task-source-context'
import { normalizeGitHubPRForBranchOutcome } from '../../../../shared/github-pr-for-branch-outcome'
import { setReactionOnSubject } from '@/lib/pr-comment-reactions'
// ─── ProjectV2 cache types ────────────────────────────────────────────
// Why: separate from CacheEntry<T> — project-view has a single GraphQL source (no issue/PR fallback) and a distinct error union.
@@ -1890,6 +1892,14 @@ export type GitHubSlice = {
line?: number
}
) => Promise<GitHubCommentResult>
setPRCommentReaction: (
repoPath: string,
prNumber: number,
reactionSubjectId: string,
content: GitHubReactionContent,
reacted: boolean,
options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null }
) => Promise<boolean>
resolveReviewThread: (
repoPath: string,
prNumber: number,
@@ -3839,6 +3849,111 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
return { ok: true, comment }
},
setPRCommentReaction: async (
repoPath,
prNumber,
reactionSubjectId,
content,
reacted,
options
) => {
const repo = get().repos?.find((candidate) =>
options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath
)
const repoId = options?.repoId ?? repo?.id
const requestSettings = getGitHubRepoSourceSettings(
get().settings,
repo,
options?.sourceContext
)
const cacheKey = sourceScopedRepoCacheKey(
repoPath,
repoId,
prCommentsCacheSuffix(prNumber, options?.prRepo),
requestSettings,
repo?.connectionId,
repo?.executionHostId,
options?.sourceContext,
repo !== undefined
)
const previousComment = get().commentsCache[cacheKey]?.data?.find(
(comment) => comment.reactionSubjectId === reactionSubjectId
)
set((state) => {
const entry = state.commentsCache[cacheKey]
if (!entry?.data) {
return state
}
return {
commentsCache: {
...state.commentsCache,
[cacheKey]: {
...entry,
data: setReactionOnSubject(entry.data, reactionSubjectId, content, reacted)
}
}
}
})
const requestContext = getGitHubWorkItemRequestContext(
get(),
requestSettings,
repoId ?? repoPath,
repoPath,
options?.sourceContext
)
let ok = false
try {
ok =
requestContext.target.kind === 'environment'
? await callRuntimeRpc<boolean>(
{ kind: 'environment', environmentId: requestContext.target.environmentId },
'github.setPRCommentReaction',
{
repo: requestContext.target.runtimeRepoId,
reactionSubjectId,
content,
reacted,
prRepo: options?.prRepo ?? null
},
{ timeoutMs: 30_000 }
)
: await window.api.gh.setPRCommentReaction({
repoPath,
repoId,
reactionSubjectId,
content,
reacted,
prRepo: options?.prRepo ?? null,
sourceContext: options?.sourceContext
})
} catch (err) {
console.error('Failed to update PR comment reaction:', err)
}
if (!ok && previousComment) {
set((state) => {
const entry = state.commentsCache[cacheKey]
if (!entry?.data) {
return state
}
return {
commentsCache: {
...state.commentsCache,
[cacheKey]: {
...entry,
data: entry.data.map((comment) =>
comment.reactionSubjectId === reactionSubjectId
? { ...comment, reactions: previousComment.reactions }
: comment
)
}
}
}
})
}
return ok
},
resolveReviewThread: async (repoPath, prNumber, threadId, resolve, options) => {
const repo = get().repos?.find((candidate) =>
options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath
@@ -3769,6 +3769,7 @@ describe('web GitHub preload API', () => {
'resolveProjectRef',
'resolveReviewThread',
'setPRAutoMerge',
'setPRCommentReaction',
'setPRFileViewed',
'starOrca',
'updateIssue',
@@ -3950,6 +3951,17 @@ describe('web GitHub preload API', () => {
expectedMethod: 'github.prComments',
expectedParams: withRepo({ repoPath, prNumber: 7, noCache: true })
},
{
key: 'setPRCommentReaction',
args: { repoPath, reactionSubjectId: 'IC_1', content: 'heart', reacted: true },
expectedMethod: 'github.setPRCommentReaction',
expectedParams: withRepo({
repoPath,
reactionSubjectId: 'IC_1',
content: 'heart',
reacted: true
})
},
{
key: 'resolveReviewThread',
args: { repoPath, threadId: 'thread-1', resolve: true },
+8
View File
@@ -289,6 +289,7 @@ type WebGitHubRouteKey =
| 'prCheckDetails'
| 'rerunPRChecks'
| 'prComments'
| 'setPRCommentReaction'
| 'resolveReviewThread'
| 'setPRFileViewed'
| 'updatePRTitle'
@@ -337,6 +338,7 @@ type WebGitHubRuntimeMethod =
| 'github.prCheckDetails'
| 'github.rerunPRChecks'
| 'github.prComments'
| 'github.setPRCommentReaction'
| 'github.resolveReviewThread'
| 'github.setPRFileViewed'
| 'github.updatePRTitle'
@@ -438,6 +440,7 @@ export const GITHUB_WEB_RPC_METHODS = {
prCheckDetails: 'github.prCheckDetails',
rerunPRChecks: 'github.rerunPRChecks',
prComments: 'github.prComments',
setPRCommentReaction: 'github.setPRCommentReaction',
resolveReviewThread: 'github.resolveReviewThread',
setPRFileViewed: 'github.setPRFileViewed',
updatePRTitle: 'github.updatePRTitle',
@@ -2385,6 +2388,11 @@ function createGitHubApi(): WebGitHubApi {
route<WebGitHubResult<'rerunPRChecks'>>(GITHUB_WEB_RPC_METHODS.rerunPRChecks, args),
prComments: (args) =>
route<WebGitHubResult<'prComments'>>(GITHUB_WEB_RPC_METHODS.prComments, args),
setPRCommentReaction: (args) =>
route<WebGitHubResult<'setPRCommentReaction'>>(
GITHUB_WEB_RPC_METHODS.setPRCommentReaction,
args
),
resolveReviewThread: (args) =>
route<WebGitHubResult<'resolveReviewThread'>>(
GITHUB_WEB_RPC_METHODS.resolveReviewThread,
+3
View File
@@ -1485,6 +1485,7 @@ export type GitHubReactionContent =
export type GitHubReaction = {
content: GitHubReactionContent
count: number
viewerHasReacted?: boolean
}
export type PRComment = {
@@ -1495,6 +1496,8 @@ export type PRComment = {
createdAt: string
url: string
reactions?: GitHubReaction[]
/** GraphQL node ID for GitHub comments that support reaction mutations. */
reactionSubjectId?: string
/** File path for inline review comments (absent for top-level conversation comments). */
path?: string
/** GraphQL node ID of the review thread — present only for inline review comments.
@@ -15,6 +15,7 @@ export const FIXTURE_COMMENTS: PRComment[] = [
body: 'Please update this handler before merge.',
createdAt: '2026-05-14T10:00:00.000Z',
url: 'https://github.com/acme/orca/pull/73#discussion_r101',
reactionSubjectId: 'PRRC_101',
threadId: 'thread-open',
path: 'src/handler.ts',
isResolved: false
@@ -25,7 +26,8 @@ export const FIXTURE_COMMENTS: PRComment[] = [
authorAvatarUrl: '',
body: 'LGTM on the overall approach.',
createdAt: '2026-05-14T11:00:00.000Z',
url: 'https://github.com/acme/orca/pull/73#issuecomment-102'
url: 'https://github.com/acme/orca/pull/73#issuecomment-102',
reactionSubjectId: 'IC_102'
},
{
id: 103,
@@ -72,7 +74,8 @@ export async function seedPRCommentsSidebarFixture(page: Page): Promise<PRCommen
url: `https://github.com/acme/orca/pull/${prNumber}`,
checksStatus: 'pending',
updatedAt: '2026-05-15T00:00:00.000Z',
mergeable: 'MERGEABLE'
mergeable: 'MERGEABLE',
prRepo: { owner: 'acme', repo: 'orca' }
}
const prCacheEntries = {
[`${repo.id}::${branch}`]: {
@@ -131,6 +134,7 @@ export async function seedPRCommentsSidebarFixture(page: Page): Promise<PRCommen
},
fetchPRChecks: async () => [],
fetchPRComments: async () => comments,
setPRCommentReaction: async () => true,
fetchUpstreamStatus: async () => undefined,
setUpstreamStatus: () => undefined
}))
@@ -124,6 +124,39 @@ test.describe('PR comments sidebar cards view', () => {
expect(positions[1]).toBeLessThan(positions[2])
})
test('adds reactions to conversation and review-thread comments', async ({ orcaPage }) => {
const { worktreeId } = await seedPRCommentsSidebarFixture(orcaPage)
await openChecks(orcaPage, worktreeId)
await expect(orcaPage.getByText('Needs review · 1')).toBeVisible({ timeout: 10_000 })
const conversationCard = orcaPage.getByTestId('pr-comment-group').filter({
hasText: 'LGTM on the overall approach.'
})
const conversationReactionButton = conversationCard.getByRole('button', {
name: 'Add reaction'
})
await conversationReactionButton.evaluate((element) => (element as HTMLElement).click())
const heartReactionButton = orcaPage.getByRole('button', { name: 'Add heart reaction' })
await expect(heartReactionButton).toBeVisible()
await heartReactionButton.evaluate((element) => (element as HTMLElement).click())
await expect(
conversationCard.getByRole('button', { name: '1 heart reaction' })
).toHaveAttribute('aria-pressed', 'true')
await expect(orcaPage.getByRole('button', { name: 'Add rocket reaction' })).toHaveCount(0)
const reviewThreadCard = orcaPage.getByTestId('pr-comment-group').filter({
hasText: 'Please update this handler before merge.'
})
const threadReactionButton = reviewThreadCard.getByRole('button', { name: 'Add reaction' })
await threadReactionButton.evaluate((element) => (element as HTMLElement).click())
await orcaPage
.getByRole('button', { name: 'Add rocket reaction' })
.evaluate((element) => (element as HTMLElement).click())
await expect(
reviewThreadCard.getByRole('button', { name: '1 rocket reaction' })
).toHaveAttribute('aria-pressed', 'true')
})
test('queues an open thread for the agent from the visible row action and menu fallback', async ({
orcaPage
}) => {