Files
mission-control/frontend/src/app/board-groups/[groupId]/page.tsx

1256 lines
46 KiB
TypeScript

"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<string>,
) => {
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 (
<div className="rounded-2xl border border-slate-200 bg-slate-50/60 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-slate-900">
{message.source ?? "User"}
</p>
<span className="text-xs text-slate-400">
{formatTimestamp(message.created_at)}
</span>
</div>
<div className="mt-2 select-text cursor-text text-sm leading-relaxed text-slate-900 break-words">
<Markdown content={message.content} variant="basic" />
</div>
{message.tags?.length ? (
<div className="mt-3 flex flex-wrap gap-2 text-[11px] text-slate-600">
{message.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-slate-200 bg-white px-2 py-0.5"
>
{tag}
</span>
))}
</div>
) : null}
</div>
);
}
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<BoardGroupMemoryRead[]>([]);
const [isChatSending, setIsChatSending] = useState(false);
const [chatError, setChatError] = useState<string | null>(null);
const [chatBroadcast, setChatBroadcast] = useState(true);
const chatMessagesRef = useRef<BoardGroupMemoryRead[]>([]);
const chatEndRef = useRef<HTMLDivElement | null>(null);
const [isNotesOpen, setIsNotesOpen] = useState(false);
const [notesMessages, setNotesMessages] = useState<BoardGroupMemoryRead[]>(
[],
);
const notesMessagesRef = useRef<BoardGroupMemoryRead[]>([]);
const notesEndRef = useRef<HTMLDivElement | null>(null);
const [notesBroadcast, setNotesBroadcast] = useState(true);
const [isNoteSending, setIsNoteSending] = useState(false);
const [noteSendError, setNoteSendError] = useState<string | null>(null);
const [heartbeatAmount, setHeartbeatAmount] = useState("10");
const [heartbeatUnit, setHeartbeatUnit] = useState<HeartbeatUnit>("m");
const [includeBoardLeads, setIncludeBoardLeads] = useState(false);
const [isHeartbeatApplying, setIsHeartbeatApplying] = useState(false);
const [heartbeatApplyError, setHeartbeatApplyError] = useState<string | null>(
null,
);
const [heartbeatApplyResult, setHeartbeatApplyResult] =
useState<BoardGroupHeartbeatApplyResult | null>(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<string>();
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<string, BoardGroupMemoryRead>();
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<string, BoardGroupMemoryRead>();
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<boolean> => {
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<boolean> => {
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 (
<DashboardShell>
<SignedOut>
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
<p className="text-sm text-slate-600">
Sign in to view board groups.
</p>
<SignInButton
mode="modal"
forceRedirectUrl={`/board-groups/${groupId ?? ""}`}
>
<Button className="mt-4">Sign in</Button>
</SignInButton>
</div>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<main className="flex-1 overflow-y-auto bg-slate-50">
<div className="sticky top-0 z-30 border-b border-slate-200 bg-white shadow-sm">
<div className="px-8 py-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Board group
</p>
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-slate-900">
{group?.name ?? "Group"}
</h1>
{group?.description ? (
<p className="mt-2 max-w-2xl text-sm text-slate-600">
{group.description}
</p>
) : (
<p className="mt-2 text-sm text-slate-400">
No description
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{group?.id ? (
<Link
href={`/board-groups/${group.id}/edit`}
className={buttonVariants({
variant: "outline",
size: "sm",
})}
title="Edit group"
>
<Settings className="mr-2 h-4 w-4" />
Edit
</Link>
) : null}
<Button
variant="outline"
size="sm"
onClick={() => {
setIsNotesOpen(false);
setNoteSendError(null);
setChatError(null);
setIsChatOpen(true);
}}
disabled={!groupId}
title="Group chat"
>
<MessageSquare className="mr-2 h-4 w-4" />
Chat
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setIsChatOpen(false);
setChatError(null);
setNoteSendError(null);
setIsNotesOpen(true);
}}
disabled={!groupId}
title="Group notes"
>
<NotebookText className="mr-2 h-4 w-4" />
Notes
</Button>
<Link
href="/boards"
className={buttonVariants({ variant: "ghost", size: "sm" })}
>
View boards
</Link>
</div>
</div>
<div className="mt-5 flex flex-wrap items-center gap-3">
<label className="inline-flex items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-300 text-blue-600"
checked={includeDone}
onChange={(event) => setIncludeDone(event.target.checked)}
/>
Include done
</label>
<div className="flex items-center gap-2 text-sm text-slate-700">
<span className="text-slate-500">Top tasks per board</span>
<div className="flex items-center gap-1 rounded-lg border border-slate-200 bg-white p-1">
{[0, 3, 5, 10].map((value) => (
<button
key={value}
type="button"
className={cn(
"rounded-md px-2.5 py-1 text-xs font-semibold transition-colors",
perBoardLimit === value
? "bg-slate-900 text-white"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900",
)}
onClick={() => setPerBoardLimit(value)}
>
{value === 0 ? "0" : value}
</button>
))}
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-slate-700">
<span className="text-slate-500">Agent pace</span>
<div className="flex flex-wrap items-center gap-1 rounded-lg border border-slate-200 bg-white p-1">
{HEARTBEAT_PRESETS.map((preset) => {
const value = `${preset.amount}${preset.unit}`;
return (
<button
key={value}
type="button"
className={cn(
"rounded-md px-2.5 py-1 text-xs font-semibold transition-colors",
heartbeatEvery === value
? "bg-slate-900 text-white"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900",
!canManageHeartbeat && "opacity-50 cursor-not-allowed",
)}
disabled={!canManageHeartbeat}
onClick={() => {
setHeartbeatAmount(String(preset.amount));
setHeartbeatUnit(preset.unit);
}}
>
{preset.label}
</button>
);
})}
</div>
<input
value={heartbeatAmount}
onChange={(event) => 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}
/>
<select
value={heartbeatUnit}
onChange={(event) =>
setHeartbeatUnit(event.target.value as HeartbeatUnit)
}
className={cn(
"h-8 rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-900 shadow-sm",
!canManageHeartbeat && "opacity-60 cursor-not-allowed",
)}
disabled={!canManageHeartbeat}
>
<option value="s">sec</option>
<option value="m">min</option>
<option value="h">hr</option>
<option value="d">day</option>
</select>
<label className="inline-flex items-center gap-2 text-xs text-slate-700">
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-300 text-blue-600"
checked={includeBoardLeads}
onChange={(event) =>
setIncludeBoardLeads(event.target.checked)
}
disabled={!canManageHeartbeat}
/>
Include leads
</label>
<Button
size="sm"
variant="outline"
onClick={() => void applyHeartbeat()}
disabled={
isHeartbeatApplying || !heartbeatEvery || !canManageHeartbeat
}
title={
canManageHeartbeat
? "Apply heartbeat"
: "Read-only access"
}
>
{isHeartbeatApplying ? "Applying…" : "Apply"}
</Button>
</div>
{!canManageHeartbeat ? (
<p className="text-xs text-slate-500">
Read-only access. You cannot change agent pace for this
group.
</p>
) : null}
</div>
</div>
</div>
<div className="p-8">
<div className="space-y-6">
{heartbeatApplyError ? (
<div className="rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700 shadow-sm">
{heartbeatApplyError}
</div>
) : null}
{heartbeatApplyResult ? (
<div className="rounded-xl border border-slate-200 bg-white p-4 text-sm text-slate-700 shadow-sm">
<p className="font-semibold text-slate-900">
Heartbeat applied
</p>
<p className="mt-1 text-slate-600">
Updated {heartbeatApplyResult.updated_agent_ids.length}{" "}
agents, failed{" "}
{heartbeatApplyResult.failed_agent_ids.length}.
</p>
</div>
) : null}
{snapshotQuery.isLoading ? (
<div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-600 shadow-sm">
Loading group snapshot
</div>
) : snapshotQuery.error ? (
<div className="rounded-xl border border-rose-200 bg-rose-50 p-6 text-sm text-rose-700 shadow-sm">
{snapshotQuery.error.message}
</div>
) : boards.length === 0 ? (
<div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-600 shadow-sm">
No boards in this group yet. Assign boards from the board
settings page.
</div>
) : (
<div className="grid gap-6 lg:grid-cols-2">
{boards.map((item) => (
<div
key={item.board.id}
className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"
>
<div className="border-b border-slate-200 px-6 py-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<Link
href={`/boards/${item.board.id}`}
className="group inline-flex items-center gap-2"
title="Open board"
>
<p className="truncate text-sm font-semibold text-slate-900 group-hover:text-blue-600">
{item.board.name}
</p>
<ArrowUpRight className="h-4 w-4 text-slate-400 group-hover:text-blue-600" />
</Link>
<p className="mt-1 text-xs text-slate-500">
Updated {formatTimestamp(item.board.updated_at)}
</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 text-xs">
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-slate-700">
Inbox {safeCount(item, "inbox")}
</span>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-emerald-800">
In progress {safeCount(item, "in_progress")}
</span>
<span className="rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-amber-900">
Review {safeCount(item, "review")}
</span>
</div>
</div>
</div>
<div className="px-6 py-4">
{item.tasks && item.tasks.length > 0 ? (
<ul className="space-y-3">
{item.tasks.map((task) => (
<li
key={task.id}
className="rounded-lg border border-slate-200 bg-slate-50/40 p-3"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
"inline-flex flex-shrink-0 items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold",
statusTone(task.status),
)}
>
{statusLabel(task.status)}
</span>
<span
className={cn(
"inline-flex flex-shrink-0 items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold",
priorityTone(task.priority),
)}
>
{task.priority}
</span>
<p className="truncate text-sm font-medium text-slate-900">
{task.title}
</p>
</div>
<p className="text-xs text-slate-500">
{formatTimestamp(task.updated_at)}
</p>
</div>
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 text-xs text-slate-600">
<p className="truncate">
Assignee:{" "}
<span className="font-medium text-slate-900">
{task.assignee ?? "Unassigned"}
</span>
</p>
<p className="font-mono text-[11px] text-slate-400">
{task.id}
</p>
</div>
</li>
))}
</ul>
) : (
<p className="text-sm text-slate-500">
No tasks in this snapshot.
</p>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
</main>
</SignedIn>
{isChatOpen || isNotesOpen ? (
<div
className="fixed inset-0 z-40 bg-slate-900/20"
onClick={() => {
setIsChatOpen(false);
setChatError(null);
setIsNotesOpen(false);
setNoteSendError(null);
}}
/>
) : null}
<aside
className={cn(
"fixed right-0 top-0 z-50 h-full w-[560px] max-w-[96vw] transform border-l border-slate-200 bg-white shadow-2xl transition-transform",
isChatOpen ? "transform-none" : "translate-x-full",
)}
>
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Group chat
</p>
<p className="mt-1 truncate text-sm font-medium text-slate-900">
Shared across linked boards. Tag @lead, @name, or @all.
</p>
</div>
<button
type="button"
onClick={() => {
setIsChatOpen(false);
setChatError(null);
}}
className="rounded-lg border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-50"
aria-label="Close group chat"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex flex-1 flex-col overflow-hidden px-6 py-4">
<div className="flex flex-wrap items-center justify-between gap-3 pb-3">
<label className="inline-flex items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-300 text-blue-600"
checked={chatBroadcast}
onChange={(event) => setChatBroadcast(event.target.checked)}
disabled={!canWriteGroup}
/>
Broadcast
</label>
<p className="text-xs text-slate-500">
{chatBroadcast
? "Notifies every agent in the group."
: "Notifies leads + mentions."}
</p>
</div>
<div className="flex-1 space-y-4 overflow-y-auto rounded-2xl border border-slate-200 bg-white p-4">
{chatHistoryQuery.error ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{chatHistoryQuery.error.message}
</div>
) : null}
{chatError ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{chatError}
</div>
) : null}
{chatHistoryQuery.isLoading && chatMessages.length === 0 ? (
<p className="text-sm text-slate-500">Loading</p>
) : chatMessages.length === 0 ? (
<p className="text-sm text-slate-500">
No messages yet. Start the conversation with a broadcast or a
mention.
</p>
) : (
chatMessages.map((message) => (
<GroupChatMessageCard key={message.id} message={message} />
))
)}
<div ref={chatEndRef} />
</div>
<BoardChatComposer
placeholder={
canWriteGroup
? "Message the whole group. Tag @lead, @name, or @all."
: "Read-only access. Group chat is disabled."
}
isSending={isChatSending}
onSend={sendGroupChat}
disabled={!canWriteGroup}
/>
</div>
</div>
</aside>
<aside
className={cn(
"fixed right-0 top-0 z-50 h-full w-[560px] max-w-[96vw] transform border-l border-slate-200 bg-white shadow-2xl transition-transform",
isNotesOpen ? "transform-none" : "translate-x-full",
)}
>
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Group notes
</p>
<p className="mt-1 truncate text-sm font-medium text-slate-900">
Shared across linked boards. Tag @lead, @name, or @all.
</p>
</div>
<button
type="button"
onClick={() => {
setIsNotesOpen(false);
setNoteSendError(null);
}}
className="rounded-lg border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-50"
aria-label="Close group notes"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex flex-1 flex-col overflow-hidden px-6 py-4">
<div className="flex flex-wrap items-center justify-between gap-3 pb-3">
<label className="inline-flex items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-300 text-blue-600"
checked={notesBroadcast}
onChange={(event) => setNotesBroadcast(event.target.checked)}
disabled={!canWriteGroup}
/>
Broadcast
</label>
<p className="text-xs text-slate-500">
{notesBroadcast
? "Notifies every agent in the group."
: "Notifies leads + mentions."}
</p>
</div>
<div className="flex-1 space-y-4 overflow-y-auto rounded-2xl border border-slate-200 bg-white p-4">
{notesHistoryQuery.error ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{notesHistoryQuery.error.message}
</div>
) : null}
{noteSendError ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{noteSendError}
</div>
) : null}
{notesHistoryQuery.isLoading && notesMessages.length === 0 ? (
<p className="text-sm text-slate-500">Loading</p>
) : notesMessages.length === 0 ? (
<p className="text-sm text-slate-500">
No notes yet. Post a note or a broadcast to share context
across boards.
</p>
) : (
notesMessages.map((message) => (
<GroupChatMessageCard key={message.id} message={message} />
))
)}
<div ref={notesEndRef} />
</div>
<BoardChatComposer
placeholder={
canWriteGroup
? "Post a shared note for all linked boards. Tag @lead, @name, or @all."
: "Read-only access. Notes are disabled."
}
isSending={isNoteSending}
onSend={sendGroupNote}
disabled={!canWriteGroup}
/>
</div>
</div>
</aside>
</DashboardShell>
);
}