"use client"; export const dynamic = "force-dynamic"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useParams, usePathname, useRouter, useSearchParams, } from "next/navigation"; import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk"; import { Activity, ArrowUpRight, MessageSquare, Pause, Plus, Pencil, Play, RefreshCcw, Settings, ShieldCheck, X, } from "lucide-react"; import { Markdown } from "@/components/atoms/Markdown"; import { StatusDot } from "@/components/atoms/StatusDot"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { TaskBoard } from "@/components/organisms/TaskBoard"; import { DependencyBanner, type DependencyBannerDependency, } from "@/components/molecules/DependencyBanner"; import { DashboardShell } from "@/components/templates/DashboardShell"; import { BoardChatComposer } from "@/components/BoardChatComposer"; import { TaskCustomFieldsEditor } from "./TaskCustomFieldsEditor"; import { BoardForgejoRepositoryLinks } from "@/components/git/BoardForgejoRepositoryLinks"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import DropdownSelect, { type DropdownSelectOption, } from "@/components/ui/dropdown-select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { ApiError } from "@/api/mutator"; import { streamAgentsApiV1AgentsStreamGet } from "@/api/generated/agents/agents"; import { streamApprovalsApiV1BoardsBoardIdApprovalsStreamGet, updateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch, } from "@/api/generated/approvals/approvals"; import { listActivityApiV1ActivityGet } from "@/api/generated/activity/activity"; import { getBoardGroupSnapshotApiV1BoardsBoardIdGroupSnapshotGet, getBoardSnapshotApiV1BoardsBoardIdSnapshotGet, } from "@/api/generated/boards/boards"; import { createBoardMemoryApiV1BoardsBoardIdMemoryPost, streamBoardMemoryApiV1BoardsBoardIdMemoryStreamGet, } from "@/api/generated/board-memory/board-memory"; import { type getMyMembershipApiV1OrganizationsMeMemberGetResponse, useGetMyMembershipApiV1OrganizationsMeMemberGet, } from "@/api/generated/organizations/organizations"; import { createTaskApiV1BoardsBoardIdTasksPost, createTaskCommentApiV1BoardsBoardIdTasksTaskIdCommentsPost, deleteTaskApiV1BoardsBoardIdTasksTaskIdDelete, listTaskCommentsApiV1BoardsBoardIdTasksTaskIdCommentsGet, streamTasksApiV1BoardsBoardIdTasksStreamGet, updateTaskApiV1BoardsBoardIdTasksTaskIdPatch, } from "@/api/generated/tasks/tasks"; import { type listTagsApiV1TagsGetResponse, useListTagsApiV1TagsGet, } from "@/api/generated/tags/tags"; import { type listOrgCustomFieldsApiV1OrganizationsMeCustomFieldsGetResponse, useListOrgCustomFieldsApiV1OrganizationsMeCustomFieldsGet, } from "@/api/generated/org-custom-fields/org-custom-fields"; import type { AgentRead, ApprovalRead, BoardGroupSnapshot, BoardMemoryRead, BoardRead, ActivityEventRead, OrganizationMemberRead, TaskCardRead, TaskCommentRead, TaskCustomFieldDefinitionRead, TagRead, TaskRead, } from "@/api/generated/model"; import { createExponentialBackoff } from "@/lib/backoff"; import { apiDatetimeToMs, localDateInputToUtcIso, parseApiDatetime, toLocalDateInput, } from "@/lib/datetime"; import { DEFAULT_HUMAN_LABEL, resolveHumanActorName, resolveMemberDisplayName, } from "@/lib/display-name"; import { AGENT_EMOJI_GLYPHS } from "@/lib/agent-emoji"; import { cn } from "@/lib/utils"; import { usePageActive } from "@/hooks/usePageActive"; import { boardCustomFieldValues, canonicalizeCustomFieldValues, customFieldPayload, customFieldPatchPayload, firstMissingRequiredCustomField, formatCustomFieldDetailValue, isCustomFieldVisible, type TaskCustomFieldValues, } from "./custom-field-utils"; type Board = BoardRead; type TaskStatus = Exclude; type TaskCustomFieldPayload = { custom_field_values?: TaskCustomFieldValues; }; type Task = Omit< TaskCardRead, "status" | "priority" | "approvals_count" | "approvals_pending_count" > & { status: TaskStatus; priority: string; approvals_count: number; approvals_pending_count: number; custom_field_values?: TaskCustomFieldValues | null; }; type Agent = AgentRead & { status: string }; type TaskComment = TaskCommentRead; type Approval = ApprovalRead & { status: string }; type BoardChatMessage = BoardMemoryRead; type LiveFeedEventType = | "task.comment" | "task.created" | "task.updated" | "task.status_changed" | "board.chat" | "board.command" | "agent.created" | "agent.online" | "agent.offline" | "agent.updated" | "approval.created" | "approval.updated" | "approval.approved" | "approval.rejected"; type LiveFeedItem = { id: string; created_at: string; message: string | null; agent_id: string | null; actor_name?: string | null; task_id: string | null; title?: string | null; event_type: LiveFeedEventType; }; const LIVE_FEED_EVENT_TYPES = new Set([ "task.comment", "task.created", "task.updated", "task.status_changed", "board.chat", "board.command", "agent.created", "agent.online", "agent.offline", "agent.updated", "approval.created", "approval.updated", "approval.approved", "approval.rejected", ]); const isLiveFeedEventType = (value: string): value is LiveFeedEventType => LIVE_FEED_EVENT_TYPES.has(value as LiveFeedEventType); type BoardTaskCreatePayload = Parameters< typeof createTaskApiV1BoardsBoardIdTasksPost >[1] & TaskCustomFieldPayload; type BoardTaskUpdatePayload = Parameters< typeof updateTaskApiV1BoardsBoardIdTasksTaskIdPatch >[2] & TaskCustomFieldPayload; const toLiveFeedFromActivity = ( event: ActivityEventRead, ): LiveFeedItem | null => { if (!isLiveFeedEventType(event.event_type)) { return null; } return { id: event.id, created_at: event.created_at, message: event.message ?? null, agent_id: event.agent_id ?? null, task_id: event.task_id ?? null, title: null, event_type: event.event_type, }; }; const toLiveFeedFromComment = (comment: TaskCommentRead): LiveFeedItem => ({ id: comment.id, created_at: comment.created_at, message: comment.message ?? null, agent_id: comment.agent_id ?? null, actor_name: null, task_id: comment.task_id ?? null, title: null, event_type: "task.comment", }); const mergeCommentsById = (...collections: TaskComment[][]): TaskComment[] => { const byId = new Map(); for (const collection of collections) { for (const comment of collection) { const existing = byId.get(comment.id); if (!existing) { byId.set(comment.id, comment); continue; } const existingTime = apiDatetimeToMs(existing.created_at) ?? 0; const incomingTime = apiDatetimeToMs(comment.created_at) ?? 0; byId.set( comment.id, incomingTime >= existingTime ? { ...existing, ...comment } : { ...comment, ...existing }, ); } } return [...byId.values()].sort((a, b) => { const aTime = apiDatetimeToMs(a.created_at) ?? 0; const bTime = apiDatetimeToMs(b.created_at) ?? 0; return bTime - aTime; }); }; const toLiveFeedFromBoardChat = (memory: BoardChatMessage): LiveFeedItem => { const content = (memory.content ?? "").trim(); const actorName = resolveHumanActorName(memory.source, DEFAULT_HUMAN_LABEL); const isCommand = content.startsWith("/"); return { id: `chat:${memory.id}`, created_at: memory.created_at, message: content || null, agent_id: null, actor_name: actorName, task_id: null, title: isCommand ? "Board command" : "Board chat", event_type: isCommand ? "board.command" : "board.chat", }; }; const normalizeAgentStatus = (value?: string | null): string => { const status = (value ?? "").trim().toLowerCase(); return status || "offline"; }; const humanizeAgentStatus = (value: string): string => value.replace(/_/g, " ").trim() || "offline"; const toLiveFeedFromAgentSnapshot = (agent: Agent): LiveFeedItem => { const status = normalizeAgentStatus(agent.status); const stamp = agent.last_seen_at ?? agent.updated_at ?? agent.created_at; const eventType: LiveFeedEventType = status === "online" ? "agent.online" : status === "offline" ? "agent.offline" : "agent.updated"; return { id: `agent:${agent.id}:snapshot:${status}:${stamp}`, created_at: stamp, message: `${agent.name} is ${humanizeAgentStatus(status)}.`, agent_id: agent.id, actor_name: null, task_id: null, title: `Agent · ${agent.name}`, event_type: eventType, }; }; const toLiveFeedFromAgentUpdate = ( agent: Agent, previous: Agent | null, ): LiveFeedItem | null => { const nextStatus = normalizeAgentStatus(agent.status); const previousStatus = previous ? normalizeAgentStatus(previous.status) : null; const statusChanged = previousStatus !== null && nextStatus !== previousStatus; const isNew = previous === null; const profileChanged = Boolean(previous) && (previous?.name !== agent.name || previous?.is_board_lead !== agent.is_board_lead || JSON.stringify(previous?.identity_profile ?? {}) !== JSON.stringify(agent.identity_profile ?? {})); let eventType: LiveFeedEventType; if (isNew) { eventType = "agent.created"; } else if (statusChanged && nextStatus === "online") { eventType = "agent.online"; } else if (statusChanged && nextStatus === "offline") { eventType = "agent.offline"; } else if (statusChanged || profileChanged) { eventType = "agent.updated"; } else { return null; } const stamp = agent.last_seen_at ?? agent.updated_at ?? agent.created_at; const message = eventType === "agent.created" ? `${agent.name} joined this board.` : eventType === "agent.online" ? `${agent.name} is online.` : eventType === "agent.offline" ? `${agent.name} is offline.` : `${agent.name} updated (${humanizeAgentStatus(nextStatus)}).`; return { id: `agent:${agent.id}:${eventType}:${stamp}`, created_at: stamp, message, agent_id: agent.id, actor_name: null, task_id: null, title: `Agent · ${agent.name}`, event_type: eventType, }; }; const humanizeLiveFeedApprovalAction = (value: string): string => { const cleaned = value.replace(/[._-]+/g, " ").trim(); if (!cleaned) return "Approval"; return cleaned.charAt(0).toUpperCase() + cleaned.slice(1); }; const toLiveFeedFromApproval = ( approval: ApprovalRead, previous: ApprovalRead | null = null, ): LiveFeedItem => { const nextStatus = approval.status ?? "pending"; const previousStatus = previous?.status ?? null; const eventType: LiveFeedEventType = previousStatus === null ? nextStatus === "approved" ? "approval.approved" : nextStatus === "rejected" ? "approval.rejected" : "approval.created" : nextStatus !== previousStatus ? nextStatus === "approved" ? "approval.approved" : nextStatus === "rejected" ? "approval.rejected" : "approval.updated" : "approval.updated"; const stamp = eventType === "approval.created" ? approval.created_at : (approval.resolved_at ?? approval.created_at); const action = humanizeLiveFeedApprovalAction(approval.action_type); const statusText = nextStatus === "approved" ? "approved" : nextStatus === "rejected" ? "rejected" : "pending"; const message = eventType === "approval.created" ? `${action} requested (${approval.confidence}% confidence).` : eventType === "approval.approved" ? `${action} approved (${approval.confidence}% confidence).` : eventType === "approval.rejected" ? `${action} rejected (${approval.confidence}% confidence).` : `${action} updated (${statusText}, ${approval.confidence}% confidence).`; return { id: `approval:${approval.id}:${eventType}:${stamp}`, created_at: stamp, message, agent_id: approval.agent_id ?? null, actor_name: null, task_id: approval.task_id ?? null, title: `Approval · ${action}`, event_type: eventType, }; }; const liveFeedEventLabel = (eventType: LiveFeedEventType): string => { if (eventType === "task.comment") return "Comment"; if (eventType === "task.created") return "Created"; if (eventType === "task.status_changed") return "Status"; if (eventType === "board.chat") return "Chat"; if (eventType === "board.command") return "Command"; if (eventType === "agent.created") return "Agent"; if (eventType === "agent.online") return "Online"; if (eventType === "agent.offline") return "Offline"; if (eventType === "agent.updated") return "Agent update"; if (eventType === "approval.created") return "Approval"; if (eventType === "approval.updated") return "Approval update"; if (eventType === "approval.approved") return "Approved"; if (eventType === "approval.rejected") return "Rejected"; return "Updated"; }; const liveFeedEventPillClass = (eventType: LiveFeedEventType): string => { if (eventType === "task.comment") { return "border-blue-200 bg-blue-50 text-blue-700"; } if (eventType === "task.created") { return "border-emerald-200 bg-emerald-50 text-emerald-700"; } if (eventType === "task.status_changed") { return "border-amber-200 bg-amber-50 text-amber-700"; } if (eventType === "board.chat") { return "border-teal-200 bg-teal-50 text-teal-700"; } if (eventType === "board.command") { return "border-fuchsia-200 bg-fuchsia-50 text-fuchsia-700"; } if (eventType === "agent.created") { return "border-violet-200 bg-violet-50 text-violet-700"; } if (eventType === "agent.online") { return "border-lime-200 bg-lime-50 text-lime-700"; } if (eventType === "agent.offline") { return "border-slate-300 bg-slate-100 text-slate-700"; } if (eventType === "agent.updated") { return "border-indigo-200 bg-indigo-50 text-indigo-700"; } if (eventType === "approval.created") { return "border-cyan-200 bg-cyan-50 text-cyan-700"; } if (eventType === "approval.updated") { return "border-sky-200 bg-sky-50 text-sky-700"; } if (eventType === "approval.approved") { return "border-emerald-200 bg-emerald-50 text-emerald-700"; } if (eventType === "approval.rejected") { return "border-rose-200 bg-rose-50 text-rose-700"; } return "border-slate-200 bg-slate-100 text-slate-700"; }; 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 normalizeTagColor = (value?: string | null) => { const cleaned = (value ?? "").trim().replace(/^#/, "").toLowerCase(); if (!/^[0-9a-f]{6}$/.test(cleaned)) return "9e9e9e"; return cleaned; }; 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 SSE_RECONNECT_BACKOFF = { baseMs: 1_000, factor: 2, jitter: 0.2, maxMs: 5 * 60_000, } as const; const formatShortTimestamp = (value: string) => { const date = parseApiDatetime(value); if (!date) return "—"; return date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); }; const commentElementId = (id: string): string => `task-comment-${id.replace(/[^a-zA-Z0-9_-]/g, "-")}`; type ToastMessage = { id: number; message: string; tone: "error" | "success"; }; const formatActionError = (err: unknown, fallback: string) => { if (err instanceof ApiError) { if (err.status === 403) { return "Read-only access. You do not have permission to make changes."; } return err.message || fallback; } if (err instanceof Error && err.message) { return err.message; } return fallback; }; const resolveBoardAccess = ( member: OrganizationMemberRead | null, boardId?: string | null, ) => { if (!member || !boardId) { return { canRead: false, canWrite: false }; } if (member.all_boards_write) { return { canRead: true, canWrite: true }; } if (member.all_boards_read) { return { canRead: true, canWrite: false }; } const entry = member.board_access?.find( (access) => access.board_id === boardId, ); if (!entry) { return { canRead: false, canWrite: false }; } const canWrite = Boolean(entry.can_write); const canRead = Boolean(entry.can_read || entry.can_write); return { canRead, canWrite }; }; const TaskCommentCard = memo(function TaskCommentCard({ comment, authorLabel, isHighlighted = false, }: { comment: TaskComment; authorLabel: string; isHighlighted?: boolean; }) { const message = (comment.message ?? "").trim(); return (
{authorLabel} {formatShortTimestamp(comment.created_at)}
{message ? (
) : (

)}
); }); TaskCommentCard.displayName = "TaskCommentCard"; const ChatMessageCard = memo(function ChatMessageCard({ message, fallbackSource, }: { message: BoardChatMessage; fallbackSource: string; }) { const sourceLabel = resolveHumanActorName(message.source, fallbackSource); return (

{sourceLabel}

{formatShortTimestamp(message.created_at)}
); }); ChatMessageCard.displayName = "ChatMessageCard"; const LiveFeedCard = memo(function LiveFeedCard({ item, taskTitle, authorName, authorRole, authorAvatar, onViewTask, isNew, }: { item: LiveFeedItem; taskTitle: string; authorName: string; authorRole?: string | null; authorAvatar: string; onViewTask?: () => void; isNew?: boolean; }) { const message = (item.message ?? "").trim(); const eventLabel = liveFeedEventLabel(item.event_type); const eventPillClass = liveFeedEventPillClass(item.event_type); return (
{authorAvatar}
{eventLabel} {authorName} {authorRole ? ( <> · {authorRole} ) : null} · {formatShortTimestamp(item.created_at)}
{message ? (
) : (

)}
); }); LiveFeedCard.displayName = "LiveFeedCard"; export default function BoardDetailPage() { const router = useRouter(); const params = useParams(); const pathname = usePathname(); const searchParams = useSearchParams(); const boardIdParam = params?.boardId; const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam; const { isSignedIn } = useAuth(); const isPageActive = usePageActive(); const taskIdFromUrl = searchParams.get("taskId"); const commentIdFromUrl = searchParams.get("commentId"); const panelFromUrl = searchParams.get("panel"); const buildUrlWithTaskAndComment = useCallback( ( taskId: string | null, commentId: string | null, panel: "chat" | null = null, ): string => { const params = new URLSearchParams(searchParams.toString()); if (taskId) { params.set("taskId", taskId); } else { params.delete("taskId"); } if (commentId) { params.set("commentId", commentId); } else { params.delete("commentId"); } if (panel) { params.set("panel", panel); } else { params.delete("panel"); } const next = params.toString(); return next ? `${pathname}?${next}` : pathname; }, [pathname, searchParams], ); const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet< getMyMembershipApiV1OrganizationsMeMemberGetResponse, ApiError >({ query: { enabled: Boolean(isSignedIn), refetchOnMount: "always", }, }); const tagsQuery = useListTagsApiV1TagsGet< listTagsApiV1TagsGetResponse, ApiError >(undefined, { query: { enabled: Boolean(isSignedIn), refetchOnMount: "always", }, }); const tags = useMemo( () => tagsQuery.data?.status === 200 ? (tagsQuery.data.data.items ?? []) : [], [tagsQuery.data], ); const customFieldDefinitionsQuery = useListOrgCustomFieldsApiV1OrganizationsMeCustomFieldsGet< listOrgCustomFieldsApiV1OrganizationsMeCustomFieldsGetResponse, ApiError >({ query: { enabled: Boolean(isSignedIn), refetchOnMount: "always", retry: false, }, }); const boardCustomFieldDefinitions = useMemo(() => { if (!boardId || customFieldDefinitionsQuery.data?.status !== 200) { return [] as TaskCustomFieldDefinitionRead[]; } return (customFieldDefinitionsQuery.data.data ?? []) .filter((definition) => (definition.board_ids ?? []).includes(boardId)) .sort((left, right) => (left.label || left.field_key).localeCompare( right.label || right.field_key, ), ); }, [boardId, customFieldDefinitionsQuery.data]); const boardAccess = useMemo( () => resolveBoardAccess( membershipQuery.data?.status === 200 ? membershipQuery.data.data : null, boardId, ), [membershipQuery.data, boardId], ); const isOrgAdmin = useMemo(() => { const member = membershipQuery.data?.status === 200 ? membershipQuery.data.data : null; return member ? ["owner", "admin"].includes(member.role) : false; }, [membershipQuery.data]); const currentUserDisplayName = useMemo(() => { const member = membershipQuery.data?.status === 200 ? membershipQuery.data.data : null; return resolveMemberDisplayName(member, DEFAULT_HUMAN_LABEL); }, [membershipQuery.data]); const canWrite = boardAccess.canWrite; const [board, setBoard] = useState(null); const [tasks, setTasks] = useState([]); const [agents, setAgents] = useState([]); const [groupSnapshot, setGroupSnapshot] = useState( null, ); const [groupSnapshotError, setGroupSnapshotError] = useState( null, ); const [isLoading, setIsLoading] = useState(false); const [hasLoadedBoardSnapshot, setHasLoadedBoardSnapshot] = useState(false); const [error, setError] = useState(null); const [selectedTask, setSelectedTask] = useState(null); const selectedTaskIdRef = useRef(null); const openedTaskIdFromUrlRef = useRef(null); const openedPanelFromUrlRef = useRef(null); const [comments, setComments] = useState([]); const [highlightedCommentId, setHighlightedCommentId] = useState< string | null >(null); const [liveFeed, setLiveFeed] = useState([]); const liveFeedRef = useRef([]); const liveFeedFlashTimersRef = useRef>({}); const [liveFeedFlashIds, setLiveFeedFlashIds] = useState< Record >({}); const [isLiveFeedHistoryLoading, setIsLiveFeedHistoryLoading] = useState(false); const [liveFeedHistoryError, setLiveFeedHistoryError] = useState< string | null >(null); const liveFeedHistoryLoadedRef = useRef(false); const [isCommentsLoading, setIsCommentsLoading] = useState(false); const [commentsError, setCommentsError] = useState(null); 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 [isChatSending, setIsChatSending] = useState(false); const [chatError, setChatError] = useState(null); const chatMessagesRef = useRef([]); const chatEndRef = useRef(null); const [isAgentsControlDialogOpen, setIsAgentsControlDialogOpen] = useState(false); const [agentsControlAction, setAgentsControlAction] = useState< "pause" | "resume" >("pause"); const [isAgentsControlSending, setIsAgentsControlSending] = useState(false); const [agentsControlError, setAgentsControlError] = useState( null, ); const [isDeletingTask, setIsDeletingTask] = useState(false); const [deleteTaskError, setDeleteTaskError] = useState(null); const [viewMode, setViewMode] = useState<"board" | "list">("board"); const [isLiveFeedOpen, setIsLiveFeedOpen] = useState(false); const [toasts, setToasts] = useState([]); const isLiveFeedOpenRef = useRef(false); const toastIdRef = useRef(0); const toastTimersRef = useRef>({}); const pushLiveFeed = useCallback((item: LiveFeedItem) => { const alreadySeen = liveFeedRef.current.some( (existing) => existing.id === item.id, ); setLiveFeed((prev) => { if (prev.some((existing) => existing.id === item.id)) { return prev; } const next = [item, ...prev]; return next.slice(0, 50); }); if (alreadySeen) return; if (!isLiveFeedOpenRef.current) return; setLiveFeedFlashIds((prev) => prev[item.id] ? prev : { ...prev, [item.id]: true }, ); if (typeof window === "undefined") return; const existingTimer = liveFeedFlashTimersRef.current[item.id]; if (existingTimer !== undefined) { window.clearTimeout(existingTimer); } liveFeedFlashTimersRef.current[item.id] = window.setTimeout(() => { delete liveFeedFlashTimersRef.current[item.id]; setLiveFeedFlashIds((prev) => { if (!prev[item.id]) return prev; const next = { ...prev }; delete next[item.id]; return next; }); }, 2200); }, []); const dismissToast = useCallback((id: number) => { setToasts((prev) => prev.filter((toast) => toast.id !== id)); const timer = toastTimersRef.current[id]; if (timer !== undefined) { window.clearTimeout(timer); delete toastTimersRef.current[id]; } }, []); const pushToast = useCallback( (message: string, tone: ToastMessage["tone"] = "error") => { const trimmed = message.trim(); if (!trimmed) return; const id = toastIdRef.current + 1; toastIdRef.current = id; setToasts((prev) => [...prev, { id, message: trimmed, tone }]); if (typeof window !== "undefined") { toastTimersRef.current[id] = window.setTimeout(() => { dismissToast(id); }, 3500); } }, [dismissToast], ); useEffect(() => { liveFeedHistoryLoadedRef.current = false; setIsLiveFeedHistoryLoading(false); setLiveFeedHistoryError(null); setLiveFeed([]); setLiveFeedFlashIds({}); if (typeof window !== "undefined") { Object.values(liveFeedFlashTimersRef.current).forEach((timerId) => { window.clearTimeout(timerId); }); } liveFeedFlashTimersRef.current = {}; }, [boardId]); useEffect(() => { return () => { if (typeof window !== "undefined") { Object.values(liveFeedFlashTimersRef.current).forEach((timerId) => { window.clearTimeout(timerId); }); } liveFeedFlashTimersRef.current = {}; }; }, []); useEffect(() => { return () => { if (typeof window !== "undefined") { Object.values(toastTimersRef.current).forEach((timerId) => { window.clearTimeout(timerId); }); } toastTimersRef.current = {}; }; }, []); useEffect(() => { if (!isLiveFeedOpen) return; if (!isSignedIn || !boardId) return; if (isLoading) return; if (liveFeedHistoryLoadedRef.current) return; let cancelled = false; setIsLiveFeedHistoryLoading(true); setLiveFeedHistoryError(null); const fetchHistory = async () => { try { const sourceTasks = tasksRef.current.length > 0 ? tasksRef.current : tasks; const sourceApprovals = approvalsRef.current.length > 0 ? approvalsRef.current : approvals; const sourceAgents = agentsRef.current.length > 0 ? agentsRef.current : agents; const sourceChatMessages = chatMessagesRef.current.length > 0 ? chatMessagesRef.current : chatMessages; const boardTaskIds = new Set(sourceTasks.map((task) => task.id)); const collected: LiveFeedItem[] = []; const seen = new Set(); const limit = 200; const recentChatMessages = [...sourceChatMessages] .sort((a, b) => { const aTime = apiDatetimeToMs(a.created_at) ?? 0; const bTime = apiDatetimeToMs(b.created_at) ?? 0; return bTime - aTime; }) .slice(0, 50); for (const memory of recentChatMessages) { const chatItem = toLiveFeedFromBoardChat(memory); if (seen.has(chatItem.id)) continue; seen.add(chatItem.id); collected.push(chatItem); if (collected.length >= 200) break; } for (const agent of sourceAgents) { if (collected.length >= 200) break; const agentItem = toLiveFeedFromAgentSnapshot(agent); if (seen.has(agentItem.id)) continue; seen.add(agentItem.id); collected.push(agentItem); if (collected.length >= 200) break; } for (const approval of sourceApprovals) { if (collected.length >= 200) break; const approvalItem = toLiveFeedFromApproval(approval); if (seen.has(approvalItem.id)) continue; seen.add(approvalItem.id); collected.push(approvalItem); if (collected.length >= 200) break; } for ( let offset = 0; collected.length < 200 && offset < 1000; offset += limit ) { const result = await listActivityApiV1ActivityGet({ limit, offset, }); if (cancelled) return; if (result.status !== 200) { throw new Error("Unable to load live feed."); } const items = result.data.items ?? []; for (const event of items) { const mapped = toLiveFeedFromActivity(event); if (!mapped?.task_id) continue; if (!boardTaskIds.has(mapped.task_id)) continue; if (seen.has(mapped.id)) continue; seen.add(mapped.id); collected.push(mapped); if (collected.length >= 200) break; } if (collected.length >= 200 || items.length < limit) { break; } } liveFeedHistoryLoadedRef.current = true; setLiveFeed((prev) => { const map = new Map(); [...prev, ...collected].forEach((item) => map.set(item.id, item)); const merged = [...map.values()]; merged.sort((a, b) => { const aTime = apiDatetimeToMs(a.created_at) ?? 0; const bTime = apiDatetimeToMs(b.created_at) ?? 0; return bTime - aTime; }); return merged.slice(0, 50); }); } catch (err) { if (cancelled) return; setLiveFeedHistoryError( err instanceof Error ? err.message : "Unable to load live feed.", ); } finally { if (cancelled) return; setIsLiveFeedHistoryLoading(false); } }; void fetchHistory(); return () => { cancelled = true; }; }, [ agents, approvals, boardId, chatMessages, isLiveFeedOpen, isLoading, isSignedIn, tasks, ]); const [isDialogOpen, setIsDialogOpen] = useState(false); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [priority, setPriority] = useState("medium"); const [createDueDate, setCreateDueDate] = useState(""); const [createTagIds, setCreateTagIds] = useState([]); const [createCustomFieldValues, setCreateCustomFieldValues] = useState({}); 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 [editDueDate, setEditDueDate] = useState(""); const [editAssigneeId, setEditAssigneeId] = useState(""); const [editTagIds, setEditTagIds] = useState([]); const [editDependsOnTaskIds, setEditDependsOnTaskIds] = useState( [], ); const [editCustomFieldValues, setEditCustomFieldValues] = useState({}); const [isSavingTask, setIsSavingTask] = useState(false); const [saveTaskError, setSaveTaskError] = useState(null); const isSidePanelOpen = isDetailOpen || isChatOpen || isLiveFeedOpen; const defaultCreateCustomFieldValues = useMemo( () => boardCustomFieldValues(boardCustomFieldDefinitions, {}), [boardCustomFieldDefinitions], ); const selectedTaskCustomFieldValues = useMemo( () => boardCustomFieldValues( boardCustomFieldDefinitions, selectedTask?.custom_field_values, ), [boardCustomFieldDefinitions, selectedTask?.custom_field_values], ); useEffect(() => { setCreateCustomFieldValues((prev) => boardCustomFieldValues(boardCustomFieldDefinitions, prev), ); }, [boardCustomFieldDefinitions]); const titleLabel = useMemo( () => (board ? `${board.name} board` : "Board"), [board], ); useEffect(() => { if (!isSidePanelOpen) return; const { body, documentElement } = document; const originalHtmlOverflow = documentElement.style.overflow; const originalBodyOverflow = body.style.overflow; const originalBodyPaddingRight = body.style.paddingRight; const scrollbarWidth = window.innerWidth - documentElement.clientWidth; documentElement.style.overflow = "hidden"; body.style.overflow = "hidden"; if (scrollbarWidth > 0) { body.style.paddingRight = `${scrollbarWidth}px`; } return () => { documentElement.style.overflow = originalHtmlOverflow; body.style.overflow = originalBodyOverflow; body.style.paddingRight = originalBodyPaddingRight; }; }, [isSidePanelOpen]); const latestTaskTimestamp = (items: Task[]) => { let latestTime = 0; items.forEach((task) => { const value = task.updated_at ?? task.created_at; if (!value) return; const time = apiDatetimeToMs(value); if (time !== null && 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 = apiDatetimeToMs(value); if (time !== null && 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 = apiDatetimeToMs(value); if (time !== null && time > latestTime) { latestTime = time; } }); return latestTime ? new Date(latestTime).toISOString() : null; }; const loadBoard = useCallback(async () => { if (!isSignedIn || !boardId) return; setHasLoadedBoardSnapshot(false); setIsLoading(true); setIsApprovalsLoading(true); setError(null); setApprovalsError(null); setChatError(null); setGroupSnapshotError(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 ?? []); try { const groupResult = await getBoardGroupSnapshotApiV1BoardsBoardIdGroupSnapshotGet( boardId, { include_self: false, include_done: false, per_board_task_limit: 5, }, ); if (groupResult.status === 200) { setGroupSnapshot(groupResult.data); } else { setGroupSnapshot(null); } } catch (groupErr) { const message = groupErr instanceof Error ? groupErr.message : "Unable to load board group snapshot."; setGroupSnapshotError(message); setGroupSnapshot(null); } } catch (err) { const message = err instanceof Error ? err.message : "Something went wrong."; setError(message); setApprovalsError(message); setChatError(message); setGroupSnapshotError(message); setGroupSnapshot(null); } finally { setIsLoading(false); setIsApprovalsLoading(false); setHasLoadedBoardSnapshot(true); } }, [boardId, isSignedIn]); useEffect(() => { void loadBoard(); }, [loadBoard]); useEffect(() => { tasksRef.current = tasks; }, [tasks]); useEffect(() => { approvalsRef.current = approvals; }, [approvals]); useEffect(() => { agentsRef.current = agents; }, [agents]); useEffect(() => { selectedTaskIdRef.current = selectedTask?.id ?? null; }, [selectedTask?.id]); useEffect(() => { chatMessagesRef.current = chatMessages; }, [chatMessages]); useEffect(() => { liveFeedRef.current = liveFeed; }, [liveFeed]); useEffect(() => { isLiveFeedOpenRef.current = isLiveFeedOpen; }, [isLiveFeedOpen]); useEffect(() => { if (!isChatOpen) return; const timeout = window.setTimeout(() => { chatEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); }, 50); return () => window.clearTimeout(timeout); }, [chatMessages, isChatOpen]); /** * Returns an ISO timestamp for the newest board chat message. * * Used as the `since` cursor when (re)connecting to the SSE endpoint so we * don't re-stream the entire chat log. */ const latestChatTimestamp = (items: BoardChatMessage[]) => { if (!items.length) return undefined; const latest = items.reduce((max, item) => { const ts = apiDatetimeToMs(item.created_at); return ts === null ? max : Math.max(max, ts); }, 0); if (!latest) return undefined; return new Date(latest).toISOString(); }; const lastAgentControlCommand = useMemo(() => { for (let i = chatMessages.length - 1; i >= 0; i -= 1) { const value = (chatMessages[i]?.content ?? "").trim().toLowerCase(); if (value === "/pause" || value === "/resume") { return value; } } return null; }, [chatMessages]); const isAgentsPaused = lastAgentControlCommand === "/pause"; useEffect(() => { if (!isPageActive) return; if (!isSignedIn || !boardId || !board) return; if (!isChatOpen && !isLiveFeedOpen) 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; // Consider the stream healthy once we receive any bytes (including pings) // and reset the backoff so a later disconnect doesn't wait the full max. 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 === "memory" && data) { try { const payload = JSON.parse(data) as { memory?: BoardChatMessage; }; if (payload.memory?.tags?.includes("chat")) { pushLiveFeed(toLiveFeedFromBoardChat(payload.memory)); 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 = apiDatetimeToMs(a.created_at) ?? 0; const bTime = apiDatetimeToMs(b.created_at) ?? 0; 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, isChatOpen, isLiveFeedOpen, isPageActive, isSignedIn, pushLiveFeed, ]); useEffect(() => { if (!isPageActive) return; 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; } | Array<{ task_id?: string; approvals_count?: number; approvals_pending_count?: number; }>; pending_approvals_count?: number; }; if (payload.approval) { const normalized = normalizeApproval(payload.approval); const previousApproval = approvalsRef.current.find( (item) => item.id === normalized.id, ) ?? null; pushLiveFeed( toLiveFeedFromApproval(normalized, previousApproval), ); 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; }); } const taskCounts = Array.isArray(payload.task_counts) ? payload.task_counts : payload.task_counts ? [payload.task_counts] : []; if (taskCounts.length > 0) { setTasks((prev) => { const countsByTaskId = new Map( taskCounts .filter((row) => Boolean(row.task_id)) .map((row) => [row.task_id as string, row]), ); return prev.map((task) => { const counts = countsByTaskId.get(task.id); if (!counts) return task; return { ...task, approvals_count: counts.approvals_count ?? task.approvals_count, approvals_pending_count: counts.approvals_pending_count ?? task.approvals_pending_count, }; }); }); } } 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, isPageActive, isSignedIn, pushLiveFeed]); useEffect(() => { if (!selectedTask) { setEditTitle(""); setEditDescription(""); setEditStatus("inbox"); setEditPriority("medium"); setEditDueDate(""); setEditAssigneeId(""); setEditTagIds([]); setEditDependsOnTaskIds([]); setEditCustomFieldValues( boardCustomFieldValues(boardCustomFieldDefinitions, {}), ); setSaveTaskError(null); return; } setEditTitle(selectedTask.title); setEditDescription(selectedTask.description ?? ""); setEditStatus(selectedTask.status); setEditPriority(selectedTask.priority); setEditDueDate(toLocalDateInput(selectedTask.due_at)); setEditAssigneeId(selectedTask.assigned_agent_id ?? ""); setEditTagIds(selectedTask.tag_ids ?? []); setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []); setEditCustomFieldValues( boardCustomFieldValues( boardCustomFieldDefinitions, selectedTask.custom_field_values, ), ); setSaveTaskError(null); }, [boardCustomFieldDefinitions, selectedTask]); useEffect(() => { if (!isPageActive) return; 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; activity?: ActivityEventRead; task?: TaskRead; comment?: TaskCommentRead; }; const liveEvent = payload.activity ? toLiveFeedFromActivity(payload.activity) : payload.type === "task.comment" && payload.comment ? toLiveFeedFromComment(payload.comment) : null; if (liveEvent) { pushLiveFeed(liveEvent); } if ( payload.comment?.task_id && payload.type === "task.comment" ) { setComments((prev) => { if ( selectedTaskIdRef.current !== payload.comment?.task_id ) { return prev; } return mergeCommentsById(prev, [ payload.comment as TaskComment, ]); }); } else if (payload.task) { const incomingTask = payload.task; setTasks((prev) => { const index = prev.findIndex( (item) => item.id === incomingTask.id, ); if (index === -1) { const assignee = incomingTask.assigned_agent_id ? (agentsRef.current.find( (agent) => agent.id === incomingTask.assigned_agent_id, )?.name ?? null) : null; const created = normalizeTask({ ...incomingTask, assignee, approvals_count: 0, approvals_pending_count: 0, } as TaskCardRead); return [created, ...prev]; } const next = [...prev]; const existing = next[index]; const assignee = incomingTask.assigned_agent_id ? (agentsRef.current.find( (agent) => agent.id === incomingTask.assigned_agent_id, )?.name ?? null) : null; const updated = normalizeTask({ ...existing, ...incomingTask, assignee, approvals_count: existing.approvals_count, approvals_pending_count: existing.approvals_pending_count, } as TaskCardRead); next[index] = { ...existing, ...updated }; return next; }); if (selectedTaskIdRef.current === incomingTask.id) { setSelectedTask((prev) => { if (!prev || prev.id !== incomingTask.id) { return prev; } return { ...prev, ...incomingTask, custom_field_values: incomingTask.custom_field_values !== undefined ? incomingTask.custom_field_values : prev.custom_field_values, }; }); } } } 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, isPageActive, isSignedIn, pushLiveFeed]); useEffect(() => { if (!isPageActive) return; if (!isSignedIn || !boardId || !isOrgAdmin) 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); const previousAgent = agentsRef.current.find( (item) => item.id === normalized.id, ) ?? null; const liveEvent = toLiveFeedFromAgentUpdate( normalized, previousAgent, ); if (liveEvent) { pushLiveFeed(liveEvent); } 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, isOrgAdmin, isPageActive, isSignedIn, pushLiveFeed]); const resetForm = () => { setTitle(""); setDescription(""); setPriority("medium"); setCreateDueDate(""); setCreateTagIds([]); setCreateCustomFieldValues(defaultCreateCustomFieldValues); setCreateError(null); }; const handleCreateTask = async () => { if (!isSignedIn || !boardId) return; const trimmed = title.trim(); if (!trimmed) { setCreateError("Add a task title to continue."); return; } const createCustomFieldPayload = customFieldPayload( boardCustomFieldDefinitions, createCustomFieldValues, ); const missingRequiredCustomField = firstMissingRequiredCustomField( boardCustomFieldDefinitions, createCustomFieldPayload, ); if (missingRequiredCustomField) { setCreateError( `Custom field "${missingRequiredCustomField}" is required.`, ); return; } setIsCreating(true); setCreateError(null); try { const payload: BoardTaskCreatePayload = { title: trimmed, description: description.trim() || null, status: "inbox", priority, due_at: localDateInputToUtcIso(createDueDate), tag_ids: createTagIds, custom_field_values: createCustomFieldPayload, }; const result = await createTaskApiV1BoardsBoardIdTasksPost( boardId, payload, ); 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) { const message = formatActionError(err, "Something went wrong."); setCreateError(message); pushToast(message); } finally { setIsCreating(false); } }; const postBoardChatMessage = useCallback( async (content: string): Promise<{ ok: boolean; error: string | null }> => { if (!isSignedIn || !boardId) { return { ok: false, error: "Sign in to send messages." }; } const trimmed = content.trim(); if (!trimmed) return { ok: false, error: null }; try { const result = await createBoardMemoryApiV1BoardsBoardIdMemoryPost( boardId, { content: trimmed, tags: ["chat"], source: currentUserDisplayName, }, ); if (result.status !== 200) { throw new Error("Unable to send message."); } const created = result.data; if (created.tags?.includes("chat")) { pushLiveFeed(toLiveFeedFromBoardChat(created)); 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 = apiDatetimeToMs(a.created_at) ?? 0; const bTime = apiDatetimeToMs(b.created_at) ?? 0; return aTime - bTime; }); return next; }); } return { ok: true, error: null }; } catch (err) { const message = formatActionError(err, "Unable to send message."); return { ok: false, error: message }; } }, [boardId, currentUserDisplayName, isSignedIn, pushLiveFeed], ); const handleSendChat = useCallback( async (content: string): Promise => { const trimmed = content.trim(); if (!trimmed) return false; setIsChatSending(true); setChatError(null); try { const result = await postBoardChatMessage(trimmed); if (!result.ok) { if (result.error) { setChatError(result.error); pushToast(result.error); } return false; } return true; } finally { setIsChatSending(false); } }, [postBoardChatMessage, pushToast], ); const openAgentsControlDialog = (action: "pause" | "resume") => { setAgentsControlAction(action); setAgentsControlError(null); setIsAgentsControlDialogOpen(true); }; const handleConfirmAgentsControl = useCallback(async () => { const command = agentsControlAction === "pause" ? "/pause" : "/resume"; setIsAgentsControlSending(true); setAgentsControlError(null); try { const result = await postBoardChatMessage(command); if (!result.ok) { const message = result.error ?? `Unable to send ${command} command.`; setAgentsControlError(message); pushToast(message); return; } setIsAgentsControlDialogOpen(false); } finally { setIsAgentsControlSending(false); } }, [agentsControlAction, postBoardChatMessage, pushToast]); 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 taskById = useMemo(() => { const map = new Map(); tasks.forEach((task) => { map.set(task.id, task); }); return map; }, [tasks]); const orderedLiveFeed = useMemo(() => { return [...liveFeed].sort((a, b) => { const aTime = apiDatetimeToMs(a.created_at) ?? 0; const bTime = apiDatetimeToMs(b.created_at) ?? 0; return bTime - aTime; }); }, [liveFeed]); const assignableAgents = useMemo( () => agents.filter((agent) => !agent.is_board_lead), [agents], ); const boardChatMentionSuggestions = useMemo(() => { const options = new Set(["lead"]); agents.forEach((agent) => { if (agent.name) { options.add(agent.name); } }); return [...options]; }, [agents]); const tagById = useMemo(() => { const map = new Map(); tags.forEach((tag) => { map.set(tag.id, tag); }); return map; }, [tags]); const createTagOptions = useMemo(() => { const selected = new Set(createTagIds); return tags.map((tag) => ({ value: tag.id, label: `${tag.name} (#${normalizeTagColor(tag.color).toUpperCase()})`, disabled: selected.has(tag.id), })); }, [createTagIds, tags]); const editTagOptions = useMemo(() => { const selected = new Set(editTagIds); return tags.map((tag) => ({ value: tag.id, label: `${tag.name} (#${normalizeTagColor(tag.color).toUpperCase()})`, disabled: selected.has(tag.id), })); }, [editTagIds, tags]); const dependencyOptions = useMemo(() => { if (!selectedTask) return []; const alreadySelected = new Set(editDependsOnTaskIds); return tasks .filter((task) => task.id !== selectedTask.id) .map((task) => ({ value: task.id, label: `${task.title} (${task.status.replace(/_/g, " ")})`, disabled: alreadySelected.has(task.id), })); }, [editDependsOnTaskIds, selectedTask, tasks]); const addTaskDependency = useCallback((dependencyId: string) => { setEditDependsOnTaskIds((prev) => prev.includes(dependencyId) ? prev : [...prev, dependencyId], ); }, []); const removeTaskDependency = useCallback((dependencyId: string) => { setEditDependsOnTaskIds((prev) => prev.filter((value) => value !== dependencyId), ); }, []); const addEditTag = useCallback((tagId: string) => { setEditTagIds((prev) => (prev.includes(tagId) ? prev : [...prev, tagId])); }, []); const removeEditTag = useCallback((tagId: string) => { setEditTagIds((prev) => prev.filter((value) => value !== tagId)); }, []); const addCreateTag = useCallback((tagId: string) => { setCreateTagIds((prev) => (prev.includes(tagId) ? prev : [...prev, tagId])); }, []); const removeCreateTag = useCallback((tagId: string) => { setCreateTagIds((prev) => prev.filter((value) => value !== tagId)); }, []); const hasTaskChanges = useMemo(() => { if (!selectedTask) return false; const normalizedTitle = editTitle.trim(); const normalizedDescription = editDescription.trim(); const currentDescription = (selectedTask.description ?? "").trim(); const currentDueDate = toLocalDateInput(selectedTask.due_at); const currentAssignee = selectedTask.assigned_agent_id ?? ""; const currentTags = [...(selectedTask.tag_ids ?? [])].sort().join("|"); const nextTags = [...editTagIds].sort().join("|"); const currentDeps = [...(selectedTask.depends_on_task_ids ?? [])] .sort() .join("|"); const nextDeps = [...editDependsOnTaskIds].sort().join("|"); const currentCustomFieldValues = canonicalizeCustomFieldValues( boardCustomFieldValues( boardCustomFieldDefinitions, selectedTask.custom_field_values, ), ); const nextCustomFieldValues = canonicalizeCustomFieldValues( customFieldPayload(boardCustomFieldDefinitions, editCustomFieldValues), ); return ( normalizedTitle !== selectedTask.title || normalizedDescription !== currentDescription || editStatus !== selectedTask.status || editPriority !== selectedTask.priority || editDueDate !== currentDueDate || editAssigneeId !== currentAssignee || currentTags !== nextTags || currentDeps !== nextDeps || currentCustomFieldValues !== nextCustomFieldValues ); }, [ editAssigneeId, editDueDate, editTagIds, editDependsOnTaskIds, editDescription, editPriority, editStatus, editTitle, editCustomFieldValues, boardCustomFieldDefinitions, selectedTask, ]); const pendingApprovals = useMemo( () => approvals.filter((approval) => approval.status === "pending"), [approvals], ); const taskApprovals = useMemo(() => { if (!selectedTask) return []; const taskId = selectedTask.id; const taskIdsForApproval = (approval: Approval) => { const payload = approval.payload ?? {}; const payloadValue = (key: string) => { const value = (payload as Record)[key]; if (typeof value === "string" || typeof value === "number") { return String(value); } return null; }; const payloadArray = (key: string) => { const value = (payload as Record)[key]; if (!Array.isArray(value)) return []; return value.filter((item): item is string => typeof item === "string"); }; const linkedTaskIds = ( approval as Approval & { task_ids?: string[] | null } ).task_ids; const singleTaskId = approval.task_id ?? payloadValue("task_id") ?? payloadValue("taskId") ?? payloadValue("taskID"); const merged = [ ...(Array.isArray(linkedTaskIds) ? linkedTaskIds : []), ...payloadArray("task_ids"), ...payloadArray("taskIds"), ...payloadArray("taskIDs"), ...(singleTaskId ? [singleTaskId] : []), ]; return [...new Set(merged)]; }; return approvals.filter((approval) => taskIdsForApproval(approval).includes(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 boardLead = useMemo( () => agents.find((agent) => agent.is_board_lead) ?? null, [agents], ); const isBoardLeadProvisioning = boardLead?.status === "provisioning"; 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(mergeCommentsById(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 }, options?: { preserveCommentTarget?: boolean; }, ) => { setIsChatOpen(false); setIsLiveFeedOpen(false); const fullTask = tasksRef.current.find((item) => item.id === task.id); if (!fullTask) return; const preserveCommentTarget = options?.preserveCommentTarget === true; const currentTaskIdFromUrl = searchParams.get("taskId"); const currentCommentIdFromUrl = searchParams.get("commentId"); const targetCommentId = preserveCommentTarget ? currentCommentIdFromUrl : null; if ( currentTaskIdFromUrl !== fullTask.id || currentCommentIdFromUrl !== targetCommentId ) { router.replace( buildUrlWithTaskAndComment(fullTask.id, targetCommentId), { scroll: false, }, ); } selectedTaskIdRef.current = fullTask.id; setSelectedTask(fullTask); setIsDetailOpen(true); void loadComments(task.id); }, [buildUrlWithTaskAndComment, loadComments, router, searchParams], ); const selectedTaskDependencies = useMemo(() => { if (!selectedTask) return []; const blockedDependencyIds = new Set( selectedTask.blocked_by_task_ids ?? [], ); return (selectedTask.depends_on_task_ids ?? []).map((dependencyId) => { const dependencyTask = taskById.get(dependencyId); const statusLabel = dependencyTask?.status ? dependencyTask.status.replace(/_/g, " ") : "unknown"; return { id: dependencyId, title: dependencyTask?.title ?? dependencyId, statusLabel, isBlocking: blockedDependencyIds.has(dependencyId), isDone: dependencyTask?.status === "done", disabled: !dependencyTask, onClick: dependencyTask ? () => { openComments({ id: dependencyId }); } : undefined, }; }); }, [openComments, selectedTask, taskById]); const selectedTaskResolvedDependencies = useMemo< DependencyBannerDependency[] >(() => { if (!selectedTask) return []; return tasks .filter((task) => task.depends_on_task_ids?.includes(selectedTask.id)) .map((task) => { const statusLabel = task.status ? task.status.replace(/_/g, " ") : "unknown"; return { id: task.id, title: task.title, statusLabel, isBlocking: false, isDone: task.status === "done", onClick: () => { openComments({ id: task.id }); }, disabled: false, }; }); }, [openComments, selectedTask, tasks]); useEffect(() => { if (!hasLoadedBoardSnapshot) return; if (!taskIdFromUrl) { openedTaskIdFromUrlRef.current = null; return; } if (openedTaskIdFromUrlRef.current === taskIdFromUrl) return; const exists = tasks.some((task) => task.id === taskIdFromUrl); if (!exists) { router.replace(buildUrlWithTaskAndComment(null, null), { scroll: false, }); return; } openedTaskIdFromUrlRef.current = taskIdFromUrl; openComments({ id: taskIdFromUrl }, { preserveCommentTarget: true }); }, [ hasLoadedBoardSnapshot, buildUrlWithTaskAndComment, openComments, router, taskIdFromUrl, tasks, ]); useEffect(() => { if (!hasLoadedBoardSnapshot) return; if (panelFromUrl !== "chat") { openedPanelFromUrlRef.current = null; return; } if (openedPanelFromUrlRef.current === "chat") return; openedPanelFromUrlRef.current = "chat"; setIsDetailOpen(false); selectedTaskIdRef.current = null; setSelectedTask(null); setComments([]); setCommentsError(null); setPostCommentError(null); setIsLiveFeedOpen(false); setIsChatOpen(true); }, [hasLoadedBoardSnapshot, panelFromUrl]); useEffect(() => { if (!isDetailOpen || !commentIdFromUrl) { setHighlightedCommentId(null); return; } const target = comments.find((comment) => comment.id === commentIdFromUrl); if (!target) return; setHighlightedCommentId(target.id); const scrollTimer = window.setTimeout(() => { const element = document.getElementById(commentElementId(target.id)); if (!element) return; element.scrollIntoView({ behavior: "smooth", block: "center" }); }, 60); const clearTimer = window.setTimeout(() => { setHighlightedCommentId((current) => current === target.id ? null : current, ); }, 4_000); return () => { window.clearTimeout(scrollTimer); window.clearTimeout(clearTimer); }; }, [commentIdFromUrl, comments, isDetailOpen]); const closeComments = () => { openedTaskIdFromUrlRef.current = null; if (searchParams.get("taskId") || searchParams.get("commentId")) { router.replace(buildUrlWithTaskAndComment(null, null), { scroll: false, }); } setIsDetailOpen(false); selectedTaskIdRef.current = null; setSelectedTask(null); setHighlightedCommentId(null); setComments([]); setCommentsError(null); setPostCommentError(null); setIsEditDialogOpen(false); }; const openBoardChat = () => { if (isDetailOpen) { closeComments(); } setIsLiveFeedOpen(false); if ( searchParams.get("panel") !== "chat" || searchParams.get("taskId") || searchParams.get("commentId") ) { router.replace(buildUrlWithTaskAndComment(null, null, "chat"), { scroll: false, }); } setIsChatOpen(true); }; const closeBoardChat = () => { if (searchParams.get("panel") === "chat") { router.replace(buildUrlWithTaskAndComment(null, null, null), { scroll: false, }); } setIsChatOpen(false); setChatError(null); }; const openLiveFeed = () => { if (isDetailOpen) { closeComments(); } if (isChatOpen) { closeBoardChat(); } setIsLiveFeedOpen(true); }; const closeLiveFeed = () => { setIsLiveFeedOpen(false); }; const handlePostComment = async (message: string): Promise => { if (!selectedTask || !boardId || !isSignedIn) return false; const trimmed = message.trim(); if (!trimmed) { setPostCommentError("Write a message before sending."); return false; } 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) => mergeCommentsById([created], prev)); return true; } catch (err) { const message = formatActionError(err, "Unable to send message."); setPostCommentError(message); pushToast(message); return false; } finally { setIsPostingComment(false); } }; const handleTaskSave = async (closeOnSuccess = false) => { if (!selectedTask || !isSignedIn || !boardId) return; const trimmedTitle = editTitle.trim(); if (!trimmedTitle) { setSaveTaskError("Title is required."); return; } const currentTaskCustomFieldValues = boardCustomFieldValues( boardCustomFieldDefinitions, selectedTask.custom_field_values, ); const editCustomFieldPayload = customFieldPayload( boardCustomFieldDefinitions, editCustomFieldValues, ); const editCustomFieldPatch = customFieldPatchPayload( boardCustomFieldDefinitions, currentTaskCustomFieldValues, editCustomFieldPayload, ); const missingRequiredCustomField = firstMissingRequiredCustomField( boardCustomFieldDefinitions.filter((definition) => Object.prototype.hasOwnProperty.call( editCustomFieldPatch, definition.field_key, ), ), editCustomFieldPatch, ); if (missingRequiredCustomField) { setSaveTaskError( `Custom field "${missingRequiredCustomField}" is required.`, ); return; } setIsSavingTask(true); setSaveTaskError(null); try { const currentDeps = [...(selectedTask.depends_on_task_ids ?? [])] .sort() .join("|"); const nextDeps = [...editDependsOnTaskIds].sort().join("|"); const depsChanged = currentDeps !== nextDeps; const currentTags = [...(selectedTask.tag_ids ?? [])].sort().join("|"); const nextTags = [...editTagIds].sort().join("|"); const tagsChanged = currentTags !== nextTags; const currentDueDate = toLocalDateInput(selectedTask.due_at); const dueDateChanged = editDueDate !== currentDueDate; const customFieldValuesChanged = Object.keys(editCustomFieldPatch).length > 0; const updatePayload: BoardTaskUpdatePayload = { title: trimmedTitle, description: editDescription.trim() || null, status: editStatus, priority: editPriority, assigned_agent_id: editAssigneeId || null, }; if (depsChanged && selectedTask.status !== "done") { updatePayload.depends_on_task_ids = editDependsOnTaskIds; } if (tagsChanged) { updatePayload.tag_ids = editTagIds; } if (dueDateChanged) { updatePayload.due_at = localDateInputToUtcIso(editDueDate); } if ( customFieldValuesChanged && Object.keys(editCustomFieldPatch).length > 0 ) { updatePayload.custom_field_values = editCustomFieldPatch; } const result = await updateTaskApiV1BoardsBoardIdTasksTaskIdPatch( boardId, selectedTask.id, updatePayload, ); if (result.status === 409) { const blockedIds = result.data.detail.blocked_by_task_ids ?? []; const blockedTitles = blockedIds .map((id) => taskTitleById.get(id) ?? id) .join(", "); setSaveTaskError( blockedTitles ? `${result.data.detail.message} Blocked by: ${blockedTitles}` : result.data.detail.message, ); return; } if (result.status === 422) { setSaveTaskError( result.data.detail?.[0]?.msg ?? "Validation error while saving task.", ); return; } 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) { const message = formatActionError(err, "Something went wrong."); setSaveTaskError(message); pushToast(message); } finally { setIsSavingTask(false); } }; const handleTaskReset = () => { if (!selectedTask) return; setEditTitle(selectedTask.title); setEditDescription(selectedTask.description ?? ""); setEditStatus(selectedTask.status); setEditPriority(selectedTask.priority); setEditDueDate(toLocalDateInput(selectedTask.due_at)); setEditAssigneeId(selectedTask.assigned_agent_id ?? ""); setEditTagIds(selectedTask.tag_ids ?? []); setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []); setEditCustomFieldValues( boardCustomFieldValues( boardCustomFieldDefinitions, selectedTask.custom_field_values, ), ); 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) { const message = formatActionError(err, "Something went wrong."); setDeleteTaskError(message); pushToast(message); } 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; if (currentTask.is_blocked && status !== "inbox") { setError("Task is blocked by incomplete dependencies."); 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 === 409) { const blockedIds = result.data.detail.blocked_by_task_ids ?? []; const blockedTitles = blockedIds .map((id) => taskTitleById.get(id) ?? id) .join(", "); throw new Error( blockedTitles ? `${result.data.detail.message} Blocked by: ${blockedTitles}` : result.data.detail.message, ); } if (result.status === 422) { throw new Error( result.data.detail?.[0]?.msg ?? "Validation error while moving 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); const message = formatActionError(err, "Unable to move task."); setError(message); pushToast(message); } }, [boardId, isSignedIn, pushToast, taskTitleById], ); 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 (AGENT_EMOJI_GLYPHS[trimmed]) return AGENT_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 formatTaskTimestamp = (value?: string | null) => { if (!value) return "—"; const date = parseApiDatetime(value); if (!date) 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 = parseApiDatetime(value); if (!date) 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 approvalPayloadValues = (payload: Approval["payload"], key: string) => { if (!payload || typeof payload !== "object") return []; const value = (payload as Record)[key]; if (!Array.isArray(value)) return []; return value.filter((item): item is string => typeof item === "string"); }; const approvalTaskIds = (approval: Approval) => { const payload = approval.payload ?? {}; const linkedTaskIds = ( approval as Approval & { task_ids?: string[] | null } ).task_ids; const singleTaskId = approval.task_id ?? approvalPayloadValue(payload, "task_id") ?? approvalPayloadValue(payload, "taskId") ?? approvalPayloadValue(payload, "taskID"); const manyTaskIds = [ ...approvalPayloadValues(payload, "task_ids"), ...approvalPayloadValues(payload, "taskIds"), ...approvalPayloadValues(payload, "taskIDs"), ]; const merged = [ ...(Array.isArray(linkedTaskIds) ? linkedTaskIds : []), ...manyTaskIds, ...(singleTaskId ? [singleTaskId] : []), ]; const deduped: string[] = []; const seen = new Set(); merged.forEach((value) => { if (seen.has(value)) return; seen.add(value); deduped.push(value); }); return deduped; }; const approvalRows = (approval: Approval) => { const payload = approval.payload ?? {}; const taskIds = approvalTaskIds(approval); 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 (taskIds.length === 1) rows.push({ label: "Task", value: taskIds[0] }); if (taskIds.length > 1) rows.push({ label: "Tasks", value: taskIds.join(", ") }); 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; if (!canWrite) { pushToast( "Read-only access. You do not have permission to update approvals.", ); 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) { const message = formatActionError(err, "Unable to update approval."); setApprovalsError(message); pushToast(message); } finally { setApprovalsUpdatingId(null); } }, [boardId, canWrite, isSignedIn, pushToast], ); return (

Sign in to view boards.

{board?.name ?? "Board"}

Keep tasks moving through your workflow.

{isBoardLeadProvisioning ? (
Provisioning board lead…
) : null}
{isOrgAdmin ? ( ) : null} {isOrgAdmin ? ( ) : null}
{canWrite && boardId ? (
) : null}
{isOrgAdmin ? ( ) : null}
{error && (
{error}
)} {isLoading ? (
Loading {titleLabel}…
) : ( <> {viewMode === "list" ? ( <> {groupSnapshotError ? (
{groupSnapshotError}
) : null} {groupSnapshot?.group ? (

Related boards

{groupSnapshot.group.name}

{groupSnapshot.group.description ? (

{groupSnapshot.group.description}

) : null}
{isOrgAdmin ? ( ) : null}
{groupSnapshot.boards && groupSnapshot.boards.length ? (
{groupSnapshot.boards.map((item) => (
Inbox {item.task_counts?.inbox ?? 0} In progress{" "} {item.task_counts?.in_progress ?? 0} Review {item.task_counts?.review ?? 0}
{item.tasks && item.tasks.length ? (
    {item.tasks.slice(0, 3).map((task) => (
  • {task.status.replace( /_/g, " ", )} {task.priority}

    {task.title}

    {formatTaskTimestamp( task.updated_at, )}

    Assignee:{" "} {task.assignee ?? "Unassigned"}

    {task.tags?.length ? (
    {task.tags .slice(0, 3) .map((tag) => ( {tag.name} ))}
    ) : null}
  • ))} {item.tasks.length > 3 ? (
  • +{item.tasks.length - 3} more…
  • ) : null}
) : (

No tasks in this snapshot.

)}
))}
) : (

No other boards in this group yet.

)}
) : groupSnapshot ? (

No board group configured

Assign this board to a group to give agents visibility into related work.

) : null} ) : null} {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} Edit task Update task details, priority, status, or assignment.
setEditTitle(event.target.value)} placeholder="Task title" disabled={!selectedTask || isSavingTask || !canWrite} />