diff --git a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx index bdade1701d0..3d235a6351d 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx @@ -21,6 +21,7 @@ let tabsByWorktree: Record = {} let ptyIdsByTabId: Record = {} let browserTabsByWorktree: Record = {} let settings: Partial | null = null +let projectGroups: unknown[] = [] let workspaceDeleteModifierPressed = false let gitConflictOperationByWorktree: Record = {} let WorktreeCard: typeof WorktreeCardComponent @@ -35,7 +36,7 @@ vi.mock('@/store', () => ({ hostedReviewCache: {}, issueCache: {}, openModal, - projectGroups: [], + projectGroups, remoteBranchConflictByWorktreeId: {}, settings, sshConnectionStates: new Map(), @@ -144,6 +145,7 @@ describe('WorktreeCard quick actions', () => { ptyIdsByTabId = {} browserTabsByWorktree = {} settings = null + projectGroups = [] workspaceDeleteModifierPressed = false gitConflictOperationByWorktree = {} }) @@ -217,6 +219,28 @@ describe('WorktreeCard quick actions', () => { expect(markup).toContain('data-worktree-card-meta-row=""') }) + it('does not render a branch icon for synthetic folder workspace path identity', () => { + settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true } + worktreeCardProperties = ['status', 'branch'] + projectGroups = [{ id: 'project-group-1' }] + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('/repo/worktrees/quick-action') + expect(markup).not.toContain('lucide-git-branch') + }) + it('does not render a pending first-agent rename title badge', () => { const markup = renderToStaticMarkup( ) => void nativeDragEnabled?: boolean affiliateListMode?: boolean + statusPrDisplay?: WorktreeCardPrDisplay | null } const EMPTY_WORKSPACE_PORTS = [] @@ -212,7 +214,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ lineageChildrenStyle, onLineageToggle, isLineageDropTarget = false, - affiliateListMode = false + affiliateListMode = false, + statusPrDisplay = null }: WorktreeCardProps) { const openModal = useAppStore((s) => s.openModal) const openTaskPage = useAppStore((s) => s.openTaskPage) @@ -380,12 +383,6 @@ const WorktreeCard = React.memo(function WorktreeCard({ const folderMetaRowContent = newCardStyle ? hasPathIdentityEnabled && Boolean(folderPathIdentityDisplay) : isFolder - const statusIdentityLabel = hasProjectGroups - ? translate( - 'auto.components.sidebar.WorktreeCard.branchFolderPathIdentity', - 'Branch or folder path' - ) - : translate('auto.components.sidebar.WorktreeCard.branchIdentity', 'Branch') const hostedReviewCacheKey = repo && branch ? getHostedReviewCacheKey( @@ -955,6 +952,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ const hoverIssue = issueDisplay const hoverLinearIssue = linearIssueDisplay const hoverReview = prDisplay + const statusLaneReview = statusPrDisplay ?? hoverReview const hoverComment = worktree.comment const metaIssue = showIssue ? hoverIssue : null const metaLinearIssue = showLinearIssue ? hoverLinearIssue : null @@ -1275,10 +1273,9 @@ const WorktreeCard = React.memo(function WorktreeCard({ unreadTooltip={unreadTooltip} onPointerDown={stopQuickActionPointerPropagation} onToggleUnread={handleToggleUnreadQuick} - prDisplay={hoverReview} + prDisplay={statusLaneReview} newCardStyle={newCardStyle} - hasBranchIdentity={Boolean(identityDisplay)} - branchIdentityLabel={statusIdentityLabel} + hasBranchIdentity={Boolean(branchIdentityDisplay)} /> ) : null} diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 1a82cccee05..c303866f52e 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -57,6 +57,7 @@ import type { ProjectOrderBy, WorktreeLineage, WorktreeMeta, + WorkspaceLineage, WorkspaceStatus, WorkspaceStatusDefinition } from '../../../../shared/types' @@ -255,6 +256,7 @@ import { getFolderWorkspaceExecutionHostIdForRows, getProjectGroupExecutionHostIdForRows } from './worktree-list-host-filtering' +import { getFolderWorkspaceCardPrDisplay } from './folder-workspace-card-pr-display' export { getScrollTopToRevealBounds, @@ -612,6 +614,7 @@ type VirtualizedWorktreeViewportProps = { repoMap: Map worktreeMap: Map worktreeLineageById: Record + workspaceLineageByChildKey: Record repoOrder: Map // The full canonical state.repos id ordering — the drag controller commits // permutations of this list, even when some repos aren't currently visible @@ -621,6 +624,7 @@ type VirtualizedWorktreeViewportProps = { onReorderHostSections: (orderedHostIds: ExecutionHostId[]) => void onHostDragActiveChange: (active: boolean) => void prCache: Record | null + hostedReviewCache: Record | null workspaceStatuses: readonly WorkspaceStatusDefinition[] projectGrouping?: ProjectGroupingModel projectGroups?: readonly ProjectGroup[] @@ -1237,11 +1241,13 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp repoMap, worktreeMap, worktreeLineageById, + workspaceLineageByChildKey, repoOrder, allRepoIds, onReorderHostSections, onHostDragActiveChange, prCache, + hostedReviewCache, workspaceStatuses, projectGrouping, projectGroups = EMPTY_PROJECT_GROUPS, @@ -4760,6 +4766,16 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp folderWorkspacePathStatus?.exists === false && (isConfirmedStaleFolderPathStatus(folderWorkspacePathStatus) || folderWorkspacePathStatus.reason === 'ambiguous-connection') + const folderPrDisplay = getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId: folderWorkspaceRow.folderWorkspace.id, + workspaceLineageByChildKey, + worktreeLineageById, + worktreeMap, + repoMap, + hostedReviewCache, + prCache, + settings + }) const isFolderBackedWorkspaceChild = groupBy === 'repo' && folderWorkspaceRow.projectGroup.createdFrom === 'folder-scan' const { surfaceInset, cardContentIndent } = getFolderWorkspaceRowGeometry({ @@ -4810,6 +4826,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp activationRowKey={folderWorktree.id} onSelectionGesture={onSelectionGesture} onContextMenuSelect={onContextMenuSelect} + statusPrDisplay={folderPrDisplay} />
@@ -4894,6 +4911,7 @@ const WorktreeList = React.memo(function WorktreeList({ const repoMap = useRepoMap() const worktreeMap = useWorktreeMap() const worktreeLineageById = useAppStore((s) => s.worktreeLineageById) + const workspaceLineageByChildKey = useAppStore((s) => s.workspaceLineageByChildKey) const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) @@ -5001,6 +5019,11 @@ const WorktreeList = React.memo(function WorktreeList({ ? s.prCache : null ) + const hostedReviewCache = useAppStore((s) => + s.settings?.experimentalNewWorktreeCardStyle === true && cardProps.includes('status') + ? s.hostedReviewCache + : null + ) const settings = useAppStore((s) => s.settings) const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) @@ -6401,11 +6424,13 @@ const WorktreeList = React.memo(function WorktreeList({ repoMap={repoMap} worktreeMap={worktreeMap} worktreeLineageById={worktreeLineageById} + workspaceLineageByChildKey={workspaceLineageByChildKey} repoOrder={repoOrder} allRepoIds={allRepoIds} onReorderHostSections={handleReorderHostSections} onHostDragActiveChange={setHostDragActive} prCache={prCache} + hostedReviewCache={hostedReviewCache} workspaceStatuses={workspaceStatuses} projectGrouping={projectGrouping} projectGroups={projectGroups} diff --git a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts new file mode 100644 index 00000000000..6ca326b3c61 --- /dev/null +++ b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest' +import type { + CheckStatus, + Repo, + Worktree, + WorktreeLineage, + WorkspaceLineage +} from '../../../../shared/types' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' +import { getFolderWorkspaceCardPrDisplay } from './folder-workspace-card-pr-display' + +const repo: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: '#999999', + addedAt: 1 +} + +function makeWorktree(overrides: Partial & { id: string }): Worktree { + const { id, ...rest } = overrides + return { + id, + repoId: repo.id, + path: `/worktrees/${id}`, + displayName: id, + branch: `refs/heads/${id}`, + head: 'abc123', + isBare: false, + isMainWorktree: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...rest + } +} + +function makeWorkspaceLineage(worktree: Worktree): WorkspaceLineage { + return { + childWorkspaceKey: worktreeWorkspaceKey(worktree.id), + childInstanceId: worktree.instanceId ?? null, + parentWorkspaceKey: folderWorkspaceKey('folder-1'), + parentInstanceId: null, + origin: 'cli', + capture: { source: 'env-workspace', confidence: 'inferred' }, + createdAt: 1 + } +} + +function makeWorktreeLineage(child: Worktree, parent: Worktree): WorktreeLineage { + return { + worktreeId: child.id, + worktreeInstanceId: child.instanceId ?? '', + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId ?? '', + origin: 'cli', + capture: { source: 'env-workspace', confidence: 'inferred' }, + createdAt: 1 + } +} + +describe('getFolderWorkspaceCardPrDisplay', () => { + it('uses failing attached PR status ahead of pending and passing PRs', () => { + const passing = makeWorktree({ id: 'passing', linkedPR: 1 }) + const pending = makeWorktree({ id: 'pending', linkedPR: 2 }) + const failing = makeWorktree({ id: 'failing', linkedPR: 3 }) + + const display = getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId: 'folder-1', + workspaceLineageByChildKey: { + [passing.id]: makeWorkspaceLineage(passing), + [pending.id]: makeWorkspaceLineage(pending), + [failing.id]: makeWorkspaceLineage(failing) + }, + worktreeLineageById: {}, + worktreeMap: new Map([ + [passing.id, passing], + [pending.id, pending], + [failing.id, failing] + ]), + repoMap: new Map([[repo.id, repo]]), + hostedReviewCache: null, + prCache: { + 'repo-1::passing': { data: makePr(1, 'success') }, + 'repo-1::pending': { data: makePr(2, 'pending') }, + 'repo-1::failing': { data: makePr(3, 'failure') } + } + }) + + expect(display).toMatchObject({ number: 3, status: 'failure' }) + }) + + it('uses pending attached PR status ahead of passing PRs', () => { + const passing = makeWorktree({ id: 'passing', linkedPR: 1 }) + const pending = makeWorktree({ id: 'pending', linkedPR: 2 }) + + const display = getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId: 'folder-1', + workspaceLineageByChildKey: { + [passing.id]: makeWorkspaceLineage(passing), + [pending.id]: makeWorkspaceLineage(pending) + }, + worktreeLineageById: {}, + worktreeMap: new Map([ + [passing.id, passing], + [pending.id, pending] + ]), + repoMap: new Map([[repo.id, repo]]), + hostedReviewCache: null, + prCache: { + 'repo-1::passing': { data: makePr(1, 'success') }, + 'repo-1::pending': { data: makePr(2, 'pending') } + } + }) + + expect(display).toMatchObject({ number: 2, status: 'pending' }) + }) + + it('includes nested attached worktree PRs', () => { + const parent = makeWorktree({ id: 'parent', instanceId: 'parent' }) + const nested = makeWorktree({ id: 'nested', instanceId: 'nested', linkedPR: 4 }) + + const display = getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId: 'folder-1', + workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) }, + worktreeLineageById: { [nested.id]: makeWorktreeLineage(nested, parent) }, + worktreeMap: new Map([ + [parent.id, parent], + [nested.id, nested] + ]), + repoMap: new Map([[repo.id, repo]]), + hostedReviewCache: null, + prCache: { + 'repo-1::nested': { data: makePr(4, 'success') } + } + }) + + expect(display).toMatchObject({ number: 4, status: 'success' }) + }) +}) + +function makePr(number: number, checksStatus: CheckStatus) { + return { + number, + title: `PR ${number}`, + state: 'open', + url: `https://example.test/pull/${number}`, + checksStatus, + updatedAt: '2026-01-01T00:00:00.000Z', + mergeable: 'UNKNOWN' + } +} diff --git a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts new file mode 100644 index 00000000000..ae9e67b8b5a --- /dev/null +++ b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts @@ -0,0 +1,272 @@ +import type { AppState } from '@/store/types' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' +import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '@/store/slices/github-cache-key' +import { branchName } from '@/lib/git-utils' +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import type { + PRInfo, + Repo, + Worktree, + WorktreeLineage, + WorkspaceLineage +} from '../../../../shared/types' +import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { getWorktreeCardPrDisplay, type WorktreeCardPrDisplay } from './worktree-card-pr-display' + +type HostedReviewCacheEntry = { + data?: HostedReviewInfo | null + linkedReviewHintKey?: string +} + +type PrCacheEntry = { + data?: PRInfo | null +} + +type FolderWorkspaceCardPrDisplayArgs = { + folderWorkspaceId: string + workspaceLineageByChildKey: Record | null | undefined + worktreeLineageById: Record | null | undefined + worktreeMap: ReadonlyMap + repoMap: ReadonlyMap + hostedReviewCache: Record | null + prCache: Record | null + settings?: AppState['settings'] +} + +const REVIEW_STATUS_PRIORITY: Record, number> = { + failure: 0, + pending: 1, + success: 2, + neutral: 3 +} + +export function getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId, + workspaceLineageByChildKey, + worktreeLineageById, + worktreeMap, + repoMap, + hostedReviewCache, + prCache, + settings +}: FolderWorkspaceCardPrDisplayArgs): WorktreeCardPrDisplay | null { + const reviews = getAttachedWorktreesForFolderWorkspaceCard({ + folderWorkspaceId, + workspaceLineageByChildKey, + worktreeLineageById, + worktreeMap + }) + .map((worktree) => + getAttachedWorktreePrDisplay({ + worktree, + repo: repoMap.get(worktree.repoId), + hostedReviewCache, + prCache, + settings + }) + ) + .filter((review): review is WorktreeCardPrDisplay => review !== null) + + if (reviews.length === 0) { + return null + } + + return reviews.sort(compareReviewDisplays)[0] ?? null +} + +function getAttachedWorktreesForFolderWorkspaceCard({ + folderWorkspaceId, + workspaceLineageByChildKey, + worktreeLineageById, + worktreeMap +}: Pick< + FolderWorkspaceCardPrDisplayArgs, + 'folderWorkspaceId' | 'workspaceLineageByChildKey' | 'worktreeLineageById' | 'worktreeMap' +>): Worktree[] { + const folderKey = folderWorkspaceKey(folderWorkspaceId) + const directChildren = Object.values(workspaceLineageByChildKey ?? {}) + .filter((lineage) => lineage.parentWorkspaceKey === folderKey) + .map((lineage) => getWorkspaceLineageChild(lineage, worktreeMap)) + .filter((worktree): worktree is Worktree => worktree !== null) + + const included = new Map(directChildren.map((worktree) => [worktree.id, worktree])) + let added = true + + while (added) { + added = false + for (const lineage of Object.values(worktreeLineageById ?? {})) { + if (included.has(lineage.worktreeId) || !included.has(lineage.parentWorktreeId)) { + continue + } + const parent = worktreeMap.get(lineage.parentWorktreeId) + const child = worktreeMap.get(lineage.worktreeId) + if (!isCurrentLineagePair(parent, child, lineage)) { + continue + } + included.set(child.id, child) + added = true + } + } + + return [...included.values()] +} + +function getWorkspaceLineageChild( + lineage: WorkspaceLineage, + worktreeMap: ReadonlyMap +): Worktree | null { + const childScope = parseWorkspaceKey(lineage.childWorkspaceKey) + if (childScope?.type !== 'worktree') { + return null + } + const worktree = worktreeMap.get(childScope.worktreeId) + if (!worktree || worktree.isArchived) { + return null + } + if (lineage.childInstanceId && lineage.childInstanceId !== worktree.instanceId) { + return null + } + return worktree +} + +function isCurrentLineagePair( + parent: Worktree | undefined, + child: Worktree | undefined, + lineage: WorktreeLineage +): child is Worktree { + return Boolean( + parent && + child && + !parent.isArchived && + !child.isArchived && + child.instanceId === lineage.worktreeInstanceId && + parent.instanceId === lineage.parentWorktreeInstanceId + ) +} + +function getAttachedWorktreePrDisplay({ + worktree, + repo, + hostedReviewCache, + prCache, + settings +}: { + worktree: Worktree + repo: Repo | undefined + hostedReviewCache: Record | null + prCache: Record | null + settings?: AppState['settings'] +}): WorktreeCardPrDisplay | null { + if (!repo) { + return null + } + + const branch = branchName(worktree.branch).trim() + if (!branch) { + return getLinkedReviewDisplay(worktree) + } + + const hostedReviewCacheKey = getHostedReviewCacheKey( + repo.path, + branch, + settings, + repo.id, + repo.connectionId, + repo.executionHostId + ) + const hostedReviewEntry = hostedReviewCache?.[hostedReviewCacheKey] as + | HostedReviewCacheEntry + | undefined + const hostedReviewDisplay = hostedReviewEntry + ? getWorktreeCardPrDisplay( + hostedReviewEntry.data, + worktree.linkedPR, + worktree.linkedGitLabMR ?? null, + worktree.linkedBitbucketPR ?? null, + worktree.linkedAzureDevOpsPR ?? null, + worktree.linkedGiteaPR ?? null, + { reviewHintKey: hostedReviewEntry.linkedReviewHintKey } + ) + : null + if (hostedReviewDisplay) { + return hostedReviewDisplay + } + + const cachedGitHubPr = getCachedGitHubPr({ worktree, repo, branch, prCache, settings }) + if (cachedGitHubPr) { + return { + provider: 'github', + number: cachedGitHubPr.number, + title: cachedGitHubPr.title, + state: cachedGitHubPr.state, + url: cachedGitHubPr.url, + status: cachedGitHubPr.checksStatus + } + } + + return getLinkedReviewDisplay(worktree) +} + +function getLinkedReviewDisplay(worktree: Worktree): WorktreeCardPrDisplay | null { + return getWorktreeCardPrDisplay( + undefined, + worktree.linkedPR, + worktree.linkedGitLabMR ?? null, + worktree.linkedBitbucketPR ?? null, + worktree.linkedAzureDevOpsPR ?? null, + worktree.linkedGiteaPR ?? null + ) +} + +function getCachedGitHubPr({ + worktree, + repo, + branch, + prCache, + settings +}: { + worktree: Worktree + repo: Repo + branch: string + prCache: Record | null + settings?: AppState['settings'] +}): PRInfo | null { + if (!prCache || worktree.linkedPR === null) { + return null + } + + const cacheKey = getGitHubPRCacheKey( + repo.path, + repo.id, + branch, + settings, + repo.connectionId, + repo.executionHostId + ) + const canUseLegacyPRCache = + !settings?.activeRuntimeEnvironmentId?.trim() && !repo.connectionId && !repo.executionHostId + const legacyRepoScopedCacheKey = canUseLegacyPRCache + ? getLegacyGitHubPRCacheKey(repo.path, repo.id, branch) + : '' + const legacyPathScopedCacheKey = canUseLegacyPRCache + ? getLegacyGitHubPRCacheKey(repo.path, undefined, branch) + : '' + const entry = + (prCache[cacheKey] as PrCacheEntry | undefined) ?? + (legacyRepoScopedCacheKey + ? (prCache[legacyRepoScopedCacheKey] as PrCacheEntry | undefined) + : undefined) ?? + (legacyPathScopedCacheKey + ? (prCache[legacyPathScopedCacheKey] as PrCacheEntry | undefined) + : undefined) + const pr = entry?.data ?? null + return pr?.number === worktree.linkedPR ? pr : null +} + +function compareReviewDisplays(left: WorktreeCardPrDisplay, right: WorktreeCardPrDisplay): number { + return getReviewDisplayPriority(left) - getReviewDisplayPriority(right) +} + +function getReviewDisplayPriority(review: WorktreeCardPrDisplay): number { + return review.status ? REVIEW_STATUS_PRIORITY[review.status] : 4 +}