OpenClawDashboard/dashboard/src/components/tasks/kanban-board.tsx
Mannu d4a3f2b869 feat: complete Tiger dashboard implementation
- Bridge: Express API server with SQLite (projects, tasks, executions, outputs)
- Dashboard: Next.js app rewired from WebSocket gateway to Tiger Bridge HTTP API
- Tasks: Kanban board with drag-drop, project management with CRUD
- Dispatch: Task dispatch to sandbox with file watcher for status updates
- UI: Container health panel, workspace browser, logs viewer, output viewer

Critical fixes:
- Use execInSandbox instead of execOnHost for container operations
- Watch symlink path instead of container-internal path
- URL-encoded params for GET requests instead of body
- PUT/DELETE support added to useBridgeRequest

Sprints 1-5 complete. Ready for VPS deployment.
2026-04-12 23:27:51 +05:30

458 lines
No EOL
14 KiB
TypeScript

"use client"
import { useState, useEffect, useCallback } from "react"
import {
DndContext,
DragOverlay,
closestCorners,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragStartEvent,
DragOverEvent,
DragEndEvent,
defaultDropAnimationSideEffects,
DropAnimation
} from "@dnd-kit/core"
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { TaskCard } from "./task-card"
import { TaskDialog } from "./task-dialog"
import { Button } from "@/components/ui/button"
import { Plus, Filter, Search, Loader2 } from "lucide-react"
import { Input } from "@/components/ui/input"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useBridgeRequest } from "@/hooks/use-bridge"
type TaskStatus = "backlog" | "ready" | "in-progress" | "review" | "done"
interface Task {
id: string
project_id: string | null
title: string
description: string
status: TaskStatus
priority: string
assigned_agent: string | null
progress: number
tags: string[]
created_at: string
updated_at: string
}
const COLUMNS: TaskStatus[] = ["backlog", "ready", "in-progress", "review", "done"]
const STATUS_LABELS: Record<TaskStatus, string> = {
"backlog": "Backlog",
"ready": "Ready",
"in-progress": "In Progress",
"review": "Review",
"done": "Done",
}
export function KanbanBoard() {
const { request } = useBridgeRequest()
const [tasks, setTasks] = useState<Task[]>([])
const [activeId, setActiveId] = useState<string | null>(null)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [editingTask, setEditingTask] = useState<Task | null>(null)
const [searchQuery, setSearchQuery] = useState("")
const [filterPriority, setFilterPriority] = useState<Set<string>>(new Set())
const [filterAgent, setFilterAgent] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
// Load tasks from API
const loadTasks = useCallback(async () => {
setLoading(true)
try {
const data = await request("/api/tiger/tasks") as { ok: boolean; tasks?: Task[] }
if (data.ok && data.tasks) {
setTasks(data.tasks.map((t: Task) => ({
...t,
tags: typeof t.tags === "string" ? JSON.parse(t.tags || "[]") : t.tags || [],
})))
}
} catch (e) {
console.error("Failed to load tasks:", e)
} finally {
setLoading(false)
}
}, [request])
useEffect(() => {
loadTasks()
}, [loadTasks])
// Update task status when dragged
const updateTaskStatus = useCallback(async (taskId: string, newStatus: TaskStatus) => {
setSaving(true)
try {
await request(`/api/tiger/tasks/${taskId}`, "POST", { status: newStatus })
setTasks(prev => prev.map(t =>
t.id === taskId ? { ...t, status: newStatus, updated_at: new Date().toISOString() } : t
))
} catch (e) {
console.error("Failed to update task:", e)
} finally {
setSaving(false)
}
}, [request])
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as string)
}, [])
const handleDragOver = useCallback((event: DragOverEvent) => {
const { active, over } = event
if (!over) return
const activeId = active.id as string
const overId = over.id as string
const activeTask = tasks.find(t => t.id === activeId)
if (!activeTask) return
// If dragging over a column
if (COLUMNS.includes(overId as TaskStatus)) {
if (activeTask.status !== overId) {
setTasks(prev => prev.map(t =>
t.id === activeId ? { ...t, status: overId as TaskStatus } : t
))
}
return
}
// If dragging over another task
const overTask = tasks.find(t => t.id === overId)
if (overTask && activeTask.status !== overTask.status) {
setTasks(prev => prev.map(t =>
t.id === activeId ? { ...t, status: overTask.status } : t
))
}
}, [tasks])
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
setActiveId(null)
if (!over) return
const activeId = active.id as string
const overId = over.id as string
if (activeId === overId) return
// Find the task and its new status
const activeTask = tasks.find(t => t.id === activeId)
if (!activeTask) return
let newStatus: TaskStatus = activeTask.status
if (COLUMNS.includes(overId as TaskStatus)) {
newStatus = overId as TaskStatus
} else {
const overTask = tasks.find(t => t.id === overId)
if (overTask) {
newStatus = overTask.status
}
}
// Persist to API
if (newStatus !== activeTask.status) {
updateTaskStatus(activeId, newStatus)
}
// Reorder within column
setTasks(prev => {
const oldIndex = prev.findIndex(t => t.id === activeId)
const newIndex = prev.findIndex(t => t.id === overId)
if (oldIndex !== -1 && newIndex !== -1) {
return arrayMove(prev, oldIndex, newIndex)
}
return prev
})
}, [tasks, updateTaskStatus])
const handleAddTask = useCallback(async (taskData: Partial<Task>) => {
try {
const result = await request("/api/tiger/tasks", "POST", {
title: taskData.title,
description: taskData.description,
priority: taskData.priority || "medium",
status: taskData.status || "backlog",
assigned_agent: taskData.assigned_agent,
}) as { ok: boolean; task?: Task }
if (result.ok && result.task) {
setTasks(prev => [result.task!, ...prev])
}
setIsDialogOpen(false)
} catch (e) {
console.error("Failed to create task:", e)
}
}, [request])
const handleUpdateTask = useCallback(async (taskData: Partial<Task>) => {
if (!editingTask) return
try {
const result = await request(`/api/tiger/tasks/${editingTask.id}`, "POST", taskData) as { ok: boolean; task?: Task }
if (result.ok && result.task) {
setTasks(prev => prev.map(t =>
t.id === editingTask.id ? { ...result.task!, tags: typeof result.task!.tags === "string" ? JSON.parse(result.task!.tags || "[]") : result.task!.tags || [] } : t
))
}
setIsDialogOpen(false)
setEditingTask(null)
} catch (e) {
console.error("Failed to update task:", e)
}
}, [editingTask, request])
const handleDeleteTask = useCallback(async (taskId: string) => {
try {
await request(`/api/tiger/tasks/${taskId}`, "POST", { _method: "DELETE" })
setTasks(prev => prev.filter(t => t.id !== taskId))
setIsDialogOpen(false)
setEditingTask(null)
} catch (e) {
console.error("Failed to delete task:", e)
}
}, [request])
const handleEditTask = useCallback((task: Task) => {
setEditingTask(task)
setIsDialogOpen(true)
}, [])
const handleRunTask = useCallback(async (taskId: string) => {
try {
const result = await request(`/api/tiger/dispatch`, "POST", { taskId }) as { ok: boolean; message?: string }
if (result.ok) {
console.log("Task dispatched:", result.message)
}
} catch (e) {
console.error("Failed to run task:", e)
}
}, [request])
// Filter tasks
const filteredTasks = tasks.filter(task => {
if (searchQuery && !task.title.toLowerCase().includes(searchQuery.toLowerCase()) &&
!task.description.toLowerCase().includes(searchQuery.toLowerCase())) {
return false
}
if (filterPriority.size > 0 && !filterPriority.has(task.priority)) {
return false
}
if (filterAgent.size > 0 && (!task.assigned_agent || !filterAgent.has(task.assigned_agent))) {
return false
}
return true
})
const activeTask = activeId ? tasks.find(t => t.id === activeId) : null
const dropAnimation: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: "0.5",
},
},
}),
}
if (loading) {
return (
<div className="flex items-center justify-center h-[400px]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<Button onClick={() => {
setEditingTask(null)
setIsDialogOpen(true)
}}>
<Plus className="h-4 w-4 mr-2" />
New Task
</Button>
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search tasks..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Filter className="h-4 w-4 mr-2" />
Priority
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{["low", "medium", "high", "urgent"].map(priority => (
<DropdownMenuCheckboxItem
key={priority}
checked={filterPriority.has(priority)}
onCheckedChange={(checked) => {
const newSet = new Set(filterPriority)
if (checked) newSet.add(priority)
else newSet.delete(priority)
setFilterPriority(newSet)
}}
>
{priority.charAt(0).toUpperCase() + priority.slice(1)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Filter className="h-4 w-4 mr-2" />
Agent
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{["manual", "coder", "researcher", "writer", "pm"].map(agent => (
<DropdownMenuCheckboxItem
key={agent}
checked={filterAgent.has(agent)}
onCheckedChange={(checked) => {
const newSet = new Set(filterAgent)
if (checked) newSet.add(agent)
else newSet.delete(agent)
setFilterAgent(newSet)
}}
>
{agent.charAt(0).toUpperCase() + agent.slice(1)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Saving indicator */}
{saving && (
<div className="text-xs text-muted-foreground flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin" />
Saving changes...
</div>
)}
{/* Kanban Board */}
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex gap-4 overflow-x-auto pb-4 min-h-[500px]">
{COLUMNS.map(status => (
<div key={status} className="min-w-[200px]">
<div className="text-sm font-medium text-muted-foreground uppercase tracking-wider px-2 mb-2">
{STATUS_LABELS[status]} ({filteredTasks.filter(t => t.status === status).length})
</div>
<SortableContext items={filteredTasks.filter(t => t.status === status).map(t => t.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{filteredTasks.filter(t => t.status === status).map(task => (
<TaskCard
key={task.id}
task={{
id: task.id,
title: task.title,
description: task.description,
status: task.status,
priority: task.priority,
assigned_agent: task.assigned_agent,
progress: task.progress,
tags: task.tags,
}}
onEdit={() => handleEditTask(task)}
/>
))}
</div>
</SortableContext>
</div>
))}
</div>
<DragOverlay dropAnimation={dropAnimation}>
{activeTask ? (
<TaskCard
task={{
id: activeTask.id,
title: activeTask.title,
description: activeTask.description,
status: activeTask.status,
priority: activeTask.priority,
assigned_agent: activeTask.assigned_agent,
progress: activeTask.progress,
tags: activeTask.tags,
}}
isOverlay
onEdit={() => {}}
/>
) : null}
</DragOverlay>
</DndContext>
{/* Task Dialog */}
<TaskDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
task={editingTask ? {
id: editingTask.id,
title: editingTask.title,
description: editingTask.description,
status: editingTask.status,
priority: editingTask.priority,
assigned_agent: editingTask.assigned_agent,
progress: editingTask.progress,
tags: editingTask.tags,
} : null}
onSubmit={editingTask ? handleUpdateTask : handleAddTask}
onDelete={editingTask ? () => handleDeleteTask(editingTask.id) : undefined}
onRun={editingTask ? () => handleRunTask(editingTask.id) : undefined}
/>
</div>
)
}