Add visible PR comment queue action (#6057)

This commit is contained in:
Jinjing
2026-06-21 23:59:29 -07:00
committed by GitHub
parent bbfaee424b
commit c139c704af
9 changed files with 141 additions and 45 deletions
@@ -1497,12 +1497,7 @@ function CommentMoreMenu({
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={4}>
{hasQueue ? (
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
onQueueForAgent?.()
}}
>
<DropdownMenuItem onSelect={() => onQueueForAgent?.()}>
<Sparkles />
{translate(
'auto.components.right.sidebar.checks.panel.content.f8a2c91d04',
@@ -1553,6 +1548,38 @@ function buildCopyText(comment: PRComment): string {
return `File: ${location}\n\n${comment.body}`
}
function QueueForAgentButton({
className,
onQueueForAgent
}: {
className?: string
onQueueForAgent: () => void
}): React.JSX.Element {
const label = translate(
'auto.components.right.sidebar.checks.panel.content.f8a2c91d04',
'Queue for agent'
)
// Why: always-visible row action, but ghost styling keeps it from reading as a card-level CTA.
return (
<button
type="button"
className={cn(
'inline-flex shrink-0 items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] text-muted-foreground transition-[background-color,color,opacity] hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50',
className
)}
aria-label={label}
title={label}
onClick={(event) => {
event.stopPropagation()
onQueueForAgent()
}}
>
<Sparkles className="size-3 shrink-0" />
{translate('auto.components.right.sidebar.checks.panel.content.a7f0c7e8d1', 'Queue')}
</button>
)
}
function PRCommentActionBadge({
actionState,
isQueued,
@@ -1690,14 +1717,11 @@ function CommentRow({
{comment.author}
</span>
)
const authorLead = selectionControl ? (
<span className="flex shrink-0 items-center">{selectionControl}</span>
) : (
authorAvatar
)
const queueButton =
!isReply && onQueueForAgent ? <QueueForAgentButton onQueueForAgent={onQueueForAgent} /> : null
const commentActions = !editing ? (
<div className="flex shrink-0 items-center gap-0.5 can-hover:opacity-0 group-hover/comment:opacity-100 transition-opacity">
const hoverActions = !editing ? (
<div className="flex items-center gap-0.5 can-hover:opacity-0 group-hover/comment:opacity-100 transition-opacity">
{showResolve &&
comment.threadId != null &&
onResolve &&
@@ -1735,9 +1759,22 @@ function CommentRow({
</div>
) : null
const commentActions = !editing ? (
<div className="flex shrink-0 items-center gap-0.5">
{presentation.useCardLayout ? null : queueButton}
{hoverActions}
</div>
) : null
const cardMetaRow =
presentation.useCardLayout && !isReply ? (
<div className={presentation.commentHeaderMeta}>
<div
className={
selectionControl
? presentation.commentHeaderMetaWithSelection
: presentation.commentHeaderMeta
}
>
{relativeTime ? <span>{relativeTime}</span> : null}
{automated ? (
<span className={presentation.botBadge}>
@@ -1755,6 +1792,12 @@ function CommentRow({
isQueued={isQueued}
presentation={presentation}
/>
{onQueueForAgent ? (
<QueueForAgentButton
className="ml-auto can-hover:opacity-0 group-hover/comment:opacity-100 group-focus-within/comment:opacity-100"
onQueueForAgent={onQueueForAgent}
/>
) : null}
</div>
) : null
@@ -1762,7 +1805,8 @@ function CommentRow({
presentation.useCardLayout && !isReply ? (
<>
<div className={presentation.commentHeaderPrimary}>
{authorLead}
{selectionControl}
{authorAvatar}
{authorName}
{commentActions}
</div>
@@ -1770,7 +1814,8 @@ function CommentRow({
</>
) : (
<>
{authorLead}
{selectionControl}
{authorAvatar}
{authorName}
{relativeTime ? (
<span className={presentation.time} aria-hidden={presentation.time === 'hidden'}>
@@ -1803,13 +1848,13 @@ function CommentRow({
return (
<div
className={cn(
'group/comment flex items-start gap-2',
'group/comment min-w-0',
presentation.commentRow,
isReply && presentation.commentRowReply,
comment.isResolved && presentation.resolvedContainer
)}
>
<div className="flex-1 min-w-0">
<div className="min-w-0">
<div
className={cn(
isReply && presentation.useCardLayout
@@ -2237,6 +2282,7 @@ export function PRCommentsList({
const isQueued = selectedGroupIds.has(groupId)
const canQueue =
canShowResolveWithAI &&
!isQueued &&
isPRCommentGroupQueueableForAI(group) &&
selectableGroupsById.has(groupId) &&
!isSelectingForAI
@@ -2357,7 +2403,7 @@ export function PRCommentsList({
className="relative"
aria-label={translate(
'auto.components.right.sidebar.checks.panel.content.d91f2a6c39',
'Send {{value0}} queued comments',
'Send {{value0}} queued comments to AI',
{ value0: selectedCommentQueueCount }
)}
disabled={
@@ -2383,7 +2429,7 @@ export function PRCommentsList({
? resolveCommentsWithAIDisabledReason
: translate(
'auto.components.right.sidebar.checks.panel.content.d91f2a6c39',
'Send {{value0}} queued comments',
'Send {{value0}} queued comments to AI',
{ value0: selectedCommentQueueCount }
)}
</TooltipContent>
@@ -1,8 +1,7 @@
import type { PRCommentGroupActionState } from '@/lib/pr-comment-action-state'
import { cn } from '@/lib/utils'
/** PR comment sidebar typography and layout variants. Tweak here or compare in
* docs/design/pr-comments-sidebar.html, then preview in-app via localStorage. */
/** PR comment sidebar typography and layout variants. */
export type PRCommentPresentationVariant = 'flat' | 'cards' | 'focus'
export const DEFAULT_PR_COMMENT_PRESENTATION_VARIANT: PRCommentPresentationVariant = 'cards'
@@ -53,6 +52,8 @@ export type PRCommentPresentationClasses = {
statusBadgeQueued: string
commentHeaderPrimary: string
commentHeaderMeta: string
/** Indents the card meta row when a selection checkbox precedes the avatar. */
commentHeaderMetaWithSelection: string
groupOpen: string
groupQueued: string
groupResolved: string
@@ -164,6 +165,7 @@ export function getPRCommentPresentationClasses(
'shrink-0 rounded border border-ring/40 bg-accent px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-foreground',
commentHeaderPrimary: 'flex min-w-0 items-center gap-1.5',
commentHeaderMeta: '',
commentHeaderMetaWithSelection: '',
groupOpen: 'border-l-2 border-l-status-success',
groupQueued: 'ring-1 ring-ring/50',
groupResolved: ''
@@ -211,7 +213,7 @@ export function getPRCommentPresentationClasses(
'rounded-full border border-border bg-muted px-1.5 py-px text-[10px] font-semibold tabular-nums text-muted-foreground',
audienceTabs: 'grid grid-cols-3 rounded-md border border-border bg-background p-0.5',
audienceTab:
'flex h-8 items-center justify-center gap-1 rounded-md px-1.5 text-[12px] font-medium text-muted-foreground transition-colors',
'flex h-7 items-center justify-center gap-1 rounded-md px-1.5 text-[11px] font-medium text-muted-foreground transition-colors',
audienceTabActive: 'bg-muted text-foreground shadow-xs',
sectionTriageLabel: cn('px-3 pt-1', RESOLVED_SECTION_LABEL),
statusBadgeOpen:
@@ -225,7 +227,13 @@ export function getPRCommentPresentationClasses(
metaIndent,
'flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground'
),
groupOpen: 'border-l-2 border-l-status-success',
// Why: checkbox (16px) + gap-2 sits before the avatar row the meta row already indents past.
commentHeaderMetaWithSelection: cn(
isFocus ? 'pl-[3.5rem]' : 'pl-[3.25rem]',
'flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground'
),
// Why: open state is conveyed by the status badge; a green card rail reads noisy in the sidebar.
groupOpen: '',
groupQueued: 'ring-1 ring-ring/50',
groupResolved: ''
}
@@ -214,7 +214,7 @@ describe('PRCommentsList comment resolution selection', () => {
)
})
it('lets a user queue one eligible comment thread for the agent from the row menu', () => {
it('lets a user queue one eligible comment thread for the agent from the visible row action', () => {
const onResolveSelectedCommentsWithAI = vi.fn()
renderList({
comments: [
@@ -231,10 +231,10 @@ describe('PRCommentsList comment resolution selection', () => {
onResolveSelectedCommentsWithAI
})
clickMenuItem('Queue for agent')
clickButton('Queue for agent')
expect(hasButton('Send 1 queued comments')).toBe(true)
clickButton('Send 1 queued comments')
expect(hasButton('Send 1 queued comments to AI')).toBe(true)
clickButton('Send 1 queued comments to AI')
expect(onResolveSelectedCommentsWithAI).toHaveBeenCalledTimes(1)
const selectedGroups = onResolveSelectedCommentsWithAI.mock.calls[0]?.[0] as PRCommentGroup[]
@@ -242,7 +242,7 @@ describe('PRCommentsList comment resolution selection', () => {
expect(selectedGroups[0]?.kind === 'thread' ? selectedGroups[0].threadId : '').toBe('thread-1')
})
it('lets a user queue one standalone comment for the agent from the row menu', () => {
it('lets a user queue one standalone comment for the agent from the visible row action', () => {
const onResolveSelectedCommentsWithAI = vi.fn()
renderList({
comments: [
@@ -255,10 +255,10 @@ describe('PRCommentsList comment resolution selection', () => {
onResolveSelectedCommentsWithAI
})
clickMenuItem('Queue for agent')
clickButton('Queue for agent')
expect(hasButton('Send 1 queued comments')).toBe(true)
clickButton('Send 1 queued comments')
expect(hasButton('Send 1 queued comments to AI')).toBe(true)
clickButton('Send 1 queued comments to AI')
expect(onResolveSelectedCommentsWithAI).toHaveBeenCalledTimes(1)
const selectedGroups = onResolveSelectedCommentsWithAI.mock.calls[0]?.[0] as PRCommentGroup[]
@@ -269,16 +269,31 @@ describe('PRCommentsList comment resolution selection', () => {
)
})
it('keeps the overflow menu queue action available as a fallback', () => {
const onResolveSelectedCommentsWithAI = vi.fn()
renderList({
comments: [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })],
onResolveSelectedCommentsWithAI
})
clickMenuItem('Queue for agent')
expect(hasButton('Send 1 queued comments to AI')).toBe(true)
clickButton('Send 1 queued comments to AI')
expect(onResolveSelectedCommentsWithAI).toHaveBeenCalledTimes(1)
})
it('clears the queued comment list from the header action', () => {
renderList({
comments: [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })]
})
clickMenuItem('Queue for agent')
clickButton('Queue for agent')
expect(hasButton('Send 1 queued comments')).toBe(true)
expect(hasButton('Send 1 queued comments to AI')).toBe(true)
clickButton('Clear queued comments')
expect(hasButton('Send 1 queued comments')).toBe(false)
expect(hasButton('Send 1 queued comments to AI')).toBe(false)
expect(container.querySelector('button[role="checkbox"]')).toBeNull()
})
@@ -286,12 +301,12 @@ describe('PRCommentsList comment resolution selection', () => {
renderList({
comments: [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })]
})
clickMenuItem('Queue for agent')
clickButton('Queue for agent')
renderList({
comments: [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: true })]
})
expect(hasButton('Send 1 queued comments')).toBe(false)
expect(hasButton('Send 1 queued comments to AI')).toBe(false)
})
})
+2 -1
View File
@@ -8832,7 +8832,7 @@
"084c516efb": "Copy commands",
"5dc3af25c0": "Select comment",
"d7a2f9c401": "Send unresolved {{value0}} comments",
"d91f2a6c39": "Send {{value0}} queued comments",
"d91f2a6c39": "Send {{value0}} queued comments to AI",
"a6de3e5a20": "Clear queued comments",
"49ea0937e4": "Add comment to resolve list",
"9fecebb29d": "Add",
@@ -8841,6 +8841,7 @@
"7c1f0a2b11": "Open",
"e8b4c1a903": "Resolved · {{value0}}",
"c3a8e5d710": "Needs review · {{value0}}",
"a7f0c7e8d1": "Queue",
"8a621a2c4f": "Grouped",
"b13f85d75c": "Timeline",
"f5cf324efa": "Comment display options",
+2 -1
View File
@@ -8831,7 +8831,7 @@
"084c516efb": "Copy commands",
"5dc3af25c0": "Seleccionar comentario",
"d7a2f9c401": "Send unresolved {{value0}} comments",
"d91f2a6c39": "Enviar {{value0}} comentarios en cola",
"d91f2a6c39": "Enviar {{value0}} comentarios en cola a la IA",
"a6de3e5a20": "Borrar comentarios en cola",
"49ea0937e4": "Agregar comentario a la lista de resolución",
"9fecebb29d": "Agregar",
@@ -8841,6 +8841,7 @@
"7c1f0a2b11": "Open",
"e8b4c1a903": "Resolved · {{value0}}",
"c3a8e5d710": "Needs review · {{value0}}",
"a7f0c7e8d1": "Queue",
"8a621a2c4f": "Grouped",
"b13f85d75c": "Timeline",
"f5cf324efa": "Comment display options",
+2 -1
View File
@@ -8831,7 +8831,7 @@
"084c516efb": "Copy commands",
"5dc3af25c0": "コメントを選択",
"d7a2f9c401": "Send unresolved {{value0}} comments",
"d91f2a6c39": "キュー内の {{value0}} 件のコメントを送信",
"d91f2a6c39": "キュー内の {{value0}} 件のコメントを AI に送信",
"a6de3e5a20": "キュー内のコメントをクリア",
"49ea0937e4": "コメントを解決リストに追加",
"9fecebb29d": "追加",
@@ -8841,6 +8841,7 @@
"7c1f0a2b11": "Open",
"e8b4c1a903": "Resolved · {{value0}}",
"c3a8e5d710": "Needs review · {{value0}}",
"a7f0c7e8d1": "Queue",
"8a621a2c4f": "Grouped",
"b13f85d75c": "Timeline",
"f5cf324efa": "Comment display options",
+2 -1
View File
@@ -8831,7 +8831,7 @@
"084c516efb": "Copy commands",
"5dc3af25c0": "댓글 선택",
"d7a2f9c401": "해결되지 않은 댓글 {{value0}}개 보내기",
"d91f2a6c39": "대기 중인 댓글 {{value0}}개 보내기",
"d91f2a6c39": "대기 중인 댓글 {{value0}}개를 AI로 보내기",
"a6de3e5a20": "대기 중인 댓글 지우기",
"49ea0937e4": "댓글을 해결 목록에 추가",
"9fecebb29d": "추가",
@@ -8841,6 +8841,7 @@
"7c1f0a2b11": "Open",
"e8b4c1a903": "Resolved · {{value0}}",
"c3a8e5d710": "Needs review · {{value0}}",
"a7f0c7e8d1": "Queue",
"8a621a2c4f": "Grouped",
"b13f85d75c": "Timeline",
"f5cf324efa": "Comment display options",
+2 -1
View File
@@ -8831,7 +8831,7 @@
"084c516efb": "Copy commands",
"5dc3af25c0": "选择评论",
"d7a2f9c401": "Send unresolved {{value0}} comments",
"d91f2a6c39": "发送 {{value0}} 条已排队评论",
"d91f2a6c39": "发送 {{value0}} 条已排队评论给 AI",
"a6de3e5a20": "清除已排队评论",
"49ea0937e4": "将评论添加到解决列表",
"9fecebb29d": "添加",
@@ -8841,6 +8841,7 @@
"7c1f0a2b11": "Open",
"e8b4c1a903": "Resolved · {{value0}}",
"c3a8e5d710": "Needs review · {{value0}}",
"a7f0c7e8d1": "Queue",
"8a621a2c4f": "Grouped",
"b13f85d75c": "Timeline",
"f5cf324efa": "Comment display options",
+25 -3
View File
@@ -125,7 +125,9 @@ test.describe('PR comments sidebar cards view', () => {
expect(positions[1]).toBeLessThan(positions[2])
})
test('queues an open thread for the agent from the row menu', async ({ orcaPage }) => {
test('queues an open thread for the agent from the visible row action and menu fallback', async ({
orcaPage
}) => {
const { worktreeId } = await seedPRCommentsSidebarFixture(orcaPage)
await openChecks(orcaPage, worktreeId)
@@ -135,12 +137,32 @@ test.describe('PR comments sidebar cards view', () => {
hasText: 'Please update this handler before merge.'
})
await openThreadCard.hover()
const visibleQueueButton = openThreadCard.getByRole('button', { name: 'Queue for agent' })
await expect(visibleQueueButton).toBeVisible()
await visibleQueueButton.click()
await expect(visibleQueueButton).toBeHidden()
await expect(
orcaPage.getByRole('button', { name: 'Send 1 queued comments to AI' })
).toBeVisible()
await expect(orcaPage.getByText('Queued', { exact: true })).toBeVisible()
await orcaPage.getByRole('button', { name: 'Clear queued comments' }).click()
await expect(
orcaPage.getByRole('button', { name: 'Send 1 queued comments to AI' })
).toBeHidden()
await openThreadCard.hover()
await expect(visibleQueueButton).toBeVisible()
const actionsMenu = openThreadCard.getByRole('button', { name: 'More comment actions' })
await actionsMenu.evaluate((element) => (element as HTMLElement).focus())
await actionsMenu.press('Enter')
await orcaPage.getByRole('menuitem', { name: 'Queue for agent' }).click({ force: true })
const queueMenuItem = orcaPage.getByRole('menuitem', { name: 'Queue for agent' })
await queueMenuItem.click({ force: true })
await expect(queueMenuItem).toBeHidden()
await expect(orcaPage.getByRole('button', { name: 'Send 1 queued comments' })).toBeVisible()
await expect(
orcaPage.getByRole('button', { name: 'Send 1 queued comments to AI' })
).toBeVisible()
await expect(orcaPage.getByText('Queued', { exact: true })).toBeVisible()
const queuedCard = orcaPage.getByTestId('pr-comment-group').filter({