"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs"; import { Activity, MessageSquare, Pencil, Settings, X } from "lucide-react"; import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { TaskBoard } from "@/components/organisms/TaskBoard"; import { DashboardShell } from "@/components/templates/DashboardShell"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { streamAgentsApiV1AgentsStreamGet } from "@/api/generated/agents/agents"; import { streamApprovalsApiV1BoardsBoardIdApprovalsStreamGet, updateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch, } from "@/api/generated/approvals/approvals"; import { getBoardSnapshotApiV1BoardsBoardIdSnapshotGet } from "@/api/generated/boards/boards"; import { createBoardMemoryApiV1BoardsBoardIdMemoryPost, streamBoardMemoryApiV1BoardsBoardIdMemoryStreamGet, } from "@/api/generated/board-memory/board-memory"; import { createTaskApiV1BoardsBoardIdTasksPost, createTaskCommentApiV1BoardsBoardIdTasksTaskIdCommentsPost, deleteTaskApiV1BoardsBoardIdTasksTaskIdDelete, listTaskCommentsApiV1BoardsBoardIdTasksTaskIdCommentsGet, streamTasksApiV1BoardsBoardIdTasksStreamGet, updateTaskApiV1BoardsBoardIdTasksTaskIdPatch, } from "@/api/generated/tasks/tasks"; import type { AgentRead, ApprovalRead, BoardMemoryRead, BoardRead, TaskCardRead, TaskCommentRead, TaskRead, } from "@/api/generated/model"; import { createExponentialBackoff } from "@/lib/backoff"; import { cn } from "@/lib/utils"; type Board = BoardRead; type TaskStatus = Exclude; type Task = Omit< TaskCardRead, "status" | "priority" | "approvals_count" | "approvals_pending_count" > & { status: TaskStatus; priority: string; approvals_count: number; approvals_pending_count: number; }; type Agent = AgentRead & { status: string }; type TaskComment = TaskCommentRead; type Approval = ApprovalRead & { status: string }; type BoardChatMessage = BoardMemoryRead; const normalizeTask = (task: TaskCardRead): Task => ({ ...task, status: task.status ?? "inbox", priority: task.priority ?? "medium", approvals_count: task.approvals_count ?? 0, approvals_pending_count: task.approvals_pending_count ?? 0, }); const normalizeAgent = (agent: AgentRead): Agent => ({ ...agent, status: agent.status ?? "offline", }); const normalizeApproval = (approval: ApprovalRead): Approval => ({ ...approval, status: approval.status ?? "pending", }); const priorities = [ { value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }, ]; const statusOptions = [ { value: "inbox", label: "Inbox" }, { value: "in_progress", label: "In progress" }, { value: "review", label: "Review" }, { value: "done", label: "Done" }, ]; const EMOJI_GLYPHS: Record = { ":gear:": "⚙️", ":sparkles:": "✨", ":rocket:": "🚀", ":megaphone:": "📣", ":chart_with_upwards_trend:": "📈", ":bulb:": "💡", ":wrench:": "🔧", ":shield:": "🛡️", ":memo:": "📝", ":brain:": "🧠", }; const SSE_RECONNECT_BACKOFF = { baseMs: 1_000, factor: 2, jitter: 0.2, maxMs: 5 * 60_000, } as const; const MARKDOWN_TABLE_COMPONENTS: Components = { table: ({ node: _node, className, ...props }) => (
), thead: ({ node: _node, className, ...props }) => ( ), tbody: ({ node: _node, className, ...props }) => ( ), tr: ({ node: _node, className, ...props }) => ( ), th: ({ node: _node, className, ...props }) => (
), td: ({ node: _node, className, ...props }) => ( ), }; export default function BoardDetailPage() { const router = useRouter(); const params = useParams(); const boardIdParam = params?.boardId; const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam; const { isSignedIn } = useAuth(); const [board, setBoard] = useState(null); const [tasks, setTasks] = useState([]); const [agents, setAgents] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [selectedTask, setSelectedTask] = useState(null); const [comments, setComments] = useState([]); const [liveFeed, setLiveFeed] = useState([]); const [isCommentsLoading, setIsCommentsLoading] = useState(false); const [commentsError, setCommentsError] = useState(null); const [newComment, setNewComment] = useState(""); const [isPostingComment, setIsPostingComment] = useState(false); const [postCommentError, setPostCommentError] = useState(null); const [isDetailOpen, setIsDetailOpen] = useState(false); const tasksRef = useRef([]); const approvalsRef = useRef([]); const agentsRef = useRef([]); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [approvals, setApprovals] = useState([]); const [isApprovalsLoading, setIsApprovalsLoading] = useState(false); const [approvalsError, setApprovalsError] = useState(null); const [approvalsUpdatingId, setApprovalsUpdatingId] = useState( null, ); const [isChatOpen, setIsChatOpen] = useState(false); const [chatMessages, setChatMessages] = useState([]); const [chatInput, setChatInput] = useState(""); const [isChatSending, setIsChatSending] = useState(false); const [chatError, setChatError] = useState(null); const chatMessagesRef = useRef([]); const chatEndRef = useRef(null); const [isDeletingTask, setIsDeletingTask] = useState(false); const [deleteTaskError, setDeleteTaskError] = useState(null); const [viewMode, setViewMode] = useState<"board" | "list">("board"); const [isLiveFeedOpen, setIsLiveFeedOpen] = useState(false); const pushLiveFeed = useCallback((comment: TaskComment) => { setLiveFeed((prev) => { if (prev.some((item) => item.id === comment.id)) { return prev; } const next = [comment, ...prev]; return next.slice(0, 50); }); }, []); const [isDialogOpen, setIsDialogOpen] = useState(false); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [priority, setPriority] = useState("medium"); const [createError, setCreateError] = useState(null); const [isCreating, setIsCreating] = useState(false); const [editTitle, setEditTitle] = useState(""); const [editDescription, setEditDescription] = useState(""); const [editStatus, setEditStatus] = useState("inbox"); const [editPriority, setEditPriority] = useState("medium"); const [editAssigneeId, setEditAssigneeId] = useState(""); const [isSavingTask, setIsSavingTask] = useState(false); const [saveTaskError, setSaveTaskError] = useState(null); const titleLabel = useMemo( () => (board ? `${board.name} board` : "Board"), [board], ); const latestTaskTimestamp = (items: Task[]) => { let latestTime = 0; items.forEach((task) => { const value = task.updated_at ?? task.created_at; if (!value) return; const time = new Date(value).getTime(); if (!Number.isNaN(time) && time > latestTime) { latestTime = time; } }); return latestTime ? new Date(latestTime).toISOString() : null; }; const latestApprovalTimestamp = (items: Approval[]) => { let latestTime = 0; items.forEach((approval) => { const value = approval.resolved_at ?? approval.created_at; if (!value) return; const time = new Date(value).getTime(); if (!Number.isNaN(time) && time > latestTime) { latestTime = time; } }); return latestTime ? new Date(latestTime).toISOString() : null; }; const latestAgentTimestamp = (items: Agent[]) => { let latestTime = 0; items.forEach((agent) => { const value = agent.updated_at ?? agent.last_seen_at; if (!value) return; const time = new Date(value).getTime(); if (!Number.isNaN(time) && time > latestTime) { latestTime = time; } }); return latestTime ? new Date(latestTime).toISOString() : null; }; const loadBoard = useCallback(async () => { if (!isSignedIn || !boardId) return; setIsLoading(true); setIsApprovalsLoading(true); setError(null); setApprovalsError(null); setChatError(null); try { const snapshotResult = await getBoardSnapshotApiV1BoardsBoardIdSnapshotGet( boardId, ); if (snapshotResult.status !== 200) { throw new Error("Unable to load board snapshot."); } const snapshot = snapshotResult.data; setBoard(snapshot.board); setTasks((snapshot.tasks ?? []).map(normalizeTask)); setAgents((snapshot.agents ?? []).map(normalizeAgent)); setApprovals((snapshot.approvals ?? []).map(normalizeApproval)); setChatMessages(snapshot.chat_messages ?? []); } catch (err) { const message = err instanceof Error ? err.message : "Something went wrong."; setError(message); setApprovalsError(message); setChatError(message); } finally { setIsLoading(false); setIsApprovalsLoading(false); } }, [boardId, isSignedIn]); useEffect(() => { void loadBoard(); }, [loadBoard]); useEffect(() => { tasksRef.current = tasks; }, [tasks]); useEffect(() => { approvalsRef.current = approvals; }, [approvals]); useEffect(() => { agentsRef.current = agents; }, [agents]); useEffect(() => { chatMessagesRef.current = chatMessages; }, [chatMessages]); useEffect(() => { if (!isChatOpen) return; const timeout = window.setTimeout(() => { chatEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); }, 50); return () => window.clearTimeout(timeout); }, [chatMessages, isChatOpen]); const latestChatTimestamp = (items: BoardChatMessage[]) => { if (!items.length) return undefined; const latest = items.reduce((max, item) => { const ts = new Date(item.created_at).getTime(); return Number.isNaN(ts) ? max : Math.max(max, ts); }, 0); if (!latest) return undefined; return new Date(latest).toISOString(); }; useEffect(() => { if (!isSignedIn || !boardId || !board) return; let isCancelled = false; const abortController = new AbortController(); const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF); let reconnectTimeout: number | undefined; const connect = async () => { try { const since = latestChatTimestamp(chatMessagesRef.current); const params = { is_chat: true, ...(since ? { since } : {}) }; const streamResult = await streamBoardMemoryApiV1BoardsBoardIdMemoryStreamGet( boardId, params, { headers: { Accept: "text/event-stream" }, signal: abortController.signal, }, ); if (streamResult.status !== 200) { throw new Error("Unable to connect board chat stream."); } const response = streamResult.data as Response; if (!(response instanceof Response) || !response.body) { throw new Error("Unable to connect board chat stream."); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (!isCancelled) { const { value, done } = await reader.read(); if (done) break; if (value && value.length) { // Consider the stream "healthy" once we receive any bytes (including pings), // then reset the backoff for future reconnects. backoff.reset(); } buffer += decoder.decode(value, { stream: true }); buffer = buffer.replace(/\r\n/g, "\n"); let boundary = buffer.indexOf("\n\n"); while (boundary !== -1) { const raw = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2); const lines = raw.split("\n"); let eventType = "message"; let data = ""; for (const line of lines) { if (line.startsWith("event:")) { eventType = line.slice(6).trim(); } else if (line.startsWith("data:")) { data += line.slice(5).trim(); } } if (eventType === "memory" && data) { try { const payload = JSON.parse(data) as { memory?: BoardChatMessage }; if (payload.memory?.tags?.includes("chat")) { setChatMessages((prev) => { const exists = prev.some( (item) => item.id === payload.memory?.id, ); if (exists) return prev; const next = [...prev, payload.memory as BoardChatMessage]; next.sort((a, b) => { const aTime = new Date(a.created_at).getTime(); const bTime = new Date(b.created_at).getTime(); return aTime - bTime; }); return next; }); } } catch { // ignore malformed } } boundary = buffer.indexOf("\n\n"); } } } catch { // Reconnect handled below. } if (!isCancelled) { if (reconnectTimeout !== undefined) { window.clearTimeout(reconnectTimeout); } const delay = backoff.nextDelayMs(); reconnectTimeout = window.setTimeout(() => { reconnectTimeout = undefined; void connect(); }, delay); } }; void connect(); return () => { isCancelled = true; abortController.abort(); if (reconnectTimeout !== undefined) { window.clearTimeout(reconnectTimeout); } }; }, [board, boardId, isSignedIn]); useEffect(() => { if (!isSignedIn || !boardId || !board) return; let isCancelled = false; const abortController = new AbortController(); const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF); let reconnectTimeout: number | undefined; const connect = async () => { try { const since = latestApprovalTimestamp(approvalsRef.current); const streamResult = await streamApprovalsApiV1BoardsBoardIdApprovalsStreamGet( boardId, since ? { since } : undefined, { headers: { Accept: "text/event-stream" }, signal: abortController.signal, }, ); if (streamResult.status !== 200) { throw new Error("Unable to connect approvals stream."); } const response = streamResult.data as Response; if (!(response instanceof Response) || !response.body) { throw new Error("Unable to connect approvals stream."); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (!isCancelled) { const { value, done } = await reader.read(); if (done) break; if (value && value.length) { backoff.reset(); } buffer += decoder.decode(value, { stream: true }); buffer = buffer.replace(/\r\n/g, "\n"); let boundary = buffer.indexOf("\n\n"); while (boundary !== -1) { const raw = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2); const lines = raw.split("\n"); let eventType = "message"; let data = ""; for (const line of lines) { if (line.startsWith("event:")) { eventType = line.slice(6).trim(); } else if (line.startsWith("data:")) { data += line.slice(5).trim(); } } if (eventType === "approval" && data) { try { const payload = JSON.parse(data) as { approval?: ApprovalRead; task_counts?: { task_id?: string; approvals_count?: number; approvals_pending_count?: number; }; pending_approvals_count?: number; }; if (payload.approval) { const normalized = normalizeApproval(payload.approval); setApprovals((prev) => { const index = prev.findIndex( (item) => item.id === normalized.id, ); if (index === -1) { return [normalized, ...prev]; } const next = [...prev]; next[index] = { ...next[index], ...normalized, }; return next; }); } if (payload.task_counts?.task_id) { const taskId = payload.task_counts.task_id; setTasks((prev) => { const index = prev.findIndex((task) => task.id === taskId); if (index === -1) return prev; const next = [...prev]; const current = next[index]; next[index] = { ...current, approvals_count: payload.task_counts?.approvals_count ?? current.approvals_count, approvals_pending_count: payload.task_counts?.approvals_pending_count ?? current.approvals_pending_count, }; return next; }); } } catch { // Ignore malformed payloads. } } boundary = buffer.indexOf("\n\n"); } } } catch { // Reconnect handled below. } if (!isCancelled) { if (reconnectTimeout !== undefined) { window.clearTimeout(reconnectTimeout); } const delay = backoff.nextDelayMs(); reconnectTimeout = window.setTimeout(() => { reconnectTimeout = undefined; void connect(); }, delay); } }; void connect(); return () => { isCancelled = true; abortController.abort(); if (reconnectTimeout !== undefined) { window.clearTimeout(reconnectTimeout); } }; }, [board, boardId, isSignedIn]); useEffect(() => { if (!selectedTask) { setEditTitle(""); setEditDescription(""); setEditStatus("inbox"); setEditPriority("medium"); setEditAssigneeId(""); setSaveTaskError(null); return; } setEditTitle(selectedTask.title); setEditDescription(selectedTask.description ?? ""); setEditStatus(selectedTask.status); setEditPriority(selectedTask.priority); setEditAssigneeId(selectedTask.assigned_agent_id ?? ""); setSaveTaskError(null); }, [selectedTask]); useEffect(() => { if (!isSignedIn || !boardId || !board) return; let isCancelled = false; const abortController = new AbortController(); const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF); let reconnectTimeout: number | undefined; const connect = async () => { try { const since = latestTaskTimestamp(tasksRef.current); const streamResult = await streamTasksApiV1BoardsBoardIdTasksStreamGet( boardId, since ? { since } : undefined, { headers: { Accept: "text/event-stream" }, signal: abortController.signal, }, ); if (streamResult.status !== 200) { throw new Error("Unable to connect task stream."); } const response = streamResult.data as Response; if (!(response instanceof Response) || !response.body) { throw new Error("Unable to connect task stream."); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (!isCancelled) { const { value, done } = await reader.read(); if (done) break; if (value && value.length) { backoff.reset(); } buffer += decoder.decode(value, { stream: true }); buffer = buffer.replace(/\r\n/g, "\n"); let boundary = buffer.indexOf("\n\n"); while (boundary !== -1) { const raw = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2); const lines = raw.split("\n"); let eventType = "message"; let data = ""; for (const line of lines) { if (line.startsWith("event:")) { eventType = line.slice(6).trim(); } else if (line.startsWith("data:")) { data += line.slice(5).trim(); } } if (eventType === "task" && data) { try { const payload = JSON.parse(data) as { type?: string; task?: TaskRead; comment?: TaskCommentRead; }; if (payload.comment?.task_id && payload.type === "task.comment") { pushLiveFeed(payload.comment); setComments((prev) => { if (selectedTask?.id !== payload.comment?.task_id) { return prev; } const exists = prev.some((item) => item.id === payload.comment?.id); if (exists) { return prev; } return [...prev, payload.comment as TaskComment]; }); } else if (payload.task) { setTasks((prev) => { const index = prev.findIndex((item) => item.id === payload.task?.id); if (index === -1) { const assignee = payload.task?.assigned_agent_id ? agentsRef.current.find( (agent) => agent.id === payload.task?.assigned_agent_id, )?.name ?? null : null; const created = normalizeTask({ ...payload.task, assignee, approvals_count: 0, approvals_pending_count: 0, } as TaskCardRead); return [created, ...prev]; } const next = [...prev]; const existing = next[index]; const assignee = payload.task?.assigned_agent_id ? agentsRef.current.find( (agent) => agent.id === payload.task?.assigned_agent_id, )?.name ?? null : null; const updated = normalizeTask({ ...existing, ...payload.task, assignee, approvals_count: existing.approvals_count, approvals_pending_count: existing.approvals_pending_count, } as TaskCardRead); next[index] = { ...existing, ...updated }; return next; }); } } catch { // Ignore malformed payloads. } } boundary = buffer.indexOf("\n\n"); } } } catch { // Reconnect handled below. } if (!isCancelled) { if (reconnectTimeout !== undefined) { window.clearTimeout(reconnectTimeout); } const delay = backoff.nextDelayMs(); reconnectTimeout = window.setTimeout(() => { reconnectTimeout = undefined; void connect(); }, delay); } }; void connect(); return () => { isCancelled = true; abortController.abort(); if (reconnectTimeout !== undefined) { window.clearTimeout(reconnectTimeout); } }; }, [board, boardId, isSignedIn, selectedTask?.id, pushLiveFeed]); useEffect(() => { if (!isSignedIn || !boardId) return; let isCancelled = false; const abortController = new AbortController(); const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF); let reconnectTimeout: number | undefined; const connect = async () => { try { const since = latestAgentTimestamp(agentsRef.current); const streamResult = await streamAgentsApiV1AgentsStreamGet( { board_id: boardId, since: since ?? null, }, { headers: { Accept: "text/event-stream" }, signal: abortController.signal, }, ); if (streamResult.status !== 200) { throw new Error("Unable to connect agent stream."); } const response = streamResult.data as Response; if (!(response instanceof Response) || !response.body) { throw new Error("Unable to connect agent stream."); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (!isCancelled) { const { value, done } = await reader.read(); if (done) break; if (value && value.length) { backoff.reset(); } buffer += decoder.decode(value, { stream: true }); buffer = buffer.replace(/\r\n/g, "\n"); let boundary = buffer.indexOf("\n\n"); while (boundary !== -1) { const raw = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2); const lines = raw.split("\n"); let eventType = "message"; let data = ""; for (const line of lines) { if (line.startsWith("event:")) { eventType = line.slice(6).trim(); } else if (line.startsWith("data:")) { data += line.slice(5).trim(); } } if (eventType === "agent" && data) { try { const payload = JSON.parse(data) as { agent?: AgentRead }; if (payload.agent) { const normalized = normalizeAgent(payload.agent); setAgents((prev) => { const index = prev.findIndex((item) => item.id === normalized.id); if (index === -1) { return [normalized, ...prev]; } const next = [...prev]; next[index] = { ...next[index], ...normalized, }; return next; }); } } catch { // Ignore malformed payloads. } } boundary = buffer.indexOf("\n\n"); } } } catch { // Reconnect handled below. } if (!isCancelled) { if (reconnectTimeout !== undefined) { window.clearTimeout(reconnectTimeout); } const delay = backoff.nextDelayMs(); reconnectTimeout = window.setTimeout(() => { reconnectTimeout = undefined; void connect(); }, delay); } }; void connect(); return () => { isCancelled = true; abortController.abort(); if (reconnectTimeout !== undefined) { window.clearTimeout(reconnectTimeout); } }; }, [board, boardId, isSignedIn]); const resetForm = () => { setTitle(""); setDescription(""); setPriority("medium"); setCreateError(null); }; const handleCreateTask = async () => { if (!isSignedIn || !boardId) return; const trimmed = title.trim(); if (!trimmed) { setCreateError("Add a task title to continue."); return; } setIsCreating(true); setCreateError(null); try { const result = await createTaskApiV1BoardsBoardIdTasksPost(boardId, { title: trimmed, description: description.trim() || null, status: "inbox", priority, }); if (result.status !== 200) throw new Error("Unable to create task."); const created = normalizeTask({ ...result.data, assignee: result.data.assigned_agent_id ? assigneeById.get(result.data.assigned_agent_id) ?? null : null, approvals_count: 0, approvals_pending_count: 0, } as TaskCardRead); setTasks((prev) => [created, ...prev]); setIsDialogOpen(false); resetForm(); } catch (err) { setCreateError(err instanceof Error ? err.message : "Something went wrong."); } finally { setIsCreating(false); } }; const handleSendChat = async () => { if (!isSignedIn || !boardId) return; const trimmed = chatInput.trim(); if (!trimmed) return; setIsChatSending(true); setChatError(null); try { const result = await createBoardMemoryApiV1BoardsBoardIdMemoryPost(boardId, { content: trimmed, tags: ["chat"], }); if (result.status !== 200) { throw new Error("Unable to send message."); } const created = result.data; if (created.tags?.includes("chat")) { setChatMessages((prev) => { const exists = prev.some((item) => item.id === created.id); if (exists) return prev; const next = [...prev, created]; next.sort((a, b) => { const aTime = new Date(a.created_at).getTime(); const bTime = new Date(b.created_at).getTime(); return aTime - bTime; }); return next; }); } setChatInput(""); } catch (err) { setChatError( err instanceof Error ? err.message : "Unable to send message.", ); } finally { setIsChatSending(false); } }; const assigneeById = useMemo(() => { const map = new Map(); agents .filter((agent) => !boardId || agent.board_id === boardId) .forEach((agent) => { map.set(agent.id, agent.name); }); return map; }, [agents, boardId]); const taskTitleById = useMemo(() => { const map = new Map(); tasks.forEach((task) => { map.set(task.id, task.title); }); return map; }, [tasks]); const orderedLiveFeed = useMemo(() => { return [...liveFeed].sort((a, b) => { const aTime = new Date(a.created_at).getTime(); const bTime = new Date(b.created_at).getTime(); return bTime - aTime; }); }, [liveFeed]); const assignableAgents = useMemo( () => agents.filter((agent) => !agent.is_board_lead), [agents], ); const hasTaskChanges = useMemo(() => { if (!selectedTask) return false; const normalizedTitle = editTitle.trim(); const normalizedDescription = editDescription.trim(); const currentDescription = (selectedTask.description ?? "").trim(); const currentAssignee = selectedTask.assigned_agent_id ?? ""; return ( normalizedTitle !== selectedTask.title || normalizedDescription !== currentDescription || editStatus !== selectedTask.status || editPriority !== selectedTask.priority || editAssigneeId !== currentAssignee ); }, [ editAssigneeId, editDescription, editPriority, editStatus, editTitle, selectedTask, ]); const orderedComments = useMemo(() => { return [...comments].sort((a, b) => { const aTime = new Date(a.created_at).getTime(); const bTime = new Date(b.created_at).getTime(); return bTime - aTime; }); }, [comments]); const pendingApprovals = useMemo( () => approvals.filter((approval) => approval.status === "pending"), [approvals], ); const taskApprovals = useMemo(() => { if (!selectedTask) return []; const taskId = selectedTask.id; return approvals.filter((approval) => approval.task_id === taskId); }, [approvals, selectedTask]); const workingAgentIds = useMemo(() => { const working = new Set(); tasks.forEach((task) => { if (task.status === "in_progress" && task.assigned_agent_id) { working.add(task.assigned_agent_id); } }); return working; }, [tasks]); const sortedAgents = useMemo(() => { const rank = (agent: Agent) => { if (workingAgentIds.has(agent.id)) return 0; if (agent.status === "online") return 1; if (agent.status === "provisioning") return 2; return 3; }; return [...agents].sort((a, b) => { const diff = rank(a) - rank(b); if (diff !== 0) return diff; return a.name.localeCompare(b.name); }); }, [agents, workingAgentIds]); const loadComments = useCallback(async (taskId: string) => { if (!isSignedIn || !boardId) return; setIsCommentsLoading(true); setCommentsError(null); try { const result = await listTaskCommentsApiV1BoardsBoardIdTasksTaskIdCommentsGet( boardId, taskId, ); if (result.status !== 200) throw new Error("Unable to load comments."); setComments(result.data.items ?? []); } catch (err) { setCommentsError(err instanceof Error ? err.message : "Something went wrong."); } finally { setIsCommentsLoading(false); } }, [boardId, isSignedIn]); const openComments = useCallback((task: { id: string }) => { setIsChatOpen(false); setIsLiveFeedOpen(false); const fullTask = tasksRef.current.find((item) => item.id === task.id); if (!fullTask) return; setSelectedTask(fullTask); setIsDetailOpen(true); void loadComments(task.id); }, [loadComments]); const closeComments = () => { setIsDetailOpen(false); setSelectedTask(null); setComments([]); setCommentsError(null); setNewComment(""); setPostCommentError(null); setIsEditDialogOpen(false); }; const openBoardChat = () => { if (isDetailOpen) { closeComments(); } setIsLiveFeedOpen(false); setIsChatOpen(true); }; const closeBoardChat = () => { setIsChatOpen(false); setChatError(null); }; const openLiveFeed = () => { if (isDetailOpen) { closeComments(); } if (isChatOpen) { closeBoardChat(); } setIsLiveFeedOpen(true); }; const closeLiveFeed = () => { setIsLiveFeedOpen(false); }; const handlePostComment = async () => { if (!selectedTask || !boardId || !isSignedIn) return; const trimmed = newComment.trim(); if (!trimmed) { setPostCommentError("Write a message before sending."); return; } setIsPostingComment(true); setPostCommentError(null); try { const result = await createTaskCommentApiV1BoardsBoardIdTasksTaskIdCommentsPost( boardId, selectedTask.id, { message: trimmed }, ); if (result.status !== 200) throw new Error("Unable to send message."); const created = result.data; setComments((prev) => [created, ...prev]); setNewComment(""); } catch (err) { setPostCommentError( err instanceof Error ? err.message : "Unable to send message.", ); } finally { setIsPostingComment(false); } }; const handleTaskSave = async (closeOnSuccess = false) => { if (!selectedTask || !isSignedIn || !boardId) return; const trimmedTitle = editTitle.trim(); if (!trimmedTitle) { setSaveTaskError("Title is required."); return; } setIsSavingTask(true); setSaveTaskError(null); try { const result = await updateTaskApiV1BoardsBoardIdTasksTaskIdPatch( boardId, selectedTask.id, { title: trimmedTitle, description: editDescription.trim() || null, status: editStatus, priority: editPriority, assigned_agent_id: editAssigneeId || null, }, ); if (result.status !== 200) throw new Error("Unable to update task."); const previous = tasksRef.current.find((task) => task.id === selectedTask.id) ?? selectedTask; const updated = normalizeTask({ ...previous, ...result.data, assignee: result.data.assigned_agent_id ? assigneeById.get(result.data.assigned_agent_id) ?? null : null, approvals_count: previous.approvals_count, approvals_pending_count: previous.approvals_pending_count, } as TaskCardRead); setTasks((prev) => prev.map((task) => (task.id === updated.id ? { ...task, ...updated } : task)), ); setSelectedTask(updated); if (closeOnSuccess) { setIsEditDialogOpen(false); } } catch (err) { setSaveTaskError(err instanceof Error ? err.message : "Something went wrong."); } finally { setIsSavingTask(false); } }; const handleTaskReset = () => { if (!selectedTask) return; setEditTitle(selectedTask.title); setEditDescription(selectedTask.description ?? ""); setEditStatus(selectedTask.status); setEditPriority(selectedTask.priority); setEditAssigneeId(selectedTask.assigned_agent_id ?? ""); setSaveTaskError(null); }; const handleDeleteTask = async () => { if (!selectedTask || !boardId || !isSignedIn) return; setIsDeletingTask(true); setDeleteTaskError(null); try { const result = await deleteTaskApiV1BoardsBoardIdTasksTaskIdDelete( boardId, selectedTask.id, ); if (result.status !== 200) throw new Error("Unable to delete task."); setTasks((prev) => prev.filter((task) => task.id !== selectedTask.id)); setIsDeleteDialogOpen(false); closeComments(); } catch (err) { setDeleteTaskError( err instanceof Error ? err.message : "Something went wrong.", ); } finally { setIsDeletingTask(false); } }; const handleTaskMove = useCallback(async (taskId: string, status: TaskStatus) => { if (!isSignedIn || !boardId) return; const currentTask = tasksRef.current.find((task) => task.id === taskId); if (!currentTask || currentTask.status === status) return; const previousTasks = tasksRef.current; setTasks((prev) => prev.map((task) => task.id === taskId ? { ...task, status, assigned_agent_id: status === "inbox" ? null : task.assigned_agent_id, assignee: status === "inbox" ? null : task.assignee, } : task, ), ); try { const result = await updateTaskApiV1BoardsBoardIdTasksTaskIdPatch( boardId, taskId, { status }, ); if (result.status !== 200) throw new Error("Unable to move task."); const assignee = result.data.assigned_agent_id ? agentsRef.current.find((agent) => agent.id === result.data.assigned_agent_id) ?.name ?? null : null; const updated = normalizeTask({ ...currentTask, ...result.data, assignee, approvals_count: currentTask.approvals_count, approvals_pending_count: currentTask.approvals_pending_count, } as TaskCardRead); setTasks((prev) => prev.map((task) => (task.id === updated.id ? { ...task, ...updated } : task)), ); } catch (err) { setTasks(previousTasks); setError(err instanceof Error ? err.message : "Unable to move task."); } }, [boardId, isSignedIn]); const agentInitials = (agent: Agent) => agent.name .split(" ") .filter(Boolean) .slice(0, 2) .map((part) => part[0]) .join("") .toUpperCase(); const resolveEmoji = (value?: string | null) => { if (!value) return null; const trimmed = value.trim(); if (!trimmed) return null; if (EMOJI_GLYPHS[trimmed]) return EMOJI_GLYPHS[trimmed]; if (trimmed.startsWith(":") && trimmed.endsWith(":")) return null; return trimmed; }; const agentAvatarLabel = (agent: Agent) => { if (agent.is_board_lead) return "⚙️"; let emojiValue: string | null = null; if (agent.identity_profile && typeof agent.identity_profile === "object") { const rawEmoji = (agent.identity_profile as Record).emoji; emojiValue = typeof rawEmoji === "string" ? rawEmoji : null; } const emoji = resolveEmoji(emojiValue); return emoji ?? agentInitials(agent); }; const agentRoleLabel = (agent: Agent) => { // Prefer the configured identity role from the API. if (agent.identity_profile && typeof agent.identity_profile === "object") { const rawRole = (agent.identity_profile as Record).role; if (typeof rawRole === "string") { const trimmed = rawRole.trim(); if (trimmed) return trimmed; } } if (agent.is_board_lead) return "Board lead"; if (agent.is_gateway_main) return "Gateway main"; return "Agent"; }; const formatCommentTimestamp = (value: string) => { const date = new Date(value); if (Number.isNaN(date.getTime())) return "—"; return date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); }; const formatTaskTimestamp = (value?: string | null) => { if (!value) return "—"; const date = new Date(value); if (Number.isNaN(date.getTime())) return "—"; return date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); }; const statusBadgeClass = (value?: string) => { switch (value) { case "in_progress": return "bg-purple-100 text-purple-700"; case "review": return "bg-indigo-100 text-indigo-700"; case "done": return "bg-emerald-100 text-emerald-700"; default: return "bg-slate-100 text-slate-600"; } }; const priorityBadgeClass = (value?: string) => { switch (value?.toLowerCase()) { case "high": return "bg-rose-100 text-rose-700"; case "medium": return "bg-amber-100 text-amber-700"; case "low": return "bg-emerald-100 text-emerald-700"; default: return "bg-slate-100 text-slate-600"; } }; const formatApprovalTimestamp = (value?: string | null) => { if (!value) return "—"; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); }; const humanizeApprovalAction = (value: string) => value .split(".") .map((part) => part .replace(/_/g, " ") .replace(/\b\w/g, (char) => char.toUpperCase()) ) .join(" · "); const approvalPayloadValue = ( payload: Approval["payload"], key: string, ) => { if (!payload || typeof payload !== "object") return null; const value = (payload as Record)[key]; if (typeof value === "string" || typeof value === "number") { return String(value); } return null; }; const approvalRows = (approval: Approval) => { const payload = approval.payload ?? {}; const taskId = approval.task_id ?? approvalPayloadValue(payload, "task_id") ?? approvalPayloadValue(payload, "taskId") ?? approvalPayloadValue(payload, "taskID"); const assignedAgentId = approvalPayloadValue(payload, "assigned_agent_id") ?? approvalPayloadValue(payload, "assignedAgentId"); const title = approvalPayloadValue(payload, "title"); const role = approvalPayloadValue(payload, "role"); const isAssign = approval.action_type.includes("assign"); const rows: Array<{ label: string; value: string }> = []; if (taskId) rows.push({ label: "Task", value: taskId }); if (isAssign) { rows.push({ label: "Assignee", value: assignedAgentId ?? "Unassigned", }); } if (title) rows.push({ label: "Title", value: title }); if (role) rows.push({ label: "Role", value: role }); return rows; }; const approvalReason = (approval: Approval) => approvalPayloadValue(approval.payload ?? {}, "reason"); const handleApprovalDecision = useCallback( async (approvalId: string, status: "approved" | "rejected") => { if (!isSignedIn || !boardId) return; setApprovalsUpdatingId(approvalId); setApprovalsError(null); try { const result = await updateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch( boardId, approvalId, { status }, ); if (result.status !== 200) { throw new Error("Unable to update approval."); } const updated = normalizeApproval(result.data); setApprovals((prev) => prev.map((item) => (item.id === approvalId ? updated : item)), ); } catch (err) { setApprovalsError( err instanceof Error ? err.message : "Unable to update approval.", ); } finally { setApprovalsUpdatingId(null); } }, [boardId, isSignedIn], ); return (

Sign in to view boards.

{board?.name ?? "Board"}

{board?.name ?? "Board"}

Keep tasks moving through your workflow.

{error && (
{error}
)} {isLoading ? (
Loading {titleLabel}…
) : ( <> {viewMode === "board" ? ( ) : (

All tasks

{tasks.length} tasks in this board

{tasks.length === 0 ? (
No tasks yet. Create your first task to get started.
) : ( tasks.map((task) => ( )) )}
)} )}
{isDetailOpen || isChatOpen || isLiveFeedOpen ? (
{ if (isChatOpen) { closeBoardChat(); } else if (isLiveFeedOpen) { closeLiveFeed(); } else { closeComments(); } }} /> ) : null}