codev / desktop /src /components /chat /ComputerUsePermissionModal.tsx
chenbhao's picture
feat: desktop
1f21206
Raw
History Blame
9.84 kB
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>
)
}