Files
aiturk-hermes-ide/apps/desktop/src/app/settings/computer-use-panel.tsx
T

241 lines
8.1 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { getActionStatus, getComputerUseStatus, grantComputerUsePermissions } from '@/hermes'
import { AlertTriangle, Check, ExternalLink, Loader2, RefreshCw, X } from '@/lib/icons'
import { upsertDesktopActionTask } from '@/store/activity'
import { notify, notifyError } from '@/store/notifications'
import type { ComputerUseStatus } from '@/types/hermes'
import { Pill } from './primitives'
interface ComputerUsePanelProps {
/** Re-read the parent toolset list after a permission/install change so the
* "Configured / Needs keys" pill stays in sync. */
onConfiguredChange?: () => void
}
// Per-OS one-liner shown when there's no TCC grant flow (Windows/Linux). macOS
// drives the permission rows instead, so it has no entry here.
const PLATFORM_NOTE: Record<string, string> = {
linux: 'Drives your desktop via the X11/XWayland accessibility stack — no permission prompt.',
win32: 'First run may trigger a Windows SmartScreen prompt for the cua-driver UIAccess worker — allow it.'
}
function tone(granted: boolean | null) {
return granted === true ? 'primary' : 'muted'
}
function GrantIcon({ granted }: { granted: boolean | null }) {
const Icon = granted === true ? Check : granted === false ? X : AlertTriangle
return <Icon className="size-3" />
}
function PermissionRow({ granted, label, hint }: { granted: boolean | null; label: string; hint: string }) {
return (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-background/55 p-2.5">
<div className="min-w-0">
<span className="text-sm font-medium">{label}</span>
<p className="mt-0.5 text-[0.7rem] text-muted-foreground">{hint}</p>
</div>
<Pill tone={tone(granted)}>
<GrantIcon granted={granted} />
{granted === true ? 'Granted' : granted === false ? 'Not granted' : 'Unknown'}
</Pill>
</div>
)
}
/**
* Cross-platform Computer Use preflight card.
*
* cua-driver runs on macOS, Windows, and Linux, but readiness differs: macOS
* needs two TCC grants (Accessibility + Screen Recording) that attach to
* cua-driver's own `com.trycua.driver` identity — not Hermes — and are
* requested via `cua-driver permissions grant` (dialog attributed to
* CuaDriver). Windows/Linux have no TCC toggles, so readiness is driver health
* from `cua-driver doctor`. The backend folds both into one `ready` signal.
*
* Binary install/upgrade stays in the cua-driver provider's post-setup runner
* below this card (the generic ToolsetConfigPanel).
*/
export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps) {
const [status, setStatus] = useState<ComputerUseStatus | null>(null)
const [loading, setLoading] = useState(true)
const [granting, setGranting] = useState(false)
const activeRef = useRef(false)
const refresh = useCallback(async () => {
try {
setStatus(await getComputerUseStatus())
} catch (err) {
notifyError(err, 'Could not read Computer Use status')
} finally {
setLoading(false)
}
}, [])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
activeRef.current = true
void refresh()
return () => void (activeRef.current = false)
}, [refresh])
const grant = useCallback(async () => {
setGranting(true)
try {
const started = await grantComputerUsePermissions()
if (!started.ok) {
notifyError(new Error('spawn failed'), 'Could not request permissions')
return
}
notify({
kind: 'info',
title: 'Approve in System Settings',
message: 'macOS will show a permission dialog attributed to CuaDriver. Approve it, then return here.'
})
// The driver waits for the user to flip the switch — poll until it exits.
for (let attempt = 0; attempt < 150 && activeRef.current; attempt += 1) {
await new Promise(resolve => window.setTimeout(resolve, 1500))
if (!activeRef.current) {
break
}
const polled = await getActionStatus(started.name, 200)
upsertDesktopActionTask(polled)
if (!polled.running) {
break
}
}
if (activeRef.current) {
await refresh()
onConfiguredChange?.()
}
} catch (err) {
if (activeRef.current) {
notifyError(err, 'Could not request permissions')
}
} finally {
if (activeRef.current) {
setGranting(false)
}
}
}, [onConfiguredChange, refresh])
if (loading) {
return (
<div className="flex items-center gap-2 px-1 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
Checking Computer Use status…
</div>
)
}
if (!status) {
return null
}
if (!status.platform_supported) {
return (
<p className="px-1 text-xs text-muted-foreground">
Computer Use isn&apos;t supported on this platform ({status.platform}).
</p>
)
}
if (!status.installed) {
return (
<p className="px-1 text-xs text-muted-foreground">
Install the cua-driver backend below to drive this machine.
{status.can_grant && ' Then grant Accessibility and Screen Recording here.'}
</p>
)
}
const failingChecks = status.checks.filter(c => c.status !== 'ok')
return (
<div className="grid gap-2">
<div className="flex flex-wrap items-center justify-between gap-2 px-1">
<div className="min-w-0">
{status.can_grant ? (
<p className="text-[0.72rem] text-muted-foreground">
Grants attach to CuaDriver&apos;s own identity (com.trycua.driver), not Hermes — so the dialog is
attributed to the process that drives your Mac.
</p>
) : (
<p className="text-[0.72rem] text-muted-foreground">{PLATFORM_NOTE[status.platform] ?? ''}</p>
)}
{status.version && <p className="text-[0.68rem] text-muted-foreground/80">{status.version}</p>}
</div>
<Button onClick={() => void refresh()} size="sm" variant="text">
<RefreshCw className="size-3.5" />
Recheck
</Button>
</div>
{status.can_grant ? (
<>
<PermissionRow
granted={status.accessibility}
hint="Lets cua-driver post clicks, keystrokes, and read the accessibility tree."
label="Accessibility"
/>
<PermissionRow
granted={status.screen_recording}
hint="Lets cua-driver capture screenshots of app windows."
label="Screen Recording"
/>
</>
) : (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-background/55 p-2.5">
<span className="text-sm font-medium">Driver health</span>
<Pill tone={tone(status.ready)}>
<GrantIcon granted={status.ready} />
{status.ready === true ? 'Ready' : status.ready === false ? 'Not ready' : 'Unknown'}
</Pill>
</div>
)}
{failingChecks.map(c => (
<p className="px-1 text-[0.7rem] text-muted-foreground" key={c.label}>
<AlertTriangle className="mr-1 inline size-3" />
{c.label}: {c.message}
</p>
))}
{status.error && (
<p className="px-1 text-[0.7rem] text-muted-foreground">
<AlertTriangle className="mr-1 inline size-3" />
{status.error}
</p>
)}
{status.ready ? (
<div className="flex items-center gap-1.5 px-1 text-xs text-muted-foreground">
<Check className="size-3.5" />
Computer Use is ready. Ask the agent to capture an app and click around.
</div>
) : (
status.can_grant && (
<Button disabled={granting} onClick={() => void grant()} size="sm">
{granting ? <Loader2 className="size-3.5 animate-spin" /> : <ExternalLink className="size-3.5" />}
{granting ? 'Waiting for approval…' : 'Grant permissions'}
</Button>
)
)}
</div>
)
}