fix(desktop): keep Computer Use toggles stable

Settings refreshes can complete after the user changes Computer Use authorization state. The page now ignores stale config reads once a local mutation has happened, so enablement and grant controls no longer visually roll back and require a second operation.

Constraint: Computer Use config is shared between the desktop settings page and runtime MCP exposure.
Rejected: Refetch after every save | still allows older in-flight reads to overwrite newer local intent.
Confidence: high
Scope-risk: narrow
Directive: Do not apply async settings snapshots after a newer local authorization mutation without a freshness check.
Tested: cd desktop && bun run test -- src/pages/ComputerUseSettings.test.tsx
Tested: bun run check:desktop
Tested: bun run check:coverage
Not-tested: bun run verify is blocked by existing path-aware policy labels for broader dirty scope.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-07 21:33:33 +08:00
parent f01ba4eda6
commit 0a4f2ae6b6
2 changed files with 132 additions and 4 deletions

View File

@ -50,6 +50,14 @@ const enabledConfig = {
},
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(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<typeof enabledConfig>()
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(<ComputerUseSettings />)
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(<ComputerUseSettings />)
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,
},
})
})
})

View File

@ -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<ReturnType<typeof computerUseApi.getAuthorizedApps>>) => {
const applyConfig = useCallback((
configResult: Awaited<ReturnType<typeof computerUseApi.getAuthorizedApps>>,
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)