diff --git a/notes/host-context-implementation-checklist.md b/notes/host-context-implementation-checklist.md index c86576ba277..dee522f517b 100644 --- a/notes/host-context-implementation-checklist.md +++ b/notes/host-context-implementation-checklist.md @@ -84,9 +84,9 @@ Done means: scheduled or manual automations behave predictably when a project ex ## 6. Settings, Accounts, And Host Ownership -- [~] Clarify which settings are client-owned versus host-owned. -- [~] Add host/account selectors to provider settings where credentials may differ by host. -- [~] Make provider rate limits, diagnostics, and “current account” views show their host/source context. +- [x] Clarify which settings are client-owned versus host-owned. +- [x] Add host/account selectors to provider settings where credentials may differ by host. +- [x] Make provider rate limits, diagnostics, and “current account” views show their host/source context. - [x] Audit source-control AI, agent defaults, command defaults, and model selections for whether they should be client-global or host-scoped. - [x] Preserve existing Windows/WSL-style setting patterns where they already solve host-specific settings. - [x] Ensure remote server version/capability information is visible enough to explain unavailable controls. @@ -258,3 +258,4 @@ Done means: reviewers and future agents can understand the vision, implementatio - [x] 2026-06-12: Extracted and tested external automation source availability copy so local missing tooling, disconnected SSH sources, connecting SSH sources, and connected SSH hosts with missing remote tooling get distinct states; verified with focused automation availability tests, targeted oxlint, and full `pnpm run typecheck`. - [x] 2026-06-12: Marked repo-id/path compatibility adapters complete after hardening the renderer project-host selector to merge repo-derived compatibility rows when hydrated project/setup arrays are empty or partial, alongside existing shared projection, persistence backfill/sync, automation context migration, and pending worktree context coverage. Verified with selector/projection tests, persistence tests, targeted oxlint, full `pnpm run typecheck`, and `git diff --check`. - [x] 2026-06-12: Marked the Host copy audit complete after replacing remaining visible stale SSH/project wording in paired-web project creation errors, Settings SSH search entries, AI Vault SSH-host workspace copy, and SSH shutdown errors. Verified with a stale-copy scan showing only comments/tests/technical internals, focused SSH IPC tests, targeted oxlint, full `pnpm run typecheck`, and `git diff --check`. +- [x] 2026-06-12: Completed Settings ownership/account rows by adding a shared provider Host scope control with a Change Host action that opens Settings > Active Server from GitHub/GitLab/Linear/Jira account surfaces and GitHub/GitLab API budget panels. Existing ownership diagnostics still distinguish desktop-client versus remote-server credentials, and rate-limit panels show the budget Host scope. Verified with settings ownership, RuntimeEnvironmentsPane, provider account, source-control card, task-tracker card, Jira card, and rate-limit panel tests, targeted oxlint, full `pnpm run typecheck`, and `git diff --check`. diff --git a/src/renderer/src/components/github/github-rate-limit-display.tsx b/src/renderer/src/components/github/github-rate-limit-display.tsx index f05f7ac479e..38297503285 100644 --- a/src/renderer/src/components/github/github-rate-limit-display.tsx +++ b/src/renderer/src/components/github/github-rate-limit-display.tsx @@ -6,6 +6,7 @@ import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { GetRateLimitResult, GitHubRateLimitSnapshot } from '../../../../shared/types' import { getProviderRateLimitScope } from '@/components/settings/provider-account-scope' +import { ProviderHostScopeControl } from '@/components/settings/ProviderHostScopeControl' import { translate } from '@/i18n/i18n' const REFRESH_INTERVAL_MS = 60_000 @@ -190,16 +191,14 @@ export function GitHubRateLimitPanel({ className }: { className?: string }): Rea 'Orca uses REST, Search, and GraphQL through the GitHub CLI.' )}

-

- - {translate( - 'auto.components.github.github.rate.limit.display.budget_scope', - 'Budget scope: {{value0}}', - { value0: budgetScope.label } - )} - {' '} - {budgetScope.description} -

+ + + + ) +} diff --git a/src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx b/src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx index 0b35b557fb8..34d3196a9fb 100644 --- a/src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx +++ b/src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx @@ -10,6 +10,8 @@ import { type StoreState = { settings: { activeRuntimeEnvironmentId: string | null } + openSettingsPage: () => void + openSettingsTarget: (target: { pane: string; repoId: string | null }) => void } const mocks = vi.hoisted(() => ({ @@ -74,7 +76,13 @@ describe('CLI source-control integration card account scope', () => { }) it('shows local-client ownership for connected GitHub CLI credentials', async () => { - mocks.store.current = { settings: { activeRuntimeEnvironmentId: null } } + const openSettingsPage = vi.fn() + const openSettingsTarget = vi.fn() + mocks.store.current = { + settings: { activeRuntimeEnvironmentId: null }, + openSettingsPage, + openSettingsTarget + } const rendered = await renderCard() @@ -84,10 +92,22 @@ describe('CLI source-control integration card account scope', () => { expect(rendered.textContent).toContain( 'Credentials and account checks for this provider are owned by this desktop client. Choose a remote Host from Settings > Active Server to edit server-owned credentials.' ) + await act(async () => { + Array.from(rendered.querySelectorAll('button')) + .find((button) => button.textContent === 'Change Host') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(openSettingsPage).toHaveBeenCalledTimes(1) + expect(openSettingsTarget).toHaveBeenCalledWith({ pane: 'servers', repoId: null }) }) it('shows remote-server ownership for GitLab CLI credential checks', async () => { - mocks.store.current = { settings: { activeRuntimeEnvironmentId: 'runtime-1' } } + mocks.store.current = { + settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() + } mocks.preflight.statuses.glabStatus = 'not-authenticated' const rendered = await renderCard() diff --git a/src/renderer/src/components/settings/cli-source-control-integration-cards.tsx b/src/renderer/src/components/settings/cli-source-control-integration-cards.tsx index 3c3508d04ea..0007e594d02 100644 --- a/src/renderer/src/components/settings/cli-source-control-integration-cards.tsx +++ b/src/renderer/src/components/settings/cli-source-control-integration-cards.tsx @@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button' import { useAppStore } from '@/store' import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell' import { getProviderAccountScope } from './provider-account-scope' +import { ProviderHostScopeControl } from './ProviderHostScopeControl' import { usePreflightCardStatuses } from './source-control-preflight-card-status' import { translate } from '@/i18n/i18n' @@ -16,16 +17,14 @@ function ProviderAccountScopeDetails({ return ( -
- - {translate( - 'auto.components.settings.cli.source.control.integration.cards.account_scope', - 'Account scope: {{value0}}', - { value0: accountScope.label } - )} - -
{accountScope.description}
-
+ {children}
) diff --git a/src/renderer/src/components/settings/jira-integration-card.test.tsx b/src/renderer/src/components/settings/jira-integration-card.test.tsx new file mode 100644 index 00000000000..5c46467c312 --- /dev/null +++ b/src/renderer/src/components/settings/jira-integration-card.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { JiraIntegrationCard } from './jira-integration-card' + +type StoreState = { + jiraStatus: { + connected: boolean + sites?: { id: string; displayName: string; siteUrl: string; email?: string }[] + } + jiraStatusChecked: boolean + jiraStatusContextKey: string | null + checkJiraConnection: () => Promise + disconnectJira: (siteId?: string) => Promise + testJiraConnection: (siteId: string) => Promise<{ ok: boolean; error?: string }> + settings: { activeRuntimeEnvironmentId: string | null } + openSettingsPage: () => void + openSettingsTarget: (target: { pane: string; repoId: string | null }) => void +} + +const mocks = vi.hoisted(() => ({ + store: { current: null as StoreState | null } +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: StoreState) => unknown) => { + if (!mocks.store.current) { + throw new Error('Store state was not installed') + } + return selector(mocks.store.current) + } +})) + +vi.mock('@/components/jira-connect-dialog', () => ({ + JiraConnectDialog: ({ onConnected }: { onConnected?: () => void }) => ( + + ) +})) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function installStore(settings: StoreState['settings']): StoreState { + const state: StoreState = { + jiraStatus: { + connected: true, + sites: [ + { + id: 'site-1', + displayName: 'Acme Jira', + siteUrl: 'https://acme.atlassian.net', + email: 'jira@example.test' + } + ] + }, + jiraStatusChecked: true, + jiraStatusContextKey: getProviderRuntimeContextKey(settings), + checkJiraConnection: vi.fn(async () => {}), + disconnectJira: vi.fn(async () => {}), + testJiraConnection: vi.fn(async () => ({ ok: true })), + settings, + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() + } + mocks.store.current = state + return state +} + +async function renderCard(): Promise { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) + return container +} + +describe('JiraIntegrationCard account scope', () => { + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + root = null + container?.remove() + container = null + mocks.store.current = null + }) + + it('shows remote-server account ownership and opens Active Server host selection', async () => { + const state = installStore({ activeRuntimeEnvironmentId: 'runtime-1' }) + + const rendered = await renderCard() + + expect(rendered.textContent).toContain('Account scope: Remote server: runtime-1') + expect(rendered.textContent).toContain('Acme Jira') + expect(rendered.textContent).toContain('https://acme.atlassian.net · jira@example.test') + + await act(async () => { + Array.from(rendered.querySelectorAll('button')) + .find((button) => button.textContent === 'Change Host') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(state.openSettingsPage).toHaveBeenCalledTimes(1) + expect(state.openSettingsTarget).toHaveBeenCalledWith({ pane: 'servers', repoId: null }) + }) +}) diff --git a/src/renderer/src/components/settings/jira-integration-card.tsx b/src/renderer/src/components/settings/jira-integration-card.tsx index a7e97b1ac05..32b1a472467 100644 --- a/src/renderer/src/components/settings/jira-integration-card.tsx +++ b/src/renderer/src/components/settings/jira-integration-card.tsx @@ -11,6 +11,7 @@ import { import { useAppStore } from '@/store' import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell' import { getProviderAccountScope } from './provider-account-scope' +import { ProviderHostScopeControl } from './ProviderHostScopeControl' import { translate } from '@/i18n/i18n' type VerificationResult = { state: 'ok' | 'error'; error?: string } @@ -108,16 +109,14 @@ export function JiraIntegrationCard(): React.JSX.Element { ) : null } > -
-
- {translate( - 'auto.components.settings.task.tracker.integration.cards.account_scope', - 'Account scope: {{value0}}', - { value0: accountScope.label } - )} -
-
{accountScope.description}
-
+ {connected && sites.length > 0 ? (
{sites.map((site) => { diff --git a/src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx b/src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx index 38139024482..8415afadece 100644 --- a/src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx +++ b/src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx @@ -5,10 +5,18 @@ import { GitLabRateLimitPanel } from '@/components/gitlab/gitlab-rate-limit-disp type StoreState = { settings: { activeRuntimeEnvironmentId: string | null } + openSettingsPage: () => void + openSettingsTarget: (target: { pane: string; repoId: string | null }) => void } const mocks = vi.hoisted(() => ({ - store: { current: { settings: { activeRuntimeEnvironmentId: null } } as StoreState } + store: { + current: { + settings: { activeRuntimeEnvironmentId: null }, + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() + } as StoreState + } })) vi.mock('@/store', () => ({ @@ -17,7 +25,11 @@ vi.mock('@/store', () => ({ describe('provider rate-limit panels account scope', () => { it('shows the local host scope for GitHub API budget', () => { - mocks.store.current = { settings: { activeRuntimeEnvironmentId: null } } + mocks.store.current = { + settings: { activeRuntimeEnvironmentId: null }, + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() + } const markup = renderToStaticMarkup() @@ -25,10 +37,15 @@ describe('provider rate-limit panels account scope', () => { expect(markup).toContain( 'GitHub API budget is fetched from the CLI on this desktop client. Choose a remote Host from Settings > Active Server to view server-owned budgets.' ) + expect(markup).toContain('Change Host') }) it('shows the remote server scope for GitLab API budget', () => { - mocks.store.current = { settings: { activeRuntimeEnvironmentId: 'runtime-1' } } + mocks.store.current = { + settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() + } const markup = renderToStaticMarkup() diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx index 2498c5cc170..307a2bff5d3 100644 --- a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx +++ b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx @@ -18,6 +18,8 @@ type StoreState = { checkLinearConnection: (force?: boolean) => Promise testLinearConnection: (workspaceId: string) => Promise<{ ok: boolean; error?: string }> settings: { activeRuntimeEnvironmentId: string | null } + openSettingsPage: () => void + openSettingsTarget: (target: { pane: string; repoId: string | null }) => void } const mocks = vi.hoisted(() => ({ @@ -68,7 +70,9 @@ function installStore( disconnectLinearWorkspace: vi.fn(async () => {}), checkLinearConnection: vi.fn(async () => {}), testLinearConnection: vi.fn(async () => ({ ok: true })), - settings + settings, + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() } mocks.store.current = state return state @@ -106,6 +110,7 @@ describe('LinearIntegrationCard account scope', () => { expect(rendered.textContent).toContain( 'Credentials and account checks for this provider are owned by this desktop client. Choose a remote Host from Settings > Active Server to edit server-owned credentials.' ) + expect(rendered.textContent).toContain('Change Host') expect(rendered.textContent).toContain('Add access with a Personal API key') await act(async () => { @@ -126,6 +131,13 @@ describe('LinearIntegrationCard account scope', () => { expect(rendered.textContent).toContain( 'Credentials and account checks for this provider are owned by this remote server. Choose a different Host from Settings > Active Server to edit another account scope.' ) + await act(async () => { + Array.from(rendered.querySelectorAll('button')) + .find((button) => button.textContent === 'Change Host') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(state.openSettingsPage).toHaveBeenCalledTimes(1) + expect(state.openSettingsTarget).toHaveBeenCalledWith({ pane: 'servers', repoId: null }) expect(rendered.textContent).toContain('Acme') expect(rendered.textContent).toContain('Acme workspace · linear@example.test') diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.tsx index 40b7d2aa583..7c89a30584e 100644 --- a/src/renderer/src/components/settings/task-tracker-integration-cards.tsx +++ b/src/renderer/src/components/settings/task-tracker-integration-cards.tsx @@ -8,6 +8,7 @@ import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' import { useAppStore } from '@/store' import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell' import { getProviderAccountScope } from './provider-account-scope' +import { ProviderHostScopeControl } from './ProviderHostScopeControl' import { translate } from '@/i18n/i18n' type VerificationResult = { state: 'ok' | 'error'; error?: string } @@ -214,16 +215,14 @@ export function LinearIntegrationCard(): React.JSX.Element { function ProviderAccountScopeRow({ scope }: { scope: ReturnType }) { return ( -
-
- {translate( - 'auto.components.settings.task.tracker.integration.cards.account_scope', - 'Account scope: {{value0}}', - { value0: scope.label } - )} -
-
{scope.description}
-
+ ) }