"use client"; export const dynamic = "force-dynamic"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk"; import { ArrowUpRight, MessageSquare, NotebookText, Settings, X, } from "lucide-react"; import { ApiError } from "@/api/mutator"; import { applyBoardGroupHeartbeatApiV1BoardGroupsGroupIdHeartbeatPost, type getBoardGroupSnapshotApiV1BoardGroupsGroupIdSnapshotGetResponse, useGetBoardGroupSnapshotApiV1BoardGroupsGroupIdSnapshotGet, } from "@/api/generated/board-groups/board-groups"; import { createBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryPost, type listBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryGetResponse, streamBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryStreamGet, useListBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryGet, } from "@/api/generated/board-group-memory/board-group-memory"; import { type getMyMembershipApiV1OrganizationsMeMemberGetResponse, useGetMyMembershipApiV1OrganizationsMeMemberGet, } from "@/api/generated/organizations/organizations"; import type { BoardGroupHeartbeatApplyResult, BoardGroupMemoryRead, OrganizationMemberRead, } from "@/api/generated/model"; import type { BoardGroupBoardSnapshot } from "@/api/generated/model"; import { Markdown } from "@/components/atoms/Markdown"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardShell } from "@/components/templates/DashboardShell"; import { BoardChatComposer } from "@/components/BoardChatComposer"; import { Button, buttonVariants } from "@/components/ui/button"; import { createExponentialBackoff } from "@/lib/backoff"; import { apiDatetimeToMs } from "@/lib/datetime"; import { cn } from "@/lib/utils"; import { usePageActive } from "@/hooks/usePageActive"; const formatTimestamp = (value?: string | null) => { if (!value) return "—"; const date = new Date(`${value}${value.endsWith("Z") ? "" : "Z"}`); if (Number.isNaN(date.getTime())) return "—"; return date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); }; const statusLabel = (value?: string | null) => { switch (value) { case "inbox": return "Inbox"; case "in_progress": return "In progress"; case "review": return "Review"; case "done": return "Done"; default: return value || "—"; } }; const statusTone = (value?: string | null) => { switch (value) { case "in_progress": return "bg-emerald-50 text-emerald-700 border-emerald-200"; case "review": return "bg-amber-50 text-amber-800 border-amber-200"; case "done": return "bg-slate-50 text-slate-600 border-slate-200"; default: return "bg-blue-50 text-blue-700 border-blue-200"; } }; const priorityTone = (value?: string | null) => { switch (value) { case "high": return "bg-rose-50 text-rose-700 border-rose-200"; case "low": return "bg-slate-50 text-slate-600 border-slate-200"; default: return "bg-indigo-50 text-indigo-700 border-indigo-200"; } }; const safeCount = (snapshot: BoardGroupBoardSnapshot, key: string) => snapshot.task_counts?.[key] ?? 0; const canWriteGroupBoards = ( member: OrganizationMemberRead | null, boardIds: Set, ) => { if (!member) return false; if (member.all_boards_write) return true; if (!member.board_access || boardIds.size === 0) return false; return member.board_access.some( (access) => access.can_write && boardIds.has(access.board_id), ); }; function GroupChatMessageCard({ message }: { message: BoardGroupMemoryRead }) { return (

{message.source ?? "User"}

{formatTimestamp(message.created_at)}
{message.tags?.length ? (
{message.tags.map((tag) => ( {tag} ))}
) : null}
); } const SSE_RECONNECT_BACKOFF = { baseMs: 1_000, factor: 2, jitter: 0.2, maxMs: 5 * 60_000, } as const; type HeartbeatUnit = "s" | "m" | "h" | "d"; const HEARTBEAT_PRESETS: Array<{ label: string; amount: number; unit: HeartbeatUnit; }> = [ { label: "30s", amount: 30, unit: "s" }, { label: "1m", amount: 1, unit: "m" }, { label: "2m", amount: 2, unit: "m" }, { label: "5m", amount: 5, unit: "m" }, { label: "10m", amount: 10, unit: "m" }, { label: "15m", amount: 15, unit: "m" }, { label: "30m", amount: 30, unit: "m" }, { label: "1h", amount: 1, unit: "h" }, ]; export default function BoardGroupDetailPage() { const { isSignedIn } = useAuth(); const params = useParams(); const groupIdParam = params?.groupId; const groupId = Array.isArray(groupIdParam) ? groupIdParam[0] : groupIdParam; const isPageActive = usePageActive(); const [includeDone, setIncludeDone] = useState(false); const [perBoardLimit, setPerBoardLimit] = useState(5); const [isChatOpen, setIsChatOpen] = useState(false); const [chatMessages, setChatMessages] = useState([]); const [isChatSending, setIsChatSending] = useState(false); const [chatError, setChatError] = useState(null); const [chatBroadcast, setChatBroadcast] = useState(true); const chatMessagesRef = useRef([]); const chatEndRef = useRef(null); const [isNotesOpen, setIsNotesOpen] = useState(false); const [notesMessages, setNotesMessages] = useState( [], ); const notesMessagesRef = useRef([]); const notesEndRef = useRef(null); const [notesBroadcast, setNotesBroadcast] = useState(true); const [isNoteSending, setIsNoteSending] = useState(false); const [noteSendError, setNoteSendError] = useState(null); const [heartbeatAmount, setHeartbeatAmount] = useState("10"); const [heartbeatUnit, setHeartbeatUnit] = useState("m"); const [includeBoardLeads, setIncludeBoardLeads] = useState(false); const [isHeartbeatApplying, setIsHeartbeatApplying] = useState(false); const [heartbeatApplyError, setHeartbeatApplyError] = useState( null, ); const [heartbeatApplyResult, setHeartbeatApplyResult] = useState(null); const heartbeatEvery = useMemo(() => { const parsed = Number.parseInt(heartbeatAmount, 10); if (!Number.isFinite(parsed) || parsed <= 0) return ""; return `${parsed}${heartbeatUnit}`; }, [heartbeatAmount, heartbeatUnit]); const snapshotQuery = useGetBoardGroupSnapshotApiV1BoardGroupsGroupIdSnapshotGet< getBoardGroupSnapshotApiV1BoardGroupsGroupIdSnapshotGetResponse, ApiError >( groupId ?? "", { include_done: includeDone, per_board_task_limit: perBoardLimit }, { query: { enabled: Boolean(isSignedIn && groupId), refetchInterval: 30_000, refetchOnMount: "always", retry: false, }, }, ); const snapshot = snapshotQuery.data?.status === 200 ? snapshotQuery.data.data : null; const group = snapshot?.group ?? null; const boards = useMemo(() => snapshot?.boards ?? [], [snapshot?.boards]); const boardIdSet = useMemo(() => { const ids = new Set(); boards.forEach((item) => { if (item.board?.id) { ids.add(item.board.id); } }); return ids; }, [boards]); const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet< getMyMembershipApiV1OrganizationsMeMemberGetResponse, ApiError >({ query: { enabled: Boolean(isSignedIn), refetchOnMount: "always", }, }); const member = membershipQuery.data?.status === 200 ? membershipQuery.data.data : null; const isAdmin = member?.role === "admin" || member?.role === "owner"; const canWriteGroup = useMemo( () => canWriteGroupBoards(member, boardIdSet), [boardIdSet, member], ); const canManageHeartbeat = Boolean(isAdmin && canWriteGroup); const chatHistoryQuery = useListBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryGet< listBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryGetResponse, ApiError >( groupId ?? "", { limit: 200, is_chat: true }, { query: { enabled: Boolean(isSignedIn && groupId && isChatOpen), refetchOnMount: "always", retry: false, }, }, ); const notesHistoryQuery = useListBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryGet< listBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryGetResponse, ApiError >( groupId ?? "", { limit: 200, is_chat: false }, { query: { enabled: Boolean(isSignedIn && groupId && isNotesOpen), refetchOnMount: "always", retry: false, }, }, ); const mergeChatMessages = useCallback( (prev: BoardGroupMemoryRead[], next: BoardGroupMemoryRead[]) => { const byId = new Map(); prev.forEach((item) => { byId.set(item.id, item); }); next.forEach((item) => { if (item.is_chat) { byId.set(item.id, item); } }); const merged = Array.from(byId.values()); merged.sort((a, b) => { const aTime = apiDatetimeToMs(a.created_at) ?? 0; const bTime = apiDatetimeToMs(b.created_at) ?? 0; return aTime - bTime; }); return merged; }, [], ); const mergeNotesMessages = useCallback( (prev: BoardGroupMemoryRead[], next: BoardGroupMemoryRead[]) => { const byId = new Map(); prev.forEach((item) => { byId.set(item.id, item); }); next.forEach((item) => { if (!item.is_chat) { byId.set(item.id, item); } }); const merged = Array.from(byId.values()); merged.sort((a, b) => { const aTime = apiDatetimeToMs(a.created_at) ?? 0; const bTime = apiDatetimeToMs(b.created_at) ?? 0; return aTime - bTime; }); return merged; }, [], ); const latestMemoryTimestamp = useCallback((items: BoardGroupMemoryRead[]) => { 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(); }, []); useEffect(() => { chatMessagesRef.current = chatMessages; }, [chatMessages]); useEffect(() => { if (!isChatOpen) return; if (chatHistoryQuery.data?.status !== 200) return; const items = chatHistoryQuery.data.data.items ?? []; setChatMessages((prev) => mergeChatMessages(prev, items)); }, [chatHistoryQuery.data, isChatOpen, mergeChatMessages]); useEffect(() => { if (!isChatOpen) return; const timeout = window.setTimeout(() => { chatEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); }, 50); return () => window.clearTimeout(timeout); }, [chatMessages, isChatOpen]); useEffect(() => { if (!isPageActive) return; if (!isSignedIn || !groupId) return; if (!isChatOpen) return; let isCancelled = false; const abortController = new AbortController(); const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF); let reconnectTimeout: number | undefined; const connect = async () => { try { const since = latestMemoryTimestamp(chatMessagesRef.current); const params = { is_chat: true, ...(since ? { since } : {}) }; const streamResult = await streamBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryStreamGet( groupId, params, { headers: { Accept: "text/event-stream" }, signal: abortController.signal, }, ); if (streamResult.status !== 200) { throw new Error("Unable to connect group chat stream."); } const response = streamResult.data as Response; if (!(response instanceof Response) || !response.body) { throw new Error("Unable to connect group 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?: BoardGroupMemoryRead; }; if (payload.memory?.is_chat) { setChatMessages((prev) => mergeChatMessages(prev, [ payload.memory as BoardGroupMemoryRead, ]), ); } } catch { // Ignore malformed events. } } boundary = buffer.indexOf("\n\n"); } } } catch { if (isCancelled) return; if (abortController.signal.aborted) return; const delay = backoff.nextDelayMs(); reconnectTimeout = window.setTimeout(() => { if (!isCancelled) void connect(); }, delay); } }; void connect(); return () => { isCancelled = true; abortController.abort(); if (reconnectTimeout) { window.clearTimeout(reconnectTimeout); } }; }, [ groupId, isChatOpen, isPageActive, isSignedIn, latestMemoryTimestamp, mergeChatMessages, ]); useEffect(() => { notesMessagesRef.current = notesMessages; }, [notesMessages]); useEffect(() => { if (!isNotesOpen) return; if (notesHistoryQuery.data?.status !== 200) return; const items = notesHistoryQuery.data.data.items ?? []; setNotesMessages((prev) => mergeNotesMessages(prev, items)); }, [isNotesOpen, mergeNotesMessages, notesHistoryQuery.data]); useEffect(() => { if (!isNotesOpen) return; const timeout = window.setTimeout(() => { notesEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); }, 50); return () => window.clearTimeout(timeout); }, [isNotesOpen, notesMessages]); useEffect(() => { if (!isPageActive) return; if (!isSignedIn || !groupId) return; if (!isNotesOpen) return; let isCancelled = false; const abortController = new AbortController(); const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF); let reconnectTimeout: number | undefined; const connect = async () => { try { const since = latestMemoryTimestamp(notesMessagesRef.current); const params = { is_chat: false, ...(since ? { since } : {}) }; const streamResult = await streamBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryStreamGet( groupId, params, { headers: { Accept: "text/event-stream" }, signal: abortController.signal, }, ); if (streamResult.status !== 200) { throw new Error("Unable to connect group notes stream."); } const response = streamResult.data as Response; if (!(response instanceof Response) || !response.body) { throw new Error("Unable to connect group notes 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 === "memory" && data) { try { const payload = JSON.parse(data) as { memory?: BoardGroupMemoryRead; }; if (payload.memory && !payload.memory.is_chat) { setNotesMessages((prev) => mergeNotesMessages(prev, [ payload.memory as BoardGroupMemoryRead, ]), ); } } catch { // Ignore malformed events. } } boundary = buffer.indexOf("\n\n"); } } } catch { if (isCancelled) return; if (abortController.signal.aborted) return; const delay = backoff.nextDelayMs(); reconnectTimeout = window.setTimeout(() => { if (!isCancelled) void connect(); }, delay); } }; void connect(); return () => { isCancelled = true; abortController.abort(); if (reconnectTimeout) { window.clearTimeout(reconnectTimeout); } }; }, [ groupId, isNotesOpen, isPageActive, isSignedIn, latestMemoryTimestamp, mergeNotesMessages, ]); const sendGroupChat = useCallback( async (content: string): Promise => { if (!isSignedIn || !groupId) { setChatError("Sign in to send messages."); return false; } if (!canWriteGroup) { setChatError("Read-only access. You cannot post group messages."); return false; } const trimmed = content.trim(); if (!trimmed) return false; setIsChatSending(true); setChatError(null); try { const tags = ["chat", ...(chatBroadcast ? ["broadcast"] : [])]; const result = await createBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryPost( groupId, { content: trimmed, tags }, ); if (result.status !== 200) { throw new Error("Unable to send message."); } const created = result.data; if (created.is_chat) { setChatMessages((prev) => mergeChatMessages(prev, [created])); } return true; } catch (err) { setChatError( err instanceof Error ? err.message : "Unable to send message.", ); return false; } finally { setIsChatSending(false); } }, [canWriteGroup, chatBroadcast, groupId, isSignedIn, mergeChatMessages], ); const sendGroupNote = useCallback( async (content: string): Promise => { if (!isSignedIn || !groupId) { setNoteSendError("Sign in to post."); return false; } if (!canWriteGroup) { setNoteSendError("Read-only access. You cannot post notes."); return false; } const trimmed = content.trim(); if (!trimmed) return false; setIsNoteSending(true); setNoteSendError(null); try { const tags = ["note", ...(notesBroadcast ? ["broadcast"] : [])]; const result = await createBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryPost( groupId, { content: trimmed, tags }, ); if (result.status !== 200) { throw new Error("Unable to post."); } const created = result.data; if (!created.is_chat) { setNotesMessages((prev) => mergeNotesMessages(prev, [created])); } return true; } catch (err) { setNoteSendError( err instanceof Error ? err.message : "Unable to post.", ); return false; } finally { setIsNoteSending(false); } }, [canWriteGroup, groupId, isSignedIn, mergeNotesMessages, notesBroadcast], ); const applyHeartbeat = useCallback(async () => { if (!isSignedIn || !groupId) { setHeartbeatApplyError("Sign in to apply."); return; } if (!canManageHeartbeat) { setHeartbeatApplyError("Read-only access. You cannot change agent pace."); return; } const trimmed = heartbeatEvery.trim(); if (!trimmed) { setHeartbeatApplyError("Heartbeat cadence is required."); return; } setIsHeartbeatApplying(true); setHeartbeatApplyError(null); try { const result = await applyBoardGroupHeartbeatApiV1BoardGroupsGroupIdHeartbeatPost( groupId, { every: trimmed, include_board_leads: includeBoardLeads }, ); if (result.status !== 200) { throw new Error("Unable to apply heartbeat."); } setHeartbeatApplyResult(result.data); } catch (err) { setHeartbeatApplyError( err instanceof Error ? err.message : "Unable to apply heartbeat.", ); } finally { setIsHeartbeatApplying(false); } }, [canManageHeartbeat, groupId, heartbeatEvery, includeBoardLeads, isSignedIn]); return (

Sign in to view board groups.

Board group

{group?.name ?? "Group"}

{group?.description ? (

{group.description}

) : (

No description

)}
{group?.id ? ( Edit ) : null} View boards
Top tasks per board
{[0, 3, 5, 10].map((value) => ( ))}
Agent pace
{HEARTBEAT_PRESETS.map((preset) => { const value = `${preset.amount}${preset.unit}`; return ( ); })}
setHeartbeatAmount(event.target.value)} className={cn( "h-8 w-20 rounded-md border bg-white px-2 text-xs text-slate-900 shadow-sm", heartbeatEvery ? "border-slate-200" : "border-rose-300 focus:border-rose-400 focus:ring-2 focus:ring-rose-100", !canManageHeartbeat && "opacity-60 cursor-not-allowed", )} placeholder="10" inputMode="numeric" type="number" min={1} step={1} disabled={!canManageHeartbeat} />
{!canManageHeartbeat ? (

Read-only access. You cannot change agent pace for this group.

) : null}
{heartbeatApplyError ? (
{heartbeatApplyError}
) : null} {heartbeatApplyResult ? (

Heartbeat applied

Updated {heartbeatApplyResult.updated_agent_ids.length}{" "} agents, failed{" "} {heartbeatApplyResult.failed_agent_ids.length}.

) : null} {snapshotQuery.isLoading ? (
Loading group snapshot…
) : snapshotQuery.error ? (
{snapshotQuery.error.message}
) : boards.length === 0 ? (
No boards in this group yet. Assign boards from the board settings page.
) : (
{boards.map((item) => (

{item.board.name}

Updated {formatTimestamp(item.board.updated_at)}

Inbox {safeCount(item, "inbox")} In progress {safeCount(item, "in_progress")} Review {safeCount(item, "review")}
{item.tasks && item.tasks.length > 0 ? (
    {item.tasks.map((task) => (
  • {statusLabel(task.status)} {task.priority}

    {task.title}

    {formatTimestamp(task.updated_at)}

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

    {task.id}

  • ))}
) : (

No tasks in this snapshot.

)}
))}
)}
{isChatOpen || isNotesOpen ? (
{ setIsChatOpen(false); setChatError(null); setIsNotesOpen(false); setNoteSendError(null); }} /> ) : null} ); }