diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 4b5f1375aa8..eef3aace0e7 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -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({ diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 07472e5c096..c1a9d0e3891 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -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 { + 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. */ diff --git a/src/main/github/comment-reactions.test.ts b/src/main/github/comment-reactions.test.ts index 4478214076f..4c0366fda3f 100644 --- a/src/main/github/comment-reactions.test.ts +++ b/src/main/github/comment-reactions.test.ts @@ -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') + }) }) diff --git a/src/main/github/comment-reactions.ts b/src/main/github/comment-reactions.ts index d93ae551c26..1bb831f30ca 100644 --- a/src/main/github/comment-reactions.ts +++ b/src/main/github/comment-reactions.ts @@ -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 = { @@ -37,10 +38,27 @@ const REACTION_ORDER: GitHubReactionContent[] = [ 'eyes' ] +const REACTION_CONTENT_TO_GRAPHQL: Record = { + '+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() + const reactionsByContent = new Map() 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 } diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index bbd26f84d6a..c137c399480 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -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', diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 6fcc53bab71..91cfc94f061 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -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 ( diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 526acea823b..33de03a4a09 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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>> { + 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: { diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts index 06cc04d917e..0a283b52072 100644 --- a/src/main/runtime/rpc/methods/github.test.ts +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index 858d22b72bb..ed715e4e0e8 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -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, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index bc6abd8cc03..87c8fce5901 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 + setPRCommentReaction: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + reactionSubjectId: string + content: GitHubReactionContent + reacted: boolean + prRepo?: GitHubOwnerRepo | null + }) => Promise resolveReviewThread: (args: { repoPath: string repoId?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 8b192819496..2cf88323b23 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -75,6 +75,7 @@ import type { GitHubCommentResult, GitHubCreateIssueResult, GitHubOwnerRepo, + GitHubReactionContent, GitHubWorkItem, JiraProjectStatusOrder, GitPushTarget, @@ -1470,6 +1471,16 @@ const api = { noCache?: boolean }): Promise => ipcRenderer.invoke('gh:prComments', args), + setPRCommentReaction: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + reactionSubjectId: string + content: GitHubReactionContent + reacted: boolean + prRepo?: GitHubOwnerRepo | null + }): Promise => ipcRenderer.invoke('gh:setPRCommentReaction', args), + resolveReviewThread: (args: { repoPath: string repoId?: string diff --git a/src/renderer/src/components/github/CommentReactions.tsx b/src/renderer/src/components/github/CommentReactions.tsx index 9c362b6520d..bbdca0fbe3d 100644 --- a/src/renderer/src/components/github/CommentReactions.tsx +++ b/src/renderer/src/components/github/CommentReactions.tsx @@ -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 = { '+1': 'πŸ‘', @@ -13,36 +19,160 @@ const REACTION_EMOJI: Record = { eyes: 'πŸ‘€' } +const REACTION_LABEL: Record = { + '+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 }): 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(null) + if (visibleReactions.length === 0 && !onReactionChange) { return null } + const changeReaction = async ( + content: GitHubReactionContent, + reacted: boolean + ): Promise => { + 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 ( -
+
{visibleReactions.map((reaction) => ( - + {onReactionChange ? ( + + ) : ( + + + {reaction.count} + )} - > - - {reaction.count} - + ))} + {onReactionChange ? ( + !pendingContent && setOpen(nextOpen)}> + + + + + + + + {pickerLabel} + + + +
+ {GITHUB_REACTION_ORDER.map((content) => { + const reaction = reactions?.find((candidate) => candidate.content === content) + const reacted = Boolean(reaction?.viewerHasReacted) + return ( + + ) + })} +
+
+
+ ) : null}
) } diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 028abac821c..44abe773e62 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -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 => { + 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} /> void onEditComment?: (comment: PRComment, body: string) => Promise onDeleteComment?: (comment: PRComment) => void | Promise + onSetReaction?: ( + comment: PRComment, + content: GitHubReactionContent, + reacted: boolean + ) => Promise onQueueForAgent?: () => void }): React.JSX.Element { const automated = isBotPRComment(comment, botAuthorOverrides) @@ -2007,13 +2015,17 @@ function CommentRow({
) : ( - +
+ + onSetReaction(comment, content, reacted) + : undefined + } + /> +
)} @@ -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 onEditComment?: (comment: PRComment, body: string) => Promise onDeleteComment?: (comment: PRComment) => void | Promise + onSetReaction?: ( + comment: PRComment, + content: GitHubReactionContent, + reacted: boolean + ) => Promise 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 @@ -2195,6 +2215,11 @@ function ResolvedCommentGroupsSection({ onReply?: (comment: PRComment, body: string) => Promise onEditComment?: (comment: PRComment, body: string) => Promise onDeleteComment?: (comment: PRComment) => void | Promise + onSetReaction?: ( + comment: PRComment, + content: GitHubReactionContent, + reacted: boolean + ) => Promise }): React.JSX.Element | null { if (groups.length === 0) { return null @@ -2230,6 +2255,7 @@ function ResolvedCommentGroupsSection({ onReply={onReply} onEditComment={onEditComment} onDeleteComment={onDeleteComment} + onSetReaction={onSetReaction} /> ))} @@ -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 onEditComment?: (comment: PRComment, body: string) => Promise onDeleteComment?: (comment: PRComment) => void | Promise + onSetReaction?: ( + comment: PRComment, + content: GitHubReactionContent, + reacted: boolean + ) => Promise }): React.JSX.Element { const presentation = React.useMemo(() => getPRCommentPresentationClasses(), []) const [commentFilter, setCommentFilter] = useState('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} /> )} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3184344d835..baadf663b56 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/lib/pr-comment-reactions.test.ts b/src/renderer/src/lib/pr-comment-reactions.test.ts new file mode 100644 index 00000000000..5e5b69fab20 --- /dev/null +++ b/src/renderer/src/lib/pr-comment-reactions.test.ts @@ -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 { + 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) + }) +}) diff --git a/src/renderer/src/lib/pr-comment-reactions.ts b/src/renderer/src/lib/pr-comment-reactions.ts new file mode 100644 index 00000000000..80f67303456 --- /dev/null +++ b/src/renderer/src/lib/pr-comment-reactions.ts @@ -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 + ) +} diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index c3947d58c28..05866e6ec71 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -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) + + 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) + + 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) + + 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() diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 599ef6cd960..d1d0ebe886c 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -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 β€” 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 + setPRCommentReaction: ( + repoPath: string, + prNumber: number, + reactionSubjectId: string, + content: GitHubReactionContent, + reacted: boolean, + options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null } + ) => Promise resolveReviewThread: ( repoPath: string, prNumber: number, @@ -3839,6 +3849,111 @@ export const createGitHubSlice: StateCreator = (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( + { 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 diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 92dd209d047..cc508040e49 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -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 }, diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 6f049d237c8..038e05e1373 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -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>(GITHUB_WEB_RPC_METHODS.rerunPRChecks, args), prComments: (args) => route>(GITHUB_WEB_RPC_METHODS.prComments, args), + setPRCommentReaction: (args) => + route>( + GITHUB_WEB_RPC_METHODS.setPRCommentReaction, + args + ), resolveReviewThread: (args) => route>( GITHUB_WEB_RPC_METHODS.resolveReviewThread, diff --git a/src/shared/types.ts b/src/shared/types.ts index c6dfff01d3c..9eb3de46c3b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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. diff --git a/tests/e2e/helpers/pr-comments-sidebar-fixture.ts b/tests/e2e/helpers/pr-comments-sidebar-fixture.ts index c6784a2f282..b44aaa84c3c 100644 --- a/tests/e2e/helpers/pr-comments-sidebar-fixture.ts +++ b/tests/e2e/helpers/pr-comments-sidebar-fixture.ts @@ -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 [], fetchPRComments: async () => comments, + setPRCommentReaction: async () => true, fetchUpstreamStatus: async () => undefined, setUpstreamStatus: () => undefined })) diff --git a/tests/e2e/pr-comments-sidebar-cards.spec.ts b/tests/e2e/pr-comments-sidebar-cards.spec.ts index 9cccce81bfb..ee95b3f74cb 100644 --- a/tests/e2e/pr-comments-sidebar-cards.spec.ts +++ b/tests/e2e/pr-comments-sidebar-cards.spec.ts @@ -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 }) => {