cc-haha/desktop/src/components/chat/ComputerUsePermissionModal.tsx
程序员阿江(Relakkes) 5cd6b5d07b Prevent desktop Computer Use from stalling on missing approvals and unstable text input
Desktop sessions were missing a visible request_access approval path and could
mis-detect their own app window as an unapproved frontmost target, which caused
Computer Use clicks to fail even after opening the intended app. On macOS, text
entry was also split across inconsistent clipboard and keystroke paths, making
Electron inputs unreliable for Chinese and short strings.

This change adds a desktop approval bridge over the existing session websocket,
renders a dedicated desktop approval modal, threads the real desktop bundle id
into the Computer Use executor, and switches macOS clipboard typing onto the
native pasteboard plus system paste shortcut path. It also makes tool error
results expandable in the desktop chat UI so frontmost-gate failures are fully
visible during debugging.

Constraint: Desktop sessions run the CLI over the SDK websocket path, so Ink tool JSX dialogs are not visible there
Constraint: macOS IME and Electron text inputs are unreliable with pyautogui.write and generic hotkey synthesis
Rejected: Reuse CLI setToolJSX dialogs in desktop mode | no transport for mid-call Ink UI over the SDK bridge
Rejected: Keep shell pbcopy/pbpaste for clipboard typing | inconsistent with NSPasteboard path and less reliable for Chinese text
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Keep desktop Computer Use approvals and macOS text-entry behavior on a single bridge/path; avoid reintroducing separate CLI-only and desktop-only codepaths for the same action
Tested: python3 -m unittest runtime/test_helpers.py
Tested: bun test src/utils/computerUse/permissions.test.ts src/server/__tests__/conversation-service.test.ts
Tested: cd desktop && bun run test ComputerUsePermissionModal chatStore
Tested: cd desktop && bun run test chatBlocks
Tested: cd desktop && bun run lint
Not-tested: End-to-end manual Computer Use interaction against a live Electron target app on macOS
2026-04-20 12:12:02 +08:00

312 lines
9.6 KiB
TypeScript

import { useMemo, useState } from 'react'
import { useTranslation } from '../../i18n'
import { computerUseApi } from '../../api/computerUse'
import { useChatStore } from '../../stores/chatStore'
import type {
ComputerUsePermissionRequest,
ComputerUsePermissionResponse,
} from '../../types/chat'
import { Button } from '../shared/Button'
import { Modal } from '../shared/Modal'
type Props = {
sessionId: string
request: ComputerUsePermissionRequest | null
}
const DEFAULT_GRANT_FLAGS = {
clipboardRead: false,
clipboardWrite: false,
systemKeyCombos: false,
} as const
function denyAllResponse(): ComputerUsePermissionResponse {
return {
granted: [],
denied: [],
flags: { ...DEFAULT_GRANT_FLAGS },
userConsented: false,
}
}
function buildAllowResponse(
request: ComputerUsePermissionRequest,
): ComputerUsePermissionResponse {
const now = Date.now()
const granted = request.apps.flatMap((app) => {
if (!app.resolved || app.alreadyGranted) return []
return [{
bundleId: app.resolved.bundleId,
displayName: app.resolved.displayName,
grantedAt: now,
tier: app.proposedTier,
}]
})
const denied = request.apps.flatMap((app) => {
if (app.resolved) return []
return [{
bundleId: app.requestedName,
reason: 'not_installed' as const,
}]
})
const flags = {
...DEFAULT_GRANT_FLAGS,
...Object.fromEntries(
Object.entries(request.requestedFlags).filter(([, value]) => value === true),
),
}
return {
granted,
denied,
flags,
userConsented: true,
}
}
export function ComputerUsePermissionModal({ sessionId, request }: Props) {
const t = useTranslation()
const respondToComputerUsePermission = useChatStore(
(s) => s.respondToComputerUsePermission,
)
const [openingPane, setOpeningPane] = useState<
'Privacy_Accessibility' | 'Privacy_ScreenCapture' | null
>(null)
const requestedFlags = useMemo(
() =>
request
? Object.entries(request.requestedFlags)
.filter(([, enabled]) => enabled)
.map(([flag]) => flag)
: [],
[request],
)
if (!request) return null
const handleDeny = () => {
respondToComputerUsePermission(
sessionId,
request.requestId,
denyAllResponse(),
)
}
const handleAllow = () => {
respondToComputerUsePermission(
sessionId,
request.requestId,
buildAllowResponse(request),
)
}
const openSettings = async (
pane: 'Privacy_Accessibility' | 'Privacy_ScreenCapture',
) => {
setOpeningPane(pane)
try {
await computerUseApi.openSettings(pane)
} finally {
setOpeningPane(null)
}
}
const tccState = request.tccState
return (
<Modal
open
onClose={handleDeny}
title={
tccState
? t('computerUseApproval.titleTcc')
: t('computerUseApproval.titleApps')
}
width={640}
footer={
tccState ? (
<Button variant="ghost" onClick={handleDeny}>
{t('computerUseApproval.deny')}
</Button>
) : (
<>
<Button variant="ghost" onClick={handleDeny}>
{t('computerUseApproval.deny')}
</Button>
<Button variant="primary" onClick={handleAllow}>
{t('computerUseApproval.allow')}
</Button>
</>
)
}
>
{tccState ? (
<div className="space-y-4">
<p className="text-sm text-[var(--color-text-secondary)]">
{t('computerUseApproval.tccHint')}
</p>
<div className="space-y-3">
<PermissionRow
label={t('computerUseApproval.accessibility')}
granted={tccState.accessibility}
actionLabel={t('computerUseApproval.openAccessibility')}
actionLoading={openingPane === 'Privacy_Accessibility'}
onAction={() => openSettings('Privacy_Accessibility')}
/>
<PermissionRow
label={t('computerUseApproval.screenRecording')}
granted={tccState.screenRecording}
actionLabel={t('computerUseApproval.openScreenRecording')}
actionLoading={openingPane === 'Privacy_ScreenCapture'}
onAction={() => openSettings('Privacy_ScreenCapture')}
/>
</div>
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3 text-xs text-[var(--color-text-tertiary)]">
{t('computerUseApproval.tryAgainHint')}
</div>
<div className="flex justify-end">
<Button variant="secondary" onClick={handleDeny}>
{t('computerUseApproval.tryAgain')}
</Button>
</div>
</div>
) : (
<div className="space-y-4">
{request.reason ? (
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-[var(--color-text-tertiary)]">
{t('computerUseApproval.reason')}
</div>
<div className="mt-1 text-sm text-[var(--color-text-primary)]">
{request.reason}
</div>
</div>
) : null}
<div className="space-y-2">
{request.apps.map((app) => {
const resolved = app.resolved
return (
<div
key={resolved?.bundleId ?? app.requestedName}
className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-[var(--color-text-primary)]">
{resolved?.displayName ?? app.requestedName}
</div>
<div className="mt-1 text-xs text-[var(--color-text-tertiary)]">
{resolved?.bundleId ?? t('computerUseApproval.notInstalled')}
</div>
</div>
<span className="rounded-full bg-[var(--color-surface-container)] px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-[var(--color-text-secondary)]">
{app.proposedTier}
</span>
</div>
{!resolved ? (
<p className="mt-2 text-xs text-[var(--color-error)]">
{t('computerUseApproval.notInstalled')}
</p>
) : null}
{app.alreadyGranted ? (
<p className="mt-2 text-xs text-[var(--color-success)]">
{t('computerUseApproval.alreadyGranted')}
</p>
) : null}
{app.isSentinel ? (
<p className="mt-2 text-xs text-[var(--color-warning)]">
{t('computerUseApproval.sensitiveApp')}
</p>
) : null}
</div>
)
})}
</div>
{requestedFlags.length > 0 ? (
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-[var(--color-text-tertiary)]">
{t('computerUseApproval.alsoRequested')}
</div>
<div className="mt-2 flex flex-wrap gap-2">
{requestedFlags.map((flag) => (
<span
key={flag}
className="rounded-full bg-[var(--color-surface-container)] px-2 py-1 text-[11px] text-[var(--color-text-secondary)]"
>
{flag}
</span>
))}
</div>
</div>
) : null}
{request.willHide && request.willHide.length > 0 ? (
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3 text-sm text-[var(--color-text-secondary)]">
{request.autoUnhideEnabled
? t('computerUseApproval.hideWhileWorkingRestore', {
count: request.willHide.length,
})
: t('computerUseApproval.hideWhileWorking', {
count: request.willHide.length,
})}
</div>
) : null}
</div>
)}
</Modal>
)
}
function PermissionRow({
label,
granted,
actionLabel,
actionLoading,
onAction,
}: {
label: string
granted: boolean
actionLabel: string
actionLoading: boolean
onAction: () => void
}) {
const t = useTranslation()
return (
<div className="flex items-center justify-between gap-4 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3">
<div>
<div className="text-sm font-semibold text-[var(--color-text-primary)]">
{label}
</div>
<div className="mt-1 text-xs text-[var(--color-text-tertiary)]">
{granted
? t('computerUseApproval.granted')
: t('computerUseApproval.notGranted')}
</div>
</div>
{!granted ? (
<Button
variant="secondary"
size="sm"
loading={actionLoading}
onClick={onAction}
>
{actionLabel}
</Button>
) : null}
</div>
)
}