Polish workspace card metadata UX (#2212)

This commit is contained in:
Neil
2026-05-17 21:14:45 -07:00
committed by GitHub
parent 0b07a8d6e6
commit 195b115cc7
8 changed files with 704 additions and 353 deletions
@@ -29,7 +29,6 @@ const GROUP_BY_OPTIONS = [
const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [
{ id: 'status', label: 'Terminal status' },
{ id: 'unread', label: 'Unread indicator' },
{ id: 'ci', label: 'CI checks' },
{ id: 'issue', label: 'Linked issue' },
{ id: 'pr', label: 'Linked PR' },
{ id: 'comment', label: 'Comment' },
@@ -84,7 +84,6 @@ function WorkspaceKanbanCard({
isActive={isActive}
isMultiSelected={isSelected}
selectedWorktrees={contextWorktrees}
hideCiCheck={worktree.isPinned}
nativeDragEnabled={nativeDragEnabled}
onActivate={onActivate}
onSelectionGesture={onSelectionGesture}
@@ -1,10 +1,12 @@
import { renderToStaticMarkup } from 'react-dom/server'
import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { Repo, Worktree, WorktreeCardProperty } from '../../../../shared/types'
const fetchHostedReviewForBranch = vi.fn()
const fetchIssue = vi.fn()
const fetchLinearIssue = vi.fn()
const openModal = vi.fn()
const updateWorktreeMeta = vi.fn()
@@ -17,9 +19,11 @@ vi.mock('@/store', () => ({
deleteStateByWorktreeId: {},
fetchHostedReviewForBranch,
fetchIssue,
fetchLinearIssue,
gitConflictOperationByWorktree: {},
hostedReviewCache,
issueCache: {},
linearIssueCache: {},
openModal,
remoteBranchConflictByWorktreeId: {},
settings: null,
@@ -89,6 +93,20 @@ function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
}
}
function makeHostedReview(overrides: Partial<HostedReviewInfo> = {}): HostedReviewInfo {
return {
provider: 'github',
number: 456,
title: 'Fix stale GH PR',
state: 'open',
url: 'https://github.com/acme/orca/pull/456',
status: 'success',
updatedAt: '2026-05-17T00:00:00.000Z',
mergeable: 'MERGEABLE',
...overrides
}
}
describe('WorktreeCard linked PR display', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -96,14 +114,60 @@ describe('WorktreeCard linked PR display', () => {
hostedReviewCache = {}
})
it('keeps the linked GH PR visible before hosted review details are cached', async () => {
it('keeps an icon-only linked GH PR badge visible before hosted review details are cached', async () => {
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree({ linkedPR: 456 })} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('PR #456')
expect(markup).toContain('Loading PR')
expect(markup).toContain('Linked PR #456')
expect(markup).not.toContain('Loading PR')
})
it('renders issue, Linear issue, PR, and notes as icon-only metadata in the closed card', async () => {
worktreeCardProperties = ['issue', 'pr', 'comment']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({
linkedIssue: 123,
linkedLinearIssue: 'ENG-123',
linkedPR: 456,
comment: 'Reviewer handoff note'
})}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('Linked issue #123')
expect(markup).toContain('Linked Linear ENG-123')
expect(markup).toContain('Linked PR #456')
expect(markup).toContain('Workspace notes')
expect(markup).not.toContain('data-slot="badge"')
expect(markup).not.toContain('Loading issue')
expect(markup).not.toContain('Loading PR')
expect(markup).not.toContain('Reviewer handoff note')
})
it('does not render the standalone CI badge and colors a failing linked PR icon red', async () => {
worktreeCardProperties = ['pr', 'ci']
hostedReviewCache = {
'local::repo-1::feature/local-branch': {
data: makeHostedReview({ status: 'failure' }),
fetchedAt: Date.now()
}
}
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree({ linkedPR: 456 })} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('Linked PR #456')
expect(markup).toContain('text-rose-500/85')
expect(markup).not.toContain('CI checks')
})
})
@@ -11,8 +11,6 @@ import {
ChevronDown,
GitMerge,
LoaderCircle,
CircleCheck,
CircleX,
Server,
ServerOff,
Workflow
@@ -27,14 +25,13 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { getWorktreeStatusLabel } from '@/lib/worktree-status'
import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { Worktree, Repo, IssueInfo } from '../../../../shared/types'
import type { Worktree, Repo, IssueInfo, LinearIssue } from '../../../../shared/types'
import { branchDisplayName, CONFLICT_OPERATION_LABELS, FilledBellIcon } from './WorktreeCardHelpers'
import {
branchDisplayName,
checksLabel,
CONFLICT_OPERATION_LABELS,
FilledBellIcon
} from './WorktreeCardHelpers'
import { IssueSection, ReviewSection, CommentSection } from './WorktreeCardMeta'
WorktreeCardDetailsHover,
WorktreeCardMetaBadges,
hasWorktreeCardDetails
} from './WorktreeCardMeta'
import { writeWorkspaceDragData } from './workspace-status'
import { useWorktreeActivityStatus } from './use-worktree-activity-status'
import { getWorktreeCardPrDisplay } from './worktree-card-pr-display'
@@ -46,7 +43,6 @@ type WorktreeCardProps = {
isMultiSelected?: boolean
selectedWorktrees?: readonly Worktree[]
hideRepoBadge?: boolean
hideCiCheck?: boolean
parentLabel?: string
lineageState?: 'valid' | 'missing'
lineageChildCount?: number
@@ -75,7 +71,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
onContextMenuSelect,
nativeDragEnabled = true,
hideRepoBadge,
hideCiCheck = false,
parentLabel,
lineageState,
lineageChildCount = 0,
@@ -88,6 +83,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch)
const settings = useAppStore((s) => s.settings)
const fetchIssue = useAppStore((s) => s.fetchIssue)
const fetchLinearIssue = useAppStore((s) => s.fetchLinearIssue)
const cardProps = useAppStore((s) => s.worktreeCardProperties)
const handleEditIssue = useCallback(
(e: React.MouseEvent) => {
@@ -119,21 +115,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
[worktree, openModal]
)
const handleEditPr = useCallback(() => {
openModal('edit-meta', {
worktreeId: worktree.id,
currentDisplayName: worktree.displayName,
currentIssue: worktree.linkedIssue,
currentPR: worktree.linkedPR,
currentComment: worktree.comment,
focus: 'pr'
})
}, [worktree, openModal])
const handleRemovePr = useCallback(() => {
updateWorktreeMeta(worktree.id, { linkedPR: null })
}, [worktree.id, updateWorktreeMeta])
const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id])
const conflictOperation = useAppStore((s) => s.gitConflictOperationByWorktree[worktree.id])
const remoteBranchConflict = useAppStore((s) => s.remoteBranchConflictByWorktreeId[worktree.id])
@@ -168,12 +149,21 @@ const WorktreeCard = React.memo(function WorktreeCard({
const hostedReviewCacheKey =
repo && branch ? getHostedReviewCacheKey(repo.path, branch, settings, repo.id) : ''
const issueCacheKey = repo && worktree.linkedIssue ? `${repo.id}::${worktree.linkedIssue}` : ''
const linearIssueCacheKey = worktree.linkedLinearIssue
? `selected::${worktree.linkedLinearIssue}`
: ''
// Subscribe to ONLY the specific cache entry, not entire review/issue caches.
const hostedReviewEntry = useAppStore((s) =>
hostedReviewCacheKey ? s.hostedReviewCache[hostedReviewCacheKey] : undefined
)
const issueEntry = useAppStore((s) => (issueCacheKey ? s.issueCache[issueCacheKey] : undefined))
const linearIssueEntry = useAppStore((s) =>
linearIssueCacheKey ? s.linearIssueCache[linearIssueCacheKey] : undefined
)
const linearIssueFallbackEntry = useAppStore((s) =>
worktree.linkedLinearIssue ? s.linearIssueCache[worktree.linkedLinearIssue] : undefined
)
const hostedReview: HostedReviewInfo | null | undefined =
hostedReviewEntry !== undefined ? hostedReviewEntry.data : undefined
@@ -194,19 +184,39 @@ const WorktreeCard = React.memo(function WorktreeCard({
title: issue === null ? 'Issue details unavailable' : 'Loading issue...'
}
: null)
const linearIssue: LinearIssue | null | undefined = worktree.linkedLinearIssue
? (linearIssueEntry?.data ?? linearIssueFallbackEntry?.data)
: null
const linearIssueDisplay = worktree.linkedLinearIssue
? linearIssue
? {
identifier: linearIssue.identifier,
title: linearIssue.title,
url: linearIssue.url,
stateName: linearIssue.state?.name,
labels: linearIssue.labels
}
: {
identifier: worktree.linkedLinearIssue,
title:
linearIssueEntry || linearIssueFallbackEntry
? 'Linear issue details unavailable'
: 'Loading Linear issue...'
}
: null
const isDeleting = deleteState?.isDeleting ?? false
const status = useWorktreeActivityStatus(worktree.id)
const showPR = cardProps.includes('pr')
const showCI = !hideCiCheck && cardProps.includes('ci')
const showIssue = cardProps.includes('issue')
const showComment = cardProps.includes('comment')
// Skip hosted-review fetches when the corresponding card sections are hidden.
// This preference is purely presentational, so background refreshes would
// spend rate limit budget on data the user cannot see.
useEffect(() => {
if (repo && !isFolder && !worktree.isBare && hostedReviewCacheKey && (showPR || showCI)) {
if (repo && !isFolder && !worktree.isBare && hostedReviewCacheKey && showPR) {
// Why: pass linkedPR so worktrees created from a PR (whose new local
// branch differs from the remote head ref) still resolve their PR/MR via
// a number-based fallback in the main process.
@@ -226,8 +236,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
fetchHostedReviewForBranch,
branch,
hostedReviewCacheKey,
showPR,
showCI
showPR
])
// Same rationale for issues: once that section is hidden, polling only burns
@@ -247,6 +256,13 @@ const WorktreeCard = React.memo(function WorktreeCard({
return () => clearInterval(interval)
}, [repo, isFolder, worktree.linkedIssue, fetchIssue, issueCacheKey, showIssue])
useEffect(() => {
if (!worktree.linkedLinearIssue || !showIssue) {
return
}
void fetchLinearIssue(worktree.linkedLinearIssue)
}, [worktree.linkedLinearIssue, fetchLinearIssue, showIssue])
// Stable click handler – ignore clicks that are really text selections.
const handleClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
@@ -349,6 +365,16 @@ const WorktreeCard = React.memo(function WorktreeCard({
// as if the workspace is read so bold emphasis never appears. The persisted
// `worktree.isUnread` flag is unchanged; only the rendering changes.
const showUnreadEmphasis = cardProps.includes('unread') && worktree.isUnread
const metaIssue = showIssue ? issueDisplay : null
const metaLinearIssue = showIssue ? linearIssueDisplay : null
const metaReview = showPR ? prDisplay : null
const metaComment = showComment ? worktree.comment : null
const hasDetails = hasWorktreeCardDetails({
issue: metaIssue,
linearIssue: metaLinearIssue,
review: metaReview,
comment: metaComment
})
const cardBody = (
<div
@@ -422,7 +448,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
{/* Content area */}
<div className="flex-1 min-w-0 flex flex-col gap-1.5">
{/* Header row: Title and Checks */}
{/* Header row: Title */}
<div className="flex items-center justify-between min-w-0 gap-2">
<div className="flex items-center gap-1.5 min-w-0">
{repo?.connectionId && (
@@ -503,108 +529,77 @@ const WorktreeCard = React.memo(function WorktreeCard({
</Tooltip>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{/* CI Checks & PR state on the right */}
{showCI && hostedReview && hostedReview.status !== 'neutral' && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center opacity-80 hover:opacity-100 transition-opacity">
{hostedReview.status === 'success' && (
<CircleCheck className="size-3.5 text-emerald-500" />
)}
{hostedReview.status === 'failure' && (
<CircleX className="size-3.5 text-rose-500" />
)}
{hostedReview.status === 'pending' && (
<LoaderCircle className="size-3.5 text-amber-500 animate-spin" />
)}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<span>CI checks {checksLabel(hostedReview.status).toLowerCase()}</span>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
{/* Subtitle row: Repo badge + Branch */}
<div className="flex items-center gap-1.5 min-w-0">
{repo && !hideRepoBadge && (
<div className="flex items-center gap-1.5 shrink-0 px-1.5 py-0.5 rounded-[4px] bg-accent border border-border dark:bg-accent/50 dark:border-border/60">
<div className="size-1.5 rounded-full" style={{ backgroundColor: repo.badgeColor }} />
<span className="text-[10px] font-semibold text-foreground truncate max-w-[6rem] leading-none lowercase">
{repo.displayName}
</span>
</div>
)}
{isFolder ? (
<Badge
variant="secondary"
className="h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 text-muted-foreground bg-accent border border-border dark:bg-accent/80 dark:border-border/50 leading-none"
>
{repo ? getRepoKindLabel(repo) : 'Folder'}
</Badge>
) : (
<span className="text-[11px] text-muted-foreground truncate leading-none">
{branch}
</span>
)}
{/* Why: the conflict operation (merge/rebase/cherry-pick) is the
only signal that the worktree is in an incomplete operation state.
Showing it on the card lets the user spot worktrees that need
attention without switching to them first. */}
{conflictOperation && conflictOperation !== 'unknown' && (
<Badge
variant="outline"
className="h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 gap-1 text-amber-600 border-amber-500/30 bg-amber-500/5 dark:text-amber-400 dark:border-amber-400/30 dark:bg-amber-400/5 leading-none"
>
<GitMerge className="size-2.5" />
{CONFLICT_OPERATION_LABELS[conflictOperation]}
</Badge>
)}
<CacheTimer worktreeId={worktree.id} />
{parentLabel && (
<Badge
variant="outline"
className={cn(
'h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 gap-1 leading-none',
lineageState === 'missing'
? 'text-muted-foreground border-border bg-muted/40'
: 'text-muted-foreground border-border bg-accent/50'
)}
>
<Workflow className="size-2.5" />
<span className="max-w-[7rem] truncate">
{lineageState === 'missing' ? 'Missing parent' : `from ${parentLabel}`}
</span>
</Badge>
)}
</div>
{/* Meta section: Issue / hosted review / Comment
Layout coupling: spacing here is used to derive size estimates in
WorktreeList's estimateSize. Update that function if changing spacing. */}
{((cardProps.includes('issue') && issueDisplay) ||
(cardProps.includes('pr') && prDisplay) ||
(cardProps.includes('comment') && worktree.comment)) && (
<div className="flex flex-col gap-[3px] mt-0.5">
{cardProps.includes('issue') && issueDisplay && (
<IssueSection issue={issueDisplay} onClick={handleEditIssue} />
<div className="flex min-w-0 flex-1 items-center gap-1.5">
{repo && !hideRepoBadge && (
<div className="flex items-center gap-1.5 shrink-0 px-1.5 py-0.5 rounded-[4px] bg-accent border border-border dark:bg-accent/50 dark:border-border/60">
<div
className="size-1.5 rounded-full"
style={{ backgroundColor: repo.badgeColor }}
/>
<span className="text-[10px] font-semibold text-foreground truncate max-w-[6rem] leading-none lowercase">
{repo.displayName}
</span>
</div>
)}
{cardProps.includes('pr') && prDisplay && (
<ReviewSection review={prDisplay} onEdit={handleEditPr} onRemove={handleRemovePr} />
{isFolder ? (
<Badge
variant="secondary"
className="h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 text-muted-foreground bg-accent border border-border dark:bg-accent/80 dark:border-border/50 leading-none"
>
{repo ? getRepoKindLabel(repo) : 'Folder'}
</Badge>
) : (
<span className="min-w-0 text-[11px] text-muted-foreground truncate leading-none">
{branch}
</span>
)}
{cardProps.includes('comment') && worktree.comment && (
<CommentSection comment={worktree.comment} onDoubleClick={handleEditComment} />
{/* Why: the conflict operation (merge/rebase/cherry-pick) is the
only signal that the worktree is in an incomplete operation state.
Showing it on the card lets the user spot worktrees that need
attention without switching to them first. */}
{conflictOperation && conflictOperation !== 'unknown' && (
<Badge
variant="outline"
className="h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 gap-1 text-amber-600 border-amber-500/30 bg-amber-500/5 dark:text-amber-400 dark:border-amber-400/30 dark:bg-amber-400/5 leading-none"
>
<GitMerge className="size-2.5" />
{CONFLICT_OPERATION_LABELS[conflictOperation]}
</Badge>
)}
<CacheTimer worktreeId={worktree.id} />
{parentLabel && (
<Badge
variant="outline"
className={cn(
'h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 gap-1 leading-none',
lineageState === 'missing'
? 'text-muted-foreground border-border bg-muted/40'
: 'text-muted-foreground border-border bg-accent/50'
)}
>
<Workflow className="size-2.5" />
<span className="max-w-[7rem] truncate">
{lineageState === 'missing' ? 'Missing parent' : `from ${parentLabel}`}
</span>
</Badge>
)}
</div>
)}
<WorktreeCardMetaBadges
issue={metaIssue}
linearIssue={metaLinearIssue}
review={metaReview}
comment={metaComment}
/>
</div>
{remoteBranchConflict && (
<div className="mt-0.5 flex items-start gap-1.5 rounded border border-amber-500/25 bg-amber-500/5 px-1.5 py-1 text-[10.5px] leading-snug text-amber-700 dark:text-amber-300">
@@ -667,7 +662,20 @@ const WorktreeCard = React.memo(function WorktreeCard({
selectedWorktrees={selectedWorktrees}
onContextMenuSelect={onContextMenuSelect}
>
{cardBody}
{hasDetails ? (
<WorktreeCardDetailsHover
issue={metaIssue}
linearIssue={metaLinearIssue}
review={metaReview}
comment={metaComment}
onEditIssue={handleEditIssue}
onEditComment={handleEditComment}
>
{cardBody}
</WorktreeCardDetailsHover>
) : (
cardBody
)}
</WorktreeContextMenu>
{repo?.connectionId && (
@@ -1,101 +1,64 @@
/**
* Issue, review, and Comment meta sections for WorktreeCard.
*
* Why extracted: keeps WorktreeCard.tsx under the 400-line oxlint limit
* while co-locating the HoverCard presentation for each metadata type.
*/
import React from 'react'
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
import { CircleDot, GitMerge, Pencil, Unlink } from 'lucide-react'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { CircleDot, ExternalLink, GitMerge, Pencil, StickyNote } from 'lucide-react'
import { cn } from '@/lib/utils'
import { LinearIcon } from '@/components/icons/LinearIcon'
import CommentMarkdown from './CommentMarkdown'
import { PullRequestIcon, prStateLabel, checksLabel } from './WorktreeCardHelpers'
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
import { PullRequestIcon } from './WorktreeCardHelpers'
import {
CLOSE_ALL_CONTEXT_MENUS_EVENT,
WORKTREE_CONTEXT_MENU_SCOPE_ATTR
} from './WorktreeContextMenu'
IssueStateBadge,
LinearStateBadge,
ReviewChecksBadge,
ReviewStateBadge
} from './WorktreeCardMetadataStatusBadges'
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
import type { IssueInfo } from '../../../../shared/types'
// ── Issue section ────────────────────────────────────────────────────
export type WorktreeCardIssueDisplay =
| IssueInfo
| {
number: number
title: string
state?: IssueInfo['state']
url?: string
labels?: string[]
}
type IssueSectionProps = {
issue:
| IssueInfo
| {
number: number
title: string
state?: IssueInfo['state']
url?: string
labels?: string[]
}
onClick: (e: React.MouseEvent) => void
export type WorktreeCardLinearIssueDisplay = {
identifier: string
title: string
url?: string
stateName?: string
labels?: string[]
}
export function IssueSection({ issue, onClick }: IssueSectionProps): React.JSX.Element {
const labels = issue.labels ?? []
return (
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>
<div
className="flex items-center gap-1.5 min-w-0 cursor-pointer group/meta -mx-1.5 px-1.5 py-0.5 rounded transition-colors hover:bg-background/40"
onClick={onClick}
>
<CircleDot className="size-3 shrink-0 text-muted-foreground opacity-60" />
<div className="flex-1 min-w-0 flex items-center gap-1.5 text-[11.5px] leading-none">
<span className="text-foreground opacity-80 font-medium shrink-0">#{issue.number}</span>
<span className="text-muted-foreground truncate group-hover/meta:text-foreground transition-colors">
{issue.title}
</span>
</div>
</div>
</HoverCardTrigger>
<HoverCardContent side="right" align="start" className="w-72 p-3 text-xs space-y-1.5">
<div className="font-semibold text-[13px]">
#{issue.number} {issue.title}
</div>
{issue.state && (
<div className="text-muted-foreground">
State: {issue.state === 'open' ? 'Open' : 'Closed'}
</div>
)}
{labels.length > 0 && (
<div className="flex flex-wrap gap-1">
{labels.map((l) => (
<Badge key={l} variant="outline" className="h-4 px-1.5 text-[9px]">
{l}
</Badge>
))}
</div>
)}
{issue.url && (
<a
href={issue.url}
target="_blank"
rel="noreferrer"
className="text-muted-foreground underline underline-offset-2 hover:text-foreground"
>
View on GitHub
</a>
)}
</HoverCardContent>
</HoverCard>
)
type WorktreeCardMetaBadgesProps = {
issue: WorktreeCardIssueDisplay | null
linearIssue: WorktreeCardLinearIssueDisplay | null
review: WorktreeCardPrDisplay | null
comment: string | null
}
// ── Hosted review section ────────────────────────────────────────────
type WorktreeCardDetailsHoverProps = WorktreeCardMetaBadgesProps & {
children: React.ReactElement
onEditIssue: (event: React.MouseEvent) => void
onEditComment: (event: React.MouseEvent) => void
}
type ReviewSectionProps = {
review: WorktreeCardPrDisplay
onEdit: () => void
onRemove: () => void
function hasComment(comment: string | null): boolean {
return (comment ?? '').trim().length > 0
}
export function hasWorktreeCardDetails({
issue,
linearIssue,
review,
comment
}: WorktreeCardMetaBadgesProps): boolean {
return Boolean(issue || linearIssue || review || hasComment(comment))
}
function getReviewLabel(review: WorktreeCardPrDisplay): 'MR' | 'PR' {
@@ -118,162 +81,310 @@ function getProviderName(review: WorktreeCardPrDisplay): string {
return 'GitHub'
}
function ReviewIcon({ review }: { review: WorktreeCardPrDisplay }): React.JSX.Element {
function ReviewIcon({
review,
className
}: {
review: WorktreeCardPrDisplay
className?: string
}): React.JSX.Element {
const Icon = review.provider === 'gitlab' ? GitMerge : PullRequestIcon
// Why: the standalone CI glyph was removed from the card header, so linked
// PR metadata carries check health unless the review is already merged.
const checkTone =
review.state !== 'merged' && review.status === 'failure'
? 'text-rose-500/85'
: review.state !== 'merged' && review.status === 'pending'
? 'text-amber-500/85'
: review.state === 'open' && review.status === 'success'
? 'text-emerald-500/80'
: null
return (
<Icon
className={cn(
'size-3 shrink-0',
className,
checkTone,
review.state === 'merged' && 'text-purple-600/70 dark:text-purple-400/70',
review.state === 'open' && 'text-emerald-500/80',
review.state === 'closed' && 'text-muted-foreground/60',
review.state === 'draft' && 'text-muted-foreground/50',
(!review.state || !['merged', 'open', 'closed', 'draft'].includes(review.state)) &&
'text-muted-foreground opacity-60'
!checkTone && review.state === 'open' && 'text-emerald-500/80',
!checkTone && review.state === 'closed' && 'text-muted-foreground/60',
!checkTone && review.state === 'draft' && 'text-muted-foreground/50',
!checkTone &&
(!review.state || !['merged', 'open', 'closed', 'draft'].includes(review.state)) &&
'text-muted-foreground opacity-70'
)}
/>
)
}
export function ReviewSection({ review, onEdit, onRemove }: ReviewSectionProps): React.JSX.Element {
const [menuOpen, setMenuOpen] = React.useState(false)
const [menuPoint, setMenuPoint] = React.useState({ x: 0, y: 0 })
const label = getReviewLabel(review)
const providerName = getProviderName(review)
const checksText =
review.status !== undefined && review.status !== 'neutral' ? checksLabel(review.status) : null
const canManageGitHubLink = review.provider === 'github'
const stateLabel = review.state ? prStateLabel(review.state) : null
const reviewRow = (
<>
<ReviewIcon review={review} />
<div className="flex-1 min-w-0 flex items-center gap-1.5 text-[11.5px] leading-none">
<span className="text-foreground opacity-80 shrink-0 group-hover/meta:underline">
{label} #{review.number}
</span>
<span className="text-muted-foreground truncate group-hover/meta:text-foreground transition-colors">
{review.title}
</span>
</div>
</>
)
const trigger = review.url ? (
<a
href={review.url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 min-w-0 cursor-pointer group/meta -mx-1.5 px-1.5 py-0.5 rounded transition-colors hover:bg-background/40"
onClick={(e) => e.stopPropagation()}
>
{reviewRow}
</a>
) : (
<button
type="button"
className="flex w-full items-center gap-1.5 min-w-0 cursor-pointer group/meta -mx-1.5 px-1.5 py-0.5 rounded transition-colors hover:bg-background/40 text-left"
onClick={(e) => {
e.stopPropagation()
onEdit()
}}
>
{reviewRow}
</button>
)
const content = (
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>{trigger}</HoverCardTrigger>
<HoverCardContent side="right" align="start" className="w-72 p-3 text-xs space-y-1.5">
<div className="font-semibold text-[13px]">
{label} #{review.number} {review.title}
</div>
{(stateLabel || checksText) && (
<div className="flex items-center gap-2 text-muted-foreground">
{stateLabel && <span>State: {stateLabel}</span>}
{checksText && <span>Checks: {checksText}</span>}
</div>
)}
{review.url && (
<a
href={review.url}
target="_blank"
rel="noreferrer"
className="text-muted-foreground underline underline-offset-2 hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
View on {providerName}
</a>
)}
</HoverCardContent>
</HoverCard>
)
if (!canManageGitHubLink) {
return content
}
function MetaIconBadge({
label,
children
}: {
label: string
children: React.ReactNode
}): React.JSX.Element {
return (
<div
className="relative"
{...{ [WORKTREE_CONTEXT_MENU_SCOPE_ATTR]: 'pr' }}
onContextMenuCapture={(event) => {
event.preventDefault()
event.stopPropagation()
window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
const bounds = event.currentTarget.getBoundingClientRect()
setMenuPoint({ x: event.clientX - bounds.left, y: event.clientY - bounds.top })
setMenuOpen(true)
}}
>
{content}
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen} modal={false}>
<DropdownMenuTrigger asChild>
<button
aria-hidden
tabIndex={-1}
className="pointer-events-none absolute size-px opacity-0"
style={{ left: menuPoint.x, top: menuPoint.y }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-44" sideOffset={0} align="start">
<DropdownMenuItem onSelect={onEdit}>
<Pencil className="size-3.5" />
Update GH PR
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onSelect={onRemove}>
<Unlink className="size-3.5" />
Remove GH PR
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<span className="inline-flex size-3.5 shrink-0 items-center justify-center text-muted-foreground/70 hover:text-foreground [&>svg]:size-3.5">
{children}
<span className="sr-only">{label}</span>
</span>
)
}
function DetailHeader({
icon,
label,
actions
}: {
icon: React.ReactNode
label: string
actions?: React.ReactNode
}): React.JSX.Element {
return (
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{icon}
<span className="truncate">{label}</span>
</div>
{actions && <div className="flex shrink-0 items-center gap-0.5">{actions}</div>}
</div>
)
}
// ── Comment section ──────────────────────────────────────────────────
function MetadataActionIcon({
label,
href,
onClick,
children
}: {
label: string
href?: string
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void
children: React.ReactNode
}): React.JSX.Element {
const trigger = href ? (
<Button asChild variant="ghost" size="icon-xs" className="size-6">
<a
href={href}
target="_blank"
rel="noreferrer"
aria-label={label}
onClick={(event) => event.stopPropagation()}
>
{children}
</a>
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-6"
aria-label={label}
onClick={(event) => {
event.stopPropagation()
onClick?.(event)
}}
>
{children}
</Button>
)
type CommentSectionProps = {
comment: string
onDoubleClick: (e: React.MouseEvent) => void
return (
<Tooltip>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
)
}
export function CommentSection({ comment, onDoubleClick }: CommentSectionProps): React.JSX.Element {
export function WorktreeCardMetaBadges({
issue,
linearIssue,
review,
comment
}: WorktreeCardMetaBadgesProps): React.JSX.Element | null {
if (!hasWorktreeCardDetails({ issue, linearIssue, review, comment })) {
return null
}
return (
<HoverCard openDelay={400}>
<HoverCardTrigger asChild>
<CommentMarkdown
content={comment}
className="text-[11px] text-muted-foreground break-words -mx-1.5 px-1.5 py-0.5 rounded transition-colors leading-normal line-clamp-2 [&_.comment-md-p]:inline [&_.comment-md-p+.comment-md-p]:before:content-['_']"
onDoubleClick={onDoubleClick}
/>
</HoverCardTrigger>
<HoverCardContent side="right" align="start" className="w-72 max-h-80 overflow-y-auto p-3">
<CommentMarkdown
content={comment}
className="text-[11.5px] text-foreground break-words leading-normal [&_.comment-md-p]:block [&_.comment-md-p+.comment-md-p]:mt-1"
/>
<div className="ml-auto flex shrink-0 items-center gap-1" aria-label="Workspace metadata">
{issue && (
<MetaIconBadge label={`Linked issue #${issue.number}`}>
<CircleDot className="text-muted-foreground" />
</MetaIconBadge>
)}
{linearIssue && (
<MetaIconBadge label={`Linked Linear ${linearIssue.identifier}`}>
<LinearIcon className="text-muted-foreground" />
</MetaIconBadge>
)}
{review && (
<MetaIconBadge label={`Linked ${getReviewLabel(review)} #${review.number}`}>
<ReviewIcon review={review} />
</MetaIconBadge>
)}
{hasComment(comment) && (
<MetaIconBadge label="Workspace notes">
<StickyNote className="text-muted-foreground" />
</MetaIconBadge>
)}
</div>
)
}
export function WorktreeCardDetailsHover({
issue,
linearIssue,
review,
comment,
children,
onEditIssue,
onEditComment
}: WorktreeCardDetailsHoverProps): React.JSX.Element {
if (!hasWorktreeCardDetails({ issue, linearIssue, review, comment })) {
return children
}
const reviewLabel = review ? getReviewLabel(review) : null
const reviewProvider = review ? getProviderName(review) : null
const issueLabels = issue?.labels ?? []
return (
<HoverCard openDelay={250} closeDelay={120}>
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
<HoverCardContent
side="right"
align="start"
sideOffset={8}
className="w-80 max-h-[28rem] overflow-y-auto p-3 text-xs"
onClick={(event) => event.stopPropagation()}
>
<div className="space-y-3">
{issue && (
<section className="space-y-1.5">
<DetailHeader
icon={<CircleDot className="size-3 text-muted-foreground" />}
label={`Issue #${issue.number}`}
actions={
<>
{issue.url && (
<MetadataActionIcon label="View on GitHub" href={issue.url}>
<ExternalLink className="size-3" />
</MetadataActionIcon>
)}
<MetadataActionIcon label="Edit issue" onClick={onEditIssue}>
<Pencil className="size-3" />
</MetadataActionIcon>
</>
}
/>
<div className="space-y-1.5">
<div className="text-[13px] font-semibold leading-snug text-foreground break-words">
{issue.title}
</div>
{(issue.state || issueLabels.length > 0) && (
<div className="flex flex-wrap gap-1">
{issue.state && <IssueStateBadge state={issue.state} />}
{issueLabels.map((label) => (
<Badge key={label} variant="outline" className="h-4 px-1.5 text-[9px]">
{label}
</Badge>
))}
</div>
)}
</div>
</section>
)}
{linearIssue && (
<section className="space-y-1.5">
<DetailHeader
icon={<LinearIcon className="size-3 text-muted-foreground" />}
label={`Linear ${linearIssue.identifier}`}
actions={
<>
{linearIssue.url && (
<MetadataActionIcon label="View on Linear" href={linearIssue.url}>
<ExternalLink className="size-3" />
</MetadataActionIcon>
)}
</>
}
/>
<div className="space-y-1.5">
<div className="text-[13px] font-semibold leading-snug text-foreground break-words">
{linearIssue.title}
</div>
{((linearIssue.labels && linearIssue.labels.length > 0) ||
linearIssue.stateName) && (
<div className="flex flex-wrap gap-1">
{linearIssue.stateName && (
<LinearStateBadge stateName={linearIssue.stateName} />
)}
{(linearIssue.labels ?? []).map((label) => (
<Badge key={label} variant="outline" className="h-4 px-1.5 text-[9px]">
{label}
</Badge>
))}
</div>
)}
</div>
</section>
)}
{review && reviewLabel && reviewProvider && (
<section className="space-y-1.5">
<DetailHeader
icon={<ReviewIcon review={review} className="size-3" />}
label={`${reviewLabel} #${review.number}`}
actions={
<>
{review.url && (
<MetadataActionIcon label={`View on ${reviewProvider}`} href={review.url}>
<ExternalLink className="size-3" />
</MetadataActionIcon>
)}
</>
}
/>
<div className="space-y-1.5">
<div className="text-[13px] font-semibold leading-snug text-foreground break-words">
{review.title}
</div>
{(review.state || (review.status && review.status !== 'neutral')) && (
<div className="flex flex-wrap gap-1">
<ReviewStateBadge state={review.state} label={reviewLabel} />
<ReviewChecksBadge status={review.status} />
</div>
)}
</div>
</section>
)}
{hasComment(comment) && (
<section className="space-y-1.5">
<DetailHeader
icon={<StickyNote className="size-3 text-muted-foreground" />}
label="Notes"
actions={
<MetadataActionIcon label="Edit notes" onClick={onEditComment}>
<Pencil className="size-3" />
</MetadataActionIcon>
}
/>
<div className="space-y-2">
<CommentMarkdown
content={comment ?? ''}
className="text-[11.5px] text-foreground break-words leading-normal [&_.comment-md-p]:block [&_.comment-md-p+.comment-md-p]:mt-1"
/>
</div>
</section>
)}
</div>
</HoverCardContent>
</HoverCard>
)
@@ -0,0 +1,170 @@
import React from 'react'
import { Badge } from '@/components/ui/badge'
import { CircleCheck, CircleDot, CircleX, Clock, GitMerge } from 'lucide-react'
import { cn } from '@/lib/utils'
import { PullRequestIcon, checksLabel } from './WorktreeCardHelpers'
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
import type { IssueInfo } from '../../../../shared/types'
function MetadataStatusBadge({
label,
children,
className
}: {
label: string
children: React.ReactNode
className?: string
}): React.JSX.Element {
return (
<Badge
variant="outline"
className={cn(
'h-4 gap-1 rounded px-1.5 text-[9px] font-medium leading-none [&>svg]:size-2.5',
className
)}
>
{children}
<span>{label}</span>
</Badge>
)
}
export function IssueStateBadge({ state }: { state: IssueInfo['state'] }): React.JSX.Element {
if (state === 'closed') {
return (
<MetadataStatusBadge
label="State: Closed"
className="border-purple-500/25 bg-purple-500/5 text-purple-600 dark:text-purple-300"
>
<CircleCheck />
</MetadataStatusBadge>
)
}
return (
<MetadataStatusBadge
label="State: Open"
className="border-emerald-500/25 bg-emerald-500/5 text-emerald-600 dark:text-emerald-300"
>
<CircleDot />
</MetadataStatusBadge>
)
}
export function LinearStateBadge({ stateName }: { stateName: string }): React.JSX.Element {
const normalized = stateName.toLowerCase()
const done = /done|closed|complete|completed|merged|resolved/.test(normalized)
const cancelled = /cancel|canceled|duplicate|wontfix/.test(normalized)
const active = /progress|doing|started|active/.test(normalized)
const Icon = done ? CircleCheck : cancelled ? CircleX : active ? Clock : CircleDot
const tone = done
? 'border-purple-500/25 bg-purple-500/5 text-purple-600 dark:text-purple-300'
: cancelled
? 'border-rose-500/25 bg-rose-500/5 text-rose-600 dark:text-rose-300'
: active
? 'border-amber-500/25 bg-amber-500/5 text-amber-600 dark:text-amber-300'
: 'border-border bg-muted/30 text-muted-foreground'
return (
<MetadataStatusBadge label={`State: ${stateName}`} className={tone}>
<Icon />
</MetadataStatusBadge>
)
}
export function ReviewStateBadge({
state,
label
}: {
state: WorktreeCardPrDisplay['state']
label: 'MR' | 'PR'
}): React.JSX.Element | null {
if (!state) {
return null
}
if (state === 'merged') {
return (
<MetadataStatusBadge
label="State: Merged"
className="border-purple-500/25 bg-purple-500/5 text-purple-600 dark:text-purple-300"
>
<GitMerge />
</MetadataStatusBadge>
)
}
if (state === 'closed') {
return (
<MetadataStatusBadge
label="State: Closed"
className="border-rose-500/25 bg-rose-500/5 text-rose-600 dark:text-rose-300"
>
<CircleX />
</MetadataStatusBadge>
)
}
if (state === 'draft') {
return (
<MetadataStatusBadge
label={`State: Draft ${label}`}
className="border-border bg-muted/30 text-muted-foreground"
>
<CircleDot />
</MetadataStatusBadge>
)
}
return (
<MetadataStatusBadge
label="State: Open"
className="border-emerald-500/25 bg-emerald-500/5 text-emerald-600 dark:text-emerald-300"
>
{label === 'MR' ? <GitMerge /> : <PullRequestIcon />}
</MetadataStatusBadge>
)
}
export function ReviewChecksBadge({
status
}: {
status: WorktreeCardPrDisplay['status']
}): React.JSX.Element | null {
if (!status || status === 'neutral') {
return null
}
const label = `Checks: ${checksLabel(status)}`
if (status === 'success') {
return (
<MetadataStatusBadge
label={label}
className="border-emerald-500/25 bg-emerald-500/5 text-emerald-600 dark:text-emerald-300"
>
<CircleCheck />
</MetadataStatusBadge>
)
}
if (status === 'failure') {
return (
<MetadataStatusBadge
label={label}
className="border-rose-500/25 bg-rose-500/5 text-rose-600 dark:text-rose-300"
>
<CircleX />
</MetadataStatusBadge>
)
}
return (
<MetadataStatusBadge
label={label}
className="border-amber-500/25 bg-amber-500/5 text-amber-600 dark:text-amber-300"
>
<Clock />
</MetadataStatusBadge>
)
}
-1
View File
@@ -86,7 +86,6 @@ export const STAR_NAG_INITIAL_THRESHOLD = 35
export const DEFAULT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [
'status',
'unread',
'ci',
'issue',
'pr',
'comment',
+1
View File
@@ -1754,6 +1754,7 @@ export type NotificationPermissionStatusResult = {
export type WorktreeCardProperty =
| 'status'
| 'unread'
// Legacy persisted preference. CI status is now represented by linked PR metadata.
| 'ci'
| 'issue'
| 'pr'