Clarify macOS access prompt guidance (#11807)

* Clarify macOS access prompt guidance

* Make settings target highlight visible
This commit is contained in:
Brennan Benson 2026-07-31 14:38:27 -07:00 committed by GitHub
parent 90963f8ee3
commit 7d24dad48a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 115 additions and 9 deletions

View File

@ -0,0 +1,52 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { DeveloperPermissionState } from '../../../../shared/developer-permissions-types'
import { FULL_DISK_ACCESS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types'
import { DeveloperPermissionsPane } from './DeveloperPermissionsPane'
let container: HTMLDivElement
let root: Root
beforeEach(() => {
Object.assign(window, {
api: {
developerPermissions: {
getStatus: vi.fn(
async (): Promise<DeveloperPermissionState[]> => [
{ id: 'full-disk-access', status: 'denied' }
]
),
request: vi.fn()
}
}
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(async () => {
await act(async () => root.unmount())
container.remove()
Reflect.deleteProperty(window, 'api')
})
it('highlights the Full Disk Access row for a targeted Settings navigation', async () => {
await act(async () => {
root.render(
<DeveloperPermissionsPane highlightedSettingId={FULL_DISK_ACCESS_SETTINGS_TARGET_ID} />
)
})
const row = container.querySelector<HTMLElement>(
`[data-settings-section="${FULL_DISK_ACCESS_SETTINGS_TARGET_ID}"]`
)
expect(row?.dataset.highlighted).toBe('true')
expect(row?.className).toContain('data-[highlighted=true]:ring-annotation-highlight/60')
await act(async () => root.render(<DeveloperPermissionsPane />))
expect(row?.dataset.highlighted).toBeUndefined()
})

View File

@ -23,6 +23,10 @@ import { Button } from '../ui/button'
import { translate } from '@/i18n/i18n'
export { getDeveloperPermissionsPaneSearchEntries } from './developer-permissions-search'
type DeveloperPermissionsPaneProps = {
highlightedSettingId?: string | null
}
type PermissionDefinition = {
id: DeveloperPermissionId
label: string
@ -202,7 +206,9 @@ function statusClass(status: DeveloperPermissionStatus | undefined): string {
return 'border-border bg-muted text-muted-foreground'
}
export function DeveloperPermissionsPane(): React.JSX.Element {
export function DeveloperPermissionsPane({
highlightedSettingId = null
}: DeveloperPermissionsPaneProps): React.JSX.Element {
const [states, setStates] = useState<DeveloperPermissionState[]>([])
const [loading, setLoading] = useState(true)
const [pendingId, setPendingId] = useState<DeveloperPermissionId | null>(null)
@ -340,9 +346,15 @@ export function DeveloperPermissionsPane(): React.JSX.Element {
{PERMISSIONS.map((permission) => {
const status = stateById.get(permission.id)
const pending = pendingId === permission.id
const settingId = `developer-permissions-${permission.id}`
return (
<div key={permission.id} className="flex items-center justify-between gap-4 px-4 py-3">
<div
key={permission.id}
data-settings-section={settingId}
data-highlighted={highlightedSettingId === settingId ? 'true' : undefined}
className="flex items-center justify-between gap-4 px-4 py-3 transition-[background-color,box-shadow] duration-500 data-[highlighted=true]:bg-annotation-highlight/10 data-[highlighted=true]:ring-2 data-[highlighted=true]:ring-inset data-[highlighted=true]:ring-annotation-highlight/60 motion-reduce:transition-none"
>
<div className="flex min-w-0 items-start gap-3">
<div className="mt-0.5 text-muted-foreground">{permission.icon}</div>
<div className="min-w-0 space-y-1">

View File

@ -177,6 +177,7 @@ const SETTINGS_NAV_GROUP_BY_ID = new Map<string, SettingsNavGroupDefinition>(
const SHORTCUTS_ESCAPE_CONFIRM_TOAST_ID = 'shortcuts-escape-confirm'
const SHORTCUTS_ESCAPE_CONFIRM_WINDOW_MS = 2200
const SETTINGS_TARGET_HIGHLIGHT_MS = 3_000
function getSettingsSectionId(
pane: SettingsNavTarget,
@ -372,6 +373,9 @@ function Settings(): React.JSX.Element {
getInitialMountedSectionIds
)
const [pendingNavRequestTick, setPendingNavRequestTick] = useState(0)
const [highlightedSettingsTargetId, setHighlightedSettingsTargetId] = useState<string | null>(
null
)
const [quickCommandAddIntentSignal, setQuickCommandAddIntentSignal] = useState(0)
const [sshHostAddIntentSignal, setSshHostAddIntentSignal] = useState(0)
const [remoteServerAddIntentSignal, setRemoteServerAddIntentSignal] = useState(0)
@ -446,6 +450,17 @@ function Settings(): React.JSX.Element {
}
}, [])
useEffect(() => {
if (!highlightedSettingsTargetId) {
return
}
const timeout = window.setTimeout(
() => setHighlightedSettingsTargetId(null),
SETTINGS_TARGET_HIGHLIGHT_MS
)
return () => window.clearTimeout(timeout)
}, [highlightedSettingsTargetId])
const requestFontSuggestions = useCallback((): void => {
if (installedFontsLoadedRef.current || installedFontsLoadPromiseRef.current) {
return
@ -661,6 +676,11 @@ function Settings(): React.JSX.Element {
}
pendingNavSectionRef.current = paneSectionId
pendingScrollTargetRef.current = settingsNavigationTarget.sectionId ?? paneSectionId
setHighlightedSettingsTargetId(
settingsNavigationTarget.pane === 'developer-permissions'
? (settingsNavigationTarget.sectionId ?? null)
: null
)
// Why: ensure Appearance's nested status-bar section is open before scrolling so the row is visible.
if (settingsNavigationTarget.pane === 'appearance') {
const accordion = resolveAppearanceAccordionDeepLink(settingsNavigationTarget.sectionId)
@ -1696,7 +1716,9 @@ function Settings(): React.JSX.Element {
searchEntries={getSectionSearchEntries('developer-permissions')}
>
{isSectionMounted('developer-permissions') ? (
<DeveloperPermissionsPane />
<DeveloperPermissionsPane
highlightedSettingId={highlightedSettingsTargetId}
/>
) : null}
</SettingsSection>
) : null}

View File

@ -9,6 +9,7 @@ import { UI_LANGUAGE_SPANISH } from '../../../shared/ui-language'
import { useAppStore } from '@/store'
import { usePluginLanguagePackStore } from '@/store/plugin-language-packs'
import { i18n } from '@/i18n/i18n'
import { FULL_DISK_ACCESS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types'
import { MacosTccPromptNoticeHost } from './MacosTccPromptNoticeHost'
import { useMacosTccPromptNotice } from './useMacosTccPromptNotice'
@ -95,7 +96,7 @@ it('isolates plugin language-pack discovery from its parent render path', async
expect(subscribeToMacosTccPromptNotice).toHaveBeenCalledOnce()
})
it('keeps the notice open until the user closes it', async () => {
it('connects the notice to the macOS prompt and keeps it open until closed', async () => {
useAppStore.setState({
settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'en' }
})
@ -111,6 +112,13 @@ it('keeps the notice open until the user closes it', async () => {
showNotice?.({ promptCount: 1 }, acknowledge)
expect(toastWarning).toHaveBeenCalledWith(
'Seeing “Orca would like to access…”?',
expect.objectContaining({
description:
'That macOS message appears when an agent or terminal tool accesses protected files. macOS names Orca because Orca launched the tool. Grant Full Disk Access to Orca and Orca Helper to reduce future prompts.'
})
)
const options = toastWarning.mock.calls[0]?.[1] as
| { duration?: number; onDismiss?: () => void }
| undefined
@ -141,4 +149,9 @@ it('acknowledges when opening Settings closes the notice', async () => {
| undefined
options?.action?.onClick()
expect(acknowledge).toHaveBeenCalledOnce()
expect(useAppStore.getState().settingsNavigationTarget).toEqual({
pane: 'developer-permissions',
repoId: null,
sectionId: FULL_DISK_ACCESS_SETTINGS_TARGET_ID
})
})

View File

@ -6,6 +6,7 @@ import { useAppStore } from '@/store'
import { usePluginLanguagePackStore } from '@/store/plugin-language-packs'
import { translate } from '@/i18n/i18n'
import { resolveUiLocale } from '@/i18n/supported-languages'
import { FULL_DISK_ACCESS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types'
import {
dismissMacosTccPromptNotice,
subscribeToMacosTccPromptNotice
@ -41,12 +42,12 @@ export function useMacosTccPromptNotice(): void {
toast.warning(
translate(
'auto.hooks.useMacosTccPromptNotice.title',
'Reduce repeated macOS file-access prompts'
'Seeing “Orca would like to access…”?'
),
{
description: translate(
'auto.hooks.useMacosTccPromptNotice.description',
'macOS attributes file access by your agents and terminal tools to Orca. Granting Full Disk Access reduces these prompts.'
'That macOS message appears when an agent or terminal tool accesses protected files. macOS names Orca because Orca launched the tool. Grant Full Disk Access to Orca and Orca Helper to reduce future prompts.'
),
duration: Infinity,
onDismiss: acknowledge,
@ -55,7 +56,11 @@ export function useMacosTccPromptNotice(): void {
onClick: () => {
acknowledge()
openSettingsPage()
openSettingsTarget({ pane: 'developer-permissions', repoId: null })
openSettingsTarget({
pane: 'developer-permissions',
repoId: null,
sectionId: FULL_DISK_ACCESS_SETTINGS_TARGET_ID
})
}
},
cancel: {

View File

@ -826,8 +826,8 @@
"pasteTooLarge": "Paste is too large."
},
"useMacosTccPromptNotice": {
"title": "Reduce repeated macOS file-access prompts",
"description": "macOS attributes file access by your agents and terminal tools to Orca. Granting Full Disk Access reduces these prompts.",
"title": "Seeing “Orca would like to access…”?",
"description": "That macOS message appears when an agent or terminal tool accesses protected files. macOS names Orca because Orca launched the tool. Grant Full Disk Access to Orca and Orca Helper to reduce future prompts.",
"openSettings": "Open Settings",
"dismiss": "Don't show again"
}

View File

@ -56,6 +56,8 @@ const SETTINGS_NAV_TARGET_SET: ReadonlySet<string> = new Set(SETTINGS_NAV_TARGET
const SETTINGS_NAV_INTENT_SET: ReadonlySet<string> = new Set(SETTINGS_NAV_INTENTS)
export type SettingsNavTarget = (typeof SETTINGS_NAV_TARGETS)[number]
export const FULL_DISK_ACCESS_SETTINGS_TARGET_ID = 'developer-permissions-full-disk-access'
export type SettingsNavigationTarget = {
pane: SettingsNavTarget
repoId: string | null