feat: allow hiding workspace card metadata (#2701)

This commit is contained in:
Jinjing 2026-05-23 13:02:50 -07:00 committed by GitHub
parent 21b30bc513
commit ace241c43f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 187 additions and 57 deletions

View File

@ -1670,18 +1670,11 @@ describe('Store', () => {
expect(ui.dismissedUpdateVersion).toBeNull()
})
it('updateUI restores retired card properties from direct UI writes', async () => {
it('updateUI restores fixed card properties from direct UI writes', async () => {
const store = await createStore()
store.updateUI({ worktreeCardProperties: ['inline-agents'] })
expect(store.getUI().worktreeCardProperties).toEqual([
'status',
'unread',
'issue',
'pr',
'comment',
'inline-agents'
])
expect(store.getUI().worktreeCardProperties).toEqual(['status', 'unread', 'inline-agents'])
})
it('persists updater reminder metadata in UI state', async () => {
@ -2083,8 +2076,11 @@ describe('Store', () => {
})
const store = await createStore()
expect(store.getUI().worktreeCardProperties).toContain('inline-agents')
expect(store.getUI().worktreeCardProperties).toContain('linear-issue')
expect(store.getUI().worktreeCardProperties).toContain('ports')
expect(store.getUI()._inlineAgentsDefaultedForExperiment).toBe(true)
expect(store.getUI()._inlineAgentsDefaultedForAllUsers).toBe(true)
expect(store.getUI()._expandedWorktreeCardPropertiesDefaulted).toBe(true)
})
it('adds inline-agents for users who launched a prior RC with the experiment off', async () => {
@ -2108,7 +2104,10 @@ describe('Store', () => {
})
const store = await createStore()
expect(store.getUI().worktreeCardProperties).toContain('inline-agents')
expect(store.getUI().worktreeCardProperties).toContain('linear-issue')
expect(store.getUI().worktreeCardProperties).toContain('ports')
expect(store.getUI()._inlineAgentsDefaultedForAllUsers).toBe(true)
expect(store.getUI()._expandedWorktreeCardPropertiesDefaulted).toBe(true)
})
it('respects a deliberate post-migration uncheck', async () => {
@ -2129,9 +2128,11 @@ describe('Store', () => {
})
const store = await createStore()
expect(store.getUI().worktreeCardProperties).not.toContain('inline-agents')
expect(store.getUI().worktreeCardProperties).toContain('linear-issue')
expect(store.getUI().worktreeCardProperties).toContain('ports')
})
it('leaves cardProps alone when inline-agents is already present', async () => {
it('adds split-out default card properties without duplicating inline-agents', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
@ -2154,10 +2155,12 @@ describe('Store', () => {
const store = await createStore()
const props = store.getUI().worktreeCardProperties
expect(props.filter((p) => p === 'inline-agents')).toHaveLength(1)
expect(props.filter((p) => p === 'linear-issue')).toHaveLength(1)
expect(props.filter((p) => p === 'ports')).toHaveLength(1)
expect(store.getUI()._inlineAgentsDefaultedForAllUsers).toBe(true)
})
it('restores retired card properties when loading old user choices', async () => {
it('adds split-out default card properties when loading old user choices', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
@ -2174,14 +2177,12 @@ describe('Store', () => {
expect(store.getUI().worktreeCardProperties).toEqual([
'status',
'unread',
'issue',
'pr',
'comment',
'ports',
'inline-agents'
])
})
it('keeps Agent activity opt-out while restoring retired card properties', async () => {
it('keeps Agent activity opt-out while adding split-out default card properties', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
@ -2195,13 +2196,26 @@ describe('Store', () => {
workspaceSession: {}
})
const store = await createStore()
expect(store.getUI().worktreeCardProperties).toEqual([
'status',
'unread',
'issue',
'pr',
'comment'
])
expect(store.getUI().worktreeCardProperties).toEqual(['status', 'unread', 'ports'])
})
it('preserves deliberate Linear and Ports opt-outs after split-out migration', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {},
ui: {
worktreeCardProperties: ['status', 'unread', 'issue', 'pr', 'comment'],
_inlineAgentsDefaultedForAllUsers: true,
_expandedWorktreeCardPropertiesDefaulted: true
},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getUI().worktreeCardProperties).not.toContain('linear-issue')
expect(store.getUI().worktreeCardProperties).not.toContain('ports')
})
it('preserves a deliberate uncheck from the experimental-toggle era (Case B)', async () => {
@ -2226,6 +2240,8 @@ describe('Store', () => {
})
const store = await createStore()
expect(store.getUI().worktreeCardProperties).not.toContain('inline-agents')
expect(store.getUI().worktreeCardProperties).toContain('linear-issue')
expect(store.getUI().worktreeCardProperties).toContain('ports')
expect(store.getUI()._inlineAgentsDefaultedForAllUsers).toBe(true)
})
@ -2249,6 +2265,8 @@ describe('Store', () => {
})
const store = await createStore()
expect(store.getUI().worktreeCardProperties).not.toContain('inline-agents')
expect(store.getUI().worktreeCardProperties).toContain('linear-issue')
expect(store.getUI().worktreeCardProperties).toContain('ports')
})
it('lapsed Case B (experiment off at upgrade time) re-adds inline-agents', async () => {
@ -2272,6 +2290,8 @@ describe('Store', () => {
})
const store = await createStore()
expect(store.getUI().worktreeCardProperties).toContain('inline-agents')
expect(store.getUI().worktreeCardProperties).toContain('linear-issue')
expect(store.getUI().worktreeCardProperties).toContain('ports')
expect(store.getUI()._inlineAgentsDefaultedForAllUsers).toBe(true)
})

View File

@ -1585,6 +1585,8 @@ export class Store {
// depend on the deprecated value continuing to round-trip.
const rawCardProps = parsed.ui?.worktreeCardProperties
const inlineAgentsMigrated = parsed.ui?._inlineAgentsDefaultedForAllUsers === true
const expandedCardPropsMigrated =
parsed.ui?._expandedWorktreeCardPropertiesDefaulted === true
const hadExperimentOn = readDeprecatedExperimentFlag(parsed)
const deliberateUncheck =
hadExperimentOn &&
@ -1602,16 +1604,33 @@ export class Store {
const candidate = needsInlineAgentsMigration
? [...rawCardProps, 'inline-agents' as const]
: rawCardProps
// Why: only Agent activity remains configurable; older hidden
// card fields must be restored because users can no longer
// toggle them back on from the sidebar menu.
const normalized = normalizeWorktreeCardProperties(candidate)
const expandedCandidate = (() => {
if (expandedCardPropsMigrated) {
return candidate
}
const next = [...candidate]
// Why: Linear used to be controlled by the generic issue
// property and Ports were always visible. Add the split-out
// properties once so existing cards keep their prior surface.
if (candidate.includes('issue') && !candidate.includes('linear-issue')) {
next.push('linear-issue' as const)
}
if (!candidate.includes('ports')) {
next.push('ports' as const)
}
return next
})()
const normalized = normalizeWorktreeCardProperties(expandedCandidate)
const changed =
normalized.length !== rawCardProps.length ||
normalized.some((property, index) => property !== rawCardProps[index])
return changed ? normalized : undefined
})()
if (migratedCardProps !== undefined || !inlineAgentsMigrated) {
if (
migratedCardProps !== undefined ||
!inlineAgentsMigrated ||
!expandedCardPropsMigrated
) {
this.loadNeedsSave = true
}
return {
@ -1630,7 +1649,8 @@ export class Store {
// a rollback to a pre-default-on build that still reads it.
// The new flag is the one that actually gates the migration.
_inlineAgentsDefaultedForExperiment: true,
_inlineAgentsDefaultedForAllUsers: true
_inlineAgentsDefaultedForAllUsers: true,
_expandedWorktreeCardPropertiesDefaulted: true
}
})(),
// Why: the workspace session is the most volatile persisted surface

View File

@ -12,8 +12,10 @@ const WorktreeCardProperty = z.enum([
'unread',
'ci',
'issue',
'linear-issue',
'pr',
'comment',
'ports',
'inline-agents'
])
const StatusBarItem = z.enum(['claude', 'codex', 'gemini', 'opencode-go', 'ssh', 'resource-usage'])

View File

@ -35,6 +35,11 @@ const GROUP_BY_OPTIONS = [
] as const
const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [
{ id: 'issue', label: 'GitHub ticket' },
{ id: 'linear-issue', label: 'Linear issue' },
{ id: 'pr', label: 'PR/MR link' },
{ id: 'comment', label: 'Notes' },
{ id: 'ports', label: 'Ports' },
// Why: toggles the inline "Agent activity" list rendered below each
// workspace card body (see WorktreeCard -> WorktreeCardAgents). Off hides
// the list; there is no alternate surface.

View File

@ -3,6 +3,7 @@ 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 { WorkspacePortScanResult } from '../../../../shared/workspace-ports'
const fetchHostedReviewForBranch = vi.fn()
const fetchIssue = vi.fn()
@ -12,6 +13,7 @@ const updateWorktreeMeta = vi.fn()
let worktreeCardProperties: WorktreeCardProperty[] = ['pr']
let hostedReviewCache: Record<string, unknown> = {}
let workspacePortScan: WorkspacePortScanResult | null = null
vi.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
@ -30,6 +32,7 @@ vi.mock('@/store', () => ({
sshConnectionStates: new Map(),
sshTargetLabels: new Map(),
updateWorktreeMeta,
workspacePortScan,
worktreeCardProperties
})
}))
@ -123,6 +126,7 @@ describe('WorktreeCard linked PR display', () => {
vi.clearAllMocks()
worktreeCardProperties = ['pr']
hostedReviewCache = {}
workspacePortScan = null
})
it('keeps an icon-only linked GH PR badge visible before hosted review details are cached', async () => {
@ -137,7 +141,7 @@ describe('WorktreeCard linked PR display', () => {
})
it('renders issue, Linear issue, PR, and notes as icon-only metadata in the closed card', async () => {
worktreeCardProperties = ['issue', 'pr', 'comment']
worktreeCardProperties = ['issue', 'linear-issue', 'pr', 'comment']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
@ -164,6 +168,67 @@ describe('WorktreeCard linked PR display', () => {
expect(markup.indexOf('Workspace notes')).toBeLessThan(markup.indexOf('Linked issue #123'))
})
it('hides individual metadata surfaces when their card properties are disabled', async () => {
worktreeCardProperties = []
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).not.toContain('Linked issue #123')
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('Reviewer handoff note')
})
it('hides live port metadata when the Ports card property is disabled', async () => {
const worktree = makeWorktree()
workspacePortScan = {
platform: 'darwin',
scannedAt: 1,
ports: [
{
id: '127.0.0.1:58941:1234',
bindHost: '127.0.0.1',
connectHost: '127.0.0.1',
port: 58941,
pid: 1234,
processName: 'node',
protocol: 'http',
kind: 'workspace',
owner: {
worktreeId: worktree.id,
repoId: worktree.repoId,
displayName: worktree.displayName,
path: worktree.path,
confidence: 'cwd'
}
}
]
}
worktreeCardProperties = []
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} />
)
expect(markup).not.toContain('live port')
expect(markup).not.toContain('Live Ports')
expect(markup).not.toContain('58941')
})
it('does not render the standalone CI badge and colors a failing linked PR icon red', async () => {
worktreeCardProperties = ['pr', 'ci']
hostedReviewCache = {

View File

@ -251,7 +251,9 @@ const WorktreeCard = React.memo(function WorktreeCard({
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')
// Skip hosted-review fetches when the corresponding card sections are hidden.
// This preference is purely presentational, so background refreshes would
@ -328,7 +330,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
}, [repo, isFolder, worktree.linkedIssue, fetchIssue, issueCacheKey, showIssue])
useEffect(() => {
if (!worktree.linkedLinearIssue || !showIssue) {
if (!worktree.linkedLinearIssue || !showLinearIssue) {
return
}
const linearIssueId = worktree.linkedLinearIssue
@ -345,7 +347,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
window.removeEventListener('focus', refreshLinearIssueIfVisible)
document.removeEventListener('visibilitychange', refreshLinearIssueIfVisible)
}
}, [worktree.linkedLinearIssue, fetchLinearIssue, showIssue])
}, [worktree.linkedLinearIssue, fetchLinearIssue, showLinearIssue])
// Stable click handler ignore clicks that are really text selections.
const handleClick = useCallback(
@ -465,7 +467,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
// `worktree.isUnread` flag is unchanged; only the rendering changes.
const showUnreadEmphasis = cardProps.includes('unread') && worktree.isUnread
const metaIssue = showIssue ? issueDisplay : null
const metaLinearIssue = showIssue ? linearIssueDisplay : null
const metaLinearIssue = showLinearIssue ? linearIssueDisplay : null
const metaReview = showPR ? prDisplay : null
const metaComment = showComment ? worktree.comment : null
const handleOpenGitHubIssueInOrca = useCallback(
@ -530,7 +532,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
review: metaReview,
comment: metaComment
})
const hasPorts = workspacePorts.length > 0
const hasPorts = showPorts && workspacePorts.length > 0
const cardBody = (
<div

View File

@ -2,7 +2,12 @@
import { createStore, type StoreApi } from 'zustand/vanilla'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getDefaultUIState } from '../../../../shared/constants'
import type { GitHubWorkItem, PersistedUIState, Worktree } from '../../../../shared/types'
import type {
GitHubWorkItem,
PersistedUIState,
Worktree,
WorktreeCardProperty
} from '../../../../shared/types'
import { createUISlice } from './ui'
import { createWorktreeNavHistorySlice } from './worktree-nav-history'
import type { AppState } from '../types'
@ -180,7 +185,7 @@ describe('createUISlice hydratePersistedUI', () => {
expect(store.getState().hideDefaultBranchWorkspace).toBe(true)
})
it('restores retired card properties during hydration', () => {
it('restores fixed card properties during hydration', () => {
const store = createUIStore()
store.getState().hydratePersistedUI(
@ -189,14 +194,7 @@ describe('createUISlice hydratePersistedUI', () => {
})
)
expect(store.getState().worktreeCardProperties).toEqual([
'status',
'unread',
'issue',
'pr',
'comment',
'inline-agents'
])
expect(store.getState().worktreeCardProperties).toEqual(['status', 'unread', 'inline-agents'])
})
it('adds the default-on Ports status item once for older persisted UI', () => {
@ -537,7 +535,7 @@ describe('createUISlice hydratePersistedUI', () => {
expect(setUI).toHaveBeenCalledWith({ taskResumeState: expected })
})
it('keeps retired card properties enabled when toggling Agent activity', () => {
it('keeps fixed card properties when toggling Agent activity', () => {
const setUI = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('window', { api: { ui: { set: setUI } } })
const store = createUIStore()
@ -545,7 +543,7 @@ describe('createUISlice hydratePersistedUI', () => {
store.setState({ worktreeCardProperties: ['inline-agents'] })
store.getState().toggleWorktreeCardProperty('inline-agents')
const expected = ['status', 'unread', 'issue', 'pr', 'comment']
const expected: WorktreeCardProperty[] = ['status', 'unread']
expect(store.getState().worktreeCardProperties).toEqual(expected)
expect(setUI).toHaveBeenCalledWith({ worktreeCardProperties: expected })
})

View File

@ -19,7 +19,6 @@ import { DEFAULT_WORKTREE_CARD_PROPERTIES } from './worktree-card-properties'
export { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults'
export {
ALWAYS_VISIBLE_WORKTREE_CARD_PROPERTIES,
DEFAULT_WORKTREE_CARD_PROPERTIES,
normalizeWorktreeCardProperties
} from './worktree-card-properties'

View File

@ -2025,9 +2025,13 @@ export type WorktreeCardProperty =
| 'unread'
// Legacy persisted preference. CI status is now represented by linked PR metadata.
| 'ci'
// GitHub issue metadata shown on workspace cards.
| 'issue'
// Linear issue metadata shown on workspace cards.
| 'linear-issue'
| 'pr'
| 'comment'
| 'ports'
// Why: inline list of agent activity rendered directly inside each
// workspace card when the experimental agent-activity feature is on. On by
// default (see DEFAULT_WORKTREE_CARD_PROPERTIES in shared/constants.ts) —
@ -2152,6 +2156,10 @@ export type PersistedUIState = {
* stamped on every prior load and so is permanently dirty for the
* prior-RC opt-out cohort the widened migration is meant to reach. */
_inlineAgentsDefaultedForAllUsers?: boolean
/** One-shot migration flag for card properties that were split out after
* the original metadata toggles shipped. Set once so later deliberate
* unchecks of Linear issue and Ports stick across restarts. */
_expandedWorktreeCardPropertiesDefaulted?: boolean
/** Snapshot of totalAgentsSpawned captured the first time we see the current
* app version. Why: the nag threshold counts agents spawned *since the
* user's last update* so a fresh install or new release does not trigger

View File

@ -1,28 +1,39 @@
import type { WorktreeCardProperty } from './types'
export const ALWAYS_VISIBLE_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [
'status',
'unread',
'issue',
'pr',
'comment'
]
const FIXED_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = ['status', 'unread']
export const DEFAULT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [
...ALWAYS_VISIBLE_WORKTREE_CARD_PROPERTIES,
...FIXED_WORKTREE_CARD_PROPERTIES,
'issue',
'linear-issue',
'pr',
'comment',
'ports',
// Why: agent activity is the primary reason users opt into the feature, so
// show it inline on each card by default. Unchecking this from the
// Workspaces view options hides the inline list entirely.
'inline-agents'
]
const WORKTREE_CARD_PROPERTY_ORDER: WorktreeCardProperty[] = [
'status',
'unread',
'ci',
'issue',
'linear-issue',
'pr',
'comment',
'ports',
'inline-agents'
]
export function normalizeWorktreeCardProperties(
properties: readonly WorktreeCardProperty[] | null | undefined
): WorktreeCardProperty[] {
const normalized = [...ALWAYS_VISIBLE_WORKTREE_CARD_PROPERTIES]
const normalized: WorktreeCardProperty[] = [...FIXED_WORKTREE_CARD_PROPERTIES]
const source = properties ?? DEFAULT_WORKTREE_CARD_PROPERTIES
for (const property of source) {
if (!normalized.includes(property)) {
for (const property of WORKTREE_CARD_PROPERTY_ORDER) {
if (source.includes(property) && !normalized.includes(property)) {
normalized.push(property)
}
}