From ace241c43ff7c2cf220a4058d1f1871aec43fc2a Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 23 May 2026 13:02:50 -0700 Subject: [PATCH] feat: allow hiding workspace card metadata (#2701) --- src/main/persistence.test.ts | 64 ++++++++++++------ src/main/persistence.ts | 32 +++++++-- src/main/runtime/rpc/methods/client-ui.ts | 2 + .../sidebar/SidebarWorkspaceOptionsMenu.tsx | 5 ++ .../sidebar/WorktreeCard.pr-display.test.tsx | 67 ++++++++++++++++++- .../src/components/sidebar/WorktreeCard.tsx | 10 +-- src/renderer/src/store/slices/ui.test.ts | 22 +++--- src/shared/constants.ts | 1 - src/shared/types.ts | 8 +++ src/shared/worktree-card-properties.ts | 33 ++++++--- 10 files changed, 187 insertions(+), 57 deletions(-) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index d0fdaeadf..f5239344f 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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) }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 920aba44c..7f4629b97 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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 diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index f9c857a05..0098722c1 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -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']) diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx index f7ae8d1ce..bf27e5dcd 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx @@ -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. 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 d765051b2..ab6aa8503 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx @@ -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 = {} +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( + + ) + + 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( + + ) + + 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 = { diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index e6bf51496..45dc3d546 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -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 = (
{ 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 }) }) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 716c4ea29..8d4038323 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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' diff --git a/src/shared/types.ts b/src/shared/types.ts index 764603960..a2e17a9a4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 diff --git a/src/shared/worktree-card-properties.ts b/src/shared/worktree-card-properties.ts index 3872e057e..ea405c3d4 100644 --- a/src/shared/worktree-card-properties.ts +++ b/src/shared/worktree-card-properties.ts @@ -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) } }