Repository URL to install this package:
|
Version:
0.7.15 ▾
|
import React, { useEffect, useRef } from 'react'
export interface VoiceNotification {
id: string
type: 'tool_called' | 'tool_result' | 'tool_approval' | 'mcp_approval'
tool: string
input?: string
output?: string
call_id?: string
// Hosted-MCP approvals are keyed by request_id (minted by the model) and
// identify the MCP server via server_label. They run in parallel to the
// function-tool approval flow and have no "always approve" affordance.
request_id?: string
server_label?: string
metadata?: any
timestamp: number
}
interface Props {
notifications: VoiceNotification[]
onApprove?: (callId: string) => void
onReject?: (callId: string) => void
onMcpApprove?: (requestId: string) => void
onMcpReject?: (requestId: string) => void
onDismiss?: (id: string) => void
}
function truncate(text: string | undefined, max: number): string {
if (!text) return ''
const s = text.length > 200 ? text.slice(0, 200) : text
const lines = s.split('\n')
if (lines.length > 3) return lines.slice(0, 3).join('\n') + '\n...'
return s.length >= max ? s.slice(0, max) + '...' : s
}
function ToolIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
)
}
function CheckIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0 text-green-400">
<polyline points="20 6 9 17 4 12" />
</svg>
)
}
function ShieldIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0 text-yellow-400">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
)
}
function NotificationCard({ n, onApprove, onReject, onMcpApprove, onMcpReject, onDismiss }: {
n: VoiceNotification
onApprove?: (id: string) => void
onReject?: (id: string) => void
onMcpApprove?: (requestId: string) => void
onMcpReject?: (requestId: string) => void
onDismiss?: (id: string) => void
}) {
const isApproval = n.type === 'tool_approval' || n.type === 'mcp_approval'
return (
<div
className="pointer-events-auto max-w-[320px] rounded-xl border border-white/10 px-4 py-3 transition-all duration-300 ease-out"
style={{
background: 'rgba(36, 36, 40, 0.75)',
backdropFilter: 'blur(40px) saturate(1.6)',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4), inset 0 0.5px 0 rgba(255, 255, 255, 0.06)',
}}
onClick={() => !isApproval && onDismiss?.(n.id)}
>
{/* Header */}
<div className="flex items-center gap-2 mb-1">
{n.type === 'tool_called' && <ToolIcon />}
{n.type === 'tool_result' && <CheckIcon />}
{n.type === 'tool_approval' && <ShieldIcon />}
{n.type === 'mcp_approval' && <ShieldIcon />}
<span className="text-xs font-medium text-white/90 truncate">
{n.type === 'tool_approval' ? 'Approve tool call?' : n.type === 'mcp_approval' ? 'Approve MCP tool call?' : n.tool}
</span>
</div>
{/* Body */}
{n.type === 'tool_called' && n.input && (
<div className="text-[11px] text-white/50 font-mono leading-snug mt-1 whitespace-pre-wrap break-all">
{truncate(n.input, 120)}
</div>
)}
{n.type === 'tool_result' && n.output && (
<div className="text-[11px] text-white/50 font-mono leading-snug mt-1 whitespace-pre-wrap break-all">
{truncate(n.output, 120)}
</div>
)}
{n.type === 'tool_approval' && (
<>
<div className="text-xs text-white/70 font-medium mt-0.5">{n.tool}</div>
{n.input && (
<div className="text-[11px] text-white/50 font-mono leading-snug mt-1 whitespace-pre-wrap break-all">
{truncate(n.input, 120)}
</div>
)}
<div className="flex gap-2 mt-3">
<button
onClick={(e) => { e.stopPropagation(); onApprove?.(n.call_id!) }}
className="flex-1 text-xs font-medium py-1.5 rounded-lg bg-green-500/20 text-green-400 hover:bg-green-500/30 border border-green-500/20 transition-colors"
>
Approve
</button>
<button
onClick={(e) => { e.stopPropagation(); onReject?.(n.call_id!) }}
className="flex-1 text-xs font-medium py-1.5 rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 border border-red-500/20 transition-colors"
>
Reject
</button>
</div>
</>
)}
{n.type === 'mcp_approval' && (
<>
<div className="text-xs text-white/70 font-medium mt-0.5">{n.tool}</div>
{n.server_label && (
<div className="text-[11px] text-white/40 mt-0.5">
Server: <span className="font-mono">{n.server_label}</span>
</div>
)}
{n.input && (
<div className="text-[11px] text-white/50 font-mono leading-snug mt-1 whitespace-pre-wrap break-all">
{truncate(n.input, 120)}
</div>
)}
<div className="flex gap-2 mt-3">
<button
onClick={(e) => { e.stopPropagation(); onMcpApprove?.(n.request_id!) }}
className="flex-1 text-xs font-medium py-1.5 rounded-lg bg-green-500/20 text-green-400 hover:bg-green-500/30 border border-green-500/20 transition-colors"
>
Approve
</button>
<button
onClick={(e) => { e.stopPropagation(); onMcpReject?.(n.request_id!) }}
className="flex-1 text-xs font-medium py-1.5 rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 border border-red-500/20 transition-colors"
>
Reject
</button>
</div>
</>
)}
</div>
)
}
export function VoiceNotificationCenter({ notifications, onApprove, onReject, onMcpApprove, onMcpReject, onDismiss }: Props) {
const timerRefs = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
// Auto-dismiss tool_result cards after 4 seconds
useEffect(() => {
const timers = timerRefs.current
for (const n of notifications) {
if (n.type === 'tool_result' && !timers.has(n.id)) {
const timer = setTimeout(() => {
onDismiss?.(n.id)
timers.delete(n.id)
}, 4000)
timers.set(n.id, timer)
}
}
// Clean up timers for removed notifications
for (const [id, timer] of timers) {
if (!notifications.find(n => n.id === id)) {
clearTimeout(timer)
timers.delete(id)
}
}
}, [notifications, onDismiss])
// Cleanup all timers on unmount
useEffect(() => {
const timers = timerRefs.current
return () => {
for (const timer of timers.values()) clearTimeout(timer)
timers.clear()
}
}, [])
if (notifications.length === 0) return null
const visible = notifications.slice(0, 5)
return (
<div className="absolute top-6 right-6 z-10 flex flex-col gap-2 pointer-events-none">
{visible.map((n, i) => (
<div
key={n.id}
className="animate-[slideInRight_0.3s_ease-out]"
style={{
opacity: i < 3 ? 1 : 0.5,
transform: i >= 3 ? `scale(0.95) translateY(${(i - 2) * 4}px)` : undefined,
transition: 'opacity 0.3s, transform 0.3s',
}}
>
<NotificationCard
n={n}
onApprove={onApprove}
onReject={onReject}
onMcpApprove={onMcpApprove}
onMcpReject={onMcpReject}
onDismiss={onDismiss}
/>
</div>
))}
</div>
)
}