diff --git a/desktop/src/pages/ComputerUseSettings.test.tsx b/desktop/src/pages/ComputerUseSettings.test.tsx index a31b720f..e2b05ade 100644 --- a/desktop/src/pages/ComputerUseSettings.test.tsx +++ b/desktop/src/pages/ComputerUseSettings.test.tsx @@ -50,6 +50,14 @@ const enabledConfig = { }, } +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise(res => { + resolve = res + }) + return { promise, resolve } +} + describe('ComputerUseSettings', () => { beforeEach(() => { useSettingsStore.setState({ locale: 'en' }) @@ -95,4 +103,114 @@ describe('ComputerUseSettings', () => { enabled: false, }) }) + + it('keeps the user-selected enablement when a stale refresh resolves later', async () => { + const staleRefresh = deferred() + computerUseApiMock.getStatus.mockResolvedValue({ + ...readyStatus, + venv: { + ...readyStatus.venv, + created: true, + }, + dependencies: { + ...readyStatus.dependencies, + installed: true, + }, + }) + computerUseApiMock.getInstalledApps.mockResolvedValue({ apps: [] }) + computerUseApiMock.getAuthorizedApps + .mockResolvedValueOnce({ + ...enabledConfig, + enabled: false, + }) + .mockReturnValueOnce(staleRefresh.promise) + + render() + + const toggle = await screen.findByLabelText('Enabled') + await waitFor(() => expect(toggle).not.toBeChecked()) + await waitFor(() => expect(computerUseApiMock.getInstalledApps).toHaveBeenCalled()) + + await act(async () => { + fireEvent.click(toggle) + await Promise.resolve() + }) + + expect(toggle).toBeChecked() + + await act(async () => { + staleRefresh.resolve({ + ...enabledConfig, + enabled: false, + }) + await staleRefresh.promise + }) + + expect(toggle).toBeChecked() + }) + + it('saves app and grant flag changes from the ready environment view', async () => { + computerUseApiMock.getStatus.mockResolvedValue({ + ...readyStatus, + venv: { + ...readyStatus.venv, + created: true, + }, + dependencies: { + ...readyStatus.dependencies, + installed: true, + }, + }) + computerUseApiMock.getInstalledApps.mockResolvedValue({ + apps: [ + { + bundleId: 'com.example.Preview', + displayName: 'Preview', + path: '/Applications/Preview.app', + }, + ], + }) + + render() + + await screen.findByText('Preview') + + await act(async () => { + fireEvent.click(screen.getByText('Preview')) + await Promise.resolve() + }) + + expect(computerUseApiMock.setAuthorizedApps).toHaveBeenCalledWith({ + authorizedApps: [ + expect.objectContaining({ + bundleId: 'com.example.Preview', + displayName: 'Preview', + }), + ], + grantFlags: { + clipboardRead: true, + clipboardWrite: true, + systemKeyCombos: true, + }, + }) + + await act(async () => { + fireEvent.click(screen.getByLabelText('Clipboard Access')) + await Promise.resolve() + }) + + expect(computerUseApiMock.setAuthorizedApps).toHaveBeenCalledWith({ + authorizedApps: [ + expect.objectContaining({ + bundleId: 'com.example.Preview', + displayName: 'Preview', + }), + ], + grantFlags: { + clipboardRead: false, + clipboardWrite: false, + systemKeyCombos: true, + }, + }) + }) }) diff --git a/desktop/src/pages/ComputerUseSettings.tsx b/desktop/src/pages/ComputerUseSettings.tsx index 4e81efc4..9cfd2556 100644 --- a/desktop/src/pages/ComputerUseSettings.tsx +++ b/desktop/src/pages/ComputerUseSettings.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo } from 'react' +import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { computerUseApi, type ComputerUseStatus, type SetupResult, type InstalledApp, type AuthorizedApp } from '../api/computerUse' import { useTranslation } from '../i18n' @@ -61,6 +61,7 @@ export function ComputerUseSettings() { const [computerUseEnabled, setComputerUseEnabled] = useState(true) const [clipboardAccess, setClipboardAccess] = useState(true) const [systemKeys, setSystemKeys] = useState(true) + const configMutationSeqRef = useRef(0) const fetchStatus = useCallback(async () => { setCheckState('loading') @@ -73,7 +74,11 @@ export function ComputerUseSettings() { } }, []) - const applyConfig = useCallback((configResult: Awaited>) => { + const applyConfig = useCallback(( + configResult: Awaited>, + requestSeq = configMutationSeqRef.current, + ) => { + if (requestSeq !== configMutationSeqRef.current) return setComputerUseEnabled(configResult.enabled) setAuthorizedApps(configResult.authorizedApps) setAuthorizedBundleIds(new Set(configResult.authorizedApps.map(a => a.bundleId))) @@ -82,14 +87,16 @@ export function ComputerUseSettings() { }, []) const fetchConfig = useCallback(async () => { + const requestSeq = configMutationSeqRef.current try { - applyConfig(await computerUseApi.getAuthorizedApps()) + applyConfig(await computerUseApi.getAuthorizedApps(), requestSeq) } catch { // API not ready } }, [applyConfig]) const fetchApps = useCallback(async () => { + const requestSeq = configMutationSeqRef.current setAppsLoading(true) try { const [appsResult, configResult] = await Promise.all([ @@ -97,7 +104,7 @@ export function ComputerUseSettings() { computerUseApi.getAuthorizedApps(), ]) setInstalledApps(appsResult.apps) - applyConfig(configResult) + applyConfig(configResult, requestSeq) } catch { // API not ready } finally { @@ -132,6 +139,7 @@ export function ComputerUseSettings() { } const toggleApp = (app: InstalledApp) => { + configMutationSeqRef.current += 1 const newSet = new Set(authorizedBundleIds) let newAuthorized = [...authorizedApps] if (newSet.has(app.bundleId)) { @@ -159,6 +167,7 @@ export function ComputerUseSettings() { } const toggleFlag = (flag: 'clipboard' | 'systemKeys', value: boolean) => { + configMutationSeqRef.current += 1 if (flag === 'clipboard') setClipboardAccess(value) else setSystemKeys(value) @@ -173,6 +182,7 @@ export function ComputerUseSettings() { } const toggleComputerUseEnabled = (value: boolean) => { + configMutationSeqRef.current += 1 setComputerUseEnabled(value) computerUseApi.setAuthorizedApps({ enabled: value }).then(() => { setAppsSaved(true)