activity-moment.tsx
1 'use client' 2 3 import { useEffect, useState } from 'react' 4 5 const NOTABLE_TOOLS: Record<string, { label: string; color: string; icon: 'brain' | 'clipboard' | 'delegate' | 'search' | 'message' }> = { 6 memory: { label: 'Committed to memory', color: '#A855F7', icon: 'brain' }, 7 memory_tool: { label: 'Committed to memory', color: '#A855F7', icon: 'brain' }, 8 manage_tasks: { label: 'Created a task', color: '#EC4899', icon: 'clipboard' }, 9 manage_schedules: { label: 'Scheduled something', color: '#EC4899', icon: 'clipboard' }, 10 schedule_wake: { label: 'Set a reminder', color: '#F59E0B', icon: 'clipboard' }, 11 manage_agents: { label: 'Created an agent', color: '#EC4899', icon: 'clipboard' }, 12 delegate_to_claude_code: { label: 'Delegated to Claude Code', color: '#38BDF8', icon: 'delegate' }, 13 delegate_to_codex_cli: { label: 'Delegated to Codex', color: '#38BDF8', icon: 'delegate' }, 14 delegate_to_opencode_cli: { label: 'Delegated to OpenCode', color: '#38BDF8', icon: 'delegate' }, 15 delegate_to_gemini_cli: { label: 'Delegated to Gemini CLI', color: '#38BDF8', icon: 'delegate' }, 16 delegate_to_cursor_cli: { label: 'Delegated to Cursor CLI', color: '#38BDF8', icon: 'delegate' }, 17 delegate_to_droid_cli: { label: 'Delegated to Factory Droid', color: '#38BDF8', icon: 'delegate' }, 18 delegate_to_qwen_code_cli: { label: 'Delegated to Qwen Code', color: '#38BDF8', icon: 'delegate' }, 19 delegate_to_agent: { label: 'Delegating task', color: '#6366F1', icon: 'delegate' }, 20 check_delegation_status: { label: 'Checking delegation', color: '#6366F1', icon: 'delegate' }, 21 web_search: { label: 'Searched the web', color: '#22C55E', icon: 'search' }, 22 connector_message_tool: { label: 'Sent a message', color: '#F97316', icon: 'message' }, 23 } 24 25 function extractSnippet(toolName: string, toolInput: string): string | null { 26 try { 27 const parsed = JSON.parse(toolInput) 28 if ((toolName === 'memory' || toolName === 'memory_tool') && parsed.title) return parsed.title 29 if ((toolName === 'memory' || toolName === 'memory_tool') && parsed.key) return parsed.key 30 if (toolName === 'manage_tasks' && parsed.title) return parsed.title 31 if (toolName === 'manage_schedules' && parsed.name) return parsed.name 32 if (toolName === 'schedule_wake' && parsed.message) return parsed.message 33 if (toolName === 'manage_agents' && parsed.name) return parsed.name 34 if (toolName === 'delegate_to_agent' && (parsed.agentName || parsed.agentId)) return parsed.agentName || parsed.agentId 35 if (toolName === 'check_delegation_status' && parsed.agentName) return parsed.agentName 36 if (toolName.startsWith('delegate_to_') && parsed.task) return parsed.task 37 if (toolName === 'web_search' && parsed.query) return parsed.query 38 if (toolName === 'connector_message_tool' && parsed.to) return parsed.to 39 } catch { /* ignore parse errors */ } 40 return null 41 } 42 43 function MomentIcon({ icon, color }: { icon: string; color: string }) { 44 switch (icon) { 45 case 'brain': 46 return ( 47 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round"> 48 <path d="M12 2a7 7 0 0 1 7 7c0 2.38-1.19 4.47-3 5.74V17a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 0 1 7-7z" /> 49 <line x1="10" y1="22" x2="14" y2="22" /> 50 </svg> 51 ) 52 case 'delegate': 53 return ( 54 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round"> 55 <path d="M7 17l9.2-9.2M17 17V7H7" /> 56 </svg> 57 ) 58 case 'search': 59 return ( 60 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round"> 61 <circle cx="11" cy="11" r="8" /> 62 <line x1="21" y1="21" x2="16.65" y2="16.65" /> 63 </svg> 64 ) 65 case 'message': 66 return ( 67 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round"> 68 <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /> 69 </svg> 70 ) 71 default: // clipboard 72 return ( 73 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round"> 74 <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" /> 75 <rect x="8" y="2" width="8" height="4" rx="1" ry="1" /> 76 </svg> 77 ) 78 } 79 } 80 81 interface Props { 82 toolName: string 83 toolInput: string 84 onDismiss: () => void 85 } 86 87 export function ActivityMoment({ toolName, toolInput, onDismiss }: Props) { 88 const config = NOTABLE_TOOLS[toolName] 89 const [phase, setPhase] = useState<'in' | 'out'>('in') 90 91 useEffect(() => { 92 const holdTimer = setTimeout(() => setPhase('out'), 2000) 93 const dismissTimer = setTimeout(onDismiss, 2500) 94 return () => { 95 clearTimeout(holdTimer) 96 clearTimeout(dismissTimer) 97 } 98 // eslint-disable-next-line react-hooks/exhaustive-deps 99 }, []) 100 101 if (!config) return null 102 103 const snippet = extractSnippet(toolName, toolInput) 104 105 return ( 106 <div 107 className="absolute bottom-full left-0 z-10 pointer-events-none mb-1.5" 108 style={{ 109 animation: phase === 'in' 110 ? 'activity-moment-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards' 111 : 'activity-moment-out 0.4s cubic-bezier(0.4, 0, 1, 1) forwards', 112 }} 113 > 114 <div 115 className="flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] shadow-lg whitespace-nowrap" 116 style={{ 117 background: 'var(--card)', 118 border: `1px solid ${config.color}40`, 119 }} 120 > 121 <MomentIcon icon={config.icon} color={config.color} /> 122 <span className="text-[10px] font-600" style={{ color: config.color }}> 123 {config.label} 124 </span> 125 {snippet && ( 126 <span className="text-[10px] text-text-3/60 max-w-[120px] truncate"> 127 {snippet} 128 </span> 129 )} 130 </div> 131 </div> 132 ) 133 } 134 135 export function isNotableTool(name: string): boolean { 136 return name in NOTABLE_TOOLS 137 } 138 139 const HEART_PATH = 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z' 140 141 export function HeartbeatMoment({ onDismiss }: { onDismiss: () => void }) { 142 const [phase, setPhase] = useState<'in' | 'out'>('in') 143 144 useEffect(() => { 145 const holdTimer = setTimeout(() => setPhase('out'), 2000) 146 const dismissTimer = setTimeout(onDismiss, 2500) 147 return () => { 148 clearTimeout(holdTimer) 149 clearTimeout(dismissTimer) 150 } 151 // eslint-disable-next-line react-hooks/exhaustive-deps 152 }, []) 153 154 return ( 155 <div 156 className="absolute bottom-full left-0 z-10 pointer-events-none mb-1.5" 157 style={{ 158 animation: phase === 'in' 159 ? 'activity-moment-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards' 160 : 'activity-moment-out 0.4s cubic-bezier(0.4, 0, 1, 1) forwards', 161 }} 162 > 163 <div 164 className="flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] shadow-lg whitespace-nowrap" 165 style={{ 166 background: 'var(--card)', 167 border: '1px solid rgba(34,197,94,0.3)', 168 }} 169 > 170 <svg width="11" height="11" viewBox="0 0 24 24" fill="#22c55e"> 171 <path d={HEART_PATH} /> 172 </svg> 173 <span className="text-[10px] font-600" style={{ color: '#22c55e' }}> 174 Heartbeat OK 175 </span> 176 </div> 177 </div> 178 ) 179 }