feat(sidebar): let the host pill be turned off per card (#24299)

This commit is contained in:
Neil
2026-09-30 23:37:15 -07:00
committed by GitHub
parent a37e5026fc
commit b245b3e019
16 changed files with 251 additions and 14 deletions
@@ -81,7 +81,8 @@ describe('Store', () => {
'pr',
'comment',
'ports',
'inline-agents'
'inline-agents',
'host'
])
expect(store.getUI()._inlineAgentsDefaultedForAllUsers).toBe(true)
expect(store.getUI()._expandedWorktreeCardPropertiesDefaulted).toBe(true)
@@ -118,7 +119,8 @@ describe('Store', () => {
'pr',
'comment',
'ports',
'inline-agents'
'inline-agents',
'host'
])
expect(store.getUI().worktreeCardProperties).not.toContain('branch')
expect(store.getUI()._inlineAgentsDefaultedForAllUsers).toBe(true)
@@ -148,7 +150,8 @@ describe('Store', () => {
'cli',
'comment',
'ports',
'inline-agents'
'inline-agents',
'host'
])
expect(store.getUI().worktreeCardProperties).not.toContain('branch')
expect(store.getUI()._worktreeCardModeDefaulted).toBe(true)
@@ -178,7 +181,8 @@ describe('Store', () => {
'jira-issue',
'pr',
'ports',
'inline-agents'
'inline-agents',
'host'
])
expect(store.getUI().worktreeCardProperties).not.toContain('branch')
})
@@ -200,7 +204,7 @@ describe('Store', () => {
})
const store = await createStore()
expect(store.getUI().worktreeCardProperties).toEqual(['status', 'unread', 'pr'])
expect(store.getUI().worktreeCardProperties).toEqual(['status', 'unread', 'pr', 'host'])
expect(store.getUI().worktreeCardProperties).not.toContain('branch')
expect(store.getUI().worktreeCardProperties).not.toContain('ports')
expect(store.getUI().worktreeCardProperties).not.toContain('inline-agents')
@@ -268,7 +272,8 @@ describe('Store', () => {
'linear-issue',
'pr',
'comment',
'ports'
'ports',
'host'
])
expect(store.getUI().worktreeCardProperties).not.toContain('branch')
expect(store.getUI().worktreeCardProperties).not.toContain('inline-agents')
@@ -306,7 +311,8 @@ describe('Store', () => {
'jira-issue',
'pr',
'ports',
'inline-agents'
'inline-agents',
'host'
])
expect(
store.getUI().worktreeCardProperties?.filter((property) => property === 'jira-issue')
@@ -336,7 +342,8 @@ describe('Store', () => {
'unread',
'issue',
'linear-issue',
'pr'
'pr',
'host'
])
expect(store.getUI().worktreeCardProperties).not.toContain('jira-issue')
})
@@ -413,6 +420,51 @@ describe('Store', () => {
expect(store.getUI().worktreeCardProperties).not.toContain('inline-agents')
})
it('backfills host once for profiles stamped before it became a card property', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: { compactWorktreeCards: false },
ui: {
worktreeCardProperties: ['status', 'unread', 'pr', 'ports', 'inline-agents'],
_inlineAgentsDefaultedForAllUsers: true,
_expandedWorktreeCardPropertiesDefaulted: true,
_jiraIssueWorktreeCardPropertyDefaulted: true
},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(
store.getUI().worktreeCardProperties?.filter((property) => property === 'host')
).toHaveLength(1)
expect(store.getUI()._hostWorktreeCardPropertyDefaulted).toBe(true)
})
it('preserves a deliberate host removal after the backfill has run', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: { compactWorktreeCards: false },
ui: {
worktreeCardProperties: ['status', 'unread', 'pr'],
_inlineAgentsDefaultedForAllUsers: true,
_expandedWorktreeCardPropertiesDefaulted: true,
_jiraIssueWorktreeCardPropertyDefaulted: true,
_hostWorktreeCardPropertyDefaulted: true
},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getUI().worktreeCardProperties).toEqual(['status', 'unread', 'pr'])
expect(store.getUI().worktreeCardProperties).not.toContain('host')
})
it.each([
['raw', ['status', 'automation']],
['normalized', ['status', 'unread', 'automation']]
@@ -78,6 +78,7 @@ export function normalizeLoadedUiState(
const inlineAgentsMigrated = parsed.ui?._inlineAgentsDefaultedForAllUsers === true
const expandedCardPropsMigrated = parsed.ui?._expandedWorktreeCardPropertiesDefaulted === true
const jiraIssueCardPropDefaulted = parsed.ui?._jiraIssueWorktreeCardPropertyDefaulted === true
const hostCardPropDefaulted = parsed.ui?._hostWorktreeCardPropertyDefaulted === true
const hadExperimentOn = readDeprecatedExperimentFlag(parsed)
const deliberateUncheck =
hadExperimentOn && Array.isArray(rawCardProps) && !rawCardProps.includes('inline-agents')
@@ -119,7 +120,12 @@ export function normalizeLoadedUiState(
jiraIssueCardPropDefaulted || expandedCandidate.includes('jira-issue')
? expandedCandidate
: [...expandedCandidate, 'jira-issue' as const]
const normalized = normalizeWorktreeCardProperties(jiraCandidate)
// Why: the host pill was unconditional before it became a property, so existing profiles get it back once rather than silently losing it.
const hostCandidate =
hostCardPropDefaulted || jiraCandidate.includes('host')
? jiraCandidate
: [...jiraCandidate, 'host' as const]
const normalized = normalizeWorktreeCardProperties(hostCandidate)
const changed =
normalized.length !== rawCardProps.length ||
normalized.some((property, index) => property !== rawCardProps[index])
@@ -129,7 +135,8 @@ export function normalizeLoadedUiState(
migratedCardProps !== undefined ||
!inlineAgentsMigrated ||
!expandedCardPropsMigrated ||
!jiraIssueCardPropDefaulted
!jiraIssueCardPropDefaulted ||
!hostCardPropDefaulted
) {
markNeedsSave()
}
@@ -213,6 +220,7 @@ export function normalizeLoadedUiState(
_inlineAgentsDefaultedForExperiment: true,
_inlineAgentsDefaultedForAllUsers: true,
_expandedWorktreeCardPropertiesDefaulted: true,
_jiraIssueWorktreeCardPropertyDefaulted: true
_jiraIssueWorktreeCardPropertyDefaulted: true,
_hostWorktreeCardPropertyDefaulted: true
}
}
@@ -14,6 +14,7 @@ type MainOwnedUIState =
| 'dashboardPopoutBounds'
| '_expandedWorktreeCardPropertiesDefaulted'
| '_jiraIssueWorktreeCardPropertyDefaulted'
| '_hostWorktreeCardPropertyDefaulted'
| 'starNagBaselineAgents'
| 'starNagAppVersion'
| 'starNagNextThreshold'
@@ -0,0 +1,151 @@
import { renderToStaticMarkup } from 'react-dom/server'
import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { Repo } from '../../../../shared/repo-types'
import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types'
import type { Worktree } from '../../../../shared/worktree/types'
const fetchHostedReviewForBranch = vi.fn()
const fetchIssue = vi.fn()
const fetchLinearIssue = vi.fn()
const openModal = vi.fn()
const updateWorktreeMeta = vi.fn()
const HOST_LABEL = 'gpu-vm'
let worktreeCardProperties: WorktreeCardProperty[] = []
let settings: Partial<GlobalSettings> | null = null
const WORKTREE_CARD_IMPORT_TIMEOUT_MS = 15_000
vi.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({
deleteStateByWorktreeId: {},
fetchHostedReviewForBranch,
fetchIssue,
fetchLinearIssue,
gitConflictOperationByWorktree: {},
hostedReviewCache: {},
issueCache: {},
linearIssueCache: {},
openModal,
projectGroups: [],
remoteBranchConflictByWorktreeId: {},
settings,
sshConnectionStates: new Map(),
sshTargetLabels: new Map(),
updateWorktreeMeta,
workspacePortScan: null,
worktreeCardProperties
})
}))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: vi.fn()
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
vi.mock('./use-worktree-activity-status', () => ({
useWorktreeActivityStatus: () => 'idle'
}))
vi.mock('./use-worktree-sleep-state', () => ({
useIsSleepingWorktree: () => false
}))
vi.mock('./CacheTimer', () => ({
default: () => null,
usePromptCacheCountdownStartedAt: () => null
}))
vi.mock('./WorktreeCardAgents', () => ({
default: () => null
}))
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu',
WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope'
}))
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-1',
path: '/repo',
displayName: 'orca',
badgeColor: '#999999',
repoIcon: { type: 'emoji', emoji: '🦊' },
addedAt: 1,
...overrides
}
}
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
return {
id: 'repo-1::/repo/worktrees/host',
repoId: 'repo-1',
path: '/repo/worktrees/host',
displayName: 'Host tree',
branch: 'feature/host',
head: 'abc123',
isBare: false,
isMainWorktree: false,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
...overrides
}
}
// Imported per test so the mocked store is seeded before the module reads it.
async function renderCardMarkup(): Promise<string> {
const { default: WorktreeCard } = await import('./WorktreeCard')
return renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree()}
repo={makeRepo()}
isActive={false}
hostContextLabel={HOST_LABEL}
/>
)
}
describe('WorktreeCard host badge toggle', () => {
beforeEach(() => {
vi.clearAllMocks()
worktreeCardProperties = []
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: false }
})
it(
'shows the execution-host label when the host card property is enabled',
async () => {
worktreeCardProperties = ['host']
expect(await renderCardMarkup()).toContain(HOST_LABEL)
},
WORKTREE_CARD_IMPORT_TIMEOUT_MS
)
it(
'hides the execution-host label when the host card property is off',
async () => {
worktreeCardProperties = ['status']
expect(await renderCardMarkup()).not.toContain(HOST_LABEL)
},
WORKTREE_CARD_IMPORT_TIMEOUT_MS
)
})
@@ -123,6 +123,13 @@ const BASE_WORKTREE_CARD_PROPERTY_OPTIONS: WorktreeCardPropertyOption[] = [
)
}
},
{
id: 'host',
properties: ['host'],
get label() {
return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.host', 'Host')
}
},
{
id: 'branch',
properties: ['branch'],
@@ -67,5 +67,11 @@ export const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [
'Agent activity'
)
}
},
{
id: 'host',
get label() {
return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.host', 'Host')
}
}
]
@@ -77,7 +77,7 @@ export function buildWorktreeCardPresentation(card: WorktreeCardController) {
showRepoIdentityInTitle && !!repo && !hideRepoBadge && !isFolder && !showPinnedRepoIcon
const showRepoBadgeInMetaRow =
!showRepoIdentityInTitle && !!repo && !hideRepoBadge && !showPinnedRepoIcon
const showHostContextBadge = !compactCards && !!hostContextLabel
const showHostContextBadge = !compactCards && !!hostContextLabel && cardProps.includes('host')
const showDetachedHeadInMetaRow = !compactCards && !isFolder && detachedHeadDisplay !== null
const showBranch =
!isFolder &&
+1
View File
@@ -5582,6 +5582,7 @@
"bdd23b4e07": "GitHub issues",
"44713a5d04": "Linear issues",
"jiraIssues": "Jira issues",
"host": "Host",
"cc17bd443b": "Detailed",
"0f9b959b31": "PR",
"e029a2d775": "Status",
+1
View File
@@ -4603,6 +4603,7 @@
"bdd23b4e07": "Issues de GitHub",
"44713a5d04": "Issues de Linear",
"jiraIssues": "Issues de Jira",
"host": "Host",
"cc17bd443b": "Detallado",
"0f9b959b31": "PR",
"e029a2d775": "Estado",
+1
View File
@@ -5515,6 +5515,7 @@
"bdd23b4e07": "Issues GitHub",
"44713a5d04": "Issues Linear",
"jiraIssues": "Tickets Jira",
"host": "Hôte",
"cc17bd443b": "Détaillé",
"0f9b959b31": "PR",
"e029a2d775": "Statut",
+1
View File
@@ -5361,6 +5361,7 @@
"bdd23b4e07": "GitHub Issue",
"44713a5d04": "Linear Issue",
"jiraIssues": "Jira Issue",
"host": "ホスト",
"cc17bd443b": "詳細",
"0f9b959b31": "PR",
"e029a2d775": "状態",
+1
View File
@@ -5361,6 +5361,7 @@
"bdd23b4e07": "GitHub 이슈",
"44713a5d04": "Linear 이슈",
"jiraIssues": "Jira 이슈",
"host": "호스트",
"cc17bd443b": "상세한",
"0f9b959b31": "PR",
"e029a2d775": "상태",
+1
View File
@@ -5361,6 +5361,7 @@
"bdd23b4e07": "GitHub 议题",
"44713a5d04": "Linear 议题",
"jiraIssues": "Jira 议题",
"host": "主机",
"cc17bd443b": "详细的",
"0f9b959b31": "PR",
"e029a2d775": "状态",
+2
View File
@@ -196,6 +196,8 @@ export type PersistedUIState = {
_expandedWorktreeCardPropertiesDefaulted?: boolean
/** One-shot backfill flag for 'jira-issue', which joined the defaults after the expansion migration had already stamped upgraded profiles. */
_jiraIssueWorktreeCardPropertyDefaulted?: boolean
/** One-shot backfill flag for 'host', which became a toggleable property after earlier profiles were already stamped. */
_hostWorktreeCardPropertyDefaulted?: boolean
/** totalAgentsSpawned snapshot at first sighting of the current app version, so the nag counts agents since last update (not from zero). */
starNagBaselineAgents?: number | null
/** App version that set the current baseline; a version change re-captures the baseline on next spawn, restarting the nag countdown. */
+2
View File
@@ -44,6 +44,8 @@ export type WorktreeCardProperty =
| 'ports'
// Inline agent-activity list rendered in each workspace card; on by default (see DEFAULT_WORKTREE_CARD_PROPERTIES in shared/constants.ts).
| 'inline-agents'
// Execution-host pill, shown only when the visible workspaces span more than one host; on by default.
| 'host'
export type WorktreeCardMode = 'Default' | 'Compact'
+4 -2
View File
@@ -21,7 +21,8 @@ export const DEFAULT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [
// Why: agent activity is the primary reason users opt into the feature, so
// the Default mode keeps it inline on each card while Compact removes the
// extra row.
'inline-agents'
'inline-agents',
'host'
]
// Why: compact cards default to the quiet preset; metadata icons remain opt-in
@@ -55,7 +56,8 @@ export const WORKTREE_CARD_PROPERTIES = [
'cli',
'comment',
'ports',
'inline-agents'
'inline-agents',
'host'
] as const satisfies readonly WorktreeCardProperty[]
export function normalizeWorktreeCardProperties(