Fix folder workspace status icons

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-06-23 21:17:38 -07:00
co-authored by Orca
parent c377007ede
commit 399dc94131
5 changed files with 489 additions and 11 deletions
@@ -21,6 +21,7 @@ let tabsByWorktree: Record<string, { id: string }[]> = {}
let ptyIdsByTabId: Record<string, string[]> = {}
let browserTabsByWorktree: Record<string, { id: string }[]> = {}
let settings: Partial<GlobalSettings> | null = null
let projectGroups: unknown[] = []
let workspaceDeleteModifierPressed = false
let gitConflictOperationByWorktree: Record<string, GitConflictOperation> = {}
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(
<WorktreeCard
worktree={makeWorktree({
id: 'folder:folder-1',
displayName: 'Docs folder',
branch: '',
path: '/repo/worktrees/quick-action'
})}
repo={undefined}
isActive={false}
/>
)
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(
<WorktreeCard
@@ -45,6 +45,7 @@ import {
import { WorktreeCardPortsDetails, WorktreeCardPortsTrigger } from './WorktreeCardPorts'
import { writeWorkspaceDragData } from './workspace-status'
import { getWorktreeCardPrDisplay } from './worktree-card-pr-display'
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
import {
coerceWorktreeCardVisibleTitle,
getWorktreeCardTitleDisplay
@@ -121,6 +122,7 @@ type WorktreeCardProps = {
onCardDragEnd?: (event: React.DragEvent<HTMLDivElement>) => 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)}
/>
</div>
) : null}
@@ -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<string, Repo>
worktreeMap: Map<string, Worktree>
worktreeLineageById: Record<string, WorktreeLineage>
workspaceLineageByChildKey: Record<string, WorkspaceLineage>
repoOrder: Map<string, number>
// 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<string, unknown> | null
hostedReviewCache: Record<string, unknown> | 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}
/>
<div className="pointer-events-auto absolute right-3 top-1.5">
<FolderPathStatusIndicator status={folderWorkspacePathStatus} />
@@ -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}
@@ -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<Worktree> & { 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'
}
}
@@ -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<string, WorkspaceLineage> | null | undefined
worktreeLineageById: Record<string, WorktreeLineage> | null | undefined
worktreeMap: ReadonlyMap<string, Worktree>
repoMap: ReadonlyMap<string, Repo>
hostedReviewCache: Record<string, unknown> | null
prCache: Record<string, unknown> | null
settings?: AppState['settings']
}
const REVIEW_STATUS_PRIORITY: Record<NonNullable<WorktreeCardPrDisplay['status']>, 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<string, Worktree>
): 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<string, unknown> | null
prCache: Record<string, unknown> | 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<string, unknown> | 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
}