mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
Polish workspace card metadata UX (#2212)
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -86,7 +86,6 @@ export const STAR_NAG_INITIAL_THRESHOLD = 35
|
||||
export const DEFAULT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [
|
||||
'status',
|
||||
'unread',
|
||||
'ci',
|
||||
'issue',
|
||||
'pr',
|
||||
'comment',
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user