task-card.tsx
1 'use client' 2 3 import { useState, useCallback, useEffect } from 'react' 4 import { useAppStore } from '@/stores/use-app-store' 5 import { useNavigate } from '@/lib/app/navigation' 6 import { useUpdateTaskMutation } from '@/features/tasks/queries' 7 import { ConfirmDialog } from '@/components/shared/confirm-dialog' 8 import { AgentAvatar } from '@/components/agents/agent-avatar' 9 import { timeAgo } from '@/lib/time-format' 10 import { InfoChip } from '@/components/ui/info-chip' 11 import type { Agent, BoardTask, Project } from '@/types' 12 13 interface TaskCardProps { 14 task: BoardTask 15 agents: Record<string, Agent> 16 projects: Record<string, Project> 17 tasksById: Record<string, BoardTask> 18 selectionMode?: boolean 19 selected?: boolean 20 onToggleSelect?: (id: string) => void 21 index?: number 22 } 23 24 export function TaskCard({ 25 task, 26 agents, 27 projects, 28 tasksById, 29 selectionMode, 30 selected, 31 onToggleSelect, 32 index = 0, 33 }: TaskCardProps) { 34 const setEditingTaskId = useAppStore((s) => s.setEditingTaskId) 35 const setTaskSheetOpen = useAppStore((s) => s.setTaskSheetOpen) 36 const setCurrentAgent = useAppStore((s) => s.setCurrentAgent) 37 const navigateTo = useNavigate() 38 const updateTaskMutation = useUpdateTaskMutation() 39 const [dragging, setDragging] = useState(false) 40 const [confirmArchive, setConfirmArchive] = useState(false) 41 const [allowDrag, setAllowDrag] = useState(false) 42 const [now, setNow] = useState(() => Date.now()) 43 44 useEffect(() => { 45 if (typeof window === 'undefined') return 46 const frame = window.requestAnimationFrame(() => { 47 const isCoarsePointer = typeof window.matchMedia === 'function' 48 ? window.matchMedia('(pointer: coarse)').matches 49 : 'ontouchstart' in window 50 setAllowDrag(!isCoarsePointer) 51 }) 52 const timer = window.setInterval(() => setNow(Date.now()), 60_000) 53 return () => { 54 window.cancelAnimationFrame(frame) 55 window.clearInterval(timer) 56 } 57 }, []) 58 59 const agent = agents[task.agentId] 60 const project = task.projectId ? projects[task.projectId] : null 61 const creatorAgent = task.createdByAgentId ? agents[task.createdByAgentId] : null 62 const delegatorAgent = task.delegatedByAgentId ? agents[task.delegatedByAgentId] : null 63 const githubSource = task.externalSource?.source === 'github' ? task.externalSource : null 64 65 const priorityConfig = { 66 critical: { label: 'Critical', cls: 'bg-red-500/10 text-red-400' }, 67 high: { label: 'High', cls: 'bg-orange-500/10 text-orange-400' }, 68 medium: { label: 'Med', cls: 'bg-amber-500/10 text-amber-400' }, 69 low: { label: 'Low', cls: 'bg-sky-500/10 text-sky-400' }, 70 } as const 71 const prio = task.priority && priorityConfig[task.priority] 72 73 const isBlocked = Array.isArray(task.blockedBy) && task.blockedBy.length > 0 74 const isOverdue = task.dueAt 75 && task.dueAt < now 76 && task.status !== 'completed' 77 && task.status !== 'failed' 78 && task.status !== 'cancelled' 79 && task.status !== 'archived' 80 const borderColor = isBlocked ? 'border-l-rose-500' 81 : task.status === 'running' ? 'border-l-emerald-500' 82 : task.status === 'failed' ? 'border-l-red-500' 83 : task.status === 'cancelled' ? 'border-l-white/15' 84 : 'border-l-transparent' 85 86 const handleQueue = async (e: React.MouseEvent) => { 87 e.stopPropagation() 88 await updateTaskMutation.mutateAsync({ id: task.id, patch: { status: 'queued' } }) 89 } 90 91 const handleArchive = async (e: React.MouseEvent) => { 92 e.stopPropagation() 93 await updateTaskMutation.mutateAsync({ id: task.id, patch: { status: 'archived' } }) 94 } 95 96 const handleViewSession = (e: React.MouseEvent) => { 97 e.stopPropagation() 98 if (task.agentId) { 99 void setCurrentAgent(task.agentId) 100 navigateTo('agents') 101 } 102 } 103 104 const handleDragStart = useCallback((e: React.DragEvent) => { 105 e.dataTransfer.setData('text/plain', task.id) 106 e.dataTransfer.effectAllowed = 'move' 107 setDragging(true) 108 }, [task.id]) 109 110 const handleDragEnd = useCallback(() => { 111 setDragging(false) 112 }, []) 113 114 return ( 115 <div 116 draggable={!selectionMode && allowDrag} 117 onDragStart={selectionMode || !allowDrag ? undefined : handleDragStart} 118 onDragEnd={selectionMode || !allowDrag ? undefined : handleDragEnd} 119 onClick={(e) => { 120 if (selectionMode && onToggleSelect) { 121 e.stopPropagation() 122 onToggleSelect(task.id) 123 } else { 124 setEditingTaskId(task.id) 125 setTaskSheetOpen(true) 126 } 127 }} 128 className={`py-3 px-4 rounded-[14px] border border-l-[3px] ${borderColor} bg-surface hover:bg-surface-2 transition-all group 129 ${selectionMode || !allowDrag ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'} touch-pan-y 130 ${dragging ? 'opacity-40 scale-[0.97]' : ''} 131 ${selected ? 'border-accent-bright/40 bg-accent-bright/[0.04] ring-1 ring-accent-bright/20 shadow-lg' : 'border-white/[0.06] hover:border-white/[0.12] hover:scale-[1.01] hover:shadow-md'}`} 132 style={{ 133 animation: 'spring-in 0.5s var(--ease-spring) both', 134 animationDelay: `${Math.min(index * 0.05, 0.4)}s` 135 }} 136 > 137 <div className="flex items-start gap-3 mb-3"> 138 {/* Selection checkbox */} 139 {(selectionMode || selected) && ( 140 <button 141 onClick={(e) => { e.stopPropagation(); onToggleSelect?.(task.id) }} 142 className={`w-5 h-5 rounded-[6px] border-2 flex items-center justify-center shrink-0 mt-0.5 cursor-pointer transition-all 143 ${selected 144 ? 'bg-accent-bright border-accent-bright' 145 : 'bg-transparent border-white/[0.2] hover:border-white/[0.4]'}`} 146 style={{ padding: 0, fontFamily: 'inherit' }} 147 > 148 {selected && ( 149 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round"><path d="M20 6L9 17l-5-5" /></svg> 150 )} 151 </button> 152 )} 153 {isBlocked && ( 154 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-rose-400 shrink-0 mt-0.5"> 155 <title>{`Blocked by: ${(task.blockedBy || []).map((bid) => tasksById[bid]?.title || bid).join(', ')}`}</title> 156 <rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /> 157 </svg> 158 )} 159 <h4 className="flex-1 text-[14px] font-600 text-text leading-[1.4] line-clamp-2">{task.title}</h4> 160 {prio && ( 161 <span className={`px-1.5 py-0.5 rounded-[5px] text-[10px] font-600 shrink-0 ${prio.cls}`}> 162 {prio.label} 163 </span> 164 )} 165 {isBlocked && ( 166 <span className="px-1.5 py-0.5 rounded-[5px] bg-rose-500/10 text-rose-400 text-[10px] font-600 shrink-0"> 167 {task.blockedBy?.length} 168 </span> 169 )} 170 </div> 171 172 {task.description && ( 173 <p className="text-[12px] text-text-3 line-clamp-2 mb-3">{task.description}</p> 174 )} 175 176 {task.objective && ( 177 <div className="mb-3 rounded-[12px] border border-white/[0.06] bg-white/[0.02] px-3 py-2"> 178 <span className="text-[10px] font-700 uppercase tracking-[0.08em] text-text-3/68">Objective</span> 179 <div className="text-[12px] font-600 text-text line-clamp-2 mt-1">{task.objective}</div> 180 </div> 181 )} 182 183 {/* Tags */} 184 {task.tags && task.tags.length > 0 && ( 185 <div className="flex flex-wrap gap-1 mb-3"> 186 {task.tags.map((tag) => ( 187 <span key={tag} className="px-1.5 py-0.5 rounded-[5px] bg-indigo-500/10 text-indigo-400 text-[10px] font-600"> 188 {tag} 189 </span> 190 ))} 191 </div> 192 )} 193 194 {/* Due date */} 195 {task.dueAt && ( 196 <p className={`text-[11px] mb-3 font-600 ${isOverdue ? 'text-red-400' : 'text-text-3/60'}`}> 197 Due {new Date(task.dueAt).toLocaleDateString([], { month: 'short', day: 'numeric' })} 198 {isOverdue && ' (overdue)'} 199 </p> 200 )} 201 202 {task.images && task.images.length > 0 && ( 203 <div className="flex gap-1.5 mb-3 overflow-x-auto"> 204 {task.images.slice(0, 3).map((url, i) => ( 205 <img key={i} src={url} alt="" className="w-12 h-12 rounded-[8px] object-cover border border-white/[0.06] shrink-0" /> 206 ))} 207 {task.images.length > 3 && ( 208 <span className="w-12 h-12 rounded-[8px] bg-surface-2 border border-white/[0.06] flex items-center justify-center text-[11px] text-text-3 font-600 shrink-0"> 209 +{task.images.length - 3} 210 </span> 211 )} 212 </div> 213 )} 214 215 {/* Schedule run stats */} 216 {task.sourceType === 'schedule' && ( 217 <div className="flex items-center gap-2 mb-3 text-[11px] text-text-3"> 218 <span className="px-1.5 py-0.5 rounded-[5px] bg-purple-500/10 text-purple-400 font-600"> 219 Run #{task.runNumber || 1} 220 </span> 221 {(task.totalRuns ?? 0) > 0 && ( 222 <> 223 <span title="Total runs">{task.totalRuns} runs</span> 224 {(task.totalCompleted ?? 0) > 0 && ( 225 <span className="text-green-400" title="Completed">{task.totalCompleted} ok</span> 226 )} 227 {(task.totalFailed ?? 0) > 0 && ( 228 <span className="text-red-400" title="Failed">{task.totalFailed} fail</span> 229 )} 230 </> 231 )} 232 </div> 233 )} 234 235 {(creatorAgent || delegatorAgent || task.sourceType === 'schedule' || githubSource) && ( 236 <div className="flex flex-wrap gap-1.5 mb-3"> 237 {delegatorAgent && ( 238 <InfoChip tone="warning"> 239 <AgentAvatar seed={delegatorAgent.avatarSeed} avatarUrl={delegatorAgent.avatarUrl} name={delegatorAgent.name} size={14} /> 240 Delegated by {delegatorAgent.name} 241 </InfoChip> 242 )} 243 {creatorAgent && creatorAgent.id !== delegatorAgent?.id && ( 244 <InfoChip tone="neutral"> 245 <AgentAvatar seed={creatorAgent.avatarSeed} avatarUrl={creatorAgent.avatarUrl} name={creatorAgent.name} size={14} /> 246 Created by {creatorAgent.name} 247 </InfoChip> 248 )} 249 {task.sourceType === 'schedule' && ( 250 <InfoChip tone="purple"> 251 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 252 <circle cx="12" cy="12" r="8" /> 253 <path d="M12 8v4l3 2" /> 254 </svg> 255 {task.sourceScheduleName ? `Scheduled via ${task.sourceScheduleName}` : 'Scheduled task'} 256 </InfoChip> 257 )} 258 {githubSource && ( 259 githubSource.url ? ( 260 <a 261 href={githubSource.url} 262 target="_blank" 263 rel="noreferrer" 264 onClick={(e) => e.stopPropagation()} 265 className="inline-flex items-center gap-1.5 rounded-[7px] bg-sky-500/10 px-2 py-1 text-[10px] font-600 text-sky-300 hover:bg-sky-500/15" 266 > 267 <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" className="shrink-0"> 268 <path d="M12 .5C5.65.5.5 5.65.5 12A11.5 11.5 0 0 0 8.36 22.9c.57.1.78-.25.78-.55 0-.27-.01-1.17-.02-2.13-3.2.69-3.88-1.36-3.88-1.36-.52-1.33-1.28-1.68-1.28-1.68-1.05-.72.08-.71.08-.71 1.16.08 1.77 1.19 1.77 1.19 1.03 1.77 2.7 1.26 3.36.96.1-.75.4-1.26.73-1.55-2.55-.29-5.24-1.28-5.24-5.68 0-1.25.45-2.27 1.18-3.07-.12-.29-.51-1.45.11-3.02 0 0 .96-.31 3.15 1.17a10.9 10.9 0 0 1 5.73 0c2.18-1.48 3.14-1.17 3.14-1.17.63 1.57.24 2.73.12 3.02.74.8 1.18 1.82 1.18 3.07 0 4.41-2.7 5.38-5.27 5.66.42.36.78 1.06.78 2.14 0 1.55-.01 2.79-.01 3.17 0 .31.2.66.79.55A11.5 11.5 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5Z" /> 269 </svg> 270 {githubSource.repo ? `${githubSource.repo}#${githubSource.number}` : `GitHub #${githubSource.number ?? githubSource.id}`} 271 </a> 272 ) : ( 273 <InfoChip tone="info"> 274 GitHub {githubSource.repo ? `${githubSource.repo}#${githubSource.number}` : `#${githubSource.number ?? githubSource.id}`} 275 </InfoChip> 276 ) 277 )} 278 </div> 279 )} 280 281 <div className="flex items-center gap-2 flex-wrap"> 282 {agent && ( 283 <span className="px-2 py-1 rounded-[6px] bg-accent-soft text-accent-bright text-[11px] font-600"> 284 {agent.name} 285 </span> 286 )} 287 {project && ( 288 <span className="inline-flex items-center gap-1 px-2 py-1 rounded-[6px] bg-white/[0.04] text-text-2 text-[11px] font-600"> 289 <span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: project.color || '#6366F1' }} /> 290 {project.name} 291 </span> 292 )} 293 <span className="text-[11px] text-text-3">{timeAgo(task.updatedAt, now)}</span> 294 {task.comments && task.comments.length > 0 && ( 295 <span className="flex items-center gap-1 text-[11px] text-text-3"> 296 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-text-3/60"> 297 <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /> 298 </svg> 299 {task.comments.length} 300 </span> 301 )} 302 {Array.isArray(task.blocks) && task.blocks.length > 0 && ( 303 <span 304 className="px-1.5 py-0.5 rounded-[5px] bg-amber-500/10 text-amber-400 text-[10px] font-600" 305 title={`Blocks: ${task.blocks.map((bid) => tasksById[bid]?.title || bid).join(', ')}`} 306 > 307 blocks {task.blocks.length} 308 </span> 309 )} 310 311 {task.status === 'backlog' && ( 312 <button 313 onClick={handleQueue} 314 className="ml-auto px-2.5 py-1 rounded-[8px] text-[11px] font-600 bg-amber-500/10 text-amber-400 border-none cursor-pointer 315 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-amber-500/20" 316 style={{ fontFamily: 'inherit' }} 317 > 318 Queue 319 </button> 320 )} 321 322 {task.sessionId && (task.status === 'running' || task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') && ( 323 <button 324 onClick={handleViewSession} 325 className="ml-auto px-2.5 py-1 rounded-[8px] text-[11px] font-600 bg-white/[0.06] text-text-2 border-none cursor-pointer 326 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-white/[0.1]" 327 style={{ fontFamily: 'inherit' }} 328 > 329 View 330 </button> 331 )} 332 333 {(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') && !task.sessionId && ( 334 <button 335 onClick={(e) => { e.stopPropagation(); setConfirmArchive(true) }} 336 aria-label="Archive task" 337 className="ml-auto px-2.5 py-1 rounded-[8px] text-[11px] font-600 bg-white/[0.04] text-text-3 border-none cursor-pointer 338 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-white/[0.08]" 339 style={{ fontFamily: 'inherit' }} 340 > 341 Archive 342 </button> 343 )} 344 </div> 345 346 {task.error && (() => { 347 const retryPending = 348 task.status !== 'failed' && 349 !task.deadLetteredAt && 350 (task.retryScheduledAt != null || /^Retry scheduled after failure/i.test(task.error)) 351 return ( 352 <p className={`mt-2 text-[11px] line-clamp-2 ${retryPending ? 'text-amber-400/80' : 'text-red-400/80'}`}> 353 {task.error} 354 </p> 355 ) 356 })()} 357 358 {/* Inline comments — show latest 2 */} 359 {task.comments && task.comments.length > 0 && ( 360 <div className="mt-3 pt-3 border-t border-white/[0.04] space-y-2"> 361 {task.comments.slice(-2).map((c) => ( 362 <div key={c.id} className="flex gap-2"> 363 <span className={`text-[11px] font-600 shrink-0 ${c.agentId ? 'text-accent-bright' : 'text-text-2'}`}> 364 {c.author}: 365 </span> 366 <p className="text-[11px] text-text-3 line-clamp-2 leading-[1.5]">{c.text}</p> 367 </div> 368 ))} 369 </div> 370 )} 371 <ConfirmDialog 372 open={confirmArchive} 373 title="Archive Task" 374 message={`Archive "${task.title}"? You can view archived tasks later.`} 375 confirmLabel="Archive" 376 onConfirm={() => { setConfirmArchive(false); handleArchive({ stopPropagation: () => {} } as React.MouseEvent) }} 377 onCancel={() => setConfirmArchive(false)} 378 /> 379 </div> 380 ) 381 }