/ src / components / tasks / task-card.tsx
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  }