Graduate compact worktree card setting (#4924)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-08 20:37:11 -04:00 committed by GitHub
parent ec7d013469
commit 04205acd76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 144 additions and 220 deletions

View File

@ -0,0 +1,94 @@
# Compact Worktree Card Setting Graduation
## Problem
`experimentalCompactWorktreeCards` is exposed in two places: the Experimental settings pane and the
sidebar workspace options menu. The sidebar menu already presents the durable user-facing model as
`Card layout` with `Detailed` and `Compact`, so the Experimental row is redundant and makes the same
preference look like two separate features.
## Goal
Graduate the preference out of Experimental. Keep the sidebar workspace options menu as the
canonical control, preserve existing users' saved compact-card choice, and remove the Experimental
pane row/search entry.
## Non-goals
- Do not change worktree card compact layout behavior.
- Do not redesign the workspace options menu.
- Do not remove workspace card property or agent activity layout controls.
- Do not change SSH, provider metadata, prompt-cache, conflict, or port behavior.
## Design
1. Add `compactWorktreeCards` to `GlobalSettings` and defaults.
2. Preserve backward compatibility by hydrating `compactWorktreeCards` from legacy
`experimentalCompactWorktreeCards` when the new field is absent.
3. Update sidebar/worktree-card reads and writes to use `compactWorktreeCards`.
4. Remove the Experimental pane row and search entry for `Compact worktree cards`.
5. Keep the legacy optional type field only as a read-only migration input.
## Data Flow
- Persistence loads settings with defaults.
- If `compactWorktreeCards` is missing, persistence copies the old
`experimentalCompactWorktreeCards` value, falling back to the default.
- The sidebar `Card layout` menu writes `compactWorktreeCards`.
- `WorktreeCard` reads `settings?.compactWorktreeCards === true`.
## Edge Cases
- Old profiles with `experimentalCompactWorktreeCards: true` still render compact cards.
- Old profiles with the flag missing still default to detailed cards.
- Search in Settings no longer finds a duplicate Experimental result.
- Compact-card behavior remains unchanged for metadata rows, unread placement, main-worktree
marker, SSH icons, prompt-cache state, conflicts, sparse checkout badges, and inline agents.
## Test Plan
- Update default-settings tests for `compactWorktreeCards`.
- Add a persistence migration test for legacy `experimentalCompactWorktreeCards`.
- Update Experimental pane tests to assert the row/search entry is absent.
- Update WorktreeCard tests to set `compactWorktreeCards`.
- Run focused tests for constants, persistence, ExperimentalPane, and WorktreeCard compact behavior.
## UI Quality Bar
The Experimental pane should simply omit the redundant row without leaving spacing gaps. The
workspace options menu should continue to show `Card layout` with `Detailed` and `Compact`.
## Review Screenshots
1. Experimental pane without `Compact worktree cards`.
2. Sidebar workspace options menu still showing `Card layout`.
## Rollout
1. Add the new setting and legacy hydration.
2. Update card/menu consumers.
3. Remove the Experimental pane/search entry.
4. Update focused tests.
5. Validate the two visible settings surfaces.
## Lightweight Eng Review
- Scope: kept to one setting graduation; no card layout behavior changes.
- Architecture/data flow: existing settings persistence remains the boundary; renderer consumers read
the new key, persistence bridges legacy profiles.
- Failure modes covered:
- Existing compact users losing the preference: migration copies the legacy flag.
- Duplicate settings search result: Experimental search entry is removed.
- Renderer/main mismatch: `GlobalSettings`, defaults, and consumers are updated together.
- Test coverage required:
- `src/shared/constants.test.ts` for the default.
- `src/main/persistence.test.ts` for legacy hydration.
- `src/renderer/src/components/settings/ExperimentalPane.test.tsx` for removal.
- Existing WorktreeCard tests updated to the new key.
- Performance/blast radius: no new IPC, polling, file watching, or provider work.
- UI quality bar: Experimental pane has no orphan row; sidebar card layout control remains present.
- Required review screenshots:
1. Experimental pane without the compact-card toggle.
2. Sidebar workspace options menu with `Card layout`.
- Residual risks: old profiles may keep the legacy key on disk until settings are next saved, but
runtime behavior uses the migrated value.

View File

@ -1,136 +0,0 @@
# Experimental Compact Worktree Cards
## Problem
PR #2843 made compact one-line worktree cards the default in
`src/renderer/src/components/sidebar/WorktreeCard.tsx:183` and
`src/renderer/src/components/sidebar/WorktreeCard.tsx:557`. The behavior is useful for dense
sidebars, but it is visually different enough that it should be trialed behind Experimental first.
Users also cannot infer why some cards remain two-line: the hidden row only disappears when it
would carry no distinct visible metadata.
## Goal
Add an Experimental setting, off by default, that controls the compact-card behavior. When disabled,
workspace cards use the pre-compact layout: branch line stays visible, unread remains in the left
status column, PR/issue/comment/port badges stay in the metadata row, and the primary worktree uses
the pre-compact `primary` badge instead of the compact star. When enabled, keep the current compact
behavior from PR #2843.
## Non-goals
- Do not redesign worktree card metadata.
- Do not remove card property settings such as PR, ports, issue, comment, unread, or status.
- Do not change SSH, GitLab/GitHub provider lookup, prompt-cache timer, or workspace port logic.
- Do not add per-repo or per-worktree compact preferences.
## Design
1. Add `experimentalCompactWorktreeCards: boolean` to `GlobalSettings`, defaulting to `false`.
2. Add an Experimental pane toggle titled `Compact worktree cards`.
Copy must explain the rule: cards collapse only when the second line would be redundant or empty;
real metadata such as a different branch, repo badge, conflict/cache state, or folder badge keeps
the card taller.
3. In `WorktreeCard`, derive `const compactCards =
settings?.experimentalCompactWorktreeCards === true` from the existing settings read and branch
layout:
- Disabled: show branch row for non-folder worktrees, keep unread in the left column, keep
PR/issue/comment/port details in the metadata row, and show the pre-compact `primary` badge.
- Enabled: use PR #2843 compact behavior, including title-row unread, details/ports cluster, and
primary star.
4. Preserve the accessible title tooltip in `WorktreeTitleInlineRename`; it is useful in both modes
and replaces the native `title` attribute.
5. Keep `CacheTimer` presentational. The card can still derive cache state, but the metadata-row gate
must only use compact gating when the experimental flag is enabled. In disabled mode, render the
metadata row even when the cache timer is inactive.
## Data Flow
- Main process persistence loads settings via `getDefaultSettings()`.
- Renderer settings pane toggles `experimentalCompactWorktreeCards` through `updateSettings`.
- `WorktreeCard` reads `settings?.experimentalCompactWorktreeCards === true`; missing/null settings
must behave as disabled so existing server-render tests and old profiles do not compact.
- Card layout branches locally; no IPC, SSH, or provider data path changes.
## Edge Cases
- Missing legacy setting hydrates to `false`, so existing users get the less-surprising two-line
default after upgrade.
- Folder repos keep their folder badge in the metadata row in both modes.
- Custom display names keep branch metadata in compact mode because the branch differs.
- Repo-grouped cards with `hideRepoBadge` can become one-line only when compact mode is enabled and
no other metadata is visible.
- Active prompt-cache timer and conflict state keep the metadata row visible in compact mode.
- SSH cards keep the SSH icon on the title row in both modes.
- Sparse checkout badges, remote-branch conflict warnings, inline agents, and lineage child chips
are independent rows/badges and must not be hidden by the compact toggle.
## Test Plan
- Unit/render tests:
- default settings value is `false`;
- Experimental pane renders the toggle and explanatory copy;
- compact disabled renders a metadata row and branch when title equals branch;
- compact disabled keeps unread in the left status column, details/ports in the metadata row, and
the `primary` badge for the main worktree;
- compact enabled hides the redundant metadata row;
- compact enabled moves unread/details/ports to the title row and uses the primary star;
- compact enabled preserves branch row when title differs from branch;
- title tooltip remains focusable and does not use native `title`.
- Validation:
- Electron screenshot with compact disabled: repeated branch cards are two-line.
- Electron screenshot with compact enabled: repeated branch cards collapse to one-line.
- Electron screenshot of Experimental setting showing explanatory copy.
## UI Quality Bar
- Toggle follows existing Experimental pane layout, spacing, typography, and switch style.
- Copy is concise and explains what counts as metadata without teaching implementation details.
- Sidebar cards do not overlap, clip icons, or jump unexpectedly when the setting changes.
- Compact-disabled mode should look understandable rather than like an accidental regression.
## Review Screenshots
1. Experimental pane with `Compact worktree cards` toggle off.
2. Sidebar card list with compact disabled: repeated branch cards show the second line.
3. Sidebar card list with compact enabled: repeated branch cards collapse to one line.
## Rollout
1. Add setting type/default/search entry/persistence tests.
2. Add Experimental pane UI.
3. Branch `WorktreeCard` layout behind the setting.
4. Update focused render tests.
5. Validate with Electron screenshots.
## Lightweight Eng Review
- Scope: Keep the PR to one persisted Experimental flag, one settings row, and local card layout
branching. No new metadata model or card explanation surface.
- Architecture/data flow: Settings persistence already merges defaults with parsed settings; adding
a default-off boolean is enough for old profiles and SSH/web clients because renderer state is
hydrated from the same settings object.
- Failure modes covered:
- Old profiles unexpectedly compacting: default false.
- Toggle copy failing to explain mixed one-line/two-line cards: explicit description.
- Tests only checking branch text instead of row presence: use `data-worktree-card-meta-row`.
- Shipping a half-compact default: assert unread placement, details/ports placement, and
primary badge/star per mode.
- Prompt-cache/conflict metadata disappearing: metadata-row gate keeps these rows in compact mode.
- Test coverage required:
- `src/shared/constants.test.ts` for default value.
- `src/renderer/src/components/settings/ExperimentalPane.test.tsx` or adjacent render test for
settings copy/toggle behavior.
- `src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx` for enabled/disabled
layout behavior.
- Existing `WorktreeTitleInlineRename.test.tsx` remains relevant for tooltip behavior.
- Performance/blast radius: Reuse the existing settings read in `WorktreeCard`; no new polling, IPC,
file watching, or provider calls.
- UI quality bar: Settings row should match existing Experimental rows; sidebar must remain dense
but mixed-height behavior should be explainable from the toggle copy.
- Required review screenshots:
1. Experimental setting off.
2. Sidebar compact disabled.
3. Sidebar compact enabled.
- Residual risks: The mixed card heights are still inherently subtle; this PR explains the rule in
settings but does not add per-card visible reasons.

View File

@ -129,7 +129,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
experimentalPet: false,
experimentalActivity: true,
experimentalTerminalAttention: false,
experimentalCompactWorktreeCards: false,
compactWorktreeCards: false,
experimentalWorktreeSymlinks: false,
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'powershell.exe',

View File

@ -133,7 +133,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
experimentalPet: false,
experimentalActivity: true,
experimentalTerminalAttention: false,
experimentalCompactWorktreeCards: false,
compactWorktreeCards: false,
experimentalWorktreeSymlinks: false,
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'powershell.exe',

View File

@ -3448,6 +3448,23 @@ describe('Store', () => {
expect(store.getSettings().experimentalPet).toBe(true)
})
it('migrates the legacy experimental compact worktree cards setting', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: { experimentalCompactWorktreeCards: true },
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().compactWorktreeCards).toBe(true)
expect(store.getSettings().experimentalCompactWorktreeCards).toBeUndefined()
})
it('defaults legacy experimentalActivity profiles off once', async () => {
writeDataFile({
schemaVersion: 1,

View File

@ -1923,6 +1923,13 @@ export class Store {
...migratedTerminalCursorStyle,
experimentalActivity: migratedExperimentalActivity,
experimentalActivityDefaultedOffForAllUsers: true,
// Why: compact worktree cards graduated from Experimental; preserve
// the old opt-in for profiles written during the rollout.
compactWorktreeCards:
parsed.settings?.compactWorktreeCards ??
parsed.settings?.experimentalCompactWorktreeCards ??
defaults.settings.compactWorktreeCards,
experimentalCompactWorktreeCards: undefined,
terminalMacOptionAsAlt: migratedOptionAsAlt,
terminalMacOptionAsAltMigrated: true,
floatingTerminalEnabled: migratedFloatingTerminalEnabled,

View File

@ -2,7 +2,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import { ExperimentalPane } from './ExperimentalPane'
import { EXPERIMENTAL_SEARCH_ENTRY } from './experimental-search'
import { EXPERIMENTAL_PANE_SEARCH_ENTRIES } from './experimental-search'
vi.mock('../../store', () => ({
useAppStore: (selector: (state: { settingsSearchQuery: string }) => unknown) =>
@ -10,15 +10,14 @@ vi.mock('../../store', () => ({
}))
describe('ExperimentalPane', () => {
it('renders compact worktree cards as an off-by-default experimental switch', () => {
it('does not render compact worktree cards after graduation from Experimental', () => {
const markup = renderToStaticMarkup(
<ExperimentalPane settings={getDefaultSettings('/tmp')} updateSettings={vi.fn()} />
)
expect(markup).toContain('Compact worktree cards')
expect(markup).toContain('aria-checked="false"')
expect(markup).toContain('single title row')
expect(markup).toContain('selected properties on a second row')
expect(EXPERIMENTAL_SEARCH_ENTRY.compactWorktreeCards.keywords).toContain('metadata')
expect(markup).not.toContain('Compact worktree cards')
expect(EXPERIMENTAL_PANE_SEARCH_ENTRIES.map((entry) => entry.title)).not.toContain(
'Compact worktree cards'
)
})
})

View File

@ -27,9 +27,6 @@ export function ExperimentalPane({
const showTerminalAttention = matchesSettingsSearch(searchQuery, [
EXPERIMENTAL_SEARCH_ENTRY.terminalAttention
])
const showCompactWorktreeCards = matchesSettingsSearch(searchQuery, [
EXPERIMENTAL_SEARCH_ENTRY.compactWorktreeCards
])
const showWorktreeSymlinks = matchesSettingsSearch(searchQuery, [
EXPERIMENTAL_SEARCH_ENTRY.symlinks
])
@ -152,46 +149,6 @@ export function ExperimentalPane({
</SearchableSetting>
) : null}
{showCompactWorktreeCards ? (
<SearchableSetting
title="Compact worktree cards"
description="Use one-line worktree cards instead of the detailed metadata row."
keywords={EXPERIMENTAL_SEARCH_ENTRY.compactWorktreeCards.keywords}
className="space-y-3 py-2"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 shrink space-y-0.5">
<Label>Compact worktree cards</Label>
<p className="text-xs text-muted-foreground">
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
type="button"
role="switch"
aria-checked={settings.experimentalCompactWorktreeCards}
onClick={() =>
updateSettings({
experimentalCompactWorktreeCards: !settings.experimentalCompactWorktreeCards
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.experimentalCompactWorktreeCards
? 'bg-foreground'
: 'bg-muted-foreground/30'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow-sm transition-transform ${
settings.experimentalCompactWorktreeCards ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
</SearchableSetting>
) : null}
{showWorktreeSymlinks ? (
<SearchableSetting
title="Symlinks on worktrees"

View File

@ -47,22 +47,6 @@ export const EXPERIMENTAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
'unread'
]
},
{
title: 'Compact worktree cards',
description: 'Use one-line worktree cards instead of the detailed metadata row.',
keywords: [
'experimental',
'worktree',
'worktrees',
'workspace',
'workspaces',
'compact',
'sidebar',
'cards',
'branch',
'metadata'
]
},
{
title: 'Symlinks on worktrees',
description:
@ -97,6 +81,5 @@ export const EXPERIMENTAL_SEARCH_ENTRY = {
pet: findEntry('Pet'),
activity: findEntry('Agents View'),
terminalAttention: findEntry('Terminal attention'),
compactWorktreeCards: findEntry('Compact worktree cards'),
symlinks: findEntry('Symlinks on worktrees')
} as const

View File

@ -128,7 +128,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
const sortLabel = SORT_OPTIONS.find((opt) => opt.id === sortBy)?.label ?? 'Sort'
const projectOrderLabel =
PROJECT_ORDER_OPTIONS.find((opt) => opt.id === projectOrderBy)?.label ?? 'Manual'
const cardLayout = settings?.experimentalCompactWorktreeCards ? 'compact' : 'detailed'
const cardLayout = settings?.compactWorktreeCards ? 'compact' : 'detailed'
const cardLayoutLabel =
CARD_LAYOUT_OPTIONS.find((opt) => opt.id === cardLayout)?.label ?? 'Detailed'
const visiblePropertyCount = PROPERTY_OPTIONS.filter((opt) =>
@ -305,7 +305,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
value={cardLayout}
onValueChange={(value) => {
void updateSettings({
experimentalCompactWorktreeCards: value === 'compact'
compactWorktreeCards: value === 'compact'
})
}}
>

View File

@ -18,7 +18,7 @@ const setWorkspacePortScanRefreshing = vi.fn()
let worktreeCardProperties: WorktreeCardProperty[] = ['pr', 'ports']
let hostedReviewCache: Record<string, unknown> = {}
let workspacePortScan: { key: string; result: WorkspacePortScanResult } | null = null
let settings: Partial<GlobalSettings> | null = { experimentalCompactWorktreeCards: true }
let settings: Partial<GlobalSettings> | null = { compactWorktreeCards: true }
vi.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
@ -152,7 +152,7 @@ describe('WorktreeCard compact hover details', () => {
worktreeCardProperties = ['pr', 'ports']
hostedReviewCache = {}
workspacePortScan = null
settings = { experimentalCompactWorktreeCards: true }
settings = { compactWorktreeCards: true }
})
it('shows PR and live port details from the compact worktree title hover', async () => {

View File

@ -218,7 +218,7 @@ describe('WorktreeCard linked PR display', () => {
})
it('keeps issue, Linear issue, PR, and notes metadata out of compact cards', async () => {
settings = { experimentalCompactWorktreeCards: true }
settings = { compactWorktreeCards: true }
worktreeCardProperties = ['issue', 'linear-issue', 'pr', 'comment']
const { default: WorktreeCard } = await import('./WorktreeCard')

View File

@ -224,7 +224,7 @@ describe('WorktreeCard quick actions', () => {
it('omits the repeated branch metadata row when compact cards are enabled', () => {
worktreeCardProperties = []
settings = { experimentalCompactWorktreeCards: true }
settings = { compactWorktreeCards: true }
const markup = renderToStaticMarkup(
<WorktreeCard
@ -241,7 +241,7 @@ describe('WorktreeCard quick actions', () => {
it('omits the branch metadata row when the workspace has a custom title', () => {
worktreeCardProperties = []
settings = { experimentalCompactWorktreeCards: true }
settings = { compactWorktreeCards: true }
const markup = renderToStaticMarkup(
<WorktreeCard
@ -281,7 +281,7 @@ describe('WorktreeCard quick actions', () => {
it('moves unread and primary into the title row when compact cards are enabled', () => {
worktreeCardProperties = ['status', 'unread']
settings = { experimentalCompactWorktreeCards: true }
settings = { compactWorktreeCards: true }
const markup = renderToStaticMarkup(
<WorktreeCard

View File

@ -164,7 +164,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 compactCards = settings?.experimentalCompactWorktreeCards === true
const compactCards = settings?.compactWorktreeCards === true
const handleEditIssue = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()

View File

@ -49,8 +49,8 @@ describe('getDefaultSettings', () => {
})
})
it('keeps compact worktree cards experimental and disabled by default', () => {
expect(getDefaultSettings('/tmp').experimentalCompactWorktreeCards).toBe(false)
it('keeps compact worktree cards disabled by default', () => {
expect(getDefaultSettings('/tmp').compactWorktreeCards).toBe(false)
})
})

View File

@ -311,7 +311,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
experimentalActivity: false,
experimentalActivityDefaultedOffForAllUsers: true,
experimentalTerminalAttention: false,
experimentalCompactWorktreeCards: false,
compactWorktreeCards: false,
experimentalWorktreeSymlinks: false,
// Why: local desktop remains the default server until the user explicitly
// selects a saved runtime environment.

View File

@ -2297,9 +2297,12 @@ export type GlobalSettings = {
* and agent-completion events. Opt-in while the signal/noise balance is
* being tested. */
experimentalTerminalAttention: boolean
/** Experimental: compact worktree cards by hiding a redundant metadata row
* when the title and branch already say the same thing. */
experimentalCompactWorktreeCards: boolean
/** Compact worktree cards by hiding a redundant metadata row when the title
* and branch already say the same thing. */
compactWorktreeCards: boolean
/** Legacy persisted key from the Experimental rollout. New writes use
* compactWorktreeCards. */
experimentalCompactWorktreeCards?: boolean
/** Experimental: when creating a worktree, automatically symlink a
* user-configured set of files/folders from the primary checkout (e.g.
* `.env`, `node_modules`) into the new worktree. Opt-in while the