1126 lines
42 KiB
TypeScript
1126 lines
42 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||
import { useMissionControl } from '@/store'
|
||
import { useSmartPoll } from '@/lib/use-smart-poll'
|
||
import { AgentAvatar } from '@/components/ui/agent-avatar'
|
||
import { MarkdownRenderer } from '@/components/markdown-renderer'
|
||
|
||
interface Task {
|
||
id: number
|
||
title: string
|
||
description?: string
|
||
status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done'
|
||
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
|
||
}
|
||
|
||
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[]
|
||
}
|
||
|
||
const statusColumns = [
|
||
{ key: 'inbox', title: 'Inbox', color: 'bg-secondary text-foreground' },
|
||
{ key: 'assigned', title: 'Assigned', color: 'bg-blue-500/20 text-blue-400' },
|
||
{ key: 'in_progress', title: 'In Progress', color: 'bg-yellow-500/20 text-yellow-400' },
|
||
{ key: 'review', title: 'Review', color: 'bg-purple-500/20 text-purple-400' },
|
||
{ key: 'quality_review', title: 'Quality Review', color: 'bg-indigo-500/20 text-indigo-400' },
|
||
{ key: 'done', title: 'Done', color: 'bg-green-500/20 text-green-400' },
|
||
]
|
||
|
||
const priorityColors: Record<string, string> = {
|
||
low: 'border-green-500',
|
||
medium: 'border-yellow-500',
|
||
high: 'border-orange-500',
|
||
critical: 'border-red-500',
|
||
}
|
||
|
||
export function TaskBoardPanel() {
|
||
const { tasks: storeTasks, setTasks: storeSetTasks, selectedTask, setSelectedTask } = useMissionControl()
|
||
const [agents, setAgents] = useState<Agent[]>([])
|
||
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 [editingTask, setEditingTask] = useState<Task | null>(null)
|
||
const dragCounter = useRef(0)
|
||
|
||
// Augment store tasks with aegisApproved flag (computed, not stored)
|
||
const tasks: Task[] = storeTasks.map(t => ({
|
||
...t,
|
||
aegisApproved: Boolean(aegisMap[t.id])
|
||
}))
|
||
|
||
// Fetch tasks and agents
|
||
const fetchData = useCallback(async () => {
|
||
try {
|
||
setLoading(true)
|
||
setError(null)
|
||
|
||
const [tasksResponse, agentsResponse] = await Promise.all([
|
||
fetch('/api/tasks'),
|
||
fetch('/api/agents')
|
||
])
|
||
|
||
if (!tasksResponse.ok || !agentsResponse.ok) {
|
||
throw new Error('Failed to fetch data')
|
||
}
|
||
|
||
const tasksData = await tasksResponse.json()
|
||
const agentsData = await agentsResponse.json()
|
||
|
||
const tasksList = tasksData.tasks || []
|
||
const taskIds = tasksList.map((task: Task) => task.id)
|
||
|
||
let newAegisMap: Record<number, boolean> = {}
|
||
if (taskIds.length > 0) {
|
||
try {
|
||
const reviewResponse = await fetch(`/api/quality-review?taskIds=${taskIds.join(',')}`)
|
||
if (reviewResponse.ok) {
|
||
const reviewData = await reviewResponse.json()
|
||
const latest = reviewData.latest || {}
|
||
newAegisMap = Object.fromEntries(
|
||
Object.entries(latest).map(([id, row]: [string, any]) => [
|
||
Number(id),
|
||
row?.reviewer === 'aegis' && row?.status === 'approved'
|
||
])
|
||
)
|
||
}
|
||
} catch {
|
||
newAegisMap = {}
|
||
}
|
||
}
|
||
|
||
storeSetTasks(tasksList)
|
||
setAegisMap(newAegisMap)
|
||
setAgents(agentsData.agents || [])
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'An error occurred')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [storeSetTasks])
|
||
|
||
useEffect(() => {
|
||
fetchData()
|
||
}, [fetchData])
|
||
|
||
// Poll as SSE fallback — pauses when SSE is delivering events
|
||
useSmartPoll(fetchData, 30000, { pauseWhenSseConnected: true })
|
||
|
||
// Group tasks by status
|
||
const tasksByStatus = statusColumns.reduce((acc, column) => {
|
||
acc[column.key] = tasks.filter(task => task.status === 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 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="flex items-center justify-center h-64">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||
<span className="ml-2 text-muted-foreground">Loading tasks...</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">
|
||
<h2 className="text-xl font-bold text-foreground">Task Board</h2>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => setShowCreateModal(true)}
|
||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-smooth text-sm font-medium"
|
||
>
|
||
+ New Task
|
||
</button>
|
||
<button
|
||
onClick={fetchData}
|
||
className="px-4 py-2 bg-secondary text-muted-foreground rounded-md hover:bg-surface-2 transition-smooth text-sm font-medium"
|
||
>
|
||
Refresh
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Error Display */}
|
||
{error && (
|
||
<div 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
|
||
onClick={() => setError(null)}
|
||
className="text-red-400/60 hover:text-red-400 ml-2"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Kanban Board */}
|
||
<div className="flex-1 flex gap-4 p-4 overflow-x-auto">
|
||
{statusColumns.map(column => (
|
||
<div
|
||
key={column.key}
|
||
className="flex-1 min-w-80 bg-card border border-border rounded-lg flex flex-col"
|
||
onDragEnter={(e) => handleDragEnter(e, column.key)}
|
||
onDragLeave={handleDragLeave}
|
||
onDragOver={handleDragOver}
|
||
onDrop={(e) => handleDrop(e, column.key)}
|
||
>
|
||
{/* Column Header */}
|
||
<div className={`${column.color} p-3 rounded-t-lg flex justify-between items-center`}>
|
||
<h3 className="font-semibold">{column.title}</h3>
|
||
<span className="text-sm bg-black/20 px-2 py-1 rounded">
|
||
{tasksByStatus[column.key]?.length || 0}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Column Body */}
|
||
<div className="flex-1 p-3 space-y-3 min-h-32">
|
||
{tasksByStatus[column.key]?.map(task => (
|
||
<div
|
||
key={task.id}
|
||
draggable
|
||
onDragStart={(e) => handleDragStart(e, task)}
|
||
onClick={() => setSelectedTask(task)}
|
||
className={`bg-surface-1 rounded-lg p-3 cursor-pointer hover:bg-surface-2 transition-smooth border-l-4 ${priorityColors[task.priority]} ${
|
||
draggedTask?.id === task.id ? 'opacity-50' : ''
|
||
}`}
|
||
>
|
||
<div className="flex justify-between items-start mb-2">
|
||
<h4 className="text-foreground font-medium text-sm leading-tight">
|
||
{task.title}
|
||
</h4>
|
||
<div className="flex items-center gap-2">
|
||
{task.aegisApproved && (
|
||
<span className="text-[10px] px-2 py-0.5 rounded bg-emerald-700 text-emerald-100">
|
||
Aegis Approved
|
||
</span>
|
||
)}
|
||
<span className={`text-xs px-2 py-1 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'
|
||
}`}>
|
||
{task.priority}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{task.description && (
|
||
<div className="mb-2 line-clamp-3 overflow-hidden">
|
||
<MarkdownRenderer content={task.description} preview />
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-between items-center text-xs text-muted-foreground">
|
||
<span className="flex items-center gap-1.5 min-w-0">
|
||
{task.assigned_to ? (
|
||
<>
|
||
<AgentAvatar name={getAgentName(task.assigned_to)} size="xs" />
|
||
<span className="truncate">{getAgentName(task.assigned_to)}</span>
|
||
</>
|
||
) : (
|
||
<span>Unassigned</span>
|
||
)}
|
||
</span>
|
||
<span className="font-medium">{formatTaskTimestamp(task.created_at)}</span>
|
||
</div>
|
||
|
||
{task.tags && task.tags.length > 0 && (
|
||
<div className="flex flex-wrap gap-1 mt-2">
|
||
{task.tags.slice(0, 3).map((tag, index) => (
|
||
<span
|
||
key={index}
|
||
className={`text-xs px-2 py-0.5 rounded-full border font-medium ${getTagColor(tag)}`}
|
||
>
|
||
{tag}
|
||
</span>
|
||
))}
|
||
{task.tags.length > 3 && (
|
||
<span className="text-muted-foreground text-xs font-medium">+{task.tags.length - 3}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Enhanced timestamp display */}
|
||
{task.updated_at && task.updated_at !== task.created_at && (
|
||
<div className="text-xs text-muted-foreground/70 mt-1">
|
||
Updated {formatTaskTimestamp(task.updated_at)}
|
||
</div>
|
||
)}
|
||
|
||
{task.due_date && (
|
||
<div className="mt-2 text-xs">
|
||
<span className={`${
|
||
task.due_date * 1000 < Date.now() ? 'text-red-400' : 'text-yellow-400'
|
||
}`}>
|
||
Due: {formatTaskTimestamp(task.due_date)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
{/* Empty State */}
|
||
{tasksByStatus[column.key]?.length === 0 && (
|
||
<div className="text-center text-muted-foreground/50 py-8 text-sm">
|
||
No tasks in {column.title.toLowerCase()}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Task Detail Modal */}
|
||
{selectedTask && !editingTask && (
|
||
<TaskDetailModal
|
||
task={selectedTask}
|
||
agents={agents}
|
||
onClose={() => setSelectedTask(null)}
|
||
onUpdate={fetchData}
|
||
onEdit={(taskToEdit) => {
|
||
setEditingTask(taskToEdit)
|
||
setSelectedTask(null)
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Create Task Modal */}
|
||
{showCreateModal && (
|
||
<CreateTaskModal
|
||
agents={agents}
|
||
onClose={() => setShowCreateModal(false)}
|
||
onCreated={fetchData}
|
||
/>
|
||
)}
|
||
|
||
{/* Edit Task Modal */}
|
||
{editingTask && (
|
||
<EditTaskModal
|
||
task={editingTask}
|
||
agents={agents}
|
||
onClose={() => setEditingTask(null)}
|
||
onUpdated={() => { fetchData(); setEditingTask(null) }}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Task Detail Modal Component (placeholder - would be implemented separately)
|
||
function TaskDetailModal({
|
||
task,
|
||
agents,
|
||
onClose,
|
||
onUpdate,
|
||
onEdit
|
||
}: {
|
||
task: Task
|
||
agents: Agent[]
|
||
onClose: () => void
|
||
onUpdate: () => void
|
||
onEdit: (task: Task) => void
|
||
}) {
|
||
const [comments, setComments] = useState<Comment[]>([])
|
||
const [loadingComments, setLoadingComments] = useState(false)
|
||
const [commentText, setCommentText] = useState('')
|
||
const [commentAuthor, setCommentAuthor] = useState('system')
|
||
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 [activeTab, setActiveTab] = useState<'details' | 'comments' | 'quality'>('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(`Sent to ${data.sent || 0} subscribers`)
|
||
} 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 renderComment = (comment: Comment, depth: number = 0) => (
|
||
<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">
|
||
<span className="font-medium text-foreground/80">{comment.author}</span>
|
||
<span>{new Date(comment.created_at * 1000).toLocaleString()}</span>
|
||
</div>
|
||
<div className="text-sm text-foreground/90 mt-1 whitespace-pre-wrap">{comment.content}</div>
|
||
{comment.replies && comment.replies.length > 0 && (
|
||
<div className="mt-3 space-y-3">
|
||
{comment.replies.map(reply => renderComment(reply, depth + 1))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||
<div 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 className="text-xl font-bold text-foreground">{task.title}</h3>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => onEdit(task)}
|
||
className="px-3 py-1.5 bg-primary/20 text-primary hover:bg-primary/30 rounded-md transition-smooth text-sm font-medium"
|
||
>
|
||
Edit
|
||
</button>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-muted-foreground hover:text-foreground text-2xl transition-smooth"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{task.description ? (
|
||
<div className="mb-4">
|
||
<MarkdownRenderer content={task.description} />
|
||
</div>
|
||
) : (
|
||
<p className="text-foreground/80 mb-4">No description</p>
|
||
)}
|
||
<div className="flex gap-2 mt-4">
|
||
{(['details', 'comments', 'quality'] as const).map(tab => (
|
||
<button
|
||
key={tab}
|
||
onClick={() => setActiveTab(tab)}
|
||
className={`px-3 py-2 text-sm rounded-md transition-smooth ${
|
||
activeTab === tab ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2'
|
||
}`}
|
||
>
|
||
{tab === 'details' ? 'Details' : tab === 'comments' ? 'Comments' : 'Quality Review'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{activeTab === 'details' && (
|
||
<div className="grid grid-cols-2 gap-4 text-sm mt-4">
|
||
<div>
|
||
<span className="text-muted-foreground">Status:</span>
|
||
<span className="text-foreground ml-2">{task.status}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-muted-foreground">Priority:</span>
|
||
<span className="text-foreground ml-2">{task.priority}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-muted-foreground">Assigned to:</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>Unassigned</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-muted-foreground">Created:</span>
|
||
<span className="text-foreground ml-2">{new Date(task.created_at * 1000).toLocaleDateString()}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'comments' && (
|
||
<div className="mt-6">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h4 className="text-lg font-semibold text-foreground">Comments</h4>
|
||
<button
|
||
onClick={fetchComments}
|
||
className="text-xs text-blue-400 hover:text-blue-300"
|
||
>
|
||
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">Loading comments...</div>
|
||
) : comments.length === 0 ? (
|
||
<div className="text-muted-foreground/50 text-sm">No comments yet.</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{comments.map(comment => renderComment(comment))}
|
||
</div>
|
||
)}
|
||
|
||
<form onSubmit={handleAddComment} className="mt-4 space-y-3">
|
||
<div>
|
||
<label className="block text-xs text-muted-foreground mb-1">Author</label>
|
||
<input
|
||
type="text"
|
||
value={commentAuthor}
|
||
onChange={(e) => setCommentAuthor(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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs text-muted-foreground mb-1">New Comment</label>
|
||
<textarea
|
||
value={commentText}
|
||
onChange={(e) => setCommentText(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"
|
||
rows={3}
|
||
/>
|
||
</div>
|
||
<div className="flex justify-end">
|
||
<button
|
||
type="submit"
|
||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-smooth text-sm"
|
||
>
|
||
Add Comment
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
<div className="mt-6 border-t border-border pt-4">
|
||
<h5 className="text-sm font-medium text-foreground mb-2">Broadcast to Subscribers</h5>
|
||
{broadcastStatus && (
|
||
<div className="text-xs text-muted-foreground mb-2">{broadcastStatus}</div>
|
||
)}
|
||
<form onSubmit={handleBroadcast} className="space-y-2">
|
||
<textarea
|
||
value={broadcastMessage}
|
||
onChange={(e) => setBroadcastMessage(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"
|
||
rows={2}
|
||
placeholder="Send a message to all task subscribers..."
|
||
/>
|
||
<div className="flex justify-end">
|
||
<button
|
||
type="submit"
|
||
className="px-3 py-2 bg-purple-500/20 text-purple-400 border border-purple-500/30 rounded-md hover:bg-purple-500/30 transition-smooth text-xs"
|
||
>
|
||
Broadcast
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'quality' && (
|
||
<div className="mt-6">
|
||
<h5 className="text-sm font-medium text-foreground mb-2">Aegis Quality Review</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">No reviews yet.</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="Reviewer (e.g., aegis)"
|
||
/>
|
||
<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="Review notes (required)"
|
||
/>
|
||
<button
|
||
type="submit"
|
||
className="px-3 py-1 bg-green-500/20 text-green-400 border border-green-500/30 rounded-md text-xs"
|
||
>
|
||
Submit
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Create Task Modal Component (placeholder)
|
||
function CreateTaskModal({
|
||
agents,
|
||
onClose,
|
||
onCreated
|
||
}: {
|
||
agents: Agent[]
|
||
onClose: () => void
|
||
onCreated: () => void
|
||
}) {
|
||
const [formData, setFormData] = useState({
|
||
title: '',
|
||
description: '',
|
||
priority: 'medium' as Task['priority'],
|
||
assigned_to: '',
|
||
tags: '',
|
||
})
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
|
||
if (!formData.title.trim()) return
|
||
|
||
try {
|
||
const response = await fetch('/api/tasks', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
...formData,
|
||
tags: formData.tags ? formData.tags.split(',').map(t => t.trim()) : [],
|
||
assigned_to: formData.assigned_to || undefined
|
||
})
|
||
})
|
||
|
||
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) {
|
||
console.error('Error creating task:', error)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||
<div className="bg-card border border-border rounded-lg max-w-md w-full">
|
||
<form onSubmit={handleSubmit} className="p-6">
|
||
<h3 className="text-xl font-bold text-foreground mb-4">Create New Task</h3>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm text-muted-foreground mb-1">Title</label>
|
||
<input
|
||
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 className="block text-sm text-muted-foreground mb-1">Description</label>
|
||
<textarea
|
||
value={formData.description}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, description: 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"
|
||
rows={3}
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm text-muted-foreground mb-1">Priority</label>
|
||
<select
|
||
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">Low</option>
|
||
<option value="medium">Medium</option>
|
||
<option value="high">High</option>
|
||
<option value="critical">Critical</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm text-muted-foreground mb-1">Assign to</label>
|
||
<select
|
||
value={formData.assigned_to}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: 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="">Unassigned</option>
|
||
{agents.map(agent => (
|
||
<option key={agent.name} value={agent.name}>
|
||
{agent.name} ({agent.role})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm text-muted-foreground mb-1">Tags (comma-separated)</label>
|
||
<input
|
||
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 bg-primary text-primary-foreground py-2 rounded-md hover:bg-primary/90 transition-smooth"
|
||
>
|
||
Create Task
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="flex-1 bg-secondary text-muted-foreground py-2 rounded-md hover:bg-surface-2 transition-smooth"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Edit Task Modal Component
|
||
function EditTaskModal({
|
||
task,
|
||
agents,
|
||
onClose,
|
||
onUpdated
|
||
}: {
|
||
task: Task
|
||
agents: Agent[]
|
||
onClose: () => void
|
||
onUpdated: () => void
|
||
}) {
|
||
const [formData, setFormData] = useState({
|
||
title: task.title,
|
||
description: task.description || '',
|
||
priority: task.priority,
|
||
status: task.status,
|
||
assigned_to: task.assigned_to || '',
|
||
tags: task.tags ? task.tags.join(', ') : '',
|
||
})
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
|
||
if (!formData.title.trim()) return
|
||
|
||
try {
|
||
const response = await fetch(`/api/tasks/${task.id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
...formData,
|
||
tags: formData.tags ? formData.tags.split(',').map(t => t.trim()) : [],
|
||
assigned_to: formData.assigned_to || undefined
|
||
})
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json()
|
||
const errorMsg = errorData.details ? errorData.details.join(', ') : errorData.error
|
||
throw new Error(errorMsg)
|
||
}
|
||
|
||
onUpdated()
|
||
} catch (error) {
|
||
console.error('Error updating task:', error)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||
<div className="bg-card border border-border rounded-lg max-w-md w-full">
|
||
<form onSubmit={handleSubmit} className="p-6">
|
||
<h3 className="text-xl font-bold text-foreground mb-4">Edit Task</h3>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm text-muted-foreground mb-1">Title</label>
|
||
<input
|
||
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 className="block text-sm text-muted-foreground mb-1">Description</label>
|
||
<textarea
|
||
value={formData.description}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, description: 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"
|
||
rows={3}
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm text-muted-foreground mb-1">Status</label>
|
||
<select
|
||
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">Inbox</option>
|
||
<option value="assigned">Assigned</option>
|
||
<option value="in_progress">In Progress</option>
|
||
<option value="review">Review</option>
|
||
<option value="quality_review">Quality Review</option>
|
||
<option value="done">Done</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm text-muted-foreground mb-1">Priority</label>
|
||
<select
|
||
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">Low</option>
|
||
<option value="medium">Medium</option>
|
||
<option value="high">High</option>
|
||
<option value="critical">Critical</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm text-muted-foreground mb-1">Assign to</label>
|
||
<select
|
||
value={formData.assigned_to}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: 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="">Unassigned</option>
|
||
{agents.map(agent => (
|
||
<option key={agent.name} value={agent.name}>
|
||
{agent.name} ({agent.role})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm text-muted-foreground mb-1">Tags (comma-separated)</label>
|
||
<input
|
||
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 bg-primary text-primary-foreground py-2 rounded-md hover:bg-primary/90 transition-smooth"
|
||
>
|
||
Save Changes
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="flex-1 bg-secondary text-muted-foreground py-2 rounded-md hover:bg-surface-2 transition-smooth"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|