feat: add worktree card layout controls (#4016)

This commit is contained in:
Jinjing 2026-05-31 01:22:31 -07:00 committed by GitHub
parent c3c265d926
commit f9b52ef8cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 202 additions and 71 deletions

View File

@ -17,8 +17,8 @@ describe('ExperimentalPane', () => {
expect(markup).toContain('Compact worktree cards')
expect(markup).toContain('aria-checked="false"')
expect(markup).toContain('Collapses a card only when its second line would be empty or repeat')
expect(markup).toContain('different branch')
expect(markup).toContain('single title row')
expect(markup).toContain('selected properties on a second row')
expect(EXPERIMENTAL_SEARCH_ENTRY.compactWorktreeCards.keywords).toContain('metadata')
})
})

View File

@ -158,7 +158,7 @@ export function ExperimentalPane({
{showCompactWorktreeCards ? (
<SearchableSetting
title="Compact worktree cards"
description="Hide redundant second lines in the worktree sidebar."
description="Use one-line worktree cards instead of the detailed metadata row."
keywords={EXPERIMENTAL_SEARCH_ENTRY.compactWorktreeCards.keywords}
className="space-y-3 py-2"
>
@ -166,9 +166,8 @@ export function ExperimentalPane({
<div className="min-w-0 shrink space-y-0.5">
<Label>Compact worktree cards</Label>
<p className="text-xs text-muted-foreground">
Collapses a card only when its second line would be empty or repeat the title. Cards
with a different branch, repo badge, folder badge, cache timer, or conflict state
keep the second line.
Keeps workspace cards to a single title row. The detailed layout restores the
branch, project, cache timer, and selected properties on a second row.
</p>
</div>
<button

View File

@ -49,7 +49,7 @@ export const EXPERIMENTAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
},
{
title: 'Compact worktree cards',
description: 'Hide redundant second lines in the worktree sidebar.',
description: 'Use one-line worktree cards instead of the detailed metadata row.',
keywords: [
'experimental',
'worktree',

View File

@ -34,6 +34,11 @@ const GROUP_BY_OPTIONS = [
{ id: 'repo', label: 'Project' }
] as const
const CARD_LAYOUT_OPTIONS = [
{ id: 'detailed', label: 'Detailed' },
{ id: 'compact', label: 'Compact' }
] as const
const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [
{ id: 'issue', label: 'GitHub ticket' },
{ id: 'linear-issue', label: 'Linear issue' },
@ -77,6 +82,8 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
const repos = useAppStore((s) => s.repos)
const worktreeCardProperties = useAppStore((s) => s.worktreeCardProperties)
const toggleWorktreeCardProperty = useAppStore((s) => s.toggleWorktreeCardProperty)
const settings = useAppStore((s) => s.settings)
const updateSettings = useAppStore((s) => s.updateSettings)
const agentActivityDisplayMode = useAppStore((s) => s.agentActivityDisplayMode)
const setAgentActivityDisplayMode = useAppStore((s) => s.setAgentActivityDisplayMode)
const sortBy = useAppStore((s) => s.sortBy)
@ -112,6 +119,9 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
(hasSleepingFilter ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedCount
const activeFilterLabel = `${activeFilterCount} ${activeFilterCount === 1 ? 'filter' : 'filters'}`
const sortLabel = SORT_OPTIONS.find((opt) => opt.id === sortBy)?.label ?? 'Sort'
const cardLayout = settings?.experimentalCompactWorktreeCards ? 'compact' : 'detailed'
const cardLayoutLabel =
CARD_LAYOUT_OPTIONS.find((opt) => opt.id === cardLayout)?.label ?? 'Detailed'
const visiblePropertyCount = PROPERTY_OPTIONS.filter((opt) =>
worktreeCardProperties.includes(opt.id)
).length
@ -228,19 +238,54 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<SidebarWorkspaceFilterSection />
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span className="flex flex-1 items-center justify-between">
<span>Card layout</span>
<span className="text-[11px] font-medium text-muted-foreground">
{cardLayoutLabel}
</span>
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className="w-44"
data-workspace-board-preserve-open={preserveWorkspaceBoardOpen ? '' : undefined}
>
<DropdownMenuRadioGroup
value={cardLayout}
onValueChange={(value) => {
void updateSettings({
experimentalCompactWorktreeCards: value === 'compact'
})
}}
>
{CARD_LAYOUT_OPTIONS.map((opt) => (
<DropdownMenuRadioItem
key={opt.id}
value={opt.id}
onSelect={(e) => e.preventDefault()}
>
{opt.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger
disabled={cardLayout === 'compact'}
className="data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
<span className="flex flex-1 items-center justify-between">
<span>Show properties</span>
{visiblePropertyCount > 0 && (
{cardLayout === 'compact' ? (
<span className="text-[11px] font-medium text-muted-foreground">Detailed only</span>
) : visiblePropertyCount > 0 ? (
<span className="text-[11px] font-medium text-muted-foreground">
{visiblePropertyCount}
</span>
)}
) : null}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
@ -280,6 +325,9 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<SidebarWorkspaceFilterSection />
<DropdownMenuSeparator />
<SidebarRepositoryFilterSection />
</DropdownMenuContent>

View File

@ -2,7 +2,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { Repo, Worktree, WorktreeCardProperty } from '../../../../shared/types'
import type { GlobalSettings, Repo, Worktree, WorktreeCardProperty } from '../../../../shared/types'
import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports'
const fetchHostedReviewForBranch = vi.fn()
@ -14,6 +14,7 @@ const updateWorktreeMeta = vi.fn()
let worktreeCardProperties: WorktreeCardProperty[] = ['pr']
let hostedReviewCache: Record<string, unknown> = {}
let workspacePortScan: WorkspacePortScanResult | null = null
let settings: Partial<GlobalSettings> | null = null
vi.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
@ -28,7 +29,7 @@ vi.mock('@/store', () => ({
linearIssueCache: {},
openModal,
remoteBranchConflictByWorktreeId: {},
settings: null,
settings,
sshConnectionStates: new Map(),
sshTargetLabels: new Map(),
updateWorktreeMeta,
@ -128,20 +129,49 @@ describe('WorktreeCard linked PR display', () => {
worktreeCardProperties = ['pr']
hostedReviewCache = {}
workspacePortScan = null
settings = null
})
it('keeps linked GH PR details off the closed card before hosted review details are cached', async () => {
it('shows linked GH PR metadata in detailed cards before hosted review details are cached', async () => {
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard worktree={makeWorktree({ linkedPR: 456 })} repo={makeRepo()} isActive={false} />
)
expect(markup).not.toContain('Linked PR #456')
expect(markup).toContain('Linked PR #456')
expect(markup).not.toContain('Loading PR')
})
it('keeps issue, Linear issue, PR, and notes metadata out of the closed card', async () => {
it('shows issue, Linear issue, PR, and notes metadata in detailed cards', async () => {
worktreeCardProperties = ['issue', 'linear-issue', 'pr', 'comment']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({
linkedIssue: 123,
linkedLinearIssue: 'ENG-123',
linkedPR: 456,
comment: 'Reviewer handoff note'
})}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('Linked issue #123')
expect(markup).toContain('Linked Linear ENG-123')
expect(markup).toContain('Linked PR #456')
expect(markup).toContain('Workspace notes')
expect(markup).not.toContain('data-slot="badge"')
expect(markup).not.toContain('Loading issue')
expect(markup).not.toContain('Loading PR')
expect(markup).not.toContain('Reviewer handoff note')
})
it('keeps issue, Linear issue, PR, and notes metadata out of compact cards', async () => {
settings = { experimentalCompactWorktreeCards: true }
worktreeCardProperties = ['issue', 'linear-issue', 'pr', 'comment']
const { default: WorktreeCard } = await import('./WorktreeCard')
@ -162,9 +192,6 @@ describe('WorktreeCard linked PR display', () => {
expect(markup).not.toContain('Linked Linear ENG-123')
expect(markup).not.toContain('Linked PR #456')
expect(markup).not.toContain('Workspace notes')
expect(markup).not.toContain('data-slot="badge"')
expect(markup).not.toContain('Loading issue')
expect(markup).not.toContain('Loading PR')
expect(markup).not.toContain('Reviewer handoff note')
})
@ -243,8 +270,8 @@ describe('WorktreeCard linked PR display', () => {
<WorktreeCard worktree={makeWorktree({ linkedPR: 456 })} repo={makeRepo()} isActive={false} />
)
expect(markup).not.toContain('Linked PR #456')
expect(markup).not.toContain('text-rose-500/85')
expect(markup).toContain('Linked PR #456')
expect(markup).toContain('text-rose-500/85')
expect(markup).not.toContain('CI checks')
})
})

View File

@ -147,17 +147,17 @@ describe('WorktreeCard quick actions', () => {
expect(markup).toContain('data-workspace-board-preserve-open=""')
})
it('renders repo identity inline without creating a repo-only metadata row', () => {
it('renders repo identity in the detailed metadata row', () => {
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('aria-label="Project orca"')
expect(markup.indexOf('aria-label="Project orca"')).toBeLessThan(markup.indexOf('Quick action'))
expect(markup).not.toContain('data-worktree-card-meta-row=""')
expect(markup).not.toContain('aria-label="Project orca"')
expect(markup).toContain('>orca</span>')
expect(markup).toContain('data-worktree-card-meta-row=""')
})
it('omits folder kind from the title row without creating a folder-only metadata row', () => {
it('renders folder kind in the detailed metadata row', () => {
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ displayName: 'Docs folder', branch: '' })}
@ -167,11 +167,11 @@ describe('WorktreeCard quick actions', () => {
)
expect(markup).toContain('Docs folder')
expect(markup).not.toContain('>Folder</span>')
expect(markup).not.toContain('data-worktree-card-meta-row=""')
expect(markup).toContain('>Folder</span>')
expect(markup).toContain('data-worktree-card-meta-row=""')
})
it('omits the branch metadata row by default when it repeats the workspace title', () => {
it('renders the repeated branch metadata row in detailed cards', () => {
worktreeCardProperties = []
const markup = renderToStaticMarkup(
@ -184,8 +184,8 @@ describe('WorktreeCard quick actions', () => {
)
expect(markup).toContain('quick-action')
expect(markup).not.toContain('text-[11px] text-muted-foreground truncate leading-none')
expect(markup).not.toContain('data-worktree-card-meta-row=""')
expect(markup).toContain('text-[11px] text-muted-foreground truncate leading-none')
expect(markup).toContain('data-worktree-card-meta-row=""')
expect(markup).toContain('tabindex="0"')
})
@ -243,7 +243,7 @@ describe('WorktreeCard quick actions', () => {
expect(markup).toContain('primary')
expect(markup).not.toContain('aria-label="Primary worktree"')
expect(markup).not.toContain('data-worktree-card-meta-row=""')
expect(markup).toContain('data-worktree-card-meta-row=""')
})
it('moves unread and primary into the title row when compact cards are enabled', () => {
@ -374,7 +374,7 @@ describe('WorktreeCard quick actions', () => {
)
expect(markup).not.toContain('Rebasing')
expect(markup).not.toContain('data-worktree-card-meta-row=""')
expect(markup).toContain('data-worktree-card-meta-row=""')
})
it('keeps non-rebase operation chips on the card', () => {

View File

@ -23,7 +23,7 @@ import WorktreeCardAgents from './WorktreeCardAgents'
import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot'
import { cn } from '@/lib/utils'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type {
GitHubWorkItem,
@ -36,9 +36,10 @@ import { branchDisplayName, CONFLICT_OPERATION_LABELS } from './WorktreeCardHelp
import {
WorktreeCardDetailsHover,
hasWorktreeCardDetails,
WorktreeCardMetaBadges,
type WorktreeCardIssueDisplay
} from './WorktreeCardMeta'
import { WorktreeCardPortsDetails } from './WorktreeCardPorts'
import { WorktreeCardPortsDetails, WorktreeCardPortsTrigger } from './WorktreeCardPorts'
import { writeWorkspaceDragData } from './workspace-status'
import { getWorktreeCardPrDisplay } from './worktree-card-pr-display'
import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups'
@ -256,11 +257,12 @@ const WorktreeCard = React.memo(function WorktreeCard({
const isDeleting = deleteState?.isDeleting ?? false
const deleteModifierPressed = useWorkspaceDeleteModifierPressed()
const showPR = cardProps.includes('pr')
const showIssue = cardProps.includes('issue')
const showLinearIssue = cardProps.includes('linear-issue')
const showComment = cardProps.includes('comment')
const showPorts = cardProps.includes('ports')
const showDetailedCardProperties = !compactCards
const showPR = showDetailedCardProperties && cardProps.includes('pr')
const showIssue = showDetailedCardProperties && cardProps.includes('issue')
const showLinearIssue = showDetailedCardProperties && cardProps.includes('linear-issue')
const showComment = showDetailedCardProperties && cardProps.includes('comment')
const showPorts = showDetailedCardProperties && cardProps.includes('ports')
// Skip hosted-review fetches when the corresponding card sections are hidden.
// This preference is purely presentational, so background refreshes would
@ -557,7 +559,9 @@ const WorktreeCard = React.memo(function WorktreeCard({
const hasPorts = showPorts && workspacePorts.length > 0
const cacheStartedAt = usePromptCacheCountdownStartedAt(worktree.id)
const cacheTtlMs = useAppStore((s) => s.settings?.promptCacheTtlMs ?? 0)
const showInlineRepoBadge = !!repo && !hideRepoBadge && !isFolder
const showInlineRepoBadge = compactCards && !!repo && !hideRepoBadge && !isFolder
const showRepoBadgeInMetaRow = !compactCards && !!repo && !hideRepoBadge
const showBranch = !isFolder && (!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 =
@ -570,9 +574,12 @@ const WorktreeCard = React.memo(function WorktreeCard({
const showCombinedStatusSlot = showStatus || (!compactCards && showUnreadQuickAction)
const showTitleRowUnread = compactCards && showUnreadQuickAction && !showStatus
const showTitleRowPrimary = compactCards && worktree.isMainWorktree && !isFolder
const hasMetaRow = hasMetadataBadge || cacheStartedAt != null
const showMetaRowDetails = !compactCards && (hasDetails || hasPorts)
// Why: detailed layout is the user's explicit choice to reserve a scannable
// metadata lane; compact layout only opens that lane for transient state.
const hasMetaRow = !compactCards || hasMetadataBadge || cacheStartedAt != null
const showHeaderActions = showTitleRowUnread || showTitleRowPrimary || showDeleteQuickAction
const showBranchIdentityHover = !isFolder && branch !== worktree.displayName
const showBranchIdentityHover = compactCards && showBranch
// Why: sidebar rows need a small surface inset, while their content remains
// aligned with the pre-inset layout and the repo header hierarchy.
const cardStyle = flushSurface
@ -584,35 +591,56 @@ const WorktreeCard = React.memo(function WorktreeCard({
: undefined
const titleDetailsWrapper =
hasDetails || hasPorts || showBranchIdentityHover
compactCards && showBranchIdentityHover
? (title: React.ReactElement) => (
<WorktreeCardDetailsHover
issue={metaIssue}
linearIssue={metaLinearIssue}
review={metaReview}
comment={metaComment}
issue={null}
linearIssue={null}
review={null}
comment={null}
branchName={showBranchIdentityHover ? branch : undefined}
workspaceTitle={worktree.displayName}
detailsAfter={hasPorts ? <WorktreeCardPortsDetails ports={workspacePorts} /> : null}
onEditIssue={handleEditIssue}
onEditComment={handleEditComment}
onOpenGitHubIssueInOrca={
metaIssue && 'url' in metaIssue && metaIssue.url
? handleOpenGitHubIssueInOrca
: undefined
}
onOpenLinearIssueInOrca={linearIssue?.url ? handleOpenLinearIssueInOrca : undefined}
onOpenReviewInOrca={
metaReview?.url && metaReview.provider === 'github'
? handleOpenReviewInOrca
: undefined
}
>
{title}
</WorktreeCardDetailsHover>
)
: undefined
const detailsAndPorts =
hasDetails || hasPorts ? (
<WorktreeCardDetailsHover
issue={metaIssue}
linearIssue={metaLinearIssue}
review={metaReview}
comment={metaComment}
detailsAfter={hasPorts ? <WorktreeCardPortsDetails ports={workspacePorts} /> : null}
onEditIssue={handleEditIssue}
onEditComment={handleEditComment}
onOpenGitHubIssueInOrca={
metaIssue && 'url' in metaIssue && metaIssue.url ? handleOpenGitHubIssueInOrca : undefined
}
onOpenLinearIssueInOrca={linearIssue?.url ? handleOpenLinearIssueInOrca : undefined}
onOpenReviewInOrca={
metaReview?.url && metaReview.provider === 'github' ? handleOpenReviewInOrca : undefined
}
>
<div className="flex shrink-0 items-center gap-1">
{hasPorts && <WorktreeCardPortsTrigger ports={workspacePorts} />}
{hasDetails && (
<WorktreeCardMetaBadges
issue={metaIssue}
linearIssue={metaLinearIssue}
review={metaReview}
comment={metaComment}
className="ml-0 pr-0"
/>
)}
</div>
</WorktreeCardDetailsHover>
) : null
const cardBody = (
<div
className={cn(
@ -816,6 +844,28 @@ const WorktreeCard = React.memo(function WorktreeCard({
{hasMetaRow && (
<div className="flex items-center gap-1.5 min-w-0" data-worktree-card-meta-row="">
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
{showRepoBadgeInMetaRow && repo && (
<div className="flex items-center gap-1.5 shrink-0 px-1.5 py-0.5 rounded-[4px] bg-accent border border-border dark:bg-accent/50 dark:border-border/60">
<RepoBadgeMark color={repo.badgeColor} />
<span className="text-[10px] font-semibold text-foreground truncate max-w-[6rem] leading-none lowercase">
{repo.displayName}
</span>
</div>
)}
{isFolder ? (
<Badge
variant="secondary"
className="h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 text-muted-foreground bg-accent border border-border dark:bg-accent/80 dark:border-border/50 leading-none"
>
{repo ? getRepoKindLabel(repo) : 'Folder'}
</Badge>
) : showBranch ? (
<span className="min-w-0 text-[11px] text-muted-foreground truncate leading-none">
{branch}
</span>
) : null}
{showConflictOperationBadge && (
<Badge
variant="outline"
@ -830,6 +880,12 @@ const WorktreeCard = React.memo(function WorktreeCard({
<CacheTimer startedAt={cacheStartedAt} ttlMs={cacheTtlMs} />
)}
</div>
{showMetaRowDetails && (
<div className="ml-auto flex shrink-0 items-center gap-1 pr-1.5">
{detailsAndPorts}
</div>
)}
</div>
)}
@ -849,7 +905,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
naturally when agents appear/disappear. When agents directly
follow the title, counterbalance the card stack gap so both rows
read as one compact header group. */}
{cardProps.includes('inline-agents') && (
{showDetailedCardProperties && cardProps.includes('inline-agents') && (
<WorktreeCardAgents
worktreeId={worktree.id}
className={hasMetaRow || remoteBranchConflict ? 'mt-0' : '-mt-1'}

View File

@ -265,7 +265,7 @@ describe('WorktreeCardAgents', () => {
expect(markup).toBe('')
})
it('renders two compact agents directly instead of hiding them behind a summary', async () => {
it('renders a compact summary affordance for two flat agents', async () => {
mockAgentActivityDisplayMode = 'compact'
mockAgents = [
mockAgent({ agentType: 'codex', state: 'done', startedAt: 1000, prompt: 'First agent' }),
@ -281,10 +281,11 @@ describe('WorktreeCardAgents', () => {
const markup = renderToStaticMarkup(<WorktreeCardAgents worktreeId="wt-1" />)
expect(markup).toContain('First agent')
expect(markup).toContain('Second agent')
expect(markup).not.toContain('All 2 agents done')
expect(markup).not.toContain('aria-label="Expand')
expect(markup).toContain('All 2 agents done')
expect(markup).toContain('Expand All 2 agents done')
expect(markup).not.toContain('First agent')
expect(markup).not.toContain('Second agent')
expect(markup).not.toContain('data-testid="agent-row"')
})
it('renders compact agent messages with images as inline thumbnails', async () => {

View File

@ -419,9 +419,9 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
if (agentActivityDisplayMode === 'compact') {
const summaryAgents = hasLineage ? rootAgents : agents
// Why: one or two compact rows still carry useful prompt/provider detail
// without making worktree cards tall; the summary is for overflow.
const shouldUseSummaryRow = summaryAgents.length > 2
// Why: compact worktree cards keep multiple active agents to a single
// predictable status line, even when there are only two agents.
const shouldUseSummaryRow = summaryAgents.length > 1
const subjectLabel = hasLineage
? `${rootAgents.length} ${rootAgents.length === 1 ? 'parent' : 'parents'}`
: `${agents.length} agents`