mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Add visible PR comment queue action (#6057)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user