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 2555d4c35..27ad01204 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx @@ -124,6 +124,12 @@ function renderWorktreeCardMarkup(element: ReactNode): string { return renderToStaticMarkup(<>{element}) } +function getInlineRenameTitleTag(markup: string): string { + const match = markup.match(/]*data-worktree-title-inline-rename=""[^>]*>/) + expect(match).not.toBeNull() + return match?.[0] ?? '' +} + describe('WorktreeCard linked PR display', () => { beforeEach(() => { vi.clearAllMocks() @@ -146,6 +152,66 @@ describe('WorktreeCard linked PR display', () => { expect(markup).not.toContain('Linked PR #456') }, 20_000) + it('keeps compact toggle-off unread and read-title visuals legacy', async () => { + settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: false } + hostedReviewCache = { + 'local::repo-1::feature/local-branch': { + data: makeHostedReview({ status: 'failure' }), + fetchedAt: Date.now() + } + } + const { default: WorktreeCard } = await import('./WorktreeCard') + + const unreadMarkup = renderWorktreeCardMarkup( + + ) + const readMarkup = renderWorktreeCardMarkup( + + ) + const readTitleTag = getInlineRenameTitleTag(readMarkup) + + expect(unreadMarkup).toContain('aria-label="Mark as read"') + expect(unreadMarkup).toContain('text-amber-500') + expect(unreadMarkup).not.toContain('PR checks: Failed · Mark read') + expect(unreadMarkup).not.toContain('size-[13px] translate-x-px') + expect(readTitleTag).toContain('font-normal text-foreground') + expect(readTitleTag).not.toContain('text-foreground/80') + }, 20_000) + + it('applies experimental unread status and read-title visuals only when enabled', async () => { + settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true } + hostedReviewCache = { + 'local::repo-1::feature/local-branch': { + data: makeHostedReview({ status: 'failure' }), + fetchedAt: Date.now() + } + } + const { default: WorktreeCard } = await import('./WorktreeCard') + + const unreadMarkup = renderWorktreeCardMarkup( + + ) + const readMarkup = renderWorktreeCardMarkup( + + ) + + expect(unreadMarkup).toContain('aria-label="Mark as read"') + expect(unreadMarkup).toContain('PR checks: Failed · Mark read') + expect(unreadMarkup).toContain('size-[13px] translate-x-px') + expect(unreadMarkup).not.toContain('lucide-bell') + expect(unreadMarkup).not.toContain('text-amber-500') + expect(getInlineRenameTitleTag(unreadMarkup)).toContain('font-semibold text-foreground') + expect(getInlineRenameTitleTag(readMarkup)).toContain('font-normal text-foreground/80') + }, 20_000) + it('shows linked GH PR status in the left status slot before hosted review details are cached when new card style is on', async () => { settings = { experimentalNewWorktreeCardStyle: true } const { default: WorktreeCard } = await import('./WorktreeCard') diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 99e2f10c8..9ad01c02e 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -1050,8 +1050,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ !!conflictOperation && conflictOperation !== 'unknown' && conflictOperation !== 'rebase' const hasMetadataBadge = showConflictOperationBadge const showUnreadQuickAction = !affiliateListMode && showStatus - // Why: the activity dot and unread bell compete for the same tiny sidebar - // lane. Keep one slot, and let an active unread bell visually win. + // Why: the slot owns the tiny unread/status lane; legacy keeps the bell, + // while the experimental card keeps the status glyph visible. const showCombinedStatusSlot = showStatus const showTitleRowPrimary = compactCards && worktree.isMainWorktree && !isFolder const showMetaRowDetails = !newCardStyle && !compactCards && (hasDetails || hasPorts) @@ -1317,13 +1317,13 @@ const WorktreeCard = React.memo(function WorktreeCard({ )} - {/* Why: weight alone carries the unread signal; color stays - at text-foreground in both states so the title keeps hierarchy - against nearby status chips. */} + {/* Why: in the experimental card, weight carries unread without a + bell, so read titles step back slightly for scan contrast. */} { state: 'open', status: 'failure' } + const gitlabReview: WorktreeCardPrDisplay = { + provider: 'gitlab', + number: 456, + title: 'Review me', + state: 'open', + status: 'pending' + } - it('lets the unread bell replace the visual status dot', () => { + it('lets the unread bell replace the visual status dot by default', () => { const markup = renderToStaticMarkup( { expect(markup).toContain('Mark as read') expect(markup).not.toContain('Active · Mark as read') expect(markup).not.toContain('bg-emerald-500') + expect(markup).toContain('text-amber-500') }) - it('shows status until an unread bell is active', () => { + it('keeps the status dot visible for unread rows when new card style is on', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('aria-label="Mark as read"') + expect(markup).toContain('Active · Mark as read') + expect(markup).toContain('bg-emerald-500') + expect(markup).not.toContain('lucide-bell') + expect(markup).not.toContain('text-amber-500') + }) + + it('shows status in the unread toggle affordance', () => { const markup = renderToStaticMarkup( { expect(markup).toContain('PR checks: Failed') expect(markup).toContain('inline-flex size-5 items-center justify-center') + expect(markup).toContain('size-[13px] translate-x-px') expect(markup).toContain('text-rose-500/85') expect(markup).not.toContain('bg-emerald-500') }) + it('uses the unified compact review glyph for GitLab MR status', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('MR checks: Pending') + expect(markup).toContain('viewBox="0 0 16 16"') + expect(markup).toContain('size-[13px] translate-x-px') + expect(markup).toContain('text-amber-500/85') + expect(markup).not.toContain('lucide-git-merge') + }) + it('uses PR status instead of the quiet done dot when new card style is on', () => { mocks.status = 'done' const markup = renderToStaticMarkup( @@ -164,6 +217,7 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).toContain('Branch') expect(markup).toContain('lucide-git-branch') + expect(markup).toContain('size-[13px] translate-x-px text-muted-foreground/70') expect(markup).toContain('text-muted-foreground/70') expect(markup).not.toContain('bg-emerald-500') }) @@ -247,11 +301,13 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).toContain('aria-label="Mark as read"') expect(markup).toContain('Mark as read') + expect(markup).not.toContain('Active · Mark as read') expect(markup).not.toContain('PR checks: Failed') expect(markup).not.toContain('bg-emerald-500') + expect(markup).toContain('text-amber-500') }) - it('overlays unread on PR status instead of replacing it when new card style is on', () => { + it('keeps PR status visible for unread rows when new card style is on', () => { const markup = renderToStaticMarkup( { 'group/unread relative flex cursor-pointer items-center justify-center rounded transition-all size-5' ) expect(markup).toContain('text-rose-500/85') - expect(markup).toContain('absolute -right-1 -top-1 size-[13px] text-amber-500') + expect(markup).not.toContain('lucide-bell') + expect(markup).not.toContain('text-amber-500') expect(markup).not.toContain('bg-emerald-500') }) - it('overlays unread on the no-review branch icon in new card style', () => { + it('keeps the branch icon visible for unread rows in new card style', () => { const markup = renderToStaticMarkup( { 'group/unread relative flex cursor-pointer items-center justify-center rounded transition-all size-5' ) expect(markup).toContain('lucide-git-branch') - expect(markup).toContain('absolute -right-1 -top-1 size-[13px] text-amber-500') + expect(markup).not.toContain('lucide-bell') + expect(markup).not.toContain('text-amber-500') expect(markup).not.toContain('bg-emerald-500') }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx index e15a41cda..c7ab75ecf 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx @@ -27,7 +27,10 @@ const QUIET_REVIEW_REPLACEABLE_STATUSES = new Set(['active', 'do // Why: a missing review display can also mean provider state is unavailable, // so the passive label names the branch cue without claiming no review exists. const BRANCH_STATUS_LABEL = 'Branch' -const branchStatusIconClassName = 'size-4 text-muted-foreground/70' +// Why: branch-style SVGs are optically left-heavy; this keeps them aligned with +// the centered activity dots in the shared status column. +const compactReviewAndBranchStatusIconClassName = 'size-[13px] translate-x-px' +const branchStatusIconClassName = `${compactReviewAndBranchStatusIconClassName} text-muted-foreground/70` function getReviewStatusTooltip(review: WorktreeCardPrDisplay): string { const label = getReviewLabel(review) @@ -84,14 +87,18 @@ export function WorktreeCardStatusSlot({ : canShowBranchStatus ? BRANCH_STATUS_LABEL : statusLabel - const reviewStatusIconClassName = 'size-4' + const reviewStatusIconClassName = compactReviewAndBranchStatusIconClassName const branchStatusIcon =