diff --git a/src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx b/src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx new file mode 100644 index 000000000..a1da4dd96 --- /dev/null +++ b/src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx @@ -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 => [ + { 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( + + ) + }) + + const row = container.querySelector( + `[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()) + expect(row?.dataset.highlighted).toBeUndefined() +}) diff --git a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx index aa5b5a569..012d52af3 100644 --- a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx +++ b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx @@ -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([]) const [loading, setLoading] = useState(true) const [pendingId, setPendingId] = useState(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 ( -
+
{permission.icon}
diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 71db4c822..17a251bea 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -177,6 +177,7 @@ const SETTINGS_NAV_GROUP_BY_ID = new Map( 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( + 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') ? ( - + ) : null} ) : null} diff --git a/src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx b/src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx index 14ae07e6b..50fbd156e 100644 --- a/src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx +++ b/src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx @@ -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 + }) }) diff --git a/src/renderer/src/hooks/useMacosTccPromptNotice.ts b/src/renderer/src/hooks/useMacosTccPromptNotice.ts index 5da212cab..2416be1f9 100644 --- a/src/renderer/src/hooks/useMacosTccPromptNotice.ts +++ b/src/renderer/src/hooks/useMacosTccPromptNotice.ts @@ -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: { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index cf6ea8d4b..a81dc4754 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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" } diff --git a/src/renderer/src/lib/settings-navigation-types.ts b/src/renderer/src/lib/settings-navigation-types.ts index 3ce9ffea0..45efa2031 100644 --- a/src/renderer/src/lib/settings-navigation-types.ts +++ b/src/renderer/src/lib/settings-navigation-types.ts @@ -56,6 +56,8 @@ const SETTINGS_NAV_TARGET_SET: ReadonlySet = new Set(SETTINGS_NAV_TARGET const SETTINGS_NAV_INTENT_SET: ReadonlySet = 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