mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
f01ba4eda6
commit
0a4f2ae6b6
@ -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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user