mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
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:
co-authored by
Cursor
Orca
brennanb2025
parent
a2e0bbc5f6
commit
c6d11efbf2
@@ -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(() => {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.'
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user