Make PR comments sidebar easier to triage and read (#5996)

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: brennanb2025 <brennankbenson@gmail.com>
This commit is contained in:
Trevin Chow
2026-06-21 23:49:43 -07:00
committed by GitHub
co-authored by Cursor Orca brennanb2025
parent a2e0bbc5f6
commit c6d11efbf2
17 changed files with 1375 additions and 259 deletions
@@ -278,6 +278,33 @@ describe('PRCommentsList', () => {
expect(markup).not.toContain('Add a PR comment')
})
it('shows resolve on open review threads', () => {
const comments: PRComment[] = [
{
id: 2,
author: 'alice',
authorAvatarUrl: '',
body: 'Please address this before merge.',
createdAt: '2026-05-14T00:00:00Z',
url: 'https://github.com/acme/widgets/pull/42#discussion_r2',
threadId: 'thread-open',
path: 'src/a.ts',
isResolved: false
}
]
const markup = renderWithTooltips(
React.createElement(PRCommentsList, {
comments,
commentsLoading: false,
onResolve: () => true
})
)
expect(markup).toContain('Resolve')
expect(markup).not.toContain('Unresolve')
})
it('renders a more-actions menu on conversation comments', () => {
const comments: PRComment[] = [
{
@@ -22,6 +22,7 @@ import {
AlertTriangle,
MoreHorizontal,
Pencil,
SlidersHorizontal,
Trash,
X
} from 'lucide-react'
@@ -39,9 +40,18 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { cn } from '@/lib/utils'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import {
@@ -53,16 +63,24 @@ import {
type PRCommentAudienceFilter
} from '@/lib/pr-comment-audience'
import {
getPRCommentGroupCount,
getPRCommentGroupId,
getPRCommentGroupRoot,
groupPRComments,
isResolvedPRCommentGroup,
PR_COMMENT_OPEN_AUTHOR_CLASS,
PR_COMMENT_RESOLVED_AUTHOR_CLASS,
PR_COMMENT_RESOLVED_CONTAINER_CLASS,
type PRCommentGroup
} from '@/lib/pr-comment-groups'
import {
getPRCommentGroupActionState,
isPRCommentGroupQueueableForAI,
partitionPRCommentGroupsForTriage,
sortPRCommentGroupsForTimeline,
type PRCommentGroupActionState
} from '@/lib/pr-comment-action-state'
import { formatPrCommentRelativeTime } from '@/lib/pr-comment-time'
import {
getPRCommentPresentationClasses,
getPRCommentGroupSurfaceClasses,
type PRCommentPresentationClasses
} from './pr-comment-presentation'
import type {
PRInfo,
PRCheckDetail,
@@ -83,6 +101,16 @@ import { useAppStore } from '@/store'
export const PullRequestIcon = GitPullRequest
type PRCommentsListDisplayMode = 'triage' | 'timeline'
const PR_COMMENT_LIST_DISPLAY_MODES: PRCommentsListDisplayMode[] = ['triage', 'timeline']
function getPRCommentsListDisplayModeLabel(mode: PRCommentsListDisplayMode): string {
return mode === 'triage'
? translate('auto.components.right.sidebar.checks.panel.content.8a621a2c4f', 'Grouped')
: translate('auto.components.right.sidebar.checks.panel.content.b13f85d75c', 'Timeline')
}
export const CHECK_ICON: Record<string, React.ComponentType<{ className?: string }>> = {
success: CircleCheck,
failure: CircleX,
@@ -1435,16 +1463,19 @@ export function isMutablePRConversationComment(comment: PRComment): boolean {
function CommentMoreMenu({
comment,
onStartEdit,
onDelete
onDelete,
onQueueForAgent
}: {
comment: PRComment
onStartEdit?: () => void
onDelete?: () => void | Promise<void>
onQueueForAgent?: () => void
}): React.JSX.Element | null {
const hasGoToComment = Boolean(comment.url)
const hasEdit = Boolean(onStartEdit)
const hasDelete = Boolean(onDelete)
if (!hasGoToComment && !hasEdit && !hasDelete) {
const hasQueue = Boolean(onQueueForAgent)
if (!hasGoToComment && !hasEdit && !hasDelete && !hasQueue) {
return null
}
@@ -1465,6 +1496,21 @@ function CommentMoreMenu({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={4}>
{hasQueue ? (
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
onQueueForAgent?.()
}}
>
<Sparkles />
{translate(
'auto.components.right.sidebar.checks.panel.content.f8a2c91d04',
'Queue for agent'
)}
</DropdownMenuItem>
) : null}
{hasQueue && (hasGoToComment || hasEdit || hasDelete) ? <DropdownMenuSeparator /> : null}
{hasGoToComment && (
<DropdownMenuItem onSelect={() => window.api.shell.openUrl(comment.url)}>
<ExternalLink />
@@ -1507,6 +1553,39 @@ function buildCopyText(comment: PRComment): string {
return `File: ${location}\n\n${comment.body}`
}
function PRCommentActionBadge({
actionState,
isQueued,
presentation
}: {
actionState: PRCommentGroupActionState
isQueued: boolean
presentation: PRCommentPresentationClasses
}): React.JSX.Element | null {
if (isQueued) {
return (
<span className={presentation.statusBadgeQueued}>
{translate('auto.components.right.sidebar.checks.panel.content.b4e8a1c902', 'Queued')}
</span>
)
}
if (actionState === 'open') {
return (
<span className={presentation.statusBadgeOpen}>
{translate('auto.components.right.sidebar.checks.panel.content.7c1f0a2b11', 'Open')}
</span>
)
}
if (actionState === 'resolved') {
return (
<span className={presentation.statusBadgeResolved}>
{translate('auto.components.right.sidebar.checks.panel.content.8987d5a3dd', 'Resolved')}
</span>
)
}
return null
}
/** A single comment row — used for both root and reply comments. */
function CommentRow({
comment,
@@ -1514,26 +1593,32 @@ function CommentRow({
showResolve,
showReply,
selectionControl,
resolveSelectionAction,
actionState,
isQueued,
replyDisabled,
replyDisabledReason,
presentation,
onResolve,
onReply,
onEditComment,
onDeleteComment
onDeleteComment,
onQueueForAgent
}: {
comment: PRComment
isReply: boolean
showResolve: boolean
showReply?: boolean
selectionControl?: React.ReactNode
resolveSelectionAction?: React.ReactNode
actionState: PRCommentGroupActionState
isQueued: boolean
replyDisabled?: boolean
replyDisabledReason?: string
presentation: PRCommentPresentationClasses
onResolve?: (threadId: string, resolve: boolean) => boolean | Promise<boolean>
onReply?: (comment: PRComment) => void
onEditComment?: (comment: PRComment, body: string) => Promise<boolean>
onDeleteComment?: (comment: PRComment) => void | Promise<void>
onQueueForAgent?: () => void
}): React.JSX.Element {
const automated = isBotPRComment(comment)
const canMutateComment = isMutablePRConversationComment(comment)
@@ -1588,94 +1673,159 @@ function CommentRow({
const trimmedDraft = draft.trim()
const canSaveEdit = !submittingEdit && trimmedDraft.length > 0 && trimmedDraft !== comment.body
const relativeTime = formatPrCommentRelativeTime(comment.createdAt, Date.now())
const authorAvatar = comment.authorAvatarUrl ? (
<img
src={comment.authorAvatarUrl}
alt={comment.author}
className={cn(isReply ? presentation.avatarReply : presentation.avatar)}
/>
) : (
<div className={cn(isReply ? presentation.avatarReply : presentation.avatar)} aria-hidden />
)
const authorName = (
<span className={cn(presentation.author, comment.isResolved && presentation.authorResolved)}>
{comment.author}
</span>
)
const authorLead = selectionControl ? (
<span className="flex shrink-0 items-center">{selectionControl}</span>
) : (
authorAvatar
)
const commentActions = !editing ? (
<div className="flex shrink-0 items-center gap-0.5 can-hover:opacity-0 group-hover/comment:opacity-100 transition-opacity">
{showResolve &&
comment.threadId != null &&
onResolve &&
(actionState === 'open' || actionState === 'resolved') && (
<ResolveButton
threadId={comment.threadId}
isResolved={comment.isResolved ?? false}
onResolve={onResolve}
/>
)}
{showReply && onReply && (
<button
className="shrink-0 rounded px-1.5 py-0.5 text-[10px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
title={
replyDisabled
? replyDisabledReason
: translate('auto.components.right.sidebar.checks.panel.content.c1f6fc006a', 'Reply')
}
disabled={replyDisabled}
onClick={(event) => {
event.stopPropagation()
onReply(comment)
}}
>
{translate('auto.components.right.sidebar.checks.panel.content.c1f6fc006a', 'Reply')}
</button>
)}
<CopyButton text={buildCopyText(comment)} />
<CommentMoreMenu
comment={comment}
onStartEdit={canMutateComment && onEditComment ? handleStartEdit : undefined}
onDelete={canMutateComment && onDeleteComment ? handleDelete : undefined}
onQueueForAgent={!isReply ? onQueueForAgent : undefined}
/>
</div>
) : null
const cardMetaRow =
presentation.useCardLayout && !isReply ? (
<div className={presentation.commentHeaderMeta}>
{relativeTime ? <span>{relativeTime}</span> : null}
{automated ? (
<span className={presentation.botBadge}>
{translate('auto.components.right.sidebar.checks.panel.content.2ba0a32bdd', 'bot')}
</span>
) : null}
{comment.path ? (
<span className={presentation.pathBadge} title={comment.path}>
{comment.path.split('/').pop()}
{formatLineRange(comment) && `:${formatLineRange(comment)}`}
</span>
) : null}
<PRCommentActionBadge
actionState={actionState}
isQueued={isQueued}
presentation={presentation}
/>
</div>
) : null
const authorLine =
presentation.useCardLayout && !isReply ? (
<>
<div className={presentation.commentHeaderPrimary}>
{authorLead}
{authorName}
{commentActions}
</div>
{cardMetaRow}
</>
) : (
<>
{authorLead}
{authorName}
{relativeTime ? (
<span className={presentation.time} aria-hidden={presentation.time === 'hidden'}>
{presentation.useCardLayout ? `· ${relativeTime}` : relativeTime}
</span>
) : null}
{automated && (
<span className={presentation.botBadge}>
{translate('auto.components.right.sidebar.checks.panel.content.2ba0a32bdd', 'bot')}
</span>
)}
{!isReply && comment.path && (
<span className={presentation.pathBadge}>
{comment.path.split('/').pop()}
{formatLineRange(comment) && `:${formatLineRange(comment)}`}
</span>
)}
{!isReply ? (
<PRCommentActionBadge
actionState={actionState}
isQueued={isQueued}
presentation={presentation}
/>
) : null}
<div className="flex-1" />
{commentActions}
</>
)
return (
<div
className={cn(
'flex items-start gap-2 py-1.5 hover:bg-accent/40 transition-colors group/comment',
isReply ? 'pl-7 pr-3' : 'px-3',
comment.isResolved && PR_COMMENT_RESOLVED_CONTAINER_CLASS
'group/comment flex items-start gap-2',
presentation.commentRow,
isReply && presentation.commentRowReply,
comment.isResolved && presentation.resolvedContainer
)}
>
{selectionControl}
<div className="flex-1 min-w-0">
{/* Author line: avatar + name + file badge aligned on center */}
<div className="flex items-center gap-1.5 min-w-0">
{comment.authorAvatarUrl ? (
<img
src={comment.authorAvatarUrl}
alt={comment.author}
className={cn('rounded-full shrink-0', isReply ? 'size-3.5' : 'size-4')}
/>
) : (
<div
className={cn('rounded-full bg-muted shrink-0', isReply ? 'size-3.5' : 'size-4')}
/>
)}
<span
className={cn(
'text-[11px] font-semibold shrink-0',
comment.isResolved ? PR_COMMENT_RESOLVED_AUTHOR_CLASS : PR_COMMENT_OPEN_AUTHOR_CLASS
)}
>
{comment.author}
</span>
{automated && (
<span className="shrink-0 rounded border border-border bg-accent/40 px-1 py-px text-[9px] font-medium uppercase tracking-wide text-muted-foreground">
{translate('auto.components.right.sidebar.checks.panel.content.2ba0a32bdd', 'bot')}
</span>
)}
{!isReply && comment.path && (
<span className="text-[10px] font-mono text-muted-foreground/60 truncate min-w-0">
{comment.path.split('/').pop()}
{formatLineRange(comment) && `:${formatLineRange(comment)}`}
</span>
)}
<div className="flex-1" />
{!editing && resolveSelectionAction}
{!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 && (
<ResolveButton
threadId={comment.threadId}
isResolved={comment.isResolved ?? false}
onResolve={onResolve}
/>
)}
{showReply && onReply && (
<button
className="shrink-0 rounded px-1.5 py-0.5 text-[10px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
title={
replyDisabled
? replyDisabledReason
: translate(
'auto.components.right.sidebar.checks.panel.content.c1f6fc006a',
'Reply'
)
}
disabled={replyDisabled}
onClick={(event) => {
event.stopPropagation()
onReply(comment)
}}
>
{translate(
'auto.components.right.sidebar.checks.panel.content.c1f6fc006a',
'Reply'
)}
</button>
)}
<CopyButton text={buildCopyText(comment)} />
<CommentMoreMenu
comment={comment}
onStartEdit={canMutateComment && onEditComment ? handleStartEdit : undefined}
onDelete={canMutateComment && onDeleteComment ? handleDelete : undefined}
/>
</div>
<div
className={cn(
isReply && presentation.useCardLayout
? presentation.commentHeaderReply
: presentation.commentHeader
)}
>
{authorLine}
</div>
{editing ? (
<div className={cn('mt-1 flex flex-col gap-1.5', isReply ? 'pl-5' : 'pl-[22px]')}>
<div
className={cn(
'mt-1 flex flex-col gap-1.5',
presentation.useCardLayout ? 'px-3 pb-3' : isReply ? 'pl-5' : 'pl-[22px]'
)}
>
<textarea
autoFocus
value={draft}
@@ -1710,9 +1860,8 @@ function CommentRow({
<CommentMarkdown
content={comment.body}
className={cn(
'mt-1 text-[11px] leading-snug text-muted-foreground',
'break-words [&_p]:my-1 [&_pre]:max-h-none [&_pre]:max-w-full [&_pre]:whitespace-pre-wrap [&_table]:w-full [&_table]:max-w-full',
isReply ? 'pl-5' : 'pl-[22px]'
isReply ? presentation.commentBodyReply : presentation.commentBody,
presentation.commentBodyMarkdown
)}
/>
)}
@@ -1725,28 +1874,34 @@ function PRCommentGroupView({
group,
replyingGroupId,
selectionControl,
resolveSelectionAction,
actionState,
isQueued,
replyDisabled,
replyDisabledReason,
presentation,
onResolve,
onStartReply,
onCancelReply,
onReply,
onEditComment,
onDeleteComment
onDeleteComment,
onQueueForAgent
}: {
group: PRCommentGroup
replyingGroupId: string | null
selectionControl?: React.ReactNode
resolveSelectionAction?: React.ReactNode
actionState: PRCommentGroupActionState
isQueued: boolean
replyDisabled?: boolean
replyDisabledReason?: string
presentation: PRCommentPresentationClasses
onResolve?: (threadId: string, resolve: boolean) => boolean | Promise<boolean>
onStartReply?: (groupId: string) => void
onCancelReply?: () => void
onReply?: (comment: PRComment, body: string) => Promise<RightPanelCommentSubmitResult>
onEditComment?: (comment: PRComment, body: string) => Promise<boolean>
onDeleteComment?: (comment: PRComment) => void | Promise<void>
onQueueForAgent?: () => void
}): React.JSX.Element {
const groupId = getPRCommentGroupId(group)
const root = getPRCommentGroupRoot(group)
@@ -1769,70 +1924,92 @@ function PRCommentGroupView({
</div>
) : null
const startReply = onStartReply ? () => onStartReply(groupId) : undefined
const surfaceClassName = cn(
getPRCommentGroupSurfaceClasses(presentation, actionState, { queued: isQueued }),
group.kind === 'standalone' ? presentation.groupStandalone : presentation.groupThread
)
const sharedRowProps = {
actionState,
isQueued,
replyDisabled,
replyDisabledReason,
presentation,
onResolve,
onEditComment,
onDeleteComment,
onQueueForAgent
}
if (group.kind === 'standalone') {
return (
<div key={group.comment.id}>
const content =
group.kind === 'standalone' ? (
<div className={surfaceClassName} data-testid="pr-comment-group">
<CommentRow
comment={group.comment}
isReply={false}
showResolve={false}
showReply={Boolean(onReply)}
selectionControl={selectionControl}
resolveSelectionAction={resolveSelectionAction}
replyDisabled={replyDisabled}
replyDisabledReason={replyDisabledReason}
onResolve={onResolve}
onReply={startReply ? () => startReply() : undefined}
onEditComment={onEditComment}
onDeleteComment={onDeleteComment}
{...sharedRowProps}
/>
{replyComposer}
</div>
) : (
<div className={surfaceClassName} data-testid="pr-comment-group">
<CommentRow
comment={group.root}
isReply={false}
showResolve={true}
showReply={Boolean(onReply)}
selectionControl={selectionControl}
onReply={startReply ? () => startReply() : undefined}
{...sharedRowProps}
/>
{group.replies.length > 0 && (
<div className={presentation.repliesContainer}>
{group.replies.map((reply) => (
<CommentRow
key={reply.id}
{...sharedRowProps}
comment={reply}
isReply={true}
showResolve={false}
showReply={false}
isQueued={false}
/>
))}
</div>
)}
{replyComposer}
</div>
)
if (!onQueueForAgent) {
return content
}
return (
<div key={group.threadId} className="py-0.5">
<CommentRow
comment={group.root}
isReply={false}
showResolve={true}
showReply={Boolean(onReply)}
selectionControl={selectionControl}
resolveSelectionAction={resolveSelectionAction}
replyDisabled={replyDisabled}
replyDisabledReason={replyDisabledReason}
onResolve={onResolve}
onReply={startReply ? () => startReply() : undefined}
onEditComment={onEditComment}
onDeleteComment={onDeleteComment}
/>
{group.replies.length > 0 && (
<div className="ml-3 border-l-2 border-border/50">
{group.replies.map((reply) => (
<CommentRow
key={reply.id}
comment={reply}
isReply={true}
showResolve={false}
showReply={false}
onResolve={onResolve}
onEditComment={onEditComment}
onDeleteComment={onDeleteComment}
/>
))}
</div>
)}
{replyComposer}
</div>
<ContextMenu>
<ContextMenuTrigger asChild>{content}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => onQueueForAgent()}>
<Sparkles />
{translate(
'auto.components.right.sidebar.checks.panel.content.f8a2c91d04',
'Queue for agent'
)}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
function ResolvedCommentGroupAccordion({
group,
function ResolvedCommentGroupsSection({
groups,
replyingGroupId,
replyDisabled,
replyDisabledReason,
presentation,
onResolve,
onStartReply,
onCancelReply,
@@ -1840,52 +2017,57 @@ function ResolvedCommentGroupAccordion({
onEditComment,
onDeleteComment
}: {
group: PRCommentGroup
groups: PRCommentGroup[]
replyingGroupId: string | null
replyDisabled?: boolean
replyDisabledReason?: string
presentation: PRCommentPresentationClasses
onResolve?: (threadId: string, resolve: boolean) => boolean | Promise<boolean>
onStartReply?: (groupId: string) => void
onCancelReply?: () => void
onReply?: (comment: PRComment, body: string) => Promise<RightPanelCommentSubmitResult>
onEditComment?: (comment: PRComment, body: string) => Promise<boolean>
onDeleteComment?: (comment: PRComment) => void | Promise<void>
}): React.JSX.Element {
const root = getPRCommentGroupRoot(group)
const count = getPRCommentGroupCount(group)
}): React.JSX.Element | null {
if (groups.length === 0) {
return null
}
return (
<Accordion type="single" collapsible>
<AccordionItem value={getPRCommentGroupId(group)} className="border-b-0">
<AccordionTrigger className="px-3 py-1.5 text-[11px] text-muted-foreground hover:bg-accent/35">
<span className="min-w-0 truncate">
{translate('auto.components.right.sidebar.checks.panel.content.8987d5a3dd', 'Resolved')}{' '}
{group.kind === 'thread'
? translate('auto.components.right.sidebar.checks.panel.content.95ad090b01', 'thread')
: translate(
'auto.components.right.sidebar.checks.panel.content.90206b6353',
'comment'
)}{' '}
{translate('auto.components.right.sidebar.checks.panel.content.0fc6f743b3', 'by')}{' '}
{root.author}
{count > 1 ? ` (${count})` : ''}
</span>
</AccordionTrigger>
<AccordionContent className="pb-1 pt-0">
<PRCommentGroupView
group={group}
replyingGroupId={replyingGroupId}
replyDisabled={replyDisabled}
replyDisabledReason={replyDisabledReason}
onResolve={onResolve}
onStartReply={onStartReply}
onCancelReply={onCancelReply}
onReply={onReply}
onEditComment={onEditComment}
onDeleteComment={onDeleteComment}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className={presentation.resolvedSection}>
<Accordion type="single" collapsible>
<AccordionItem value="resolved-all" className="border-b-0">
<AccordionTrigger className={presentation.resolvedSectionTrigger}>
<span className="min-w-0 truncate">
{translate(
'auto.components.right.sidebar.checks.panel.content.e8b4c1a903',
'Resolved · {{value0}}',
{ value0: groups.length }
)}
</span>
</AccordionTrigger>
<AccordionContent className={presentation.resolvedSectionContent}>
{groups.map((group) => (
<PRCommentGroupView
key={getPRCommentGroupId(group)}
group={group}
replyingGroupId={replyingGroupId}
actionState="resolved"
isQueued={false}
replyDisabled={replyDisabled}
replyDisabledReason={replyDisabledReason}
presentation={presentation}
onResolve={onResolve}
onStartReply={onStartReply}
onCancelReply={onCancelReply}
onReply={onReply}
onEditComment={onEditComment}
onDeleteComment={onDeleteComment}
/>
))}
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
)
}
@@ -1962,7 +2144,9 @@ export function PRCommentsList({
onEditComment?: (comment: PRComment, body: string) => Promise<boolean>
onDeleteComment?: (comment: PRComment) => void | Promise<void>
}): React.JSX.Element {
const presentation = React.useMemo(() => getPRCommentPresentationClasses(), [])
const [commentFilter, setCommentFilter] = useState<PRCommentAudienceFilter>('all')
const [displayMode, setDisplayMode] = useState<PRCommentsListDisplayMode>('triage')
const [replyingGroupId, setReplyingGroupId] = useState<string | null>(null)
const [isAddingComment, setIsAddingComment] = useState(false)
const addCommentSurfaceRef = useRef<HTMLDivElement>(null)
@@ -1983,6 +2167,9 @@ export function PRCommentsList({
[commentFilter, comments]
)
const groups = React.useMemo(() => groupPRComments(visibleComments), [visibleComments])
const triageGroups = React.useMemo(() => partitionPRCommentGroupsForTriage(groups), [groups])
// Why: triage mode prioritizes actionability; timeline restores the host discussion history.
const timelineGroups = React.useMemo(() => sortPRCommentGroupsForTimeline(groups), [groups])
const canShowResolveWithAI = Boolean(
onResolveSelectedCommentsWithAI && selectableGroups.length > 0
)
@@ -2039,44 +2226,39 @@ export function PRCommentsList({
)}
checked={checked}
onCheckedChange={(value) => toggleGroupSelection(groupId, value === true)}
className="mt-0.5"
className="shrink-0"
/>
)
}
const renderResolveSelectionAction = (group: PRCommentGroup): React.ReactNode => {
if (isSelectingForAI || !selectableGroupsById.has(getPRCommentGroupId(group))) {
return null
}
const renderCommentGroup = (group: PRCommentGroup): React.JSX.Element => {
const groupId = getPRCommentGroupId(group)
const actionState = getPRCommentGroupActionState(group)
const isQueued = selectedGroupIds.has(groupId)
const canQueue =
canShowResolveWithAI &&
isPRCommentGroupQueueableForAI(group) &&
selectableGroupsById.has(groupId) &&
!isSelectingForAI
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="xs"
className="shrink-0 text-muted-foreground hover:text-foreground"
aria-label={translate(
'auto.components.right.sidebar.checks.panel.content.49ea0937e4',
'Add comment to resolve list'
)}
onClick={(event) => {
event.stopPropagation()
addGroupToSelection(groupId)
}}
>
<Sparkles className="size-3" />
{translate('auto.components.right.sidebar.checks.panel.content.9fecebb29d', 'Add')}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.right.sidebar.checks.panel.content.49ea0937e4',
'Add comment to resolve list'
)}
</TooltipContent>
</Tooltip>
<PRCommentGroupView
key={groupId}
group={group}
replyingGroupId={replyingGroupId}
selectionControl={renderSelectionControl(group)}
actionState={actionState}
isQueued={isQueued}
replyDisabled={commentsDisabled}
replyDisabledReason={commentsDisabledReason}
presentation={presentation}
onResolve={onResolve}
onStartReply={setReplyingGroupId}
onCancelReply={() => setReplyingGroupId(null)}
onReply={onReply}
onEditComment={onEditComment}
onDeleteComment={onDeleteComment}
onQueueForAgent={canQueue ? () => addGroupToSelection(groupId) : undefined}
/>
)
}
@@ -2119,14 +2301,14 @@ export function PRCommentsList({
return (
<div className="border-t border-border">
{/* Header */}
<div className="flex flex-col gap-2.5 border-b border-border px-3 py-2.5">
<div className={presentation.sectionHeader}>
<div className="flex min-w-0 items-center gap-2">
<MessageSquare className="size-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium text-foreground">
<span className={presentation.sectionHeaderLabel}>
{translate('auto.components.right.sidebar.checks.panel.content.94557d68e2', 'Comments')}
</span>
{comments.length > 0 && (
<span className="text-[10px] text-muted-foreground">{comments.length}</span>
<span className={presentation.sectionCount}>{comments.length}</span>
)}
<div className="-mr-1 ml-auto flex items-center gap-0.5">
{canShowResolveWithAI && (
@@ -2274,10 +2456,46 @@ export function PRCommentsList({
</TooltipContent>
</Tooltip>
)}
{comments.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-foreground"
aria-label={translate(
'auto.components.right.sidebar.checks.panel.content.f5cf324efa',
'Comment display options'
)}
>
<SlidersHorizontal className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" sideOffset={6}>
<DropdownMenuLabel>
{translate(
'auto.components.right.sidebar.checks.panel.content.5e6e5a13fa',
'View'
)}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={displayMode}
onValueChange={(value) => setDisplayMode(value as PRCommentsListDisplayMode)}
>
{PR_COMMENT_LIST_DISPLAY_MODES.map((mode) => (
<DropdownMenuRadioItem key={mode} value={mode}>
{getPRCommentsListDisplayModeLabel(mode)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{comments.length > 0 && (
<div className="grid grid-cols-3 rounded-md border border-border bg-background p-0.5">
<div className={presentation.audienceTabs}>
{getPrCommentAudienceFilters().map((filter) => {
const isActive = commentFilter === filter.value
return (
@@ -2285,8 +2503,8 @@ export function PRCommentsList({
key={filter.value}
type="button"
className={cn(
'flex h-7 items-center justify-center gap-1 rounded-md px-1.5 text-[11px] font-medium text-muted-foreground transition-colors',
isActive && 'bg-muted text-foreground'
presentation.audienceTab,
isActive && presentation.audienceTabActive
)}
aria-pressed={isActive}
onClick={() => setCommentFilter(filter.value)}
@@ -2329,34 +2547,30 @@ export function PRCommentsList({
{getPRCommentAudienceEmptyLabel(commentFilter)}
</div>
) : (
<div className="py-1">
{groups.map((group) => {
if (isResolvedPRCommentGroup(group)) {
return (
<ResolvedCommentGroupAccordion
key={getPRCommentGroupId(group)}
group={group}
replyingGroupId={replyingGroupId}
replyDisabled={commentsDisabled}
replyDisabledReason={commentsDisabledReason}
onResolve={onResolve}
onStartReply={setReplyingGroupId}
onCancelReply={() => setReplyingGroupId(null)}
onReply={onReply}
onEditComment={onEditComment}
onDeleteComment={onDeleteComment}
/>
)
}
return (
<PRCommentGroupView
key={getPRCommentGroupId(group)}
group={group}
<div className={presentation.list}>
{displayMode === 'timeline' ? (
timelineGroups.map(renderCommentGroup)
) : (
<>
{triageGroups.open.length > 0 ? (
<>
<div className={presentation.sectionTriageLabel}>
{translate(
'auto.components.right.sidebar.checks.panel.content.c3a8e5d710',
'Needs review · {{value0}}',
{ value0: triageGroups.open.length }
)}
</div>
{triageGroups.open.map(renderCommentGroup)}
</>
) : null}
{triageGroups.conversation.map(renderCommentGroup)}
<ResolvedCommentGroupsSection
groups={triageGroups.resolved}
replyingGroupId={replyingGroupId}
selectionControl={renderSelectionControl(group)}
resolveSelectionAction={renderResolveSelectionAction(group)}
replyDisabled={commentsDisabled}
replyDisabledReason={commentsDisabledReason}
presentation={presentation}
onResolve={onResolve}
onStartReply={setReplyingGroupId}
onCancelReply={() => setReplyingGroupId(null)}
@@ -2364,8 +2578,8 @@ export function PRCommentsList({
onEditComment={onEditComment}
onDeleteComment={onDeleteComment}
/>
)
})}
</>
)}
</div>
)}
{onAddComment && comments.length > 0 && isAddingComment && renderAddCommentComposer(false)}
@@ -0,0 +1,40 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import {
DEFAULT_PR_COMMENT_PRESENTATION_VARIANT,
getPRCommentPresentationClasses,
resolvePRCommentPresentationVariant
} from './pr-comment-presentation'
describe('pr-comment-presentation', () => {
it('defaults to cards layout', () => {
expect(DEFAULT_PR_COMMENT_PRESENTATION_VARIANT).toBe('cards')
})
it('returns card layout tokens for cards and focus variants', () => {
const cards = getPRCommentPresentationClasses('cards')
expect(cards.useCardLayout).toBe(true)
expect(cards.commentBody).toContain('text-[13px]')
expect(cards.commentBody).toContain('text-foreground')
expect(cards.group).toContain('bg-secondary')
expect(cards.group).toContain('shadow-xs')
expect(cards.avatar).toContain('border-border')
expect(cards.avatar).toContain('bg-background')
expect(getPRCommentPresentationClasses('focus').useCardLayout).toBe(true)
expect(getPRCommentPresentationClasses('focus').commentBody).toContain('text-[14px]')
})
it('preserves the legacy flat layout tokens', () => {
const flat = getPRCommentPresentationClasses('flat')
expect(flat.useCardLayout).toBe(false)
expect(flat.commentBody).toContain('text-muted-foreground')
expect(flat.commentBody).toContain('text-[11px]')
})
it('falls back to the default variant when localStorage is unset', () => {
window.localStorage.removeItem('orca:pr-comment-presentation')
expect(resolvePRCommentPresentationVariant()).toBe(DEFAULT_PR_COMMENT_PRESENTATION_VARIANT)
})
})
@@ -0,0 +1,232 @@
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. */
export type PRCommentPresentationVariant = 'flat' | 'cards' | 'focus'
export const DEFAULT_PR_COMMENT_PRESENTATION_VARIANT: PRCommentPresentationVariant = 'cards'
export const PR_COMMENT_PRESENTATION_VARIANTS: PRCommentPresentationVariant[] = [
'flat',
'cards',
'focus'
]
const STORAGE_KEY = 'orca:pr-comment-presentation'
export type PRCommentPresentationClasses = {
variant: PRCommentPresentationVariant
useCardLayout: boolean
list: string
group: string
groupStandalone: string
groupThread: string
commentRow: string
commentRowReply: string
commentHeader: string
commentHeaderReply: string
commentBody: string
commentBodyReply: string
commentBodyMarkdown: string
author: string
authorResolved: string
avatar: string
avatarReply: string
botBadge: string
pathBadge: string
time: string
resolvedContainer: string
repliesContainer: string
resolvedSection: string
resolvedSectionTrigger: string
resolvedSectionContent: string
sectionHeader: string
sectionHeaderLabel: string
sectionCount: string
audienceTabs: string
audienceTab: string
audienceTabActive: string
sectionTriageLabel: string
statusBadgeOpen: string
statusBadgeResolved: string
statusBadgeQueued: string
commentHeaderPrimary: string
commentHeaderMeta: string
groupOpen: string
groupQueued: string
groupResolved: string
}
export function getPRCommentGroupSurfaceClasses(
presentation: PRCommentPresentationClasses,
actionState: PRCommentGroupActionState,
options?: { queued?: boolean }
): string {
const classes = [presentation.group]
if (options?.queued) {
classes.push(presentation.groupQueued)
// Why: queued selection already owns the leading affordance; stacking the
// open rail next to its checkbox makes the card edge visually crowded.
return classes.join(' ')
}
if (actionState === 'open' && presentation.groupOpen) {
classes.push(presentation.groupOpen)
} else if (actionState === 'resolved') {
classes.push(presentation.groupResolved)
}
return classes.join(' ')
}
const MARKDOWN_BASE =
'break-words [&_p]:my-1.5 [&_pre]:max-h-none [&_pre]:max-w-full [&_pre]:whitespace-pre-wrap [&_table]:w-full [&_table]:max-w-full'
// Why: in light mode card and canvas are both #fff, so border-border alone disappears.
// overflow-clip preserves rounded clipping without letting focused row actions scroll content.
const COMMENT_CARD_SURFACE =
'overflow-clip rounded-lg border border-border bg-secondary shadow-xs dark:bg-card dark:shadow-none'
const COMMENT_CARD_DIVIDER = 'border-border dark:border-border/60'
// Why: placeholders used bg-muted on bg-secondary cards — same grey in light mode.
const COMMENT_AVATAR =
'shrink-0 rounded-full border border-border bg-background object-cover shadow-xs dark:shadow-none'
const RESOLVED_SECTION_LABEL =
'text-[11px] font-semibold uppercase tracking-wider text-muted-foreground'
const RESOLVED_SECTION_TRIGGER = cn(
RESOLVED_SECTION_LABEL,
'rounded-none border-0 bg-transparent px-3 py-2 shadow-none hover:bg-accent/40 hover:text-foreground hover:no-underline'
)
function isVariant(value: string | null): value is PRCommentPresentationVariant {
return value === 'flat' || value === 'cards' || value === 'focus'
}
/** Resolve the active variant. In dev, override with
* localStorage.setItem('orca:pr-comment-presentation', 'cards' | 'flat' | 'focus'). */
export function resolvePRCommentPresentationVariant(): PRCommentPresentationVariant {
if (typeof window === 'undefined') {
return DEFAULT_PR_COMMENT_PRESENTATION_VARIANT
}
const stored = window.localStorage.getItem(STORAGE_KEY)
if (isVariant(stored)) {
return stored
}
return DEFAULT_PR_COMMENT_PRESENTATION_VARIANT
}
export function getPRCommentPresentationClasses(
variant: PRCommentPresentationVariant = resolvePRCommentPresentationVariant()
): PRCommentPresentationClasses {
if (variant === 'flat') {
return {
variant,
useCardLayout: false,
list: 'py-1',
group: 'py-0.5',
groupStandalone: '',
groupThread: 'py-0.5',
commentRow: 'py-1.5 px-3 transition-colors hover:bg-accent/40',
commentRowReply: 'pl-7 pr-3',
commentHeader: 'flex min-w-0 items-center gap-1.5',
commentHeaderReply: 'flex min-w-0 items-center gap-1.5',
commentBody: 'mt-1 pl-[22px] text-[11px] leading-snug text-muted-foreground',
commentBodyReply: 'mt-1 pl-5 text-[11px] leading-snug text-muted-foreground',
commentBodyMarkdown: MARKDOWN_BASE,
author: 'shrink-0 text-[11px] font-semibold text-foreground',
authorResolved: 'text-muted-foreground',
avatar: `size-4 ${COMMENT_AVATAR}`,
avatarReply: `size-3.5 ${COMMENT_AVATAR}`,
botBadge:
'shrink-0 rounded border border-border bg-accent/40 px-1 py-px text-[9px] font-medium uppercase tracking-wide text-muted-foreground',
pathBadge: 'min-w-0 flex-1 truncate text-[10px] font-mono text-muted-foreground/60',
time: 'hidden',
resolvedContainer: 'opacity-50',
repliesContainer: 'ml-3 border-l-2 border-border/50',
resolvedSection: 'mt-1 border-t border-border pt-1',
resolvedSectionTrigger: RESOLVED_SECTION_TRIGGER,
resolvedSectionContent: 'flex flex-col gap-2 pb-1 pt-1',
sectionHeader: 'flex flex-col gap-2.5 border-b border-border px-3 py-2.5',
sectionHeaderLabel: 'text-[11px] font-medium text-foreground',
sectionCount: 'text-[10px] text-muted-foreground',
audienceTabs: 'grid grid-cols-3 rounded-md border border-border bg-background p-0.5',
audienceTab:
'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',
sectionTriageLabel: cn('px-3 pt-2', RESOLVED_SECTION_LABEL),
statusBadgeOpen:
'shrink-0 rounded border border-status-success-border bg-status-success-background px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-status-success',
statusBadgeResolved:
'shrink-0 rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground',
statusBadgeQueued:
'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: '',
groupOpen: 'border-l-2 border-l-status-success',
groupQueued: 'ring-1 ring-ring/50',
groupResolved: ''
}
}
const isFocus = variant === 'focus'
const bodySize = isFocus ? 'text-[14px] leading-relaxed' : 'text-[13px] leading-relaxed'
const authorSize = isFocus ? 'text-[14px]' : 'text-[13px]'
const listGap = isFocus ? 'gap-3' : 'gap-2'
const bodyPadding = isFocus ? 'px-4 py-3' : 'px-4 py-2.5'
const headerPadding = isFocus ? 'px-3.5 py-2.5' : 'px-3 py-2'
const metaIndent = isFocus ? 'pl-8' : 'pl-7'
return {
variant,
useCardLayout: true,
list: `flex flex-col ${listGap} px-3 py-2`,
group: COMMENT_CARD_SURFACE,
groupStandalone: '',
groupThread: '',
commentRow: 'group/comment',
commentRowReply: `border-t ${COMMENT_CARD_DIVIDER} bg-muted/25 dark:bg-muted/10`,
commentHeader: `flex flex-col gap-1 border-b ${COMMENT_CARD_DIVIDER} ${headerPadding}`,
commentHeaderReply: `flex min-w-0 items-center gap-2 ${headerPadding}`,
commentBody: `${bodyPadding} ${bodySize} text-foreground`,
commentBodyReply: `${bodyPadding} ${bodySize} text-foreground`,
commentBodyMarkdown: MARKDOWN_BASE,
author: `min-w-0 flex-1 truncate ${authorSize} font-semibold text-foreground`,
authorResolved: 'text-muted-foreground',
avatar: `size-5 ${COMMENT_AVATAR}`,
avatarReply: `size-4 ${COMMENT_AVATAR}`,
botBadge:
'shrink-0 rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground',
pathBadge: 'min-w-0 max-w-full truncate font-mono text-muted-foreground',
time: 'shrink-0 text-[11px] text-muted-foreground',
resolvedContainer: 'opacity-60',
repliesContainer: 'flex flex-col',
resolvedSection: 'mt-1 border-t border-border pt-1',
resolvedSectionTrigger: RESOLVED_SECTION_TRIGGER,
resolvedSectionContent: 'flex flex-col gap-2 pb-1 pt-1',
sectionHeader: 'flex flex-col gap-2.5 border-b border-border px-3 py-2.5',
sectionHeaderLabel: 'text-[11px] font-semibold uppercase tracking-wider text-muted-foreground',
sectionCount:
'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',
audienceTabActive: 'bg-muted text-foreground shadow-xs',
sectionTriageLabel: cn('px-3 pt-1', RESOLVED_SECTION_LABEL),
statusBadgeOpen:
'shrink-0 rounded border border-status-success-border bg-status-success-background px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-status-success',
statusBadgeResolved:
'shrink-0 rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground',
statusBadgeQueued:
'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-2',
commentHeaderMeta: cn(
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',
groupQueued: 'ring-1 ring-ring/50',
groupResolved: ''
}
}
@@ -1,9 +1,48 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({
children,
onSelect
}: {
children: ReactNode
onSelect?: (event: Event) => void
}) => (
<button
type="button"
role="menuitem"
onClick={() => onSelect?.({ preventDefault: () => {} } as unknown as Event)}
>
{children}
</button>
),
DropdownMenuRadioGroup: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuRadioItem: ({
children,
onSelect
}: {
children: ReactNode
onSelect?: (event: Event) => void
}) => (
<button
type="button"
role="menuitemradio"
onClick={() => onSelect?.({ preventDefault: () => {} } as unknown as Event)}
>
{children}
</button>
),
DropdownMenuSeparator: () => <hr />,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
import type { PRComment } from '../../../../shared/types'
import type { PRCommentGroup } from '@/lib/pr-comment-groups'
import { clearPRCommentsListSelection } from './pr-comments-list-selection'
@@ -89,6 +128,25 @@ function hasButton(label: string): boolean {
)
}
function clickMenuItem(label: string): void {
clickButton('More comment actions')
const menuItem =
[...document.body.querySelectorAll('[role="menuitem"]')].find((candidate) =>
candidate.textContent?.includes(label)
) ??
[...document.body.querySelectorAll('button')].find(
(candidate) =>
candidate.textContent?.includes(label) ||
candidate.getAttribute('aria-label')?.includes(label)
)
if (!menuItem) {
throw new Error(`Menu item not found: ${label}`)
}
act(() => {
menuItem.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
}
describe('PRCommentsList comment resolution selection', () => {
it('shows the bulk action when loaded unresolved comment groups are selectable', () => {
renderList({
@@ -105,7 +163,7 @@ describe('PRCommentsList comment resolution selection', () => {
})
expect(hasButton('Send unresolved PR comments')).toBe(true)
expect(container.textContent).toContain('Add')
expect(container.textContent).not.toMatch(/\bAdd\b/)
})
it('sends all canonical groups even when the active audience filter hides the root', () => {
@@ -156,7 +214,7 @@ describe('PRCommentsList comment resolution selection', () => {
)
})
it('lets a user add one eligible comment thread to the resolve list from the row', () => {
it('lets a user queue one eligible comment thread for the agent from the row menu', () => {
const onResolveSelectedCommentsWithAI = vi.fn()
renderList({
comments: [
@@ -173,7 +231,7 @@ describe('PRCommentsList comment resolution selection', () => {
onResolveSelectedCommentsWithAI
})
clickButton('Add comment to resolve list')
clickMenuItem('Queue for agent')
expect(hasButton('Send 1 queued comments')).toBe(true)
clickButton('Send 1 queued comments')
@@ -184,7 +242,7 @@ describe('PRCommentsList comment resolution selection', () => {
expect(selectedGroups[0]?.kind === 'thread' ? selectedGroups[0].threadId : '').toBe('thread-1')
})
it('lets a user add one standalone comment to the resolve list from the row', () => {
it('lets a user queue one standalone comment for the agent from the row menu', () => {
const onResolveSelectedCommentsWithAI = vi.fn()
renderList({
comments: [
@@ -197,7 +255,7 @@ describe('PRCommentsList comment resolution selection', () => {
onResolveSelectedCommentsWithAI
})
clickButton('Add comment to resolve list')
clickMenuItem('Queue for agent')
expect(hasButton('Send 1 queued comments')).toBe(true)
clickButton('Send 1 queued comments')
@@ -215,7 +273,7 @@ describe('PRCommentsList comment resolution selection', () => {
renderList({
comments: [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })]
})
clickButton('Add comment to resolve list')
clickMenuItem('Queue for agent')
expect(hasButton('Send 1 queued comments')).toBe(true)
clickButton('Clear queued comments')
@@ -228,7 +286,7 @@ describe('PRCommentsList comment resolution selection', () => {
renderList({
comments: [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })]
})
clickButton('Add comment to resolve list')
clickMenuItem('Queue for agent')
renderList({
comments: [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: true })]
@@ -1,10 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
getPRCommentGroupId,
getPRCommentGroupRoot,
groupPRComments,
type PRCommentGroup
} from '@/lib/pr-comment-groups'
import { getPRCommentGroupId, groupPRComments, type PRCommentGroup } from '@/lib/pr-comment-groups'
import { isPRCommentGroupQueueableForAI } from '@/lib/pr-comment-action-state'
import type { PRComment } from '../../../../shared/types'
export type PRCommentsListSelection = {
@@ -101,7 +97,7 @@ export function usePRCommentsListSelection(
// audience filter doesn't silently drop already-selected comments.
const canonicalGroups = useMemo(() => groupPRComments(comments), [comments])
const selectableGroups = useMemo(
() => canonicalGroups.filter((group) => getPRCommentGroupRoot(group).isResolved !== true),
() => canonicalGroups.filter(isPRCommentGroupQueueableForAI),
[canonicalGroups]
)
const selectableGroupsById = useMemo(() => {
+10 -1
View File
@@ -8835,7 +8835,16 @@
"d91f2a6c39": "Send {{value0}} queued comments",
"a6de3e5a20": "Clear queued comments",
"49ea0937e4": "Add comment to resolve list",
"9fecebb29d": "Add"
"9fecebb29d": "Add",
"f8a2c91d04": "Queue for agent",
"b4e8a1c902": "Queued",
"7c1f0a2b11": "Open",
"e8b4c1a903": "Resolved · {{value0}}",
"c3a8e5d710": "Needs review · {{value0}}",
"8a621a2c4f": "Grouped",
"b13f85d75c": "Timeline",
"f5cf324efa": "Comment display options",
"5e6e5a13fa": "View"
},
"empty": {
"state": {
+10 -1
View File
@@ -8835,7 +8835,16 @@
"a6de3e5a20": "Borrar comentarios en cola",
"49ea0937e4": "Agregar comentario a la lista de resolución",
"9fecebb29d": "Agregar",
"b8c4e2a1f7": "View full logs"
"b8c4e2a1f7": "View full logs",
"f8a2c91d04": "Queue for agent",
"b4e8a1c902": "Queued",
"7c1f0a2b11": "Open",
"e8b4c1a903": "Resolved · {{value0}}",
"c3a8e5d710": "Needs review · {{value0}}",
"8a621a2c4f": "Grouped",
"b13f85d75c": "Timeline",
"f5cf324efa": "Comment display options",
"5e6e5a13fa": "View"
},
"empty": {
"state": {
+10 -1
View File
@@ -8835,7 +8835,16 @@
"a6de3e5a20": "キュー内のコメントをクリア",
"49ea0937e4": "コメントを解決リストに追加",
"9fecebb29d": "追加",
"b8c4e2a1f7": "View full logs"
"b8c4e2a1f7": "View full logs",
"f8a2c91d04": "Queue for agent",
"b4e8a1c902": "Queued",
"7c1f0a2b11": "Open",
"e8b4c1a903": "Resolved · {{value0}}",
"c3a8e5d710": "Needs review · {{value0}}",
"8a621a2c4f": "Grouped",
"b13f85d75c": "Timeline",
"f5cf324efa": "Comment display options",
"5e6e5a13fa": "View"
},
"empty": {
"state": {
+10 -1
View File
@@ -8835,7 +8835,16 @@
"a6de3e5a20": "대기 중인 댓글 지우기",
"49ea0937e4": "댓글을 해결 목록에 추가",
"9fecebb29d": "추가",
"b8c4e2a1f7": "전체 로그 보기"
"b8c4e2a1f7": "전체 로그 보기",
"f8a2c91d04": "Queue for agent",
"b4e8a1c902": "Queued",
"7c1f0a2b11": "Open",
"e8b4c1a903": "Resolved · {{value0}}",
"c3a8e5d710": "Needs review · {{value0}}",
"8a621a2c4f": "Grouped",
"b13f85d75c": "Timeline",
"f5cf324efa": "Comment display options",
"5e6e5a13fa": "View"
},
"empty": {
"state": {
+10 -1
View File
@@ -8835,7 +8835,16 @@
"a6de3e5a20": "清除已排队评论",
"49ea0937e4": "将评论添加到解决列表",
"9fecebb29d": "添加",
"b8c4e2a1f7": "View full logs"
"b8c4e2a1f7": "View full logs",
"f8a2c91d04": "Queue for agent",
"b4e8a1c902": "Queued",
"7c1f0a2b11": "Open",
"e8b4c1a903": "Resolved · {{value0}}",
"c3a8e5d710": "Needs review · {{value0}}",
"8a621a2c4f": "Grouped",
"b13f85d75c": "Timeline",
"f5cf324efa": "Comment display options",
"5e6e5a13fa": "View"
},
"empty": {
"state": {
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import type { PRComment } from '../../../shared/types'
import { groupPRComments } from './pr-comment-groups'
import {
getPRCommentGroupActionState,
isPRCommentGroupQueueableForAI,
partitionPRCommentGroupsForTriage,
sortPRCommentGroupsForTimeline
} from './pr-comment-action-state'
function comment(overrides: Partial<PRComment> & { id: number }): PRComment {
return {
author: 'alice',
authorAvatarUrl: '',
body: 'body',
createdAt: '2026-06-16T12:00:00Z',
url: '',
...overrides
}
}
describe('pr-comment-action-state', () => {
it('classifies resolved, open review threads, and conversation comments', () => {
const groups = groupPRComments([
comment({ id: 1, threadId: 't-open', path: 'src/a.ts', isResolved: false }),
comment({ id: 2, threadId: 't-resolved', path: 'src/b.ts', isResolved: true }),
comment({ id: 3, body: 'General discussion' })
])
expect(getPRCommentGroupActionState(groups[0]!)).toBe('open')
expect(getPRCommentGroupActionState(groups[1]!)).toBe('resolved')
expect(getPRCommentGroupActionState(groups[2]!)).toBe('conversation')
})
it('partitions groups for triage sections', () => {
const groups = groupPRComments([
comment({ id: 1, threadId: 't-open', path: 'src/a.ts', isResolved: false }),
comment({ id: 2, body: 'FYI' }),
comment({ id: 3, threadId: 't-resolved', path: 'src/b.ts', isResolved: true })
])
expect(partitionPRCommentGroupsForTriage(groups)).toEqual({
open: [groups[0]],
conversation: [groups[1]],
resolved: [groups[2]]
})
})
it('treats unknown thread resolution as conversation, not open', () => {
const [group] = groupPRComments([comment({ id: 1, threadId: 't-unknown', path: 'src/a.ts' })])
expect(getPRCommentGroupActionState(group!)).toBe('conversation')
expect(isPRCommentGroupQueueableForAI(group!)).toBe(true)
})
it('sorts comment groups chronologically for timeline mode', () => {
const groups = groupPRComments([
comment({ id: 3, createdAt: '2026-06-16T12:00:00Z', body: 'third' }),
comment({ id: 1, createdAt: '2026-06-16T10:00:00Z', body: 'first' }),
comment({ id: 2, createdAt: '2026-06-16T11:00:00Z', body: 'second' })
])
const sorted = sortPRCommentGroupsForTimeline(groups)
expect(sorted.map((group) => getPRCommentGroupActionState(group))).toEqual([
'conversation',
'conversation',
'conversation'
])
expect(
sorted.map((group) => (group.kind === 'standalone' ? group.comment.body : group.root.body))
).toEqual(['first', 'second', 'third'])
})
})
@@ -0,0 +1,58 @@
import type { PRCommentGroup } from './pr-comment-groups'
import { getPRCommentGroupId, getPRCommentGroupRoot } from './pr-comment-groups'
/** How a comment group should read in the PR sidebar triage UI. */
export type PRCommentGroupActionState = 'open' | 'conversation' | 'resolved'
/** Whether Orca knows this thread is still open on the host. */
export function getPRCommentGroupActionState(group: PRCommentGroup): PRCommentGroupActionState {
const root = getPRCommentGroupRoot(group)
if (root.isResolved === true) {
return 'resolved'
}
if (root.threadId && root.isResolved === false) {
return 'open'
}
return 'conversation'
}
/** Groups the agent can address via the resolve-comments workflow. */
export function isPRCommentGroupQueueableForAI(group: PRCommentGroup): boolean {
return getPRCommentGroupActionState(group) !== 'resolved'
}
export function partitionPRCommentGroupsForTriage(groups: readonly PRCommentGroup[]): {
open: PRCommentGroup[]
conversation: PRCommentGroup[]
resolved: PRCommentGroup[]
} {
const open: PRCommentGroup[] = []
const conversation: PRCommentGroup[] = []
const resolved: PRCommentGroup[] = []
for (const group of groups) {
const state = getPRCommentGroupActionState(group)
if (state === 'resolved') {
resolved.push(group)
} else if (state === 'open') {
open.push(group)
} else {
conversation.push(group)
}
}
return { open, conversation, resolved }
}
function groupTimelineMs(group: PRCommentGroup): number {
const ts = Date.parse(getPRCommentGroupRoot(group).createdAt)
return Number.isNaN(ts) ? 0 : ts
}
export function sortPRCommentGroupsForTimeline(
groups: readonly PRCommentGroup[]
): PRCommentGroup[] {
return [...groups].sort(
(left, right) =>
groupTimelineMs(left) - groupTimelineMs(right) ||
getPRCommentGroupId(left).localeCompare(getPRCommentGroupId(right))
)
}
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { formatPrCommentRelativeTime } from './pr-comment-time'
const NOW = Date.parse('2026-06-21T20:00:00.000Z')
describe('formatPrCommentRelativeTime', () => {
it('formats recent and older comment timestamps compactly', () => {
expect(formatPrCommentRelativeTime('2026-06-21T19:59:45.000Z', NOW)).toBe('just now')
expect(formatPrCommentRelativeTime('2026-06-21T19:35:00.000Z', NOW)).toBe('25m ago')
expect(formatPrCommentRelativeTime('2026-06-21T17:00:00.000Z', NOW)).toBe('3h ago')
expect(formatPrCommentRelativeTime('2026-06-18T20:00:00.000Z', NOW)).toBe('3d ago')
expect(formatPrCommentRelativeTime('2026-04-21T20:00:00.000Z', NOW)).toBe('2mo ago')
expect(formatPrCommentRelativeTime('2024-06-21T20:00:00.000Z', NOW)).toBe('2y ago')
})
it('returns an empty label for invalid timestamps', () => {
expect(formatPrCommentRelativeTime('not a date', NOW)).toBe('')
})
})
+29
View File
@@ -0,0 +1,29 @@
// Relative timestamp for PR comments (ISO string in, "Xm/Xh/Xd" out). Pure and
// unit-testable; nowMs is passed in so tests stay deterministic.
export function formatPrCommentRelativeTime(iso: string, nowMs: number): string {
const ts = Date.parse(iso)
if (Number.isNaN(ts)) {
return ''
}
const delta = nowMs - ts
if (delta < 60_000) {
return 'just now'
}
const minutes = Math.floor(delta / 60_000)
if (minutes < 60) {
return `${minutes}m ago`
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `${hours}h ago`
}
const days = Math.floor(hours / 24)
if (days < 30) {
return `${days}d ago`
}
const months = Math.floor(days / 30)
if (months < 12) {
return `${months}mo ago`
}
return `${Math.floor(months / 12)}y ago`
}
@@ -0,0 +1,142 @@
import type { Page } from '@stablyai/playwright-test'
import type { PRComment, PRInfo } from '../../../src/shared/types'
export type PRCommentsSidebarSeed = {
worktreeId: string
branch: string
prNumber: number
}
export const FIXTURE_COMMENTS: PRComment[] = [
{
id: 101,
author: 'alice',
authorAvatarUrl: '',
body: 'Please update this handler before merge.',
createdAt: '2026-05-14T10:00:00.000Z',
url: 'https://github.com/acme/orca/pull/73#discussion_r101',
threadId: 'thread-open',
path: 'src/handler.ts',
isResolved: false
},
{
id: 102,
author: 'bob',
authorAvatarUrl: '',
body: 'LGTM on the overall approach.',
createdAt: '2026-05-14T11:00:00.000Z',
url: 'https://github.com/acme/orca/pull/73#issuecomment-102'
},
{
id: 103,
author: 'carol',
authorAvatarUrl: '',
body: 'Already fixed upstream.',
createdAt: '2026-05-13T09:00:00.000Z',
url: 'https://github.com/acme/orca/pull/73#discussion_r103',
threadId: 'thread-resolved',
path: 'src/legacy.ts',
isResolved: true
}
]
/** Seed an open PR on e2e-secondary with mixed comment triage states for sidebar tests. */
export async function seedPRCommentsSidebarFixture(page: Page): Promise<PRCommentsSidebarSeed> {
return page.evaluate(async (fixtureComments: PRComment[]) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const state = store.getState()
const worktrees = Object.values(state.worktreesByRepo).flat()
const worktree = worktrees.find(
(entry) => entry.branch.replace(/^refs\/heads\//, '') === 'e2e-secondary'
)
if (!worktree) {
throw new Error('seeded e2e-secondary worktree not found')
}
state.setActiveWorktree(worktree.id)
const repo = state.repos.find((entry) => entry.id === worktree.repoId)
if (!repo) {
throw new Error('active repo not found')
}
const branch = worktree.branch.replace(/^refs\/heads\//, '')
const prNumber = 73
const pr: PRInfo = {
number: prNumber,
title: 'E2E PR comments sidebar',
state: 'open',
url: `https://github.com/acme/orca/pull/${prNumber}`,
checksStatus: 'pending',
updatedAt: '2026-05-15T00:00:00.000Z',
mergeable: 'MERGEABLE'
}
const prCacheEntries = {
[`${repo.id}::${branch}`]: {
data: pr,
fetchedAt: Date.now()
},
[`${repo.path}::${branch}`]: {
data: pr,
fetchedAt: Date.now()
}
}
const comments = fixtureComments
store.setState((current) => ({
prCache: {
...current.prCache,
...prCacheEntries
},
repos: current.repos.map((candidate) =>
candidate.id === repo.id ? { ...candidate, worktreeBaseRef: 'origin/main' } : candidate
),
gitStatusByWorktree: {
...current.gitStatusByWorktree,
[worktree.id]: []
},
remoteStatusesByWorktree: {
...current.remoteStatusesByWorktree,
[worktree.id]: {
hasUpstream: true,
upstreamName: `origin/${branch}`,
ahead: 0,
behind: 0
}
},
settings: current.settings
? {
...current.settings,
sourceControlAi: {
...current.settings.sourceControlAi,
enabled: true
}
}
: current.settings,
fetchPRForBranch: async (repoPath: string, targetBranch: string) => {
if (targetBranch !== branch) {
return null
}
store.setState((next) => ({
prCache: {
...next.prCache,
...prCacheEntries
}
}))
return pr
},
fetchPRChecks: async () => [],
fetchPRComments: async () => comments,
fetchUpstreamStatus: async () => undefined,
setUpstreamStatus: () => undefined
}))
window.localStorage.setItem('orca:pr-comment-presentation', 'cards')
return { worktreeId: worktree.id, branch, prNumber }
}, FIXTURE_COMMENTS)
}
+185
View File
@@ -0,0 +1,185 @@
import type { Locator } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { openChecks } from './helpers/source-control-ai-generation'
import { seedPRCommentsSidebarFixture } from './helpers/pr-comments-sidebar-fixture'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
async function visibleTextX(card: Locator, text: string): Promise<number> {
const textBox = await card.evaluate((element, targetText) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
while (walker.nextNode()) {
const node = walker.currentNode
const value = node.textContent ?? ''
const index = value.indexOf(targetText)
if (index === -1) {
continue
}
const range = document.createRange()
range.setStart(node, index)
range.setEnd(node, index + targetText.length)
const rect = range.getBoundingClientRect()
return { x: rect.x }
}
return null
}, text)
if (!textBox) {
throw new Error(`visible text not found: ${text}`)
}
return textBox.x
}
async function expectOpenTextNotShiftedLeft(
openCard: Locator,
conversationCard: Locator,
openText: string,
conversationText: string
): Promise<void> {
const delta =
(await visibleTextX(openCard, openText)) -
(await visibleTextX(conversationCard, conversationText))
// Why: the open rail is a real border, but focused row actions must not scroll content left.
expect(delta).toBeGreaterThanOrEqual(0)
expect(delta).toBeLessThanOrEqual(3)
}
test.describe('PR comments sidebar cards view', () => {
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
})
test('groups open, conversation, and resolved comments in cards layout', async ({ orcaPage }) => {
const { worktreeId } = await seedPRCommentsSidebarFixture(orcaPage)
await openChecks(orcaPage, worktreeId)
const commentsSection = orcaPage.getByText('Comments', { exact: true })
await expect(commentsSection).toBeVisible({ timeout: 10_000 })
await expect(orcaPage.getByText('Needs review · 1')).toBeVisible()
await expect(orcaPage.getByText('Please update this handler before merge.')).toBeVisible()
await expect(orcaPage.getByText('alice')).toBeVisible()
await expect(orcaPage.getByText('Open', { exact: true })).toBeVisible()
await expect(orcaPage.getByText('LGTM on the overall approach.')).toBeVisible()
const openThreadCard = orcaPage.getByTestId('pr-comment-group').filter({
hasText: 'Please update this handler before merge.'
})
const conversationCard = orcaPage.getByTestId('pr-comment-group').filter({
hasText: 'LGTM on the overall approach.'
})
await expect(openThreadCard).toBeVisible()
await expect(conversationCard).toBeVisible()
await expect(openThreadCard).toHaveClass(/shadow-xs/)
await expectOpenTextNotShiftedLeft(
openThreadCard,
conversationCard,
'Please update this handler before merge.',
'LGTM on the overall approach.'
)
await expectOpenTextNotShiftedLeft(openThreadCard, conversationCard, 'alice', 'bob')
const resolvedTrigger = orcaPage.getByRole('button', { name: 'Resolved · 1' })
await expect(resolvedTrigger).toBeVisible()
await expect(orcaPage.getByText('Already fixed upstream.')).toBeHidden()
await resolvedTrigger.click()
await expect(orcaPage.getByText('Already fixed upstream.')).toBeVisible()
await expect(orcaPage.getByText('Resolved', { exact: true })).toBeVisible()
await expect(
orcaPage
.getByTestId('pr-comment-group')
.filter({ hasText: 'Already fixed upstream.' })
.getByRole('button', { name: 'Unresolve', exact: true })
).toBeVisible()
await expect(orcaPage.getByRole('button', { name: /^Add$/ })).toHaveCount(0)
})
test('can switch from grouped to chronological timeline order', async ({ orcaPage }) => {
const { worktreeId } = await seedPRCommentsSidebarFixture(orcaPage)
await openChecks(orcaPage, worktreeId)
await expect(orcaPage.getByText('Needs review · 1')).toBeVisible({ timeout: 10_000 })
await orcaPage.getByRole('button', { name: 'Comment display options' }).click()
await orcaPage.getByRole('menuitemradio', { name: 'Timeline' }).click()
await expect(orcaPage.getByText('Needs review · 1')).toHaveCount(0)
await expect(orcaPage.getByText('Already fixed upstream.')).toBeVisible()
const comments = [
orcaPage.getByText('Already fixed upstream.'),
orcaPage.getByText('Please update this handler before merge.'),
orcaPage.getByText('LGTM on the overall approach.')
]
const positions = await Promise.all(
comments.map(async (comment) => {
const box = await comment.boundingBox()
if (!box) {
throw new Error(`Comment not visible: ${await comment.textContent()}`)
}
return box.y
})
)
expect(positions[0]).toBeLessThan(positions[1])
expect(positions[1]).toBeLessThan(positions[2])
})
test('queues an open thread for the agent from the row menu', async ({ orcaPage }) => {
const { worktreeId } = await seedPRCommentsSidebarFixture(orcaPage)
await openChecks(orcaPage, worktreeId)
await expect(orcaPage.getByText('Needs review · 1')).toBeVisible({ timeout: 10_000 })
const openThreadCard = orcaPage.getByTestId('pr-comment-group').filter({
hasText: 'Please update this handler before merge.'
})
await openThreadCard.hover()
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 })
await expect(orcaPage.getByRole('button', { name: 'Send 1 queued comments' })).toBeVisible()
await expect(orcaPage.getByText('Queued', { exact: true })).toBeVisible()
const queuedCard = orcaPage.getByTestId('pr-comment-group').filter({
hasText: 'Please update this handler before merge.'
})
const queuedCardBox = await queuedCard.boundingBox()
const checkboxBox = await orcaPage
.getByRole('checkbox', { name: 'Select comment' })
.first()
.boundingBox()
if (!queuedCardBox || !checkboxBox) {
throw new Error('queued card and checkbox must be measurable')
}
expect(checkboxBox.x - queuedCardBox.x).toBeGreaterThanOrEqual(8)
})
test('keeps open card content aligned while the row menu is open', async ({ orcaPage }) => {
const { worktreeId } = await seedPRCommentsSidebarFixture(orcaPage)
await openChecks(orcaPage, worktreeId)
await expect(orcaPage.getByText('Needs review · 1')).toBeVisible({ timeout: 10_000 })
const openThreadCard = orcaPage.getByTestId('pr-comment-group').filter({
hasText: 'Please update this handler before merge.'
})
const conversationCard = orcaPage.getByTestId('pr-comment-group').filter({
hasText: 'LGTM on the overall approach.'
})
await openThreadCard.hover()
const actionsMenu = openThreadCard.getByRole('button', { name: 'More comment actions' })
await actionsMenu.evaluate((element) => (element as HTMLElement).focus())
await actionsMenu.press('Enter')
await expect(orcaPage.getByRole('menuitem', { name: 'Queue for agent' })).toBeVisible()
await expectOpenTextNotShiftedLeft(
openThreadCard,
conversationCard,
'Please update this handler before merge.',
'LGTM on the overall approach.'
)
})
})