/ src / components / chat / activity-moment.tsx
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  }