Show repo icon on pinned worktree cards (#4517)

The Pinned section mixes worktrees from every repo, yet cards identified
their repo only by a color dot — and showed no repo cue at all when the
sidebar was grouped by repo (hideRepoBadge). Render each repo's configured
icon (lucide/emoji/image) as a leading glyph on pinned cards regardless of
grouping, so a mixed-repo pinned list is scannable at a glance.

Extract a shared RepoIdentityChip so the pinned icon and the compact inline
badge use one chip + tooltip shell.
This commit is contained in:
Trevin Chow 2026-06-02 20:48:38 -07:00 committed by GitHub
parent fef6e042f5
commit da3c359a74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 198 additions and 15 deletions

View File

@ -0,0 +1,145 @@
import { renderToStaticMarkup } from 'react-dom/server'
import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings, 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()
let worktreeCardProperties: WorktreeCardProperty[] = []
let settings: Partial<GlobalSettings> | null = null
vi.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({
deleteStateByWorktreeId: {},
fetchHostedReviewForBranch,
fetchIssue,
fetchLinearIssue,
gitConflictOperationByWorktree: {},
hostedReviewCache: {},
issueCache: {},
linearIssueCache: {},
openModal,
remoteBranchConflictByWorktreeId: {},
settings,
sshConnectionStates: new Map(),
sshTargetLabels: new Map(),
updateWorktreeMeta,
workspacePortScan: null,
worktreeCardProperties
})
}))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: vi.fn()
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
vi.mock('./use-worktree-activity-status', () => ({
useWorktreeActivityStatus: () => 'idle'
}))
vi.mock('./CacheTimer', () => ({
default: () => null,
usePromptCacheCountdownStartedAt: () => null
}))
vi.mock('./WorktreeCardAgents', () => ({
default: () => null
}))
vi.mock('./SshDisconnectedDialog', () => ({
SshDisconnectedDialog: () => null
}))
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu',
WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope'
}))
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-1',
path: '/repo',
displayName: 'orca',
badgeColor: '#999999',
repoIcon: { type: 'emoji', emoji: '🦊' },
addedAt: 1,
...overrides
}
}
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
return {
id: 'repo-1::/repo/worktrees/pinned',
repoId: 'repo-1',
path: '/repo/worktrees/pinned',
displayName: 'Pinned tree',
branch: 'feature/pinned',
head: 'abc123',
isBare: false,
isMainWorktree: false,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: true,
sortOrder: 0,
lastActivityAt: 1,
...overrides
}
}
describe('WorktreeCard pinned repo icon', () => {
beforeEach(() => {
vi.clearAllMocks()
worktreeCardProperties = []
settings = null
})
it('shows the configured repo icon for pinned cards even when the repo badge is hidden', async () => {
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree()}
repo={makeRepo()}
isActive={false}
inPinnedSection
// grouped-by-repo hides the normal badge; the pinned icon must still show
hideRepoBadge
/>
)
expect(markup).toContain('🦊')
expect(markup).toContain('Project orca')
})
it('does not render the leading pinned repo icon for non-pinned cards', async () => {
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ isPinned: false })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).not.toContain('🦊')
expect(markup).not.toContain('Project orca')
})
})

View File

@ -45,6 +45,8 @@ import { writeWorkspaceDragData } from './workspace-status'
import { getWorktreeCardPrDisplay } from './worktree-card-pr-display'
import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
import { RepoIconGlyph } from '@/components/repo/repo-icon'
import { resolveRepoHeaderColor } from './project-header-color'
import { installWindowVisibilityInterval, isWindowVisible } from '@/lib/window-visibility-interval'
import { isMacAppDataPath } from '@/lib/passive-macos-app-data-access'
import { runWorktreeDelete } from './delete-worktree-flow'
@ -67,6 +69,7 @@ type WorktreeCardProps = {
revealHighlightTone?: 'default' | 'ai'
selectedWorktrees?: readonly Worktree[]
hideRepoBadge?: boolean
inPinnedSection?: boolean
contentIndent?: number
flushSurface?: boolean
lineageChildCount?: number
@ -100,6 +103,32 @@ function isWebClient(): boolean {
return Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__)
}
// Why: the pinned repo icon and the compact inline badge share one chip shell;
// keep the box + tooltip identical so both repo cues read as the same affordance.
function RepoIdentityChip({
repo,
children
}: {
repo: Repo
children: React.ReactNode
}): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-sidebar-border bg-sidebar-accent/55"
aria-label={`Project ${repo.displayName}`}
>
{children}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{repo.displayName}
</TooltipContent>
</Tooltip>
)
}
const WorktreeCard = React.memo(function WorktreeCard({
worktree,
repo,
@ -117,6 +146,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
onCardDragEnd,
nativeDragEnabled = true,
hideRepoBadge,
inPinnedSection = false,
contentIndent = 0,
flushSurface = false,
lineageChildCount = 0,
@ -591,8 +621,12 @@ const WorktreeCard = React.memo(function WorktreeCard({
const hasPorts = showPorts && workspacePorts.length > 0
const cacheStartedAt = usePromptCacheCountdownStartedAt(worktree.id)
const cacheTtlMs = useAppStore((s) => s.settings?.promptCacheTtlMs ?? 0)
const showInlineRepoBadge = compactCards && !!repo && !hideRepoBadge && !isFolder
const showRepoBadgeInMetaRow = !compactCards && !!repo && !hideRepoBadge
// Why: pinned trees mix repos in one section; a leading repo icon keeps the
// list scannable, so it shows regardless of groupBy's hideRepoBadge.
const showPinnedRepoIcon = inPinnedSection && !!repo
const showInlineRepoBadge =
compactCards && !!repo && !hideRepoBadge && !isFolder && !showPinnedRepoIcon
const showRepoBadgeInMetaRow = !compactCards && !!repo && !hideRepoBadge && !showPinnedRepoIcon
const showDetachedHeadInMetaRow = !compactCards && !isFolder && detachedHeadDisplay !== null
const showBranch =
!isFolder && branch.length > 0 && (!compactCards || branch !== worktree.displayName)
@ -778,6 +812,17 @@ const WorktreeCard = React.memo(function WorktreeCard({
{/* Header row: Title */}
<div className="flex items-center justify-between min-w-0 gap-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5">
{showPinnedRepoIcon && (
<RepoIdentityChip repo={repo}>
<RepoIconGlyph
repoIcon={repo.repoIcon}
color={resolveRepoHeaderColor(repo.badgeColor)}
className="size-full"
iconClassName="size-3"
/>
</RepoIdentityChip>
)}
{repo?.connectionId && (
<Tooltip>
<TooltipTrigger asChild>
@ -796,19 +841,9 @@ const WorktreeCard = React.memo(function WorktreeCard({
)}
{showInlineRepoBadge && (
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-sidebar-border bg-sidebar-accent/55"
aria-label={`Project ${repo.displayName}`}
>
<RepoBadgeMark color={repo.badgeColor} className="size-2 rounded-[2px]" />
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{repo.displayName}
</TooltipContent>
</Tooltip>
<RepoIdentityChip repo={repo}>
<RepoBadgeMark color={repo.badgeColor} className="size-2 rounded-[2px]" />
</RepoIdentityChip>
)}
{/* Why: weight alone carries the unread signal; color stays

View File

@ -3142,6 +3142,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
onCardDragStart={handleWorktreeCardDragStart}
onCardDragEnd={clearWorktreeDrag}
hideRepoBadge={groupBy === 'repo'}
// Why: pinned worktrees only render in the Pinned group, so
// isPinned marks the mixed-repo pinned section that needs icons.
inPinnedSection={itemRow.worktree.isPinned}
lineageChildCount={itemRow.lineageChildCount}
lineageCollapsed={itemRow.lineageCollapsed}
lineageChildren={lineageChildren}