HuggingClaw-MissionControl / src /components /panels /task-board-panel.tsx
nyk
feat: add awaiting_owner task status detection (#428)
1ec4113 unverified
Raw
History Blame Contribute Delete
103 kB
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback, useRef } from 'react'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useMissionControl } from '@/store'
import { useSmartPoll } from '@/lib/use-smart-poll'
import { createClientLogger } from '@/lib/client-logger'
import { useFocusTrap } from '@/lib/use-focus-trap'
import { AgentAvatar } from '@/components/ui/agent-avatar'
import { MarkdownRenderer } from '@/components/markdown-renderer'
import { Button } from '@/components/ui/button'
import { ProjectManagerModal } from '@/components/modals/project-manager-modal'
import { SessionMessage, shouldShowTimestamp, type SessionTranscriptMessage } from '@/components/chat/session-message'
const log = createClientLogger('TaskBoard')
interface Task {
id: number
title: string
description?: string
status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done' | 'awaiting_owner'
priority: 'low' | 'medium' | 'high' | 'critical' | 'urgent'
assigned_to?: string
created_by: string
created_at: number
updated_at: number
due_date?: number
estimated_hours?: number
actual_hours?: number
tags?: string[]
metadata?: any
aegisApproved?: boolean
project_id?: number
project_ticket_no?: number
project_name?: string
project_prefix?: string
ticket_ref?: string
github_issue_number?: number
github_repo?: string
github_branch?: string
github_pr_number?: number
github_pr_state?: string
}
interface Agent {
id: number
name: string
role: string
status: 'offline' | 'idle' | 'busy' | 'error'
taskStats?: {
total: number
assigned: number
in_progress: number
completed: number
}
}
interface Comment {
id: number
task_id: number
author: string
content: string
created_at: number
parent_id?: number
mentions?: string[]
replies?: Comment[]
}
interface Project {
id: number
name: string
slug: string
ticket_prefix: string
status: 'active' | 'archived'
}
interface MentionOption {
handle: string
recipient: string
type: 'user' | 'agent'
display: string
role?: string
}
const STATUS_COLUMN_KEYS = [
{ key: 'inbox', titleKey: 'colInbox', color: 'bg-secondary text-foreground' },
{ key: 'assigned', titleKey: 'colAssigned', color: 'bg-blue-500/20 text-blue-400' },
{ key: 'awaiting_owner', titleKey: 'colAwaitingOwner', color: 'bg-orange-500/20 text-orange-400' },
{ key: 'in_progress', titleKey: 'colInProgress', color: 'bg-yellow-500/20 text-yellow-400' },
{ key: 'review', titleKey: 'colReview', color: 'bg-purple-500/20 text-purple-400' },
{ key: 'quality_review', titleKey: 'colQualityReview', color: 'bg-indigo-500/20 text-indigo-400' },
{ key: 'done', titleKey: 'colDone', color: 'bg-green-500/20 text-green-400' },
]
const AWAITING_OWNER_KEYWORDS = [
'waiting for', 'waiting on', 'needs human', 'manual action',
'account creation', 'browser login', 'approval needed',
'owner action', 'human required', 'blocked on owner',
'awaiting owner', 'awaiting human', 'needs owner',
]
function detectAwaitingOwner(task: Task): boolean {
if (task.status === 'awaiting_owner') return true
if (task.status !== 'assigned' && task.status !== 'in_progress') return false
const text = `${task.title} ${task.description || ''}`.toLowerCase()
return AWAITING_OWNER_KEYWORDS.some(kw => text.includes(kw))
}
/** Build a human-readable label for a session key like "agent:nefes:telegram-group-123" */
function formatSessionLabel(s: { key: string; channel?: string; kind?: string; label?: string }): string {
if (s.label) return s.label
// Extract the identifier part after the last colon: "agent:name:main" → "main"
const parts = (s.key || '').split(':')
const identifier = parts.length > 2 ? parts.slice(2).join(':') : s.key
const channel = s.channel || ''
if (channel && identifier !== 'main') {
return `${channel} (${identifier})`
}
if (channel) return `${channel} (${s.kind || 'default'})`
return identifier || s.key
}
/** Fetch active gateway sessions for a given agent name. */
function useAgentSessions(agentName: string | undefined) {
const [sessions, setSessions] = useState<Array<{ key: string; id: string; channel?: string; kind?: string; label?: string; displayLabel: string }>>([])
useEffect(() => {
if (!agentName) { setSessions([]); return }
let cancelled = false
fetch('/api/sessions')
.then(r => r.json())
.then(data => {
if (cancelled) return
const all = (data.sessions || []) as Array<{ key: string; id: string; agent?: string; channel?: string; kind?: string; label?: string; active?: boolean }>
const filtered = all.filter(s =>
s.agent?.toLowerCase() === agentName.toLowerCase() ||
s.key?.toLowerCase().includes(agentName.toLowerCase())
)
setSessions(filtered.map(s => ({
key: s.key,
id: s.id,
channel: s.channel,
kind: s.kind,
label: s.label,
displayLabel: formatSessionLabel(s),
})))
})
.catch(() => { if (!cancelled) setSessions([]) })
return () => { cancelled = true }
}, [agentName])
return sessions
}
const priorityColors: Record<string, string> = {
low: 'border-l-green-500',
medium: 'border-l-yellow-500',
high: 'border-l-orange-500',
critical: 'border-l-red-500',
}
function useMentionTargets() {
const [mentionTargets, setMentionTargets] = useState<MentionOption[]>([])
useEffect(() => {
let cancelled = false
const run = async () => {
try {
const response = await fetch('/api/mentions?limit=200')
if (!response.ok) return
const data = await response.json()
if (!cancelled) setMentionTargets(data.mentions || [])
} catch {
// mention autocomplete is non-critical
}
}
run()
return () => { cancelled = true }
}, [])
return mentionTargets
}
function MentionTextarea({
id,
value,
onChange,
rows = 3,
placeholder,
className,
mentionTargets,
}: {
id?: string
value: string
onChange: (next: string) => void
rows?: number
placeholder?: string
className?: string
mentionTargets: MentionOption[]
}) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const [open, setOpen] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const [query, setQuery] = useState('')
const [range, setRange] = useState<{ start: number; end: number } | null>(null)
const [openUpwards, setOpenUpwards] = useState(false)
const filtered = mentionTargets
.filter((target) => {
if (!query) return true
const q = query.toLowerCase()
return target.handle.includes(q) || target.display.toLowerCase().includes(q)
})
.slice(0, 8)
const detectMentionQuery = (nextValue: string, caret: number) => {
const left = nextValue.slice(0, caret)
const match = left.match(/(?:^|[^\w.-])@([A-Za-z0-9._-]{0,63})$/)
if (!match) {
setOpen(false)
setQuery('')
setRange(null)
return
}
const matched = match[1] || ''
const start = caret - matched.length - 1
setQuery(matched)
setRange({ start, end: caret })
setActiveIndex(0)
setOpen(true)
}
const insertMention = (option: MentionOption) => {
if (!range) return
const next = `${value.slice(0, range.start)}@${option.handle} ${value.slice(range.end)}`
onChange(next)
setOpen(false)
setQuery('')
const cursor = range.start + option.handle.length + 2
requestAnimationFrame(() => {
const node = textareaRef.current
if (!node) return
node.focus()
node.setSelectionRange(cursor, cursor)
})
}
useEffect(() => {
if (!open) return
const node = textareaRef.current
if (!node) return
const rect = node.getBoundingClientRect()
const estimatedMenuHeight = Math.min(Math.max(filtered.length, 1) * 46 + 12, 224)
const availableBelow = window.innerHeight - rect.bottom
const availableAbove = rect.top
setOpenUpwards(availableBelow < estimatedMenuHeight && availableAbove > availableBelow)
}, [open, filtered.length])
return (
<div className="relative">
<textarea
id={id}
ref={textareaRef}
value={value}
onChange={(e) => {
const nextValue = e.target.value
onChange(nextValue)
detectMentionQuery(nextValue, e.target.selectionStart || 0)
}}
onClick={(e) => detectMentionQuery(value, (e.target as HTMLTextAreaElement).selectionStart || 0)}
onKeyUp={(e) => detectMentionQuery(value, (e.target as HTMLTextAreaElement).selectionStart || 0)}
onKeyDown={(e) => {
if (!open || filtered.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setActiveIndex((prev) => (prev + 1) % filtered.length)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setActiveIndex((prev) => (prev - 1 + filtered.length) % filtered.length)
return
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
insertMention(filtered[activeIndex])
return
}
if (e.key === 'Escape') {
setOpen(false)
}
}}
rows={rows}
placeholder={placeholder}
className={className}
/>
{open && filtered.length > 0 && (
<div className={`absolute z-[60] w-full bg-surface-1 border border-border rounded-md shadow-xl max-h-56 overflow-y-auto ${
openUpwards ? 'bottom-full mb-1' : 'mt-1'
}`}>
{filtered.map((option, index) => (
<button
key={`${option.type}-${option.handle}-${option.recipient}`}
type="button"
onMouseDown={(e) => {
e.preventDefault()
insertMention(option)
}}
className={`w-full text-left px-3 py-2 text-xs border-b last:border-b-0 border-border/40 ${
index === activeIndex ? 'bg-primary/20 text-primary' : 'text-foreground hover:bg-surface-2'
}`}
>
<div className="font-mono">@{option.handle}</div>
<div className="text-muted-foreground">
{option.display} • {option.type}{option.role ? ` • ${option.role}` : ''}
</div>
</button>
))}
</div>
)}
</div>
)
}
type DunkPhase = 'idle' | 'success' | 'error' | 'dismissing'
function DunkItButton({ taskId, onDunked }: { taskId: number; onDunked: (id: number) => void }) {
const t = useTranslations('taskBoard')
const [phase, setPhase] = useState<DunkPhase>('idle')
const handleClick = async (e: React.MouseEvent) => {
e.stopPropagation()
if (phase !== 'idle') return
try {
const res = await fetch(`/api/tasks/${taskId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'done' }),
})
if (!res.ok) throw new Error('Failed')
setPhase('success')
setTimeout(() => {
setPhase('dismissing')
setTimeout(() => onDunked(taskId), 400)
}, 600)
} catch {
setPhase('error')
setTimeout(() => setPhase('idle'), 1500)
}
}
return (
<button
onClick={handleClick}
disabled={phase !== 'idle' && phase !== 'error'}
title={t('dunkIt')}
style={{
padding: '2px 8px',
fontSize: '11px',
borderRadius: '4px',
border: '1px solid',
cursor: phase === 'idle' ? 'pointer' : 'default',
transition: 'all 0.3s ease',
transform: phase === 'success' ? 'scale(1.15)' : phase === 'dismissing' ? 'scale(0.8) translateY(-10px)' : 'scale(1)',
opacity: phase === 'dismissing' ? 0 : 1,
borderColor: phase === 'success' ? 'rgb(34 197 94 / 0.5)' : phase === 'error' ? 'rgb(239 68 68 / 0.5)' : 'hsl(var(--border))',
backgroundColor: phase === 'success' ? 'rgb(34 197 94 / 0.15)' : phase === 'error' ? 'rgb(239 68 68 / 0.15)' : 'transparent',
color: phase === 'success' ? 'rgb(34 197 94)' : phase === 'error' ? 'rgb(239 68 68)' : 'inherit',
}}
>
{phase === 'success' ? '!' : phase === 'error' ? '!!' : phase === 'dismissing' ? '!' : 'Dunk'}
</button>
)
}
interface SpawnFormData {
task: string
model: string
label: string
timeoutSeconds: number
}
export function TaskBoardPanel() {
const t = useTranslations('taskBoard')
const statusColumns = STATUS_COLUMN_KEYS.map(col => ({ ...col, title: t(col.titleKey as any) }))
const { tasks: storeTasks, setTasks: storeSetTasks, selectedTask, setSelectedTask, activeProject, availableModels, spawnRequests, addSpawnRequest, updateSpawnRequest, dashboardMode } = useMissionControl()
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const [agents, setAgents] = useState<Agent[]>([])
const [projects, setProjects] = useState<Project[]>([])
const [projectFilter, setProjectFilter] = useState<string>(
activeProject ? String(activeProject.id) : 'all'
)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [aegisMap, setAegisMap] = useState<Record<number, boolean>>({})
const [draggedTask, setDraggedTask] = useState<Task | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const [showProjectManager, setShowProjectManager] = useState(false)
const [editingTask, setEditingTask] = useState<Task | null>(null)
const [showSpawnForm, setShowSpawnForm] = useState(false)
const [spawnFormData, setSpawnFormData] = useState<SpawnFormData>({
task: '',
model: 'sonnet',
label: '',
timeoutSeconds: 300
})
const [isSpawning, setIsSpawning] = useState(false)
const [gnapStatus, setGnapStatus] = useState<{ enabled: boolean; taskCount?: number; lastSync?: string } | null>(null)
const [gnapSyncing, setGnapSyncing] = useState(false)
const isLocal = dashboardMode === 'local'
const dragCounter = useRef(0)
const selectedTaskIdFromUrl = Number.parseInt(searchParams.get('taskId') || '', 10)
const updateTaskUrl = useCallback((taskId: number | null, mode: 'push' | 'replace' = 'push') => {
const params = new URLSearchParams(searchParams.toString())
if (typeof taskId === 'number' && Number.isFinite(taskId)) {
params.set('taskId', String(taskId))
} else {
params.delete('taskId')
}
const query = params.toString()
const href = query ? `${pathname}?${query}` : pathname
if (mode === 'replace') {
router.replace(href)
return
}
router.push(href)
}, [pathname, router, searchParams])
// Augment store tasks with aegisApproved flag (computed, not stored)
const tasks: Task[] = storeTasks.map(t => ({
...t,
aegisApproved: Boolean(aegisMap[t.id])
}))
// Fetch tasks, agents, and projects
const fetchData = useCallback(async () => {
try {
setError(null)
const tasksQuery = new URLSearchParams()
if (projectFilter !== 'all') {
tasksQuery.set('project_id', projectFilter)
}
const tasksUrl = tasksQuery.toString() ? `/api/tasks?${tasksQuery.toString()}` : '/api/tasks'
const [tasksResponse, agentsResponse, projectsResponse] = await Promise.all([
fetch(tasksUrl),
fetch('/api/agents'),
fetch('/api/projects')
])
if (!tasksResponse.ok || !agentsResponse.ok || !projectsResponse.ok) {
throw new Error('Failed to fetch data')
}
const tasksData = await tasksResponse.json()
const agentsData = await agentsResponse.json()
const projectsData = await projectsResponse.json()
const tasksList = tasksData.tasks || []
const taskIds = tasksList.map((task: Task) => task.id)
// Render primary board data first; hydrate Aegis approvals in background.
storeSetTasks(tasksList)
setAgents(agentsData.agents || [])
setProjects(projectsData.projects || [])
if (taskIds.length > 0) {
fetch(`/api/quality-review?taskIds=${taskIds.join(',')}`)
.then((reviewResponse) => reviewResponse.ok ? reviewResponse.json() : null)
.then((reviewData) => {
const latest = reviewData?.latest || {}
const newAegisMap: Record<number, boolean> = Object.fromEntries(
Object.entries(latest).map(([id, row]: [string, any]) => [
Number(id),
row?.reviewer === 'aegis' && row?.status === 'approved'
])
)
setAegisMap(newAegisMap)
})
.catch(() => {
setAegisMap({})
})
} else {
setAegisMap({})
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred')
} finally {
setLoading(false)
}
}, [projectFilter, storeSetTasks])
useEffect(() => {
fetchData()
}, [fetchData])
// Fetch GNAP status
useEffect(() => {
fetch('/api/gnap')
.then(r => r.ok ? r.json() : null)
.then(data => { if (data) setGnapStatus(data) })
.catch(() => {})
}, [])
const handleGnapSync = useCallback(async () => {
setGnapSyncing(true)
try {
const res = await fetch('/api/gnap?action=sync', { method: 'POST' })
if (res.ok) {
const data = await res.json()
setGnapStatus(prev => prev ? { ...prev, taskCount: data.pushed, lastSync: data.lastSync } : prev)
}
} catch { /* ignore */ }
finally { setGnapSyncing(false) }
}, [])
// Sync global activeProject into local projectFilter
useEffect(() => {
const newFilter = activeProject ? String(activeProject.id) : 'all'
setProjectFilter(newFilter)
}, [activeProject])
useEffect(() => {
if (!Number.isFinite(selectedTaskIdFromUrl)) {
if (selectedTask) setSelectedTask(null)
return
}
const match = tasks.find((task) => task.id === selectedTaskIdFromUrl)
if (match) {
if (selectedTask?.id !== match.id) {
setSelectedTask(match)
}
return
}
if (!loading) {
setError(`Task #${selectedTaskIdFromUrl} not found in current workspace`)
setSelectedTask(null)
}
}, [loading, selectedTask, selectedTaskIdFromUrl, setSelectedTask, tasks])
// Poll as SSE fallback — pauses when SSE is delivering events
useSmartPoll(fetchData, 30000, { pauseWhenSseConnected: true })
// Group tasks by status, overriding for awaiting_owner detection
const tasksByStatus = statusColumns.reduce((acc, column) => {
acc[column.key] = tasks.filter(task => {
const effectiveStatus = detectAwaitingOwner(task) ? 'awaiting_owner' : task.status
return effectiveStatus === column.key
})
return acc
}, {} as Record<string, Task[]>)
// Drag and drop handlers
const handleDragStart = (e: React.DragEvent, task: Task) => {
setDraggedTask(task)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/html', e.currentTarget.outerHTML)
}
const handleDragEnter = (e: React.DragEvent, status: string) => {
e.preventDefault()
dragCounter.current++
e.currentTarget.classList.add('drag-over')
}
const handleDragLeave = (e: React.DragEvent) => {
dragCounter.current--
if (dragCounter.current === 0) {
e.currentTarget.classList.remove('drag-over')
}
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
}
const { updateTask } = useMissionControl()
const handleDrop = async (e: React.DragEvent, newStatus: string) => {
e.preventDefault()
dragCounter.current = 0
e.currentTarget.classList.remove('drag-over')
if (!draggedTask || draggedTask.status === newStatus) {
setDraggedTask(null)
return
}
const previousStatus = draggedTask.status
try {
if (newStatus === 'done') {
const reviewResponse = await fetch(`/api/quality-review?taskId=${draggedTask.id}`)
if (!reviewResponse.ok) {
throw new Error('Unable to verify Aegis approval')
}
const reviewData = await reviewResponse.json()
const latest = reviewData.reviews?.find((review: any) => review.reviewer === 'aegis')
if (!latest || latest.status !== 'approved') {
throw new Error('Aegis approval is required before moving to done')
}
}
// Optimistically update via Zustand store
updateTask(draggedTask.id, {
status: newStatus as Task['status'],
updated_at: Math.floor(Date.now() / 1000)
})
// Update on server
const response = await fetch('/api/tasks', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tasks: [{ id: draggedTask.id, status: newStatus }]
})
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error || 'Failed to update task status')
}
} catch (err) {
// Revert optimistic update via Zustand store
updateTask(draggedTask.id, { status: previousStatus })
setError(err instanceof Error ? err.message : 'Failed to update task status')
} finally {
setDraggedTask(null)
}
}
// Format relative time for tasks
const formatTaskTimestamp = (timestamp: number) => {
const now = new Date().getTime()
const time = new Date(timestamp * 1000).getTime()
const diff = now - time
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`
return 'just now'
}
const handleSpawn = async () => {
if (!spawnFormData.task.trim() || !spawnFormData.label.trim()) return
setIsSpawning(true)
const spawnId = `spawn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
addSpawnRequest({
id: spawnId,
task: spawnFormData.task,
model: spawnFormData.model,
label: spawnFormData.label,
timeoutSeconds: spawnFormData.timeoutSeconds,
status: 'pending',
createdAt: Date.now()
})
try {
const response = await fetch('/api/spawn', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(spawnFormData),
})
const result = await response.json()
if (result.success) {
updateSpawnRequest(spawnId, {
status: 'running',
result: result.sessionInfo || 'Agent spawned successfully'
})
setSpawnFormData({ task: '', model: 'sonnet', label: '', timeoutSeconds: 300 })
setShowSpawnForm(false)
} else {
updateSpawnRequest(spawnId, {
status: 'failed',
error: result.error || 'Unknown error'
})
}
} catch (error) {
log.error('Spawn error:', error)
updateSpawnRequest(spawnId, {
status: 'failed',
error: error instanceof Error ? error.message : 'Network error'
})
} finally {
setIsSpawning(false)
}
}
const getTagColor = (tag: string) => {
const lowerTag = tag.toLowerCase()
if (lowerTag.includes('urgent') || lowerTag.includes('critical')) {
return 'bg-red-500/20 text-red-400 border-red-500/30'
}
if (lowerTag.includes('bug') || lowerTag.includes('fix')) {
return 'bg-orange-500/20 text-orange-400 border-orange-500/30'
}
if (lowerTag.includes('feature') || lowerTag.includes('enhancement')) {
return 'bg-green-500/20 text-green-400 border-green-500/30'
}
if (lowerTag.includes('research') || lowerTag.includes('analysis')) {
return 'bg-purple-500/20 text-purple-400 border-purple-500/30'
}
if (lowerTag.includes('deploy') || lowerTag.includes('release')) {
return 'bg-blue-500/20 text-blue-400 border-blue-500/30'
}
return 'bg-muted-foreground/10 text-muted-foreground border-muted-foreground/20'
}
// Get agent name by session key
const getAgentName = (sessionKey?: string) => {
const agent = agents.find(a => a.name === sessionKey)
return agent?.name || sessionKey || 'Unassigned'
}
if (loading) {
return (
<div className="h-full flex flex-col" role="status" aria-live="polite">
<div className="flex justify-between items-center p-4 border-b border-border flex-shrink-0">
<div className="flex items-center gap-3">
<div className="h-7 w-28 bg-surface-1 rounded-md animate-pulse" />
<div className="h-9 w-36 bg-surface-1 rounded-md animate-pulse" />
</div>
<div className="flex gap-2">
<div className="h-9 w-20 bg-surface-1 rounded-md animate-pulse" />
<div className="h-9 w-24 bg-surface-1 rounded-md animate-pulse" />
</div>
</div>
<div className="flex-1 flex gap-4 p-4 overflow-x-auto">
{Array.from({ length: 4 }).map((_, colIdx) => (
<div key={colIdx} className="flex-1 min-w-80 bg-card border border-border rounded-lg flex flex-col">
<div className="p-3 rounded-t-lg bg-surface-1 animate-pulse">
<div className="h-5 w-24 bg-surface-2 rounded" />
</div>
<div className="flex-1 p-3 space-y-3">
{Array.from({ length: 3 - colIdx }).map((_, cardIdx) => (
<div key={cardIdx} className="bg-surface-1 rounded-lg p-3 border-l-4 border-border space-y-2 animate-pulse">
<div className="h-4 w-3/4 bg-surface-2 rounded" />
<div className="h-3 w-full bg-surface-2/60 rounded" />
<div className="h-3 w-1/2 bg-surface-2/40 rounded" />
<div className="flex justify-between items-center pt-1">
<div className="h-3 w-20 bg-surface-2/50 rounded" />
<div className="h-3 w-16 bg-surface-2/50 rounded" />
</div>
</div>
))}
</div>
</div>
))}
</div>
<span className="sr-only">{t('loadingTasks')}</span>
</div>
)
}
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex justify-between items-center p-4 border-b border-border flex-shrink-0">
<div className="flex items-center gap-3">
<h2 className="text-xl font-bold text-foreground">{t('title')}</h2>
{gnapStatus?.enabled && (
<button
onClick={handleGnapSync}
disabled={gnapSyncing}
className="inline-flex items-center gap-1.5 px-2 py-0.5 text-xs font-medium rounded-full bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25 transition-colors disabled:opacity-50"
title={gnapStatus.lastSync ? `Last sync: ${gnapStatus.lastSync}` : 'Click to sync'}
>
GNAP
{gnapStatus.taskCount != null && (
<span className="text-emerald-400/70">{gnapStatus.taskCount}</span>
)}
{gnapSyncing && (
<svg className="w-3 h-3 animate-spin" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M8 1.5a6.5 6.5 0 1 1-4.5 2" />
</svg>
)}
</button>
)}
<div className="relative">
<select
value={projectFilter}
onChange={(e) => setProjectFilter(e.target.value)}
className="h-9 px-3 pr-8 bg-surface-1 text-foreground border border-border rounded-md text-sm appearance-none cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<option value="all">{t('allProjects')}</option>
{projects.map((project) => (
<option key={project.id} value={String(project.id)}>
{project.name} ({project.ticket_prefix})
</option>
))}
</select>
<svg className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 6l4 4 4-4" />
</svg>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setShowProjectManager(true)}>
{t('projects')}
</Button>
{!isLocal && (
<Button variant="outline" onClick={() => setShowSpawnForm(!showSpawnForm)}>
{showSpawnForm ? t('close') : t('spawnSubAgent')}
</Button>
)}
<Button onClick={() => setShowCreateModal(true)}>
{t('newTask')}
</Button>
<Button variant="ghost" size="icon-sm" onClick={fetchData} title={t('refresh')}>
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M1.5 8a6.5 6.5 0 0 1 11.25-4.5M14.5 8a6.5 6.5 0 0 1-11.25 4.5" />
<path d="M13.5 2v3h-3M2.5 14v-3h3" />
</svg>
</Button>
</div>
</div>
{/* Spawn Form (collapsible) */}
{showSpawnForm && (
<div className="border-b border-border bg-surface-0 p-4">
<div className="grid md:grid-cols-2 gap-4 max-w-4xl">
<div className="space-y-3">
<textarea
value={spawnFormData.task}
onChange={(e) => setSpawnFormData(prev => ({ ...prev, task: e.target.value }))}
placeholder={t('spawnTaskPlaceholder')}
className="w-full h-20 px-3 py-2 border border-border rounded-md bg-background text-foreground text-sm placeholder-muted-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary/50"
disabled={isSpawning}
/>
<div className="flex gap-2">
<input
type="text"
value={spawnFormData.label}
onChange={(e) => setSpawnFormData(prev => ({ ...prev, label: e.target.value }))}
placeholder={t('spawnLabelPlaceholder')}
className="flex-1 px-3 py-1.5 border border-border rounded-md bg-background text-foreground text-sm placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
disabled={isSpawning}
/>
<select
value={spawnFormData.model}
onChange={(e) => setSpawnFormData(prev => ({ ...prev, model: e.target.value }))}
className="px-3 py-1.5 border border-border rounded-md bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
disabled={isSpawning}
>
{availableModels.map((model) => (
<option key={model.alias} value={model.alias}>{model.alias}</option>
))}
</select>
<input
type="number"
min="10"
max="3600"
value={spawnFormData.timeoutSeconds}
onChange={(e) => setSpawnFormData(prev => ({ ...prev, timeoutSeconds: parseInt(e.target.value) || 300 }))}
className="w-20 px-2 py-1.5 border border-border rounded-md bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
title={t('timeoutSeconds')}
disabled={isSpawning}
/>
<Button
onClick={handleSpawn}
disabled={isSpawning || !spawnFormData.task.trim() || !spawnFormData.label.trim()}
size="sm"
>
{isSpawning ? t('spawning') : t('spawn')}
</Button>
</div>
</div>
{/* Active spawn requests */}
<div className="space-y-2 max-h-32 overflow-y-auto">
{spawnRequests.length === 0 ? (
<div className="text-xs text-muted-foreground text-center py-4">{t('noActiveSpawnRequests')}</div>
) : (
spawnRequests.slice(0, 5).map((request) => (
<div key={request.id} className="flex items-center justify-between px-3 py-2 border border-border rounded-md text-sm">
<div className="flex items-center gap-2 min-w-0">
<span className="font-medium text-foreground truncate">{request.label}</span>
<span className={`px-1.5 py-0.5 text-xs rounded-full ${
request.status === 'pending' ? 'bg-yellow-500/20 text-yellow-400' :
request.status === 'running' ? 'bg-blue-500/20 text-blue-400' :
request.status === 'completed' ? 'bg-green-500/20 text-green-400' :
'bg-red-500/20 text-red-400'
}`}>
{request.status}
</span>
</div>
<span className="text-xs text-muted-foreground">{request.model}</span>
</div>
))
)}
</div>
</div>
</div>
)}
{/* Error Display */}
{error && (
<div role="alert" className="bg-red-500/10 border border-red-500/20 text-red-400 p-3 m-4 rounded-lg text-sm flex items-center justify-between">
<span>{error}</span>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setError(null)}
className="text-red-400/60 hover:text-red-400 ml-2"
aria-label={t('dismissError')}
>
×
</Button>
</div>
)}
{/* Kanban Board */}
<div className="flex-1 flex gap-4 p-4 overflow-x-auto" role="region" aria-label={t('taskBoard')}>
{statusColumns.map(column => (
<div
key={column.key}
role="region"
aria-label={t('columnAriaLabel', { title: column.title, count: tasksByStatus[column.key]?.length || 0 })}
className="flex-1 min-w-80 bg-surface-0 border border-border/60 rounded-xl flex flex-col transition-colors duration-200 [&.drag-over]:border-primary/40 [&.drag-over]:bg-primary/[0.02]"
onDragEnter={(e) => handleDragEnter(e, column.key)}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, column.key)}
>
{/* Column Header */}
<div className={`${column.color} px-4 py-3 rounded-t-xl flex justify-between items-center border-b border-border/30`}>
<h3 className="font-semibold text-sm tracking-wide">{column.title}</h3>
<span className="text-xs font-mono bg-white/10 px-2 py-0.5 rounded-md min-w-[1.75rem] text-center">
{tasksByStatus[column.key]?.length || 0}
</span>
</div>
{/* Column Body */}
<div className="flex-1 p-2.5 space-y-2.5 min-h-32 h-full overflow-y-auto">
{tasksByStatus[column.key]?.map(task => (
<div
key={task.id}
draggable
role="button"
tabIndex={0}
aria-label={`${task.title}, ${task.priority} priority, ${task.status}`}
onDragStart={(e) => handleDragStart(e, task)}
onClick={() => {
setSelectedTask(task)
updateTaskUrl(task.id)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setSelectedTask(task)
updateTaskUrl(task.id)
}
}}
className={`group bg-card rounded-lg p-3 cursor-pointer border border-border/40 shadow-sm hover:shadow-md hover:shadow-black/10 hover:border-border/70 transition-all duration-200 ease-out border-l-4 ${priorityColors[task.priority]} ${
draggedTask?.id === task.id ? 'opacity-40 scale-[0.97] rotate-1' : ''
} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background`}
>
{/* Drag handle + Title row */}
<div className="flex items-start gap-2 mb-2">
{/* Grip handle — visible on hover */}
<svg className="w-3.5 h-3.5 mt-0.5 text-muted-foreground/0 group-hover:text-muted-foreground/40 transition-colors shrink-0 cursor-grab" viewBox="0 0 16 16" fill="currentColor">
<circle cx="5" cy="3" r="1.5" /><circle cx="11" cy="3" r="1.5" />
<circle cx="5" cy="8" r="1.5" /><circle cx="11" cy="8" r="1.5" />
<circle cx="5" cy="13" r="1.5" /><circle cx="11" cy="13" r="1.5" />
</svg>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-start gap-2">
<h4 className="text-foreground font-medium text-sm leading-tight line-clamp-2">
{task.title}
</h4>
<div className="flex items-center gap-1.5 shrink-0">
{task.metadata?.recurrence?.enabled && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-cyan-500/20 text-cyan-400 font-mono" title={task.metadata.recurrence.natural_text || task.metadata.recurrence.cron_expr}>
{t('recurring')}
</span>
)}
{task.metadata?.recurrence?.parent_task_id && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-cyan-500/10 text-cyan-400/70 font-mono" title={t('spawnedFromTask', { id: task.metadata.recurrence.parent_task_id })}>
{t('spawned')}
</span>
)}
{task.ticket_ref && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/15 text-primary font-mono">
{task.ticket_ref}
</span>
)}
{task.github_issue_number && task.github_repo && (
<a
href={`https://github.com/${task.github_repo}/issues/${task.github_issue_number}`}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] px-1.5 py-0.5 rounded bg-[#24292e]/30 text-gray-300 hover:text-white font-mono flex items-center gap-1 transition-colors"
onClick={(e) => e.stopPropagation()}
title={`GitHub issue #${task.github_issue_number}`}
>
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
#{task.github_issue_number}
</a>
)}
{task.github_pr_number && task.github_repo && (
<a
href={`https://github.com/${task.github_repo}/pull/${task.github_pr_number}`}
target="_blank"
rel="noopener noreferrer"
className={`text-[10px] px-1.5 py-0.5 rounded font-mono flex items-center gap-1 transition-colors ${
task.github_pr_state === 'merged' ? 'bg-purple-500/20 text-purple-400' :
task.github_pr_state === 'closed' ? 'bg-red-500/20 text-red-400' :
'bg-green-500/20 text-green-400'
}`}
onClick={(e) => e.stopPropagation()}
title={`PR #${task.github_pr_number} (${task.github_pr_state || 'open'})`}
>
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="currentColor"><path d="M7.177 3.073L9.573.677A.25.25 0 0110 .854v4.792a.25.25 0 01-.427.177L7.177 3.427a.25.25 0 010-.354zM3.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122v5.256a2.251 2.251 0 11-1.5 0V5.372A2.25 2.25 0 011.5 3.25zM11 2.5h-1V4h1a1 1 0 011 1v5.628a2.251 2.251 0 101.5 0V5A2.5 2.5 0 0011 2.5zm1 10.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0zM3.75 12a.75.75 0 100 1.5.75.75 0 000-1.5z"/></svg>
PR #{task.github_pr_number}
</a>
)}
{task.aegisApproved && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/15 text-emerald-400 border border-emerald-500/20">
Aegis
</span>
)}
{detectAwaitingOwner(task) && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-500/20 text-orange-400 border border-orange-500/30 font-mono">
{t('colAwaitingOwner')}
</span>
)}
</div>
</div>
</div>
</div>
{task.description && (
<div className="mb-2 ml-5.5 line-clamp-2 overflow-hidden text-xs text-muted-foreground">
<MarkdownRenderer content={task.description} preview />
</div>
)}
{/* Footer: assignee, priority, timestamp */}
<div className="flex items-center justify-between gap-2 ml-5.5 mt-auto pt-2 border-t border-border/20">
<span className="flex items-center gap-1.5 min-w-0 text-xs text-muted-foreground">
{task.assigned_to ? (
<>
<AgentAvatar name={getAgentName(task.assigned_to)} size="xs" />
<span className="truncate max-w-[8rem]">{getAgentName(task.assigned_to)}</span>
</>
) : (
<span className="text-muted-foreground/50 italic">{t('unassigned')}</span>
)}
</span>
<div className="flex items-center gap-1.5 shrink-0">
{task.status !== 'done' && (
<DunkItButton taskId={task.id} onDunked={() => fetchData()} />
)}
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
task.priority === 'critical' ? 'bg-red-500/20 text-red-400' :
task.priority === 'high' ? 'bg-orange-500/20 text-orange-400' :
task.priority === 'medium' ? 'bg-yellow-500/20 text-yellow-400' :
'bg-green-500/20 text-green-400'
}`}>
{t(`priority_${task.priority}` as any)}
</span>
<span className="text-[10px] text-muted-foreground/60">{formatTaskTimestamp(task.created_at)}</span>
</div>
</div>
{/* Tags row */}
{task.tags && task.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2 ml-5.5">
{task.tags.slice(0, 3).map((tag, index) => (
<span
key={index}
className={`text-[10px] px-1.5 py-0.5 rounded-full border font-medium ${getTagColor(tag)}`}
>
{tag}
</span>
))}
{task.tags.length > 3 && (
<span className="text-muted-foreground/60 text-[10px]">+{task.tags.length - 3}</span>
)}
</div>
)}
{/* Due date — prominent when overdue */}
{task.due_date && (
<div className="mt-1.5 ml-5.5 text-[10px]">
<span className={`inline-flex items-center gap-1 ${
task.due_date * 1000 < Date.now() ? 'text-red-400 font-medium' : 'text-muted-foreground/60'
}`}>
{task.due_date * 1000 < Date.now() ? '! ' : ''}{t('due')} {formatTaskTimestamp(task.due_date)}
</span>
</div>
)}
</div>
))}
{/* Empty State */}
{tasksByStatus[column.key]?.length === 0 && (
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground/30">
<svg className="w-8 h-8 mb-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 12h6M12 9v6" strokeLinecap="round" />
</svg>
<span className="text-xs">{t('dropTasksHere')}</span>
</div>
)}
</div>
</div>
))}
</div>
{/* Claude Code Tasks */}
<ClaudeCodeTasksSection />
{/* Hermes Scheduled Tasks */}
<HermesCronSection />
{/* Task Detail Modal */}
{selectedTask && !editingTask && (
<TaskDetailModal
task={selectedTask}
agents={agents}
projects={projects}
onClose={() => {
setSelectedTask(null)
updateTaskUrl(null)
}}
onUpdate={fetchData}
onEdit={(taskToEdit) => {
setEditingTask(taskToEdit)
setSelectedTask(null)
updateTaskUrl(null, 'replace')
}}
onDelete={fetchData}
/>
)}
{/* Create Task Modal */}
{showCreateModal && (
<CreateTaskModal
agents={agents}
projects={projects}
onClose={() => setShowCreateModal(false)}
onCreated={fetchData}
/>
)}
{/* Edit Task Modal */}
{editingTask && (
<EditTaskModal
task={editingTask}
agents={agents}
projects={projects}
onClose={() => setEditingTask(null)}
onUpdated={() => { fetchData(); setEditingTask(null) }}
/>
)}
{showProjectManager && (
<ProjectManagerModal
onClose={() => setShowProjectManager(false)}
onChanged={fetchData}
/>
)}
</div>
)
}
// Task Detail Modal Component (placeholder - would be implemented separately)
function TaskDetailModal({
task,
agents,
projects,
onClose,
onUpdate,
onEdit,
onDelete
}: {
task: Task
agents: Agent[]
projects: Project[]
onClose: () => void
onUpdate: () => void
onEdit: (task: Task) => void
onDelete: () => void
}) {
const t = useTranslations('taskBoard')
const router = useRouter()
const { currentUser } = useMissionControl()
const commentAuthor = currentUser?.username || 'system'
const resolvedProjectName =
task.project_name ||
projects.find((project) => project.id === task.project_id)?.name
const [comments, setComments] = useState<Comment[]>([])
const [loadingComments, setLoadingComments] = useState(false)
const [commentText, setCommentText] = useState('')
const [commentError, setCommentError] = useState<string | null>(null)
const [broadcastMessage, setBroadcastMessage] = useState('')
const [broadcastStatus, setBroadcastStatus] = useState<string | null>(null)
const [reviews, setReviews] = useState<any[]>([])
const [reviewStatus, setReviewStatus] = useState<'approved' | 'rejected'>('approved')
const [reviewNotes, setReviewNotes] = useState('')
const [reviewError, setReviewError] = useState<string | null>(null)
const mentionTargets = useMentionTargets()
const [activeTab, setActiveTab] = useState<'details' | 'comments' | 'quality' | 'session'>('details')
const [reviewer, setReviewer] = useState('aegis')
const fetchReviews = useCallback(async () => {
try {
const response = await fetch(`/api/quality-review?taskId=${task.id}`)
if (!response.ok) throw new Error('Failed to fetch reviews')
const data = await response.json()
setReviews(data.reviews || [])
} catch (error) {
setReviewError('Failed to load quality reviews')
}
}, [task.id])
const fetchComments = useCallback(async () => {
try {
setLoadingComments(true)
const response = await fetch(`/api/tasks/${task.id}/comments`)
if (!response.ok) throw new Error('Failed to fetch comments')
const data = await response.json()
setComments(data.comments || [])
} catch (error) {
setCommentError('Failed to load comments')
} finally {
setLoadingComments(false)
}
}, [task.id])
useEffect(() => {
fetchComments()
}, [fetchComments])
useEffect(() => {
fetchReviews()
}, [fetchReviews])
useSmartPoll(fetchComments, 15000)
const handleAddComment = async (e: React.FormEvent) => {
e.preventDefault()
if (!commentText.trim()) return
try {
setCommentError(null)
const response = await fetch(`/api/tasks/${task.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
author: commentAuthor || 'system',
content: commentText
})
})
if (!response.ok) throw new Error('Failed to add comment')
setCommentText('')
await fetchComments()
onUpdate()
} catch (error) {
setCommentError('Failed to add comment')
}
}
const handleBroadcast = async (e: React.FormEvent) => {
e.preventDefault()
if (!broadcastMessage.trim()) return
try {
setBroadcastStatus(null)
const response = await fetch(`/api/tasks/${task.id}/broadcast`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
author: commentAuthor || 'system',
message: broadcastMessage
})
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || 'Broadcast failed')
setBroadcastMessage('')
setBroadcastStatus(t('broadcastSent', { count: data.sent || 0 }))
} catch (error) {
setBroadcastStatus('Failed to broadcast')
}
}
const handleSubmitReview = async (e: React.FormEvent) => {
e.preventDefault()
try {
setReviewError(null)
const response = await fetch('/api/quality-review', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
taskId: task.id,
reviewer,
status: reviewStatus,
notes: reviewNotes
})
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || 'Failed to submit review')
setReviewNotes('')
await fetchReviews()
onUpdate()
} catch (error) {
setReviewError('Failed to submit review')
}
}
const parseCommentContent = (raw: string): { text: string; meta?: { model?: string; provider?: string; durationMs?: number; tokens?: number } } => {
// Strip ANSI escape codes
const stripped = raw.replace(/\x1b\[[0-9;]*m/g, '').replace(/\[3[0-9]m/g, '').replace(/\[39m/g, '')
// Try to parse as JSON payload (OpenClaw agent result format)
try {
const parsed = JSON.parse(stripped)
if (parsed && typeof parsed === 'object') {
let text = ''
let meta: { model?: string; provider?: string; durationMs?: number; tokens?: number } | undefined
// Extract text from payloads array
if (Array.isArray(parsed.payloads)) {
text = parsed.payloads
.map((p: any) => (typeof p === 'string' ? p : p?.text || '').trim())
.filter(Boolean)
.join('\n')
}
// Extract compact meta
if (parsed.meta?.agentMeta) {
const am = parsed.meta.agentMeta
meta = {
model: am.model,
provider: am.provider,
durationMs: parsed.meta.durationMs,
tokens: am.usage?.total,
}
}
if (text) return { text, meta }
}
} catch {
// Not JSON — treat as plain text
}
// Clean up any remaining ANSI prefixes from log lines
const cleaned = stripped
.split('\n')
.map(line => line.replace(/^\[[\w/-]+\]\s*/, '').trim())
.filter(line => line && !line.startsWith('{') && !line.startsWith('"'))
.join('\n')
return { text: cleaned || stripped }
}
const renderComment = (comment: Comment, depth: number = 0) => {
const { text, meta } = parseCommentContent(comment.content)
return (
<div key={comment.id} className={`border-l-2 border-border pl-3 ${depth > 0 ? 'ml-4' : ''}`}>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground/80">{comment.author}</span>
{meta && (
<span className="px-1.5 py-0.5 rounded bg-secondary text-[10px] text-muted-foreground">
{meta.model}{meta.tokens ? ` · ${meta.tokens.toLocaleString()} tok` : ''}{meta.durationMs ? ` · ${(meta.durationMs / 1000).toFixed(1)}s` : ''}
</span>
)}
</div>
<span>{new Date(comment.created_at * 1000).toLocaleString()}</span>
</div>
<div className="text-sm text-foreground/90 mt-1 whitespace-pre-wrap">{text}</div>
{comment.replies && comment.replies.length > 0 && (
<div className="mt-3 space-y-3">
{comment.replies.map(reply => renderComment(reply, depth + 1))}
</div>
)}
</div>
)
}
const dialogRef = useFocusTrap(onClose)
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="task-detail-title" className="bg-card border border-border rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex justify-between items-start mb-4">
<h3 id="task-detail-title" className="text-xl font-bold text-foreground">{task.title}</h3>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => onEdit(task)} className="text-primary hover:bg-primary/20">
{t('edit')}
</Button>
<Button
variant="destructive"
size="sm"
onClick={async () => {
if (!confirm(t('deleteTaskConfirm', { title: task.title }))) return
try {
const res = await fetch(`/api/tasks/${task.id}`, { method: 'DELETE' })
if (!res.ok) {
const errorData = await res.json().catch(() => ({ error: 'Failed to delete task' }))
throw new Error(errorData.error || 'Failed to delete task')
}
// Close modal immediately on successful deletion
// SSE will handle the task.deleted event and remove the task from the UI
onClose()
} catch (error) {
// Show error to user
const errorMessage = error instanceof Error ? error.message : 'Failed to delete task'
alert(errorMessage)
// Don't close modal on error
}
}}
>
{t('delete')}
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onClose}
aria-label={t('closeTaskDetails')}
className="text-xl"
>
×
</Button>
</div>
</div>
{task.description ? (
<div className="mb-4">
<MarkdownRenderer content={task.description} />
</div>
) : (
<p className="text-foreground/80 mb-4">{t('noDescription')}</p>
)}
<div className="flex gap-2 mt-4" role="tablist" aria-label={t('taskDetailTabs')}>
{(['details', 'comments', 'quality'] as const).map(tab => (
<Button
key={tab}
role="tab"
size="sm"
variant={activeTab === tab ? 'default' : 'secondary'}
aria-selected={activeTab === tab}
aria-controls={`tabpanel-${tab}`}
onClick={() => setActiveTab(tab)}
>
{tab === 'details' ? t('tabDetails') : tab === 'comments' ? t('tabComments') : t('tabQualityReview')}
</Button>
))}
{task.metadata?.dispatch_session_id && (
<Button
role="tab"
size="sm"
variant={activeTab === 'session' ? 'default' : 'secondary'}
aria-selected={activeTab === 'session'}
aria-controls="tabpanel-session"
onClick={() => setActiveTab('session')}
>
{t('tabSession')}
{task.status === 'in_progress' && (
<span className="ml-1.5 inline-block h-1.5 w-1.5 rounded-full bg-green-400 animate-pulse" />
)}
</Button>
)}
</div>
{activeTab === 'details' && (
<div id="tabpanel-details" role="tabpanel" aria-label={t('tabDetails')} className="grid grid-cols-2 gap-4 text-sm mt-4">
{task.ticket_ref && (
<div>
<span className="text-muted-foreground">{t('ticket')}:</span>
<span className="text-foreground ml-2 font-mono">{task.ticket_ref}</span>
</div>
)}
{resolvedProjectName && (
<div>
<span className="text-muted-foreground">{t('project')}:</span>
<span className="text-foreground ml-2">{resolvedProjectName}</span>
</div>
)}
<div>
<span className="text-muted-foreground">{t('status')}:</span>
<span className="text-foreground ml-2">{task.status}</span>
</div>
<div>
<span className="text-muted-foreground">{t('priority')}:</span>
<span className="text-foreground ml-2">{t(`priority_${task.priority}` as any)}</span>
</div>
<div>
<span className="text-muted-foreground">{t('assignedTo')}:</span>
<span className="text-foreground ml-2 inline-flex items-center gap-1.5">
{task.assigned_to ? (
<>
<AgentAvatar name={task.assigned_to} size="xs" />
<span>{task.assigned_to}</span>
</>
) : (
<span>{t('unassigned')}</span>
)}
</span>
</div>
<div>
<span className="text-muted-foreground">{t('created')}:</span>
<span className="text-foreground ml-2">{new Date(task.created_at * 1000).toLocaleDateString()}</span>
</div>
{(task.github_issue_number || task.github_branch || task.github_pr_number) && (
<>
<div className="col-span-2 mt-2 pt-2 border-t border-border/50">
<span className="text-muted-foreground text-xs font-medium uppercase tracking-wider">GitHub</span>
</div>
{task.github_issue_number && task.github_repo && (
<div>
<span className="text-muted-foreground">{t('issue')}:</span>
<a
href={`https://github.com/${task.github_repo}/issues/${task.github_issue_number}`}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline ml-2 font-mono"
>
{task.github_repo}#{task.github_issue_number}
</a>
</div>
)}
{task.github_branch && (
<div>
<span className="text-muted-foreground">{t('branch')}:</span>
<span className="text-foreground ml-2 font-mono text-xs">{task.github_branch}</span>
</div>
)}
{task.github_pr_number && task.github_repo && (
<div>
<span className="text-muted-foreground">{t('pr')}:</span>
<a
href={`https://github.com/${task.github_repo}/pull/${task.github_pr_number}`}
target="_blank"
rel="noopener noreferrer"
className={`ml-2 font-mono hover:underline ${
task.github_pr_state === 'merged' ? 'text-purple-400' :
task.github_pr_state === 'closed' ? 'text-red-400' :
'text-green-400'
}`}
>
#{task.github_pr_number} ({task.github_pr_state || 'open'})
</a>
</div>
)}
</>
)}
{task.metadata?.dispatch_session_id && (
<>
<div className="col-span-2 mt-2 pt-2 border-t border-border/50">
<span className="text-muted-foreground text-xs font-medium uppercase tracking-wider">Agent Session</span>
</div>
<div className="col-span-2">
<Button
variant="secondary"
size="sm"
onClick={() => setActiveTab('session')}
className="font-mono text-xs"
>
View Session {task.metadata.dispatch_session_id.slice(0, 8)}...
</Button>
{task.status === 'in_progress' && (
<span className="ml-2 text-xs text-green-400 animate-pulse">{t('live')}</span>
)}
</div>
</>
)}
</div>
)}
{activeTab === 'comments' && (
<div id="tabpanel-comments" role="tabpanel" aria-label={t('tabComments')} className="mt-6">
<div className="flex items-center justify-between mb-3">
<h4 className="text-lg font-semibold text-foreground">{t('tabComments')}</h4>
<Button variant="link" size="xs" onClick={fetchComments} className="text-blue-400 hover:text-blue-300">
{t('refresh')}
</Button>
</div>
{commentError && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 p-2 rounded-md text-sm mb-3">
{commentError}
</div>
)}
{loadingComments ? (
<div className="text-muted-foreground text-sm">{t('loadingComments')}</div>
) : comments.length === 0 ? (
<div className="text-muted-foreground/50 text-sm">{t('noComments')}</div>
) : (
<div className="space-y-4">
{comments.map(comment => renderComment(comment))}
</div>
)}
<form onSubmit={handleAddComment} className="mt-4 space-y-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{t('postingAs')}</span>
<span className="font-medium text-foreground">{commentAuthor}</span>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">{t('newComment')}</label>
<MentionTextarea
value={commentText}
onChange={setCommentText}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50"
rows={3}
mentionTargets={mentionTargets}
/>
<p className="text-[11px] text-muted-foreground mt-1">Use <span className="font-mono">@</span> to mention users and agents.</p>
</div>
<div className="flex justify-end">
<Button type="submit">
{t('addComment')}
</Button>
</div>
</form>
<div className="mt-5 bg-blue-500/5 border border-blue-500/15 rounded-lg p-3 text-xs text-muted-foreground space-y-1">
<div className="font-medium text-blue-300">How notifications work</div>
<div><strong className="text-foreground">Comments</strong> are persisted on the task and notify all subscribers. Subscribers are auto-added when they: create the task, are assigned to it, comment on it, or are @mentioned.</div>
<div><strong className="text-foreground">Broadcasts</strong> send a one-time notification to all current subscribers without creating a comment record.</div>
</div>
<div className="mt-6 border-t border-border pt-4">
<h5 className="text-sm font-medium text-foreground mb-2">{t('broadcastToSubscribers')}</h5>
{broadcastStatus && (
<div className="text-xs text-muted-foreground mb-2">{broadcastStatus}</div>
)}
<form onSubmit={handleBroadcast} className="space-y-2">
<MentionTextarea
value={broadcastMessage}
onChange={setBroadcastMessage}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50"
rows={2}
placeholder={t('broadcastPlaceholder')}
mentionTargets={mentionTargets}
/>
<div className="flex justify-end">
<Button type="submit" size="sm" className="bg-purple-500/20 text-purple-400 border border-purple-500/30 hover:bg-purple-500/30">
{t('broadcast')}
</Button>
</div>
</form>
</div>
</div>
)}
{activeTab === 'quality' && (
<div id="tabpanel-quality" role="tabpanel" aria-label={t('tabQualityReview')} className="mt-6">
<h5 className="text-sm font-medium text-foreground mb-2">{t('aegisQualityReview')}</h5>
{reviewError && (
<div className="text-xs text-red-400 mb-2">{reviewError}</div>
)}
{reviews.length > 0 ? (
<div className="space-y-2 mb-3">
{reviews.map((review) => (
<div key={review.id} className="text-xs text-foreground/80 bg-surface-1/40 rounded p-2">
<div className="flex justify-between">
<span>{review.reviewer} — {review.status}</span>
<span>{new Date(review.created_at * 1000).toLocaleString()}</span>
</div>
{review.notes && <div className="mt-1">{review.notes}</div>}
</div>
))}
</div>
) : (
<div className="text-xs text-muted-foreground mb-3">{t('noReviews')}</div>
)}
<form onSubmit={handleSubmitReview} className="space-y-2">
<div className="flex gap-2">
<input
type="text"
value={reviewer}
onChange={(e) => setReviewer(e.target.value)}
className="bg-surface-1 text-foreground border border-border rounded-md px-2 py-1 text-xs"
placeholder={t('reviewerPlaceholder')}
/>
<select
value={reviewStatus}
onChange={(e) => setReviewStatus(e.target.value as 'approved' | 'rejected')}
className="bg-surface-1 text-foreground border border-border rounded-md px-2 py-1 text-xs"
>
<option value="approved">approved</option>
<option value="rejected">rejected</option>
</select>
<input
type="text"
value={reviewNotes}
onChange={(e) => setReviewNotes(e.target.value)}
className="flex-1 bg-surface-1 text-foreground border border-border rounded-md px-2 py-1 text-xs"
placeholder={t('reviewNotesPlaceholder')}
/>
<Button type="submit" variant="success" size="xs">
{t('submit')}
</Button>
</div>
</form>
</div>
)}
{activeTab === 'session' && task.metadata?.dispatch_session_id && (
<div id="tabpanel-session" role="tabpanel" aria-label="Session" className="mt-4">
<TaskSessionFeed
sessionId={task.metadata.dispatch_session_id}
agentName={task.assigned_to}
isLive={task.status === 'in_progress'}
/>
</div>
)}
</div>
</div>
</div>
)
}
function TaskSessionFeed({ sessionId, agentName, isLive }: { sessionId: string; agentName?: string; isLive: boolean }) {
const t = useTranslations('taskBoard')
const [messages, setMessages] = useState<SessionTranscriptMessage[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const prevCountRef = useRef(0)
const fetchTranscript = useCallback(async () => {
try {
const res = await fetch(`/api/sessions/transcript?kind=claude-code&id=${encodeURIComponent(sessionId)}&limit=100`)
if (!res.ok) throw new Error(`Failed to fetch transcript: ${res.status}`)
const data = await res.json()
setMessages(data.messages || [])
setError(null)
} catch (err: any) {
setError(err.message || 'Failed to load session transcript')
} finally {
setLoading(false)
}
}, [sessionId])
// Initial fetch
useEffect(() => { fetchTranscript() }, [fetchTranscript])
// Auto-refresh when live
useEffect(() => {
if (!isLive) return
const interval = setInterval(fetchTranscript, 5000)
return () => clearInterval(interval)
}, [isLive, fetchTranscript])
// Auto-scroll on new messages
useEffect(() => {
if (messages.length > prevCountRef.current && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
prevCountRef.current = messages.length
}, [messages.length])
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{agentName && (
<span className="flex items-center gap-1.5">
<AgentAvatar name={agentName} size="xs" />
<span className="font-medium text-foreground">{agentName}</span>
</span>
)}
<span className="font-mono text-muted-foreground/50">{sessionId.slice(0, 12)}...</span>
{isLive && (
<span className="flex items-center gap-1 text-green-400">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-green-400 animate-pulse" />
{t('live')}
</span>
)}
</div>
<Button variant="link" size="xs" onClick={fetchTranscript} className="text-blue-400 hover:text-blue-300">
{t('refresh')}
</Button>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 p-2 rounded-md text-xs">
{error}
</div>
)}
{loading ? (
<div className="text-muted-foreground text-sm py-4 text-center">{t('loadingTranscript')}</div>
) : messages.length === 0 ? (
<div className="text-muted-foreground/50 text-sm py-4 text-center">{t('noSessionMessages')}</div>
) : (
<div ref={scrollRef} className="max-h-[50vh] overflow-y-auto space-y-0.5 rounded border border-border/30 bg-black/10 p-2">
{messages.map((msg, idx) => (
<SessionMessage
key={idx}
message={msg}
showTimestamp={shouldShowTimestamp(msg, messages[idx - 1])}
/>
))}
</div>
)}
</div>
)
}
// Claude Code Tasks Section — read-only bridge
function ClaudeCodeTasksSection() {
const t = useTranslations('taskBoard')
const [expanded, setExpanded] = useState(false)
const [data, setData] = useState<{ teams: any[]; tasks: any[] }>({ teams: [], tasks: [] })
const [loaded, setLoaded] = useState(false)
useEffect(() => {
if (!expanded || loaded) return
fetch('/api/claude-tasks')
.then(r => r.json())
.then(d => { setData(d); setLoaded(true) })
.catch(() => setLoaded(true))
}, [expanded, loaded])
const tasksByTeam = data.tasks.reduce<Record<string, any[]>>((acc, t) => {
(acc[t.teamName] ||= []).push(t)
return acc
}, {})
const statusColor = (s: string) =>
s === 'completed' ? 'text-green-400' :
s === 'in_progress' ? 'text-blue-400' :
s === 'blocked' ? 'text-red-400' :
s === 'awaiting_owner' ? 'text-orange-400' :
'text-muted-foreground'
return (
<div className="mt-4 border border-border rounded-lg overflow-hidden">
<button
onClick={() => setExpanded(prev => !prev)}
className="w-full flex items-center justify-between px-4 py-3 bg-card hover:bg-secondary/50 transition-colors text-left"
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{t('claudeCodeTasks')}</span>
{data.tasks.length > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-cyan-500/20 text-cyan-400">{data.tasks.length}</span>
)}
</div>
<span className="text-muted-foreground text-xs">{expanded ? t('collapse') : t('expand')}</span>
</button>
{expanded && (
<div className="p-4 border-t border-border space-y-4">
{!loaded ? (
<div className="text-sm text-muted-foreground">{t('loading')}</div>
) : data.tasks.length === 0 ? (
<div className="text-sm text-muted-foreground text-center py-8">
<p className="font-medium">{t('noTeamTasksFound')}</p>
<p className="text-xs mt-1 text-muted-foreground/70">{t('noTeamTasksDesc')}</p>
</div>
) : (
Object.entries(tasksByTeam).map(([team, tasks]) => (
<div key={team}>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-foreground">{team}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-secondary text-muted-foreground">{t('taskCount', { count: tasks.length })}</span>
{data.teams.find(t => t.name === team)?.members?.length > 0 && (
<span className="text-[10px] text-muted-foreground">
{data.teams.find(t => t.name === team).members.map((m: any) => m.name).join(', ')}
</span>
)}
</div>
<div className="space-y-1">
{tasks.map((task: any) => (
<div key={task.id} className="flex items-center gap-3 px-3 py-2 rounded bg-surface-1 border border-border text-sm">
<span className={`text-[10px] font-mono ${statusColor(task.status)}`}>{task.status}</span>
<span className="text-foreground flex-1 truncate">{task.subject}</span>
{task.owner && <span className="text-[10px] text-muted-foreground">{task.owner}</span>}
{task.blockedBy?.length > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/15 text-red-400">{t('blocked')}</span>
)}
</div>
))}
</div>
</div>
))
)}
</div>
)}
</div>
)
}
function HermesCronSection() {
const t = useTranslations('taskBoard')
const [expanded, setExpanded] = useState(false)
const [data, setData] = useState<{ cronJobs: any[] }>({ cronJobs: [] })
const [loaded, setLoaded] = useState(false)
useEffect(() => {
if (!expanded || loaded) return
fetch('/api/hermes/tasks')
.then(r => r.json())
.then(d => { setData(d); setLoaded(true) })
.catch(() => setLoaded(true))
}, [expanded, loaded])
return (
<div className="mt-4 border border-border rounded-lg overflow-hidden">
<button
onClick={() => setExpanded(prev => !prev)}
className="w-full flex items-center justify-between px-4 py-3 bg-card hover:bg-secondary/50 transition-colors text-left"
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{t('hermesScheduledTasks')}</span>
{data.cronJobs.length > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-500/20 text-purple-400">{data.cronJobs.length}</span>
)}
</div>
<span className="text-muted-foreground text-xs">{expanded ? t('collapse') : t('expand')}</span>
</button>
{expanded && (
<div className="p-4 border-t border-border space-y-2">
{!loaded ? (
<div className="text-sm text-muted-foreground">{t('loading')}</div>
) : data.cronJobs.length === 0 ? (
<div className="text-sm text-muted-foreground text-center py-8">
<p className="font-medium">{t('noScheduledTasksFound')}</p>
<p className="text-xs mt-1 text-muted-foreground/70">{t('noScheduledTasksDesc')}</p>
</div>
) : (
data.cronJobs.map((job: any) => (
<div key={job.id} className="flex items-center gap-3 px-3 py-2 rounded bg-surface-1 border border-border text-sm">
<span className={`text-[10px] font-mono shrink-0 ${job.enabled ? 'text-purple-400' : 'text-muted-foreground/50'}`}>
{job.schedule || t('noSchedule')}
</span>
<span className="text-foreground flex-1 truncate">{job.prompt || job.id}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${job.enabled ? 'bg-green-500/15 text-green-400' : 'bg-muted text-muted-foreground'}`}>
{job.enabled ? t('enabled') : t('disabled')}
</span>
{job.lastRunAt && (
<span className="text-[10px] text-muted-foreground/60 shrink-0">{job.lastRunAt}</span>
)}
</div>
))
)}
</div>
)}
</div>
)
}
// Create Task Modal Component (placeholder)
function CreateTaskModal({
agents,
projects,
onClose,
onCreated
}: {
agents: Agent[]
projects: Project[]
onClose: () => void
onCreated: () => void
}) {
const [formData, setFormData] = useState({
title: '',
description: '',
priority: 'medium' as Task['priority'],
project_id: projects[0]?.id ? String(projects[0].id) : '',
assigned_to: '',
tags: '',
target_session: '',
})
const t = useTranslations('taskBoard')
const agentSessions = useAgentSessions(formData.assigned_to || undefined)
const [isRecurring, setIsRecurring] = useState(false)
const [scheduleInput, setScheduleInput] = useState('')
const [parsedSchedule, setParsedSchedule] = useState<{ cronExpr: string; humanReadable: string } | null>(null)
const [scheduleError, setScheduleError] = useState('')
const mentionTargets = useMentionTargets()
const handleScheduleChange = async (value: string) => {
setScheduleInput(value)
setScheduleError('')
setParsedSchedule(null)
if (!value.trim()) return
try {
const res = await fetch(`/api/schedule-parse?input=${encodeURIComponent(value.trim())}`)
const data = await res.json()
if (data.cronExpr) {
setParsedSchedule(data)
} else {
setScheduleError('Could not parse schedule')
}
} catch {
setScheduleError('Parse failed')
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.title.trim()) return
if (isRecurring && !parsedSchedule) return
const metadata: Record<string, unknown> = {}
if (isRecurring && parsedSchedule) {
metadata.recurrence = {
cron_expr: parsedSchedule.cronExpr,
natural_text: parsedSchedule.humanReadable,
enabled: true,
last_spawned_at: null,
spawn_count: 0,
parent_task_id: null,
}
}
if (formData.target_session) {
metadata.target_session = formData.target_session
}
try {
const response = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
project_id: formData.project_id ? Number(formData.project_id) : undefined,
tags: formData.tags ? formData.tags.split(',').map(t => t.trim()) : [],
assigned_to: formData.assigned_to || undefined,
metadata,
})
})
if (!response.ok) {
const errorData = await response.json()
const errorMsg = errorData.details ? errorData.details.join(', ') : errorData.error
throw new Error(errorMsg)
}
onCreated()
onClose()
} catch (error) {
log.error('Error creating task:', error)
}
}
const dialogRef = useFocusTrap(onClose)
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="create-task-title" className="bg-card border border-border rounded-lg max-w-md w-full">
<form onSubmit={handleSubmit} className="p-6">
<h3 id="create-task-title" className="text-xl font-bold text-foreground mb-4">{t('createNewTask')}</h3>
<div className="space-y-4">
<div>
<label htmlFor="create-title" className="block text-sm text-muted-foreground mb-1">{t('fieldTitle')}</label>
<input
id="create-title"
type="text"
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
required
/>
</div>
<div>
<label htmlFor="create-description" className="block text-sm text-muted-foreground mb-1">{t('fieldDescription')}</label>
<MentionTextarea
id="create-description"
value={formData.description}
onChange={(next) => setFormData(prev => ({ ...prev, description: next }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
rows={3}
mentionTargets={mentionTargets}
/>
<p className="text-[11px] text-muted-foreground mt-1">Tip: type <span className="font-mono">@</span> for mention autocomplete.</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="create-priority" className="block text-sm text-muted-foreground mb-1">{t('fieldPriority')}</label>
<select
id="create-priority"
value={formData.priority}
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as Task['priority'] }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
>
<option value="low">{t('priority_low')}</option>
<option value="medium">{t('priority_medium')}</option>
<option value="high">{t('priority_high')}</option>
<option value="critical">{t('priority_critical')}</option>
</select>
</div>
<div>
<label htmlFor="create-project" className="block text-sm text-muted-foreground mb-1">{t('fieldProject')}</label>
<select
id="create-project"
value={formData.project_id}
onChange={(e) => setFormData(prev => ({ ...prev, project_id: e.target.value }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
>
{projects.map(project => (
<option key={project.id} value={String(project.id)}>
{project.name} ({project.ticket_prefix})
</option>
))}
</select>
</div>
</div>
<div>
<label htmlFor="create-assignee" className="block text-sm text-muted-foreground mb-1">{t('fieldAssignTo')}</label>
<select
id="create-assignee"
value={formData.assigned_to}
onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value, target_session: '' }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
>
<option value="">{t('unassigned')}</option>
{agents.map(agent => (
<option key={agent.name} value={agent.name}>
{agent.name} ({agent.role})
</option>
))}
</select>
</div>
{formData.assigned_to && agentSessions.length > 0 && (
<div>
<label htmlFor="create-target-session" className="block text-sm text-muted-foreground mb-1">Target Session</label>
<select
id="create-target-session"
value={formData.target_session}
onChange={(e) => setFormData(prev => ({ ...prev, target_session: e.target.value }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
>
<option value="">New session (default)</option>
{agentSessions.map(s => (
<option key={s.key} value={s.key}>
{s.displayLabel}
</option>
))}
</select>
<p className="text-[11px] text-muted-foreground mt-1">Send task to an existing agent session instead of creating a new one.</p>
</div>
)}
<div>
<label htmlFor="create-tags" className="block text-sm text-muted-foreground mb-1">{t('fieldTags')}</label>
<input
id="create-tags"
type="text"
value={formData.tags}
onChange={(e) => setFormData(prev => ({ ...prev, tags: e.target.value }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
placeholder="frontend, urgent, bug"
/>
</div>
{/* Recurring Schedule */}
<div className="border border-border rounded-md p-3 space-y-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isRecurring}
onChange={(e) => {
setIsRecurring(e.target.checked)
if (!e.target.checked) {
setParsedSchedule(null)
setScheduleInput('')
setScheduleError('')
}
}}
className="rounded border-border"
/>
<span className="text-sm text-foreground">{t('makeRecurring')}</span>
</label>
{isRecurring && (
<div>
<input
type="text"
value={scheduleInput}
onChange={(e) => handleScheduleChange(e.target.value)}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50"
placeholder='e.g. "every morning at 9am" or "every 2 hours"'
/>
{parsedSchedule && (
<p className="text-xs text-cyan-400 mt-1">
{parsedSchedule.humanReadable} <span className="text-muted-foreground font-mono">({parsedSchedule.cronExpr})</span>
</p>
)}
{scheduleError && (
<p className="text-xs text-red-400 mt-1">{scheduleError}. Try: &quot;daily at 9am&quot;, &quot;every 2 hours&quot;, &quot;weekly on monday&quot;</p>
)}
</div>
)}
</div>
</div>
<div className="flex gap-3 mt-6">
<Button type="submit" className="flex-1" disabled={isRecurring && !parsedSchedule}>
{isRecurring ? t('createRecurringTask') : t('createTask')}
</Button>
<Button type="button" variant="secondary" onClick={onClose} className="flex-1">
{t('cancel')}
</Button>
</div>
</form>
</div>
</div>
)
}
// Edit Task Modal Component
function EditTaskModal({
task,
agents,
projects,
onClose,
onUpdated
}: {
task: Task
agents: Agent[]
projects: Project[]
onClose: () => void
onUpdated: () => void
}) {
const t = useTranslations('taskBoard')
const [formData, setFormData] = useState({
title: task.title,
description: task.description || '',
priority: task.priority,
status: task.status,
project_id: task.project_id ? String(task.project_id) : (projects[0]?.id ? String(projects[0].id) : ''),
assigned_to: task.assigned_to || '',
tags: task.tags ? task.tags.join(', ') : '',
target_session: task.metadata?.target_session || '',
})
const mentionTargets = useMentionTargets()
const agentSessions = useAgentSessions(formData.assigned_to || undefined)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.title.trim()) return
try {
const existingMeta = task.metadata || {}
const updatedMeta = { ...existingMeta }
if (formData.target_session) {
updatedMeta.target_session = formData.target_session
} else {
delete updatedMeta.target_session
}
const response = await fetch(`/api/tasks/${task.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
project_id: formData.project_id ? Number(formData.project_id) : undefined,
tags: formData.tags ? formData.tags.split(',').map(t => t.trim()) : [],
assigned_to: formData.assigned_to || undefined,
metadata: updatedMeta,
})
})
if (!response.ok) {
const errorData = await response.json()
const errorMsg = errorData.details ? errorData.details.join(', ') : errorData.error
throw new Error(errorMsg)
}
onUpdated()
} catch (error) {
log.error('Error updating task:', error)
}
}
const dialogRef = useFocusTrap(onClose)
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="edit-task-title" className="bg-card border border-border rounded-lg max-w-md w-full">
<form onSubmit={handleSubmit} className="p-6">
<h3 id="edit-task-title" className="text-xl font-bold text-foreground mb-4">{t('editTask')}</h3>
<div className="space-y-4">
<div>
<label htmlFor="edit-title" className="block text-sm text-muted-foreground mb-1">{t('fieldTitle')}</label>
<input
id="edit-title"
type="text"
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
required
/>
</div>
<div>
<label htmlFor="edit-description" className="block text-sm text-muted-foreground mb-1">{t('fieldDescription')}</label>
<MentionTextarea
id="edit-description"
value={formData.description}
onChange={(next) => setFormData(prev => ({ ...prev, description: next }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
rows={3}
mentionTargets={mentionTargets}
/>
<p className="text-[11px] text-muted-foreground mt-1">Tip: type <span className="font-mono">@</span> for mention autocomplete.</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="edit-status" className="block text-sm text-muted-foreground mb-1">{t('fieldStatus')}</label>
<select
id="edit-status"
value={formData.status}
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value as Task['status'] }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
>
<option value="inbox">{t('colInbox')}</option>
<option value="assigned">{t('colAssigned')}</option>
<option value="in_progress">{t('colInProgress')}</option>
<option value="review">{t('colReview')}</option>
<option value="quality_review">{t('colQualityReview')}</option>
<option value="done">{t('colDone')}</option>
</select>
</div>
<div>
<label htmlFor="edit-priority" className="block text-sm text-muted-foreground mb-1">{t('fieldPriority')}</label>
<select
id="edit-priority"
value={formData.priority}
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as Task['priority'] }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
>
<option value="low">{t('priority_low')}</option>
<option value="medium">{t('priority_medium')}</option>
<option value="high">{t('priority_high')}</option>
<option value="critical">{t('priority_critical')}</option>
</select>
</div>
</div>
<div>
<label htmlFor="edit-project" className="block text-sm text-muted-foreground mb-1">{t('fieldProject')}</label>
<select
id="edit-project"
value={formData.project_id}
onChange={(e) => setFormData(prev => ({ ...prev, project_id: e.target.value }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
>
{projects.map(project => (
<option key={project.id} value={String(project.id)}>
{project.name} ({project.ticket_prefix})
</option>
))}
</select>
</div>
<div>
<label htmlFor="edit-assignee" className="block text-sm text-muted-foreground mb-1">{t('fieldAssignTo')}</label>
<select
id="edit-assignee"
value={formData.assigned_to}
onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value, target_session: '' }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
>
<option value="">{t('unassigned')}</option>
{agents.map(agent => (
<option key={agent.name} value={agent.name}>
{agent.name} ({agent.role})
</option>
))}
</select>
</div>
{formData.assigned_to && agentSessions.length > 0 && (
<div>
<label htmlFor="edit-target-session" className="block text-sm text-muted-foreground mb-1">Target Session</label>
<select
id="edit-target-session"
value={formData.target_session}
onChange={(e) => setFormData(prev => ({ ...prev, target_session: e.target.value }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
>
<option value="">New session (default)</option>
{agentSessions.map(s => (
<option key={s.key} value={s.key}>
{s.displayLabel}
</option>
))}
</select>
<p className="text-[11px] text-muted-foreground mt-1">Send task to an existing agent session instead of creating a new one.</p>
</div>
)}
<div>
<label htmlFor="edit-tags" className="block text-sm text-muted-foreground mb-1">{t('fieldTags')}</label>
<input
id="edit-tags"
type="text"
value={formData.tags}
onChange={(e) => setFormData(prev => ({ ...prev, tags: e.target.value }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
placeholder="frontend, urgent, bug"
/>
</div>
</div>
<div className="flex gap-3 mt-6">
<Button type="submit" className="flex-1">
{t('saveChanges')}
</Button>
<Button type="button" variant="secondary" onClick={onClose} className="flex-1">
{t('cancel')}
</Button>
</div>
</form>
</div>
</div>
)
}