diff --git a/src/main/persistence-worktree-card-properties.test.ts b/src/main/persistence-worktree-card-properties.test.ts index e370168af4f..555ae14caf7 100644 --- a/src/main/persistence-worktree-card-properties.test.ts +++ b/src/main/persistence-worktree-card-properties.test.ts @@ -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']] diff --git a/src/main/persistence/loading-store/normalize-loaded-ui-state.ts b/src/main/persistence/loading-store/normalize-loaded-ui-state.ts index 4b208a6fa72..8a4f838f193 100644 --- a/src/main/persistence/loading-store/normalize-loaded-ui-state.ts +++ b/src/main/persistence/loading-store/normalize-loaded-ui-state.ts @@ -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 } } diff --git a/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts index 5f077b9d204..fd8235f5174 100644 --- a/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts +++ b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts @@ -14,6 +14,7 @@ type MainOwnedUIState = | 'dashboardPopoutBounds' | '_expandedWorktreeCardPropertiesDefaulted' | '_jiraIssueWorktreeCardPropertyDefaulted' + | '_hostWorktreeCardPropertyDefaulted' | 'starNagBaselineAgents' | 'starNagAppVersion' | 'starNagNextThreshold' diff --git a/src/renderer/src/components/sidebar/WorktreeCard.host-badge-toggle.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.host-badge-toggle.test.tsx new file mode 100644 index 00000000000..e8fac60fbf5 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCard.host-badge-toggle.test.tsx @@ -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 | 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 { + return { + id: 'repo-1', + path: '/repo', + displayName: 'orca', + badgeColor: '#999999', + repoIcon: { type: 'emoji', emoji: '🦊' }, + addedAt: 1, + ...overrides + } +} + +function makeWorktree(overrides: Partial = {}): 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 { + const { default: WorktreeCard } = await import('./WorktreeCard') + return renderToStaticMarkup( + + ) +} + +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 + ) +}) diff --git a/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts index 576b9bbf080..225b2e6a47d 100644 --- a/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts +++ b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts @@ -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'], diff --git a/src/renderer/src/components/sidebar/worktree-card-display-property-options.ts b/src/renderer/src/components/sidebar/worktree-card-display-property-options.ts index 7a009b384fa..c538691d3f2 100644 --- a/src/renderer/src/components/sidebar/worktree-card-display-property-options.ts +++ b/src/renderer/src/components/sidebar/worktree-card-display-property-options.ts @@ -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') + } } ] diff --git a/src/renderer/src/components/sidebar/worktree-card-presentation.tsx b/src/renderer/src/components/sidebar/worktree-card-presentation.tsx index 1e246848b6d..25376e33054 100644 --- a/src/renderer/src/components/sidebar/worktree-card-presentation.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-presentation.tsx @@ -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 && diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c968a644784..d8ecc0df004 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5582,6 +5582,7 @@ "bdd23b4e07": "GitHub issues", "44713a5d04": "Linear issues", "jiraIssues": "Jira issues", + "host": "Host", "cc17bd443b": "Detailed", "0f9b959b31": "PR", "e029a2d775": "Status", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 978571d4d1b..5ab6bbaaa5d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4603,6 +4603,7 @@ "bdd23b4e07": "Issues de GitHub", "44713a5d04": "Issues de Linear", "jiraIssues": "Issues de Jira", + "host": "Host", "cc17bd443b": "Detallado", "0f9b959b31": "PR", "e029a2d775": "Estado", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index e6e37b3ff00..6465f23c87e 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -5515,6 +5515,7 @@ "bdd23b4e07": "Issues GitHub", "44713a5d04": "Issues Linear", "jiraIssues": "Tickets Jira", + "host": "Hôte", "cc17bd443b": "Détaillé", "0f9b959b31": "PR", "e029a2d775": "Statut", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 6b07469b890..4af40935b24 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5361,6 +5361,7 @@ "bdd23b4e07": "GitHub Issue", "44713a5d04": "Linear Issue", "jiraIssues": "Jira Issue", + "host": "ホスト", "cc17bd443b": "詳細", "0f9b959b31": "PR", "e029a2d775": "状態", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 10644fb1cf2..a72fbaa9eaa 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5361,6 +5361,7 @@ "bdd23b4e07": "GitHub 이슈", "44713a5d04": "Linear 이슈", "jiraIssues": "Jira 이슈", + "host": "호스트", "cc17bd443b": "상세한", "0f9b959b31": "PR", "e029a2d775": "상태", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 9a8e73c1e6c..cf73cbb7d8f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5361,6 +5361,7 @@ "bdd23b4e07": "GitHub 议题", "44713a5d04": "Linear 议题", "jiraIssues": "Jira 议题", + "host": "主机", "cc17bd443b": "详细的", "0f9b959b31": "PR", "e029a2d775": "状态", diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index f4cbca03f8b..feb9a29d213 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -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. */ diff --git a/src/shared/ui-chrome-types.ts b/src/shared/ui-chrome-types.ts index 8d55b56f3e5..2e7557bb592 100644 --- a/src/shared/ui-chrome-types.ts +++ b/src/shared/ui-chrome-types.ts @@ -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' diff --git a/src/shared/worktree/card-properties.ts b/src/shared/worktree/card-properties.ts index d0b09a01344..16dc2b14a12 100644 --- a/src/shared/worktree/card-properties.ts +++ b/src/shared/worktree/card-properties.ts @@ -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(