From 195b115cc77dccaa61b5fc78afc744826425deef Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 17 May 2026 21:14:45 -0700 Subject: [PATCH] Polish workspace card metadata UX (#2212) --- .../src/components/sidebar/SidebarHeader.tsx | 1 - .../sidebar/WorkspaceKanbanCard.tsx | 1 - .../sidebar/WorktreeCard.pr-display.test.tsx | 70 ++- .../src/components/sidebar/WorktreeCard.tsx | 260 ++++---- .../components/sidebar/WorktreeCardMeta.tsx | 553 +++++++++++------- .../WorktreeCardMetadataStatusBadges.tsx | 170 ++++++ src/shared/constants.ts | 1 - src/shared/types.ts | 1 + 8 files changed, 704 insertions(+), 353 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorktreeCardMetadataStatusBadges.tsx diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx index bacade26ae1..11ea42d867c 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx @@ -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' }, diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx index bc9aac548f5..9d75b3df87f 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx @@ -84,7 +84,6 @@ function WorkspaceKanbanCard({ isActive={isActive} isMultiSelected={isSelected} selectedWorktrees={contextWorktrees} - hideCiCheck={worktree.isPinned} nativeDragEnabled={nativeDragEnabled} onActivate={onActivate} onSelectionGesture={onSelectionGesture} diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx index 6dae3209f29..a437b7a5a2a 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx @@ -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 { } } +function makeHostedReview(overrides: Partial = {}): 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( ) - 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( + + ) + + 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( + + ) + + expect(markup).toContain('Linked PR #456') + expect(markup).toContain('text-rose-500/85') + expect(markup).not.toContain('CI checks') }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 2a04eecda5e..39900122599 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -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) => { @@ -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 = (
- {/* Header row: Title and Checks */} + {/* Header row: Title */}
{repo?.connectionId && ( @@ -503,108 +529,77 @@ const WorktreeCard = React.memo(function WorktreeCard({ )}
- -
- {/* CI Checks & PR state on the right */} - {showCI && hostedReview && hostedReview.status !== 'neutral' && ( - - - - {hostedReview.status === 'success' && ( - - )} - {hostedReview.status === 'failure' && ( - - )} - {hostedReview.status === 'pending' && ( - - )} - - - - CI checks {checksLabel(hostedReview.status).toLowerCase()} - - - )} -
{/* Subtitle row: Repo badge + Branch */}
- {repo && !hideRepoBadge && ( -
-
- - {repo.displayName} - -
- )} - - {isFolder ? ( - - {repo ? getRepoKindLabel(repo) : 'Folder'} - - ) : ( - - {branch} - - )} - - {/* 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' && ( - - - {CONFLICT_OPERATION_LABELS[conflictOperation]} - - )} - - - - {parentLabel && ( - - - - {lineageState === 'missing' ? 'Missing parent' : `from ${parentLabel}`} - - - )} -
- - {/* 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)) && ( -
- {cardProps.includes('issue') && issueDisplay && ( - +
+ {repo && !hideRepoBadge && ( +
+
+ + {repo.displayName} + +
)} - {cardProps.includes('pr') && prDisplay && ( - + + {isFolder ? ( + + {repo ? getRepoKindLabel(repo) : 'Folder'} + + ) : ( + + {branch} + )} - {cardProps.includes('comment') && worktree.comment && ( - + + {/* 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' && ( + + + {CONFLICT_OPERATION_LABELS[conflictOperation]} + + )} + + + + {parentLabel && ( + + + + {lineageState === 'missing' ? 'Missing parent' : `from ${parentLabel}`} + + )}
- )} + + +
{remoteBranchConflict && (
@@ -667,7 +662,20 @@ const WorktreeCard = React.memo(function WorktreeCard({ selectedWorktrees={selectedWorktrees} onContextMenuSelect={onContextMenuSelect} > - {cardBody} + {hasDetails ? ( + + {cardBody} + + ) : ( + cardBody + )} {repo?.connectionId && ( diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx index a8279c21b85..f7817ae2b2c 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx @@ -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 ( - - -
- -
- #{issue.number} - - {issue.title} - -
-
-
- -
- #{issue.number} {issue.title} -
- {issue.state && ( -
- State: {issue.state === 'open' ? 'Open' : 'Closed'} -
- )} - {labels.length > 0 && ( -
- {labels.map((l) => ( - - {l} - - ))} -
- )} - {issue.url && ( - - View on GitHub - - )} -
-
- ) +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 ( ) } -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 = ( - <> - -
- - {label} #{review.number} - - - {review.title} - -
- - ) - - const trigger = review.url ? ( - e.stopPropagation()} - > - {reviewRow} - - ) : ( - - ) - - const content = ( - - {trigger} - -
- {label} #{review.number} {review.title} -
- {(stateLabel || checksText) && ( -
- {stateLabel && State: {stateLabel}} - {checksText && Checks: {checksText}} -
- )} - {review.url && ( - e.stopPropagation()} - > - View on {providerName} - - )} -
-
- ) - - if (!canManageGitHubLink) { - return content - } - +function MetaIconBadge({ + label, + children +}: { + label: string + children: React.ReactNode +}): React.JSX.Element { return ( -
{ - 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} - - - + ) : ( + + ) -type CommentSectionProps = { - comment: string - onDoubleClick: (e: React.MouseEvent) => void + return ( + + {trigger} + + {label} + + + ) } -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 ( - - - - - - +
+ {issue && ( + + + + )} + {linearIssue && ( + + + + )} + {review && ( + + + + )} + {hasComment(comment) && ( + + + + )} +
+ ) +} + +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 ( + + {children} + event.stopPropagation()} + > +
+ {issue && ( +
+ } + label={`Issue #${issue.number}`} + actions={ + <> + {issue.url && ( + + + + )} + + + + + } + /> +
+
+ {issue.title} +
+ {(issue.state || issueLabels.length > 0) && ( +
+ {issue.state && } + {issueLabels.map((label) => ( + + {label} + + ))} +
+ )} +
+
+ )} + + {linearIssue && ( +
+ } + label={`Linear ${linearIssue.identifier}`} + actions={ + <> + {linearIssue.url && ( + + + + )} + + } + /> +
+
+ {linearIssue.title} +
+ {((linearIssue.labels && linearIssue.labels.length > 0) || + linearIssue.stateName) && ( +
+ {linearIssue.stateName && ( + + )} + {(linearIssue.labels ?? []).map((label) => ( + + {label} + + ))} +
+ )} +
+
+ )} + + {review && reviewLabel && reviewProvider && ( +
+ } + label={`${reviewLabel} #${review.number}`} + actions={ + <> + {review.url && ( + + + + )} + + } + /> +
+
+ {review.title} +
+ {(review.state || (review.status && review.status !== 'neutral')) && ( +
+ + +
+ )} +
+
+ )} + + {hasComment(comment) && ( +
+ } + label="Notes" + actions={ + + + + } + /> +
+ +
+
+ )} +
) diff --git a/src/renderer/src/components/sidebar/WorktreeCardMetadataStatusBadges.tsx b/src/renderer/src/components/sidebar/WorktreeCardMetadataStatusBadges.tsx new file mode 100644 index 00000000000..e3bd2f5eb6f --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCardMetadataStatusBadges.tsx @@ -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 ( + svg]:size-2.5', + className + )} + > + {children} + {label} + + ) +} + +export function IssueStateBadge({ state }: { state: IssueInfo['state'] }): React.JSX.Element { + if (state === 'closed') { + return ( + + + + ) + } + + return ( + + + + ) +} + +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 ( + + + + ) +} + +export function ReviewStateBadge({ + state, + label +}: { + state: WorktreeCardPrDisplay['state'] + label: 'MR' | 'PR' +}): React.JSX.Element | null { + if (!state) { + return null + } + + if (state === 'merged') { + return ( + + + + ) + } + + if (state === 'closed') { + return ( + + + + ) + } + + if (state === 'draft') { + return ( + + + + ) + } + + return ( + + {label === 'MR' ? : } + + ) +} + +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 ( + + + + ) + } + + if (status === 'failure') { + return ( + + + + ) + } + + return ( + + + + ) +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 4935dbebc61..a73013f207b 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -86,7 +86,6 @@ export const STAR_NAG_INITIAL_THRESHOLD = 35 export const DEFAULT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [ 'status', 'unread', - 'ci', 'issue', 'pr', 'comment', diff --git a/src/shared/types.ts b/src/shared/types.ts index 343953cf790..e4765981b8f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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'