diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx new file mode 100644 index 000000000..cb6128d1d --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx @@ -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 | 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 { + return { + id: 'repo-1', + path: '/repo', + displayName: 'orca', + badgeColor: '#999999', + repoIcon: { type: 'emoji', emoji: '🦊' }, + addedAt: 1, + ...overrides + } +} + +function makeWorktree(overrides: Partial = {}): 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( + + ) + + 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( + + ) + + expect(markup).not.toContain('🦊') + expect(markup).not.toContain('Project orca') + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index a7713cb88..65d345815 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -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 ( + + + + {children} + + + + {repo.displayName} + + + ) +} + 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 */}
+ {showPinnedRepoIcon && ( + + + + )} + {repo?.connectionId && ( @@ -796,19 +841,9 @@ const WorktreeCard = React.memo(function WorktreeCard({ )} {showInlineRepoBadge && ( - - - - - - - - {repo.displayName} - - + + + )} {/* Why: weight alone carries the unread signal; color stays diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index f1832f935..9e6abeadd 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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}