From c3a089ff4432a5b071e32a63e8c5337f86822c1b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:39:41 -0700 Subject: [PATCH] Align folder workspace rows with new card style (#6137) Co-authored-by: Orca --- .../WorktreeCard.affiliate-list-mode.test.tsx | 1 + .../WorktreeCard.compact-hover.test.tsx | 75 +++++ ...orktreeCard.hosted-review-refresh.test.tsx | 1 + .../sidebar/WorktreeCard.lineage.test.tsx | 1 + .../WorktreeCard.pinned-repo-icon.test.tsx | 1 + .../sidebar/WorktreeCard.pr-display.test.tsx | 1 + .../WorktreeCard.quick-actions.test.tsx | 33 ++- ...WorktreeCard.ssh-reconnect-prompt.test.tsx | 1 + .../src/components/sidebar/WorktreeCard.tsx | 61 ++-- .../WorktreeCardDisplayMenuSection.test.tsx | 22 ++ .../WorktreeCardDisplayMenuSection.tsx | 6 +- .../sidebar/WorktreeCardStatusSlot.test.tsx | 23 +- .../sidebar/WorktreeCardStatusSlot.tsx | 11 +- .../WorktreeList.lineage-child-card.test.ts | 24 +- .../src/components/sidebar/WorktreeList.tsx | 59 ++-- .../folder-workspace-card-pr-display.test.ts | 160 +++++++++++ .../folder-workspace-card-pr-display.ts | 272 ++++++++++++++++++ .../sidebar-workspace-option-items.test.ts | 26 ++ .../sidebar/sidebar-workspace-option-items.ts | 23 +- .../sidebar/worktree-list-indentation.test.ts | 124 ++++++++ .../sidebar/worktree-list-indentation.ts | 55 ++++ src/renderer/src/i18n/locales/en.json | 10 +- src/renderer/src/i18n/locales/es.json | 10 +- src/renderer/src/i18n/locales/ja.json | 10 +- src/renderer/src/i18n/locales/ko.json | 10 +- src/renderer/src/i18n/locales/zh.json | 13 +- 26 files changed, 958 insertions(+), 75 deletions(-) create mode 100644 src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts create mode 100644 src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts diff --git a/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx index 910fd31e1de..31e29c83984 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx @@ -30,6 +30,7 @@ vi.mock('@/store', () => ({ linearIssueCache: {}, openModal, openTaskPage: vi.fn(), + projectGroups: [], ptyIdsByTabId: {}, remoteBranchConflictByWorktreeId: {}, renamingWorktreeId: null, diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx index d93201f4738..b37e5e671aa 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx @@ -17,6 +17,7 @@ const setWorkspacePortScanRefreshing = vi.fn() let worktreeCardProperties: WorktreeCardProperty[] = ['status', 'ports'] let hostedReviewCache: Record = {} +let projectGroups: unknown[] = [] let workspacePortScan: { key: string; result: WorkspacePortScanResult } | null = null let settings: Partial | null = { compactWorktreeCards: true } @@ -35,6 +36,7 @@ vi.mock('@/store', () => ({ linearIssueCache: {}, openModal, openTaskPage, + projectGroups, ptyIdsByTabId: {}, recordFeatureInteraction, remoteBranchConflictByWorktreeId: {}, @@ -172,6 +174,7 @@ describe('WorktreeCard compact hover details', () => { vi.clearAllMocks() worktreeCardProperties = ['status', 'ports'] hostedReviewCache = {} + projectGroups = [] workspacePortScan = null settings = { compactWorktreeCards: true } }) @@ -561,6 +564,78 @@ describe('WorktreeCard compact hover details', () => { expect(markup).toContain('text-[13px] leading-5') }) + it('does not show a folder path row in new-card mode when no project groups exist', async () => { + settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } + worktreeCardProperties = ['status', 'branch'] + const { default: WorktreeCard } = await import('./WorktreeCard') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).not.toContain('data-worktree-card-meta-row=""') + expect(markup).toContain('Docs folder') + expect(markup).not.toContain('/Users/x/projects/my-app') + }) + + it('shows a folder path row in new-card mode through the branch setting when project groups exist', async () => { + settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } + worktreeCardProperties = ['status', 'branch'] + projectGroups = [{ id: 'group-1' }] + const { default: WorktreeCard } = await import('./WorktreeCard') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('data-worktree-card-meta-row=""') + expect(markup).toContain(' /Users/x/projects/my-app ') + expect(markup).not.toContain('>Folder') + }) + + it('keeps hidden folder path identity available from the new-card hover', async () => { + settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } + worktreeCardProperties = ['status'] + projectGroups = [{ id: 'group-1' }] + const { default: WorktreeCard } = await import('./WorktreeCard') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).not.toContain('data-worktree-card-meta-row=""') + expect(markup).toContain('data-hover-open-delay="100"') + expect(markup).toContain('/Users/x/projects/my-app') + expect(markup).toContain('Docs folder') + }) + it('shows the branch row for migrated Default cards with branch enabled', async () => { settings = { compactWorktreeCards: false } worktreeCardProperties = ['status', 'branch'] diff --git a/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx index f233618f162..48ce2425822 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx @@ -33,6 +33,7 @@ vi.mock('@/store', () => ({ issueCache: {}, linearIssueCache: {}, openModal, + projectGroups: [], remoteBranchConflictByWorktreeId: {}, settings: { experimentalNewWorktreeCardStyle: true }, sshConnectionStates: new Map(), diff --git a/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx index e144c5182ee..71fa30bed26 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx @@ -24,6 +24,7 @@ vi.mock('@/store', () => ({ issueCache: {}, linearIssueCache: {}, openModal, + projectGroups: [], remoteBranchConflictByWorktreeId: {}, settings: null, sshConnectionStates: new Map(), 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 index 37b5426cfdf..88d5d65d818 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx @@ -25,6 +25,7 @@ vi.mock('@/store', () => ({ issueCache: {}, linearIssueCache: {}, openModal, + projectGroups: [], remoteBranchConflictByWorktreeId: {}, settings, sshConnectionStates: new Map(), 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 83311e622da..dbf90b0faa2 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx @@ -29,6 +29,7 @@ vi.mock('@/store', () => ({ issueCache: {}, linearIssueCache: {}, openModal, + projectGroups: [], remoteBranchConflictByWorktreeId: {}, settings, sshConnectionStates: new Map(), 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 ced090972f4..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,6 +36,7 @@ vi.mock('@/store', () => ({ hostedReviewCache: {}, issueCache: {}, openModal, + projectGroups, remoteBranchConflictByWorktreeId: {}, settings, sshConnectionStates: new Map(), @@ -143,6 +145,7 @@ describe('WorktreeCard quick actions', () => { ptyIdsByTabId = {} browserTabsByWorktree = {} settings = null + projectGroups = [] workspaceDeleteModifierPressed = false gitConflictOperationByWorktree = {} }) @@ -181,7 +184,7 @@ describe('WorktreeCard quick actions', () => { expect(markup).not.toContain('bg-black/[0.08]') }) - it('renders folder kind and directory in the detailed metadata row', () => { + it('renders folder directory name in the detailed metadata row without a Folder badge', () => { const markup = renderToStaticMarkup( { ) expect(markup).toContain('Docs folder') - expect(markup).toContain('>Folder') + expect(markup).not.toContain('>Folder') expect(markup).toContain('>quick-action') expect(markup).toContain('data-worktree-card-meta-row=""') }) - it('renders synthetic folder workspace directory in the detailed metadata row', () => { + it('renders synthetic folder workspace directory name in the detailed metadata row without a Folder badge', () => { const markup = renderToStaticMarkup( { ) expect(markup).toContain('Docs folder') - expect(markup).toContain('>Folder') + expect(markup).not.toContain('>Folder') expect(markup).toContain('>quick-action') 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( ({ issueCache: {}, linearIssueCache: {}, openModal, + projectGroups: [], remoteBranchConflictByWorktreeId: {}, runtimeStatusByEnvironmentId, settings: null, diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index d8f306c0508..386ee7dcda5 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -26,7 +26,7 @@ import WorktreeCardAgents from './WorktreeCardAgents' import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot' import { cn } from '@/lib/utils' import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation' -import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind' +import { isFolderRepo } from '../../../../shared/repo-kind' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import type { GitHubWorkItem, @@ -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) => 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) @@ -228,6 +231,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ const fetchIssue = useAppStore((s) => s.fetchIssue) const fetchLinearIssue = useAppStore((s) => s.fetchLinearIssue) const cardProps = useAppStore((s) => s.worktreeCardProperties) + const projectGroups = useAppStore((s) => s.projectGroups) const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true const compactCards = !newCardStyle && settings?.compactWorktreeCards === true const activeSurfaceIsSecondary = isActiveSurface && activeSurfaceVariant === 'secondary' @@ -367,6 +371,18 @@ const WorktreeCard = React.memo(function WorktreeCard({ const folderWorkspaceId = workspaceScope?.type === 'folder' ? workspaceScope.folderWorkspaceId : null const isFolder = repo ? isFolderRepo(repo) : folderWorkspaceId !== null + // Why: project groups are the product gate for folder workspaces, so folder + // paths stay hidden from identity surfaces until that capability exists. + const hasProjectGroups = projectGroups.length > 0 + const branchIdentityDisplay = !isFolder && branch.length > 0 ? branch : undefined + const folderPathIdentityDisplay = + isFolder && hasProjectGroups && worktree.path.trim().length > 0 ? worktree.path : undefined + const identityDisplay = branchIdentityDisplay ?? folderPathIdentityDisplay + const hasPathIdentityEnabled = cardProps.includes('branch') + const showIdentityInNewCard = newCardStyle && hasPathIdentityEnabled && Boolean(identityDisplay) + const folderMetaRowContent = newCardStyle + ? hasPathIdentityEnabled && Boolean(folderPathIdentityDisplay) + : isFolder const hostedReviewCacheKey = repo && branch ? getHostedReviewCacheKey( @@ -936,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 @@ -1051,7 +1068,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ const showBranch = !isFolder && branch.length > 0 && - (newCardStyle ? cardProps.includes('branch') : !compactCards || branch !== worktree.displayName) + !newCardStyle && + (!compactCards || branch !== worktree.displayName) // Why: rebases already surface in source control; keep dense cards from // carrying a persistent rebase chip while preserving other interruption cues. const showConflictOperationBadge = @@ -1070,30 +1088,28 @@ const WorktreeCard = React.memo(function WorktreeCard({ const hasDetailedMetaRowContent = Boolean( (showRepoBadgeInMetaRow && repo) || showHostContextBadge || - isFolder || + folderMetaRowContent || showBranch || + showIdentityInNewCard || showDetachedHeadInMetaRow || showConflictOperationBadge || cacheStartedAt != null || showMetaRowDetails ) const hasMetaRow = compactCards - ? hasMetadataBadge || (newCardStyle && showBranch) || cacheStartedAt != null + ? hasMetadataBadge || cacheStartedAt != null : hasDetailedMetaRowContent const showHeaderActions = showTitleRowPrimary || showDeleteQuickAction // Why: the hover owns full identity when the row truncates; normalize once // so title/branch de-dupe and identity-only hover eligibility stay in sync. const trimmedVisibleCardTitle = visibleCardTitle.trim() const showBranchIdentityHover = newCardStyle - ? !isFolder && - branch.length > 0 && + ? Boolean(identityDisplay) && !cardProps.includes('branch') && - branch !== trimmedVisibleCardTitle + identityDisplay !== trimmedVisibleCardTitle : compactCards && showBranch const hoverBranchName = newCardStyle - ? !isFolder && branch.length > 0 - ? branch - : undefined + ? identityDisplay : showBranchIdentityHover ? branch : undefined @@ -1257,9 +1273,9 @@ const WorktreeCard = React.memo(function WorktreeCard({ unreadTooltip={unreadTooltip} onPointerDown={stopQuickActionPointerPropagation} onToggleUnread={handleToggleUnreadQuick} - prDisplay={hoverReview} + prDisplay={statusLaneReview} newCardStyle={newCardStyle} - hasBranchIdentity={!isFolder && branch.length > 0} + hasBranchIdentity={Boolean(branchIdentityDisplay)} /> ) : null} @@ -1372,17 +1388,6 @@ const WorktreeCard = React.memo(function WorktreeCard({ } /> - {isFolder && ( - - {repo - ? getRepoKindLabel(repo) - : translate('auto.components.sidebar.WorktreeCard.93aebe4529', 'Folder')} - - )} - {typeof worktree.firstAgentMessageRenameError === 'string' && worktree.firstAgentMessageRenameError.length > 0 && !titleRenaming ? ( @@ -1542,7 +1547,13 @@ const WorktreeCard = React.memo(function WorktreeCard({ )} - {isFolder ? ( + {showIdentityInNewCard ? ( + + ) : isFolder && !newCardStyle ? ( ({ useAppStore: (selector: (state: unknown) => unknown) => selector({ agentActivityDisplayMode: 'compact', + projectGroups, setAgentActivityDisplayMode, setWorktreeCardMode, setWorktreeCardProperties, @@ -112,6 +114,7 @@ function renderMenu(): void { beforeEach(() => { settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: false } + projectGroups = [] worktreeCardProperties = [ 'status', 'unread', @@ -153,4 +156,23 @@ describe('WorktreeCardDisplayMenuSection', () => { expect(setWorktreeCardMode).toHaveBeenCalledWith('Compact') }) + + it('keeps branch-only copy when project groups are unavailable', () => { + settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } + + renderMenu() + + expect(container?.textContent).toContain('Branch name') + expect(container?.textContent).not.toContain('Branch / folder path') + }) + + it('mentions folder paths when project groups can create folder workspaces', () => { + settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } + projectGroups = [{ id: 'group-1' }] + + renderMenu() + + expect(container?.textContent).toContain('Branch / folder path') + expect(container?.textContent).not.toContain('Branch name') + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.tsx b/src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.tsx index f2447813ca5..535ab541435 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.tsx @@ -32,6 +32,7 @@ export function WorktreeCardDisplayMenuSection({ const setWorktreeCardMode = useAppStore((s) => s.setWorktreeCardMode) const agentActivityDisplayMode = useAppStore((s) => s.agentActivityDisplayMode) const setAgentActivityDisplayMode = useAppStore((s) => s.setAgentActivityDisplayMode) + const projectGroups = useAppStore((s) => s.projectGroups) const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true const cardLayout = settings?.compactWorktreeCards ? 'compact' : 'detailed' const cardLayoutLabel = @@ -39,9 +40,10 @@ export function WorktreeCardDisplayMenuSection({ const visiblePropertyCount = PROPERTY_OPTIONS.filter((opt) => worktreeCardProperties.includes(opt.id) ).length + const hasProjectGroups = projectGroups.length > 0 const worktreeCardPropertyOptions = useMemo( - () => getWorktreeCardPropertyOptions({ newCardStyle }), - [newCardStyle] + () => getWorktreeCardPropertyOptions({ newCardStyle, hasProjectGroups }), + [newCardStyle, hasProjectGroups] ) const handleWorktreeCardPropertyChange = useCallback( (properties: readonly WorktreeCardProperty[], checked: boolean): void => { diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx index 13cf96fe897..4fdb666088a 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx @@ -205,7 +205,7 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).not.toContain('bg-neutral-500/40') }) - it('uses a branch icon instead of the quiet active dot when new card style has no review', () => { + it('uses a branch icon with branch-only tooltip copy by default', () => { const markup = renderToStaticMarkup( { ) expect(markup).toContain('Branch') + expect(markup).not.toContain('Branch or folder path') 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') }) + it('uses context-aware branch or folder path tooltip copy', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Branch or folder path') + expect(markup).toContain('lucide-git-branch') + }) + it('keeps the quiet dot when the row has no branch identity', () => { const markup = renderToStaticMarkup( (['active', 'done', 'inactive']) // 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' +// so the passive label names the identity cue without claiming no review exists. +function getDefaultBranchIdentityLabel(): string { + return translate('auto.components.sidebar.WorktreeCardStatusSlot.branchIdentity', 'Branch') +} // 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' @@ -93,6 +97,7 @@ export function WorktreeCardStatusSlot({ prDisplay = null, newCardStyle = false, hasBranchIdentity = false, + branchIdentityLabel, className }: WorktreeCardStatusSlotProps): React.JSX.Element | null { const status = useWorktreeActivityStatus(worktreeId) @@ -112,7 +117,7 @@ export function WorktreeCardStatusSlot({ canShowReviewStatus && prDisplay ? getReviewStatusTooltip(prDisplay) : canShowBranchStatus - ? BRANCH_STATUS_LABEL + ? (branchIdentityLabel ?? getDefaultBranchIdentityLabel()) : statusLabel const passiveStatusTooltip = newCardStyle && isUnread ? `${passiveStatusLabel} · Unread` : passiveStatusLabel diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts index 6ce0830d27d..6d35ee6a17f 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts @@ -340,6 +340,7 @@ function makeFolderWorkspacePathStatusState(): Record { function setFolderWorkspaceFixtureState( options: { createdFrom?: ProjectGroup['createdFrom'] + experimentalNewWorktreeCardStyle?: boolean nestedGroup?: boolean } = {} ): void { @@ -405,7 +406,9 @@ function setFolderWorkspaceFixtureState( setRenamingWorktreeId: vi.fn(), setShowSleepingWorkspaces: vi.fn(), setSortBy: vi.fn(), - settings: null, + settings: options.experimentalNewWorktreeCardStyle + ? { experimentalNewWorktreeCardStyle: true } + : null, renamingWorktreeId: null, showSleepingWorkspaces: true, sortBy: 'manual', @@ -1040,6 +1043,25 @@ describe('WorktreeList lineage child card renderer', () => { ).toBe(20) }) + it('uses comparable new-card worktree geometry for experimental folder workspace rows', async () => { + setFolderWorkspaceFixtureState({ experimentalNewWorktreeCardStyle: true }) + const markup = await renderWorktreeListMarkup() + const folderWorktreeId = folderWorkspaceKey('folder-workspace-1') + const cardOpeningTag = getCardOpeningTag(markup, folderWorktreeId) + const surfaceOpeningTag = getFolderWorkspaceSurfaceOpeningTag(markup, 'folder-workspace-1') + const cardContentIndent = getDataNumber(cardOpeningTag, 'data-content-indent') + + expect(surfaceOpeningTag).toContain('padding-left:14px') + expect(cardOpeningTag).toContain('data-content-indent="16"') + expect(cardOpeningTag).toContain('data-flush-surface="true"') + expect( + getFlushCardContentStart({ + cardContentIndent, + surfaceInset: getPaddingLeft(surfaceOpeningTag) + }) + ).toBe(30) + }) + it('preserves manual folder workspace indentation outside folder-scanned groups', async () => { setFolderWorkspaceFixtureState({ createdFrom: 'manual' }) const markup = await renderWorktreeListMarkup() diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 1236d6dad5c..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' @@ -225,8 +226,7 @@ import { LINEAGE_CHILDREN_INLINE_OFFSET, getFolderBackedRepoWorktreeCardContentIndent, getFolderBackedRepoWorktreeCardSurfaceInset, - getFolderWorkspaceCardContentIndent, - getFolderWorkspaceCardSurfaceInset, + getFolderWorkspaceRowGeometry, getLineageChildrenInlineStyle, getLineageNestedRowGeometry, getProjectGroupHeaderPaddingLeft, @@ -256,6 +256,7 @@ import { getFolderWorkspaceExecutionHostIdForRows, getProjectGroupExecutionHostIdForRows } from './worktree-list-host-filtering' +import { getFolderWorkspaceCardPrDisplay } from './folder-workspace-card-pr-display' export { getScrollTopToRevealBounds, @@ -613,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 @@ -622,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[] @@ -1238,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, @@ -4761,30 +4766,25 @@ 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 contentIndent = isFolderBackedWorkspaceChild - ? getFolderWorkspaceCardContentIndent({ - groupDepth: folderWorkspaceRow.groupDepth - }) - : getWorktreeCardContentIndent({ - isGrouped: groupBy !== 'none', - groupDepth: folderWorkspaceRow.groupDepth, - lineageDepth: folderWorkspaceRow.depth - }) - // Why: folder workspace surfaces should step inward with their - // project-group nesting, matching lineage child card surfaces - // instead of spanning from the sidebar edge at every depth. - const surfaceInset = isFolderBackedWorkspaceChild - ? getFolderWorkspaceCardSurfaceInset({ - isGrouped: true, - groupDepth: folderWorkspaceRow.groupDepth - }) - : getWorktreeCardSurfaceInset({ - isGrouped: groupBy !== 'none', - groupDepth: folderWorkspaceRow.groupDepth - }) - const insetContentIndent = Math.max(0, contentIndent - surfaceInset) + const { surfaceInset, cardContentIndent } = getFolderWorkspaceRowGeometry({ + experimentalNewWorktreeCardStyle: newCardStyle, + isFolderBackedWorkspaceChild, + isGrouped: groupBy !== 'none', + groupDepth: folderWorkspaceRow.groupDepth, + lineageDepth: folderWorkspaceRow.depth + }) return (
@@ -4910,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) @@ -5017,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) @@ -6417,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 +} diff --git a/src/renderer/src/components/sidebar/sidebar-workspace-option-items.test.ts b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.test.ts index e43f8b9d7ca..40efe8f0937 100644 --- a/src/renderer/src/components/sidebar/sidebar-workspace-option-items.test.ts +++ b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.test.ts @@ -36,4 +36,30 @@ describe('worktree card property options', () => { expect(options.map((option) => option.label)).toContain('Linear issues') expect(options.map((option) => option.label)).toContain('Automation') }) + + it('uses branch-only copy by default and without project groups', () => { + const defaultOptions = getWorktreeCardPropertyOptions() + const newCardOptions = getWorktreeCardPropertyOptions({ + newCardStyle: true, + hasProjectGroups: false + }) + + expect(defaultOptions.find((option) => option.id === 'branch')?.label).toBe('Branch name') + expect(newCardOptions.find((option) => option.id === 'branch')?.label).toBe('Branch name') + }) + + it('keeps branch-only copy for legacy cards even with project groups', () => { + const options = getWorktreeCardPropertyOptions({ hasProjectGroups: true }) + + expect(options.find((option) => option.id === 'branch')?.label).toBe('Branch name') + }) + + it('mentions folder paths only for new card style with project groups', () => { + const options = getWorktreeCardPropertyOptions({ + newCardStyle: true, + hasProjectGroups: true + }) + + expect(options.find((option) => option.id === 'branch')?.label).toBe('Branch / folder path') + }) }) diff --git a/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts index 1772ea4c14d..8623d35d0bb 100644 --- a/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts +++ b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts @@ -158,21 +158,38 @@ const ISSUE_WORKTREE_CARD_PROPERTY_OPTIONS: WorktreeCardPropertyOption[] = [ type WorktreeCardPropertyOptionsInput = { newCardStyle?: boolean + hasProjectGroups?: boolean } export function getWorktreeCardPropertyOptions({ - newCardStyle = false + newCardStyle = false, + hasProjectGroups = false }: WorktreeCardPropertyOptionsInput = {}): WorktreeCardPropertyOption[] { const issueOptions = newCardStyle ? ISSUE_WORKTREE_CARD_PROPERTY_OPTIONS : [TASK_WORKTREE_CARD_PROPERTY_OPTION] + const branchOption: WorktreeCardPropertyOption = { + id: 'branch', + properties: ['branch'], + get label() { + // Why: new-card project groups can contain folder workspaces, so the + // branch setting copy must describe both repo and folder identity. + return newCardStyle && hasProjectGroups + ? translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.folderPathIdentity', + 'Branch / folder path' + ) + : translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.219ebf1961', 'Branch name') + } + } if (newCardStyle) { - return [...issueOptions, ...BASE_WORKTREE_CARD_PROPERTY_OPTIONS.slice(1)] + return [...issueOptions, ...BASE_WORKTREE_CARD_PROPERTY_OPTIONS.slice(1, -1), branchOption] } return [ BASE_WORKTREE_CARD_PROPERTY_OPTIONS[0], ...issueOptions, - ...BASE_WORKTREE_CARD_PROPERTY_OPTIONS.slice(1) + ...BASE_WORKTREE_CARD_PROPERTY_OPTIONS.slice(1, -1), + branchOption ] } diff --git a/src/renderer/src/components/sidebar/worktree-list-indentation.test.ts b/src/renderer/src/components/sidebar/worktree-list-indentation.test.ts index 0e65ebbdd6c..cb9d12aeaf5 100644 --- a/src/renderer/src/components/sidebar/worktree-list-indentation.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-indentation.test.ts @@ -12,6 +12,7 @@ import { getFolderBackedRepoWorktreeCardSurfaceInset, getFolderWorkspaceCardContentIndent, getFolderWorkspaceCardSurfaceInset, + getFolderWorkspaceRowGeometry, getFlushWorktreeCardPaddingLeft, getLineageChildrenInlineStyle, getLineageEffectiveChildStart, @@ -21,6 +22,17 @@ import { getWorktreeCardSurfaceInset } from './worktree-list-indentation' +function getFlushCardContentStart(args: { + surfaceInset: number + cardContentIndent: number +}): number { + return ( + args.surfaceInset + + WORKTREE_CARD_SURFACE_MARGIN + + Math.max(FLUSH_CARD_MIN_CONTENT_INSET, args.cardContentIndent - FLUSH_CARD_CONTENT_PULLBACK) + ) +} + describe('worktree list indentation', () => { it('keeps ungrouped workspaces flush with the list', () => { expect(getWorktreeCardContentIndent({ isGrouped: false, groupDepth: 4, lineageDepth: 0 })).toBe( @@ -78,6 +90,118 @@ describe('worktree list indentation', () => { expect(getFolderWorkspaceCardSurfaceInset({ isGrouped: false, groupDepth: 2 })).toBe(0) }) + it('preserves legacy folder-scanned folder workspace row geometry', () => { + const geometry = getFolderWorkspaceRowGeometry({ + experimentalNewWorktreeCardStyle: false, + isFolderBackedWorkspaceChild: true, + isGrouped: true, + groupDepth: 1, + lineageDepth: 0 + }) + + expect(geometry).toEqual({ + surfaceInset: 14, + cardContentIndent: 6 + }) + expect(getFlushCardContentStart(geometry)).toBe(20) + }) + + it('preserves legacy nested folder-scanned folder workspace row geometry', () => { + const geometry = getFolderWorkspaceRowGeometry({ + experimentalNewWorktreeCardStyle: false, + isFolderBackedWorkspaceChild: true, + isGrouped: true, + groupDepth: 2, + lineageDepth: 0 + }) + + expect(geometry).toEqual({ + surfaceInset: 24, + cardContentIndent: 6 + }) + expect(getFlushCardContentStart(geometry)).toBe(30) + }) + + it('preserves legacy manual grouped folder workspace row geometry', () => { + const geometry = getFolderWorkspaceRowGeometry({ + experimentalNewWorktreeCardStyle: false, + isFolderBackedWorkspaceChild: false, + isGrouped: true, + groupDepth: 1, + lineageDepth: 0 + }) + + expect(geometry).toEqual({ + surfaceInset: 14, + cardContentIndent: 24 + }) + expect(getFlushCardContentStart(geometry)).toBe(38) + }) + + it('uses comparable repo worktree geometry for experimental folder-scanned folder workspaces', () => { + const geometry = getFolderWorkspaceRowGeometry({ + experimentalNewWorktreeCardStyle: true, + isFolderBackedWorkspaceChild: true, + isGrouped: true, + groupDepth: 1, + lineageDepth: 0 + }) + + expect(geometry).toEqual({ + surfaceInset: 14, + cardContentIndent: 16 + }) + expect(getFlushCardContentStart(geometry)).toBe(30) + }) + + it('uses comparable repo worktree geometry for experimental nested folder workspaces', () => { + const geometry = getFolderWorkspaceRowGeometry({ + experimentalNewWorktreeCardStyle: true, + isFolderBackedWorkspaceChild: true, + isGrouped: true, + groupDepth: 4, + lineageDepth: 3 + }) + + expect(geometry).toEqual({ + surfaceInset: 54, + cardContentIndent: 6 + }) + expect(getFlushCardContentStart(geometry)).toBe(60) + }) + + it('keeps experimental manual grouped folder workspaces on normal worktree geometry', () => { + const geometry = getFolderWorkspaceRowGeometry({ + experimentalNewWorktreeCardStyle: true, + isFolderBackedWorkspaceChild: false, + isGrouped: true, + groupDepth: 1, + lineageDepth: 0 + }) + + expect(geometry).toEqual({ + surfaceInset: 14, + cardContentIndent: 24 + }) + expect(getFlushCardContentStart(geometry)).toBe(38) + }) + + it('keeps experimental flat folder workspaces on normal worktree geometry', () => { + const geometry = getFolderWorkspaceRowGeometry({ + experimentalNewWorktreeCardStyle: true, + isFolderBackedWorkspaceChild: false, + isGrouped: false, + groupDepth: 3, + lineageDepth: 0 + }) + + expect(geometry).toEqual({ + surfaceInset: 0, + cardContentIndent: 0 + }) + expect(getFlushCardContentStart(geometry)).toBe(6) + }) + it('caps header indentation separately from workspace content indentation', () => { expect(getProjectGroupHeaderPaddingLeft(100)).toBe(70) }) diff --git a/src/renderer/src/components/sidebar/worktree-list-indentation.ts b/src/renderer/src/components/sidebar/worktree-list-indentation.ts index f312e197e3e..08787a8ae25 100644 --- a/src/renderer/src/components/sidebar/worktree-list-indentation.ts +++ b/src/renderer/src/components/sidebar/worktree-list-indentation.ts @@ -102,6 +102,61 @@ export function getFolderWorkspaceCardSurfaceInset(args: { return Math.min(genericSurfaceInset, Math.max(0, maxSurfaceInset)) } +export function getFolderWorkspaceRowGeometry(args: { + experimentalNewWorktreeCardStyle: boolean + isFolderBackedWorkspaceChild: boolean + isGrouped: boolean + groupDepth: number + lineageDepth: number +}): { + surfaceInset: number + cardContentIndent: number +} { + if (args.experimentalNewWorktreeCardStyle && args.isFolderBackedWorkspaceChild) { + // Why: standalone folder workspace rows do not get a lineage wrapper + // offset, so align them to the comparable folder-backed repo row anchor. + const contentAnchor = getFolderBackedRepoWorktreeCardContentIndent({ + groupDepth: args.groupDepth, + lineageDepth: 0 + }) + const surfaceInset = getFolderBackedRepoWorktreeCardSurfaceInset({ + groupDepth: args.groupDepth, + lineageDepth: 0 + }) + + return { + surfaceInset, + cardContentIndent: Math.max(0, contentAnchor - surfaceInset) + } + } + + const contentIndent = args.isFolderBackedWorkspaceChild + ? getFolderWorkspaceCardContentIndent({ + groupDepth: args.groupDepth + }) + : getWorktreeCardContentIndent({ + isGrouped: args.isGrouped, + groupDepth: args.groupDepth, + lineageDepth: args.lineageDepth + }) + // Why: legacy folder-scanned folder workspaces keep their compact anchor, + // while all other folder rows share the normal worktree row surface path. + const surfaceInset = args.isFolderBackedWorkspaceChild + ? getFolderWorkspaceCardSurfaceInset({ + isGrouped: true, + groupDepth: args.groupDepth + }) + : getWorktreeCardSurfaceInset({ + isGrouped: args.isGrouped, + groupDepth: args.groupDepth + }) + + return { + surfaceInset, + cardContentIndent: Math.max(0, contentIndent - surfaceInset) + } +} + export function getWorktreeCardSurfaceInset(args: { isGrouped: boolean groupDepth: number diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c05c5058070..db36d7b51b5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3779,7 +3779,8 @@ "8d62c68b35": "Notes", "2d74665a56": "Ports", "65a9820bd1": "Agent statuses", - "219ebf1961": "Branch name" + "219ebf1961": "Branch name", + "folderPathIdentity": "Branch / folder path" }, "SshDisconnectedDialog": { "ca4a7892af": "Connecting...", @@ -3855,7 +3856,9 @@ "021538e1d1": "SSH disconnected", "runtimeHostDisconnected": "Server disconnected", "runtimeHostProject": "Project on Orca server", - "automationCreated": "Created by automation" + "automationCreated": "Created by automation", + "branchIdentity": "Branch", + "branchFolderPathIdentity": "Branch or folder path" }, "WorktreeCardAgents": { "1b0a156717": "Agents" @@ -4301,6 +4304,9 @@ "current": "Current", "searchPlaceholder": "Search worktrees...", "empty": "No matching eligible worktrees." + }, + "WorktreeCardStatusSlot": { + "branchIdentity": "Branch" } }, "shared": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 1aad6ed2381..495c24dfeaf 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3765,7 +3765,8 @@ "automation": "Automation", "2d74665a56": "Puertos", "65a9820bd1": "Estados del agente", - "219ebf1961": "Nombre de rama" + "219ebf1961": "Nombre de rama", + "folderPathIdentity": "Branch / folder path" }, "SshDisconnectedDialog": { "ca4a7892af": "Conectando...", @@ -3841,7 +3842,9 @@ "021538e1d1": "SSH desconectado", "runtimeHostDisconnected": "Server disconnected", "runtimeHostProject": "Project on Orca server", - "automationCreated": "Created by automation" + "automationCreated": "Created by automation", + "branchIdentity": "Branch", + "branchFolderPathIdentity": "Branch or folder path" }, "WorktreeCardAgents": { "1b0a156717": "Agents" @@ -4301,6 +4304,9 @@ "current": "Actual", "searchPlaceholder": "Buscar árboles de trabajo...", "empty": "No hay árboles de trabajo elegibles que coincidan." + }, + "WorktreeCardStatusSlot": { + "branchIdentity": "Branch" } }, "shared": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 3e2ac5ce512..78a9c4dffad 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3746,7 +3746,8 @@ "2d74665a56": "ポート", "65a9820bd1": "Agent ステータス", "219ebf1961": "ブランチ名", - "automation": "Automation" + "automation": "Automation", + "folderPathIdentity": "Branch / folder path" }, "SshDisconnectedDialog": { "ca4a7892af": "接続中...", @@ -3822,7 +3823,9 @@ "021538e1d1": "SSH が切断されました", "runtimeHostDisconnected": "Server disconnected", "runtimeHostProject": "Project on Orca server", - "automationCreated": "Created by automation" + "automationCreated": "Created by automation", + "branchIdentity": "Branch", + "branchFolderPathIdentity": "Branch or folder path" }, "WorktreeCardAgents": { "1b0a156717": "Agents" @@ -4301,6 +4304,9 @@ "current": "現在", "searchPlaceholder": "ワークツリーを検索...", "empty": "一致する対象ワークツリーがありません。" + }, + "WorktreeCardStatusSlot": { + "branchIdentity": "Branch" } }, "shared": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d452e81de2c..00981aea841 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3746,7 +3746,8 @@ "2d74665a56": "포트", "65a9820bd1": "Agent 상태", "219ebf1961": "브랜치 이름", - "automation": "자동화" + "automation": "자동화", + "folderPathIdentity": "Branch / folder path" }, "SshDisconnectedDialog": { "ca4a7892af": "연결 중...", @@ -3822,7 +3823,9 @@ "021538e1d1": "SSH 연결이 끊어졌습니다.", "runtimeHostDisconnected": "Server disconnected", "runtimeHostProject": "Project on Orca server", - "automationCreated": "Created by automation" + "automationCreated": "Created by automation", + "branchIdentity": "Branch", + "branchFolderPathIdentity": "Branch or folder path" }, "WorktreeCardAgents": { "1b0a156717": "Agents" @@ -4301,6 +4304,9 @@ "current": "현재", "searchPlaceholder": "워크트리 검색...", "empty": "일치하는 적격 워크트리가 없습니다." + }, + "WorktreeCardStatusSlot": { + "branchIdentity": "Branch" } }, "shared": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 0c7753af883..4aa4d8ad800 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1102,7 +1102,8 @@ "importHostSetup": "导入", "notePasteTooLarge": "笔记字段的粘贴内容过大。", "reuseExistingBranch": "复用分支", - "reuseExistingBranchHint": "检出已有分支,而不是基于它创建新分支。" + "reuseExistingBranchHint": "检出已有分支,而不是基于它创建新分支。", + "createMultiple": "Create more" }, "NewWorkspaceComposerModal": { "fa90f739a5": "创建工作区之前选择项目、工作区名称和 Agent。" @@ -3745,7 +3746,8 @@ "2d74665a56": "端口", "65a9820bd1": "Agent 状态", "219ebf1961": "分支名称", - "automation": "Automation" + "automation": "Automation", + "folderPathIdentity": "Branch / folder path" }, "SshDisconnectedDialog": { "ca4a7892af": "正在连接...", @@ -3821,7 +3823,9 @@ "021538e1d1": "SSH 已断开连接", "runtimeHostDisconnected": "Server disconnected", "runtimeHostProject": "Project on Orca server", - "automationCreated": "Created by automation" + "automationCreated": "Created by automation", + "branchIdentity": "Branch", + "branchFolderPathIdentity": "Branch or folder path" }, "WorktreeCardAgents": { "1b0a156717": "Agents" @@ -4300,6 +4304,9 @@ "current": "当前", "searchPlaceholder": "搜索工作树...", "empty": "没有匹配的可用工作树。" + }, + "WorktreeCardStatusSlot": { + "branchIdentity": "Branch" } }, "shared": {