1256 lines
46 KiB
TypeScript
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>
|
|
);
|
|
}
|