Pipeline/frontend/src/app/boards/[boardId]/page.tsx

4738 lines
172 KiB
TypeScript

"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<TaskCardRead["status"], undefined>;
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<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",
]);
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<string, TaskComment>();
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 (
<div
id={commentElementId(comment.id)}
className={cn(
"scroll-mt-28 rounded-xl border bg-white p-3 transition",
isHighlighted
? "border-blue-300 ring-2 ring-blue-200"
: "border-slate-200",
)}
>
<div className="flex items-center justify-between text-xs text-slate-500">
<span>{authorLabel}</span>
<span>{formatShortTimestamp(comment.created_at)}</span>
</div>
{message ? (
<div className="mt-2 select-text cursor-text text-sm leading-relaxed text-slate-900 break-words">
<Markdown content={message} variant="comment" />
</div>
) : (
<p className="mt-2 text-sm text-slate-900"></p>
)}
</div>
);
});
TaskCommentCard.displayName = "TaskCommentCard";
const ChatMessageCard = memo(function ChatMessageCard({
message,
fallbackSource,
}: {
message: BoardChatMessage;
fallbackSource: string;
}) {
const sourceLabel = resolveHumanActorName(message.source, fallbackSource);
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">{sourceLabel}</p>
<span className="text-xs text-slate-400">
{formatShortTimestamp(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>
</div>
);
});
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 (
<div
className={cn(
"rounded-xl border p-3 transition-colors duration-300",
isNew
? "border-blue-200 bg-blue-50/70 shadow-sm hover:border-blue-300 motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-95 motion-safe:slide-in-from-right-2 motion-safe:duration-300"
: "border-slate-200 bg-white hover:border-slate-300",
)}
>
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-700">
{authorAvatar}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<button
type="button"
onClick={onViewTask}
disabled={!onViewTask}
className={cn(
"text-left text-sm font-semibold leading-snug text-slate-900",
onViewTask
? "cursor-pointer transition hover:text-slate-950 hover:underline"
: "cursor-default",
)}
title={taskTitle}
style={{
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{taskTitle}
</button>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-slate-500">
<span
className={cn(
"rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide",
eventPillClass,
)}
>
{eventLabel}
</span>
<span className="font-medium text-slate-700">{authorName}</span>
{authorRole ? (
<>
<span className="text-slate-300">·</span>
<span className="text-slate-500">{authorRole}</span>
</>
) : null}
<span className="text-slate-300">·</span>
<span className="text-slate-400">
{formatShortTimestamp(item.created_at)}
</span>
</div>
</div>
</div>
{message ? (
<div className="mt-3 select-text cursor-text text-sm leading-relaxed text-slate-900 break-words">
<Markdown content={message} variant="basic" />
</div>
) : (
<p className="mt-3 text-sm text-slate-500"></p>
)}
</div>
);
});
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<Board | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
const [groupSnapshot, setGroupSnapshot] = useState<BoardGroupSnapshot | null>(
null,
);
const [groupSnapshotError, setGroupSnapshotError] = useState<string | null>(
null,
);
const [isLoading, setIsLoading] = useState(false);
const [hasLoadedBoardSnapshot, setHasLoadedBoardSnapshot] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const selectedTaskIdRef = useRef<string | null>(null);
const openedTaskIdFromUrlRef = useRef<string | null>(null);
const openedPanelFromUrlRef = useRef<string | null>(null);
const [comments, setComments] = useState<TaskComment[]>([]);
const [highlightedCommentId, setHighlightedCommentId] = useState<
string | null
>(null);
const [liveFeed, setLiveFeed] = useState<LiveFeedItem[]>([]);
const liveFeedRef = useRef<LiveFeedItem[]>([]);
const liveFeedFlashTimersRef = useRef<Record<string, number>>({});
const [liveFeedFlashIds, setLiveFeedFlashIds] = useState<
Record<string, boolean>
>({});
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<string | null>(null);
const [isPostingComment, setIsPostingComment] = useState(false);
const [postCommentError, setPostCommentError] = useState<string | null>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const tasksRef = useRef<Task[]>([]);
const approvalsRef = useRef<Approval[]>([]);
const agentsRef = useRef<Agent[]>([]);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [approvals, setApprovals] = useState<Approval[]>([]);
const [isApprovalsLoading, setIsApprovalsLoading] = useState(false);
const [approvalsError, setApprovalsError] = useState<string | null>(null);
const [approvalsUpdatingId, setApprovalsUpdatingId] = useState<string | null>(
null,
);
const [isChatOpen, setIsChatOpen] = useState(false);
const [chatMessages, setChatMessages] = useState<BoardChatMessage[]>([]);
const [isChatSending, setIsChatSending] = useState(false);
const [chatError, setChatError] = useState<string | null>(null);
const chatMessagesRef = useRef<BoardChatMessage[]>([]);
const chatEndRef = useRef<HTMLDivElement | null>(null);
const [isAgentsControlDialogOpen, setIsAgentsControlDialogOpen] =
useState(false);
const [agentsControlAction, setAgentsControlAction] = useState<
"pause" | "resume"
>("pause");
const [isAgentsControlSending, setIsAgentsControlSending] = useState(false);
const [agentsControlError, setAgentsControlError] = useState<string | null>(
null,
);
const [isDeletingTask, setIsDeletingTask] = useState(false);
const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"board" | "list">("board");
const [isLiveFeedOpen, setIsLiveFeedOpen] = useState(false);
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const isLiveFeedOpenRef = useRef(false);
const toastIdRef = useRef(0);
const toastTimersRef = useRef<Record<number, number>>({});
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<string>();
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<string, LiveFeedItem>();
[...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<string[]>([]);
const [createCustomFieldValues, setCreateCustomFieldValues] =
useState<TaskCustomFieldValues>({});
const [createError, setCreateError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [editTitle, setEditTitle] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editStatus, setEditStatus] = useState<TaskStatus>("inbox");
const [editPriority, setEditPriority] = useState("medium");
const [editDueDate, setEditDueDate] = useState("");
const [editAssigneeId, setEditAssigneeId] = useState("");
const [editTagIds, setEditTagIds] = useState<string[]>([]);
const [editDependsOnTaskIds, setEditDependsOnTaskIds] = useState<string[]>(
[],
);
const [editCustomFieldValues, setEditCustomFieldValues] =
useState<TaskCustomFieldValues>({});
const [isSavingTask, setIsSavingTask] = useState(false);
const [saveTaskError, setSaveTaskError] = useState<string | null>(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<boolean> => {
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<string, string>();
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<string, string>();
tasks.forEach((task) => {
map.set(task.id, task.title);
});
return map;
}, [tasks]);
const taskById = useMemo(() => {
const map = new Map<string, Task>();
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<string>(["lead"]);
agents.forEach((agent) => {
if (agent.name) {
options.add(agent.name);
}
});
return [...options];
}, [agents]);
const tagById = useMemo(() => {
const map = new Map<string, TagRead>();
tags.forEach((tag) => {
map.set(tag.id, tag);
});
return map;
}, [tags]);
const createTagOptions = useMemo<DropdownSelectOption[]>(() => {
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<DropdownSelectOption[]>(() => {
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<DropdownSelectOption[]>(() => {
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<string, unknown>)[key];
if (typeof value === "string" || typeof value === "number") {
return String(value);
}
return null;
};
const payloadArray = (key: string) => {
const value = (payload as Record<string, unknown>)[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<string>();
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<DependencyBannerDependency[]>(() => {
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<boolean> => {
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<string, unknown>)
.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<string, unknown>).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<string, unknown>)[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<string, unknown>)[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<string>();
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 (
<DashboardShell>
<SignedOut>
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-2xl surface-panel p-10 text-center">
<p className="text-sm text-muted">Sign in to view boards.</p>
<SignInButton
mode="modal"
forceRedirectUrl="/boards"
signUpForceRedirectUrl="/boards"
>
<Button>Sign in</Button>
</SignInButton>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<main
className={cn(
"flex-1 bg-gradient-to-br from-slate-50 to-slate-100",
isSidePanelOpen ? "overflow-hidden" : "overflow-y-auto",
)}
>
<div className="sticky top-0 z-30 border-b border-slate-200 bg-white shadow-sm">
<div className="px-4 py-4 md:px-8 md:py-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="mt-2 text-2xl font-semibold text-slate-900 tracking-tight">
{board?.name ?? "Board"}
</h1>
<p className="mt-1 text-sm text-slate-500">
Keep tasks moving through your workflow.
</p>
{isBoardLeadProvisioning ? (
<div className="mt-3 inline-flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-800">
<RefreshCcw className="h-3.5 w-3.5 animate-spin" />
<span>Provisioning board lead</span>
</div>
) : null}
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1 rounded-lg bg-slate-100 p-1">
<button
className={cn(
"rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
viewMode === "board"
? "bg-slate-900 text-white"
: "text-slate-600 hover:bg-slate-200 hover:text-slate-900",
)}
onClick={() => setViewMode("board")}
>
Board
</button>
<button
className={cn(
"rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
viewMode === "list"
? "bg-slate-900 text-white"
: "text-slate-600 hover:bg-slate-200 hover:text-slate-900",
)}
onClick={() => setViewMode("list")}
>
List
</button>
</div>
<Button
onClick={() => setIsDialogOpen(true)}
className="h-9 w-9 p-0"
aria-label="New task"
title={canWrite ? "New task" : "Read-only access"}
disabled={!canWrite}
>
<Plus className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => router.push(`/boards/${boardId}/approvals`)}
className="relative h-9 w-9 p-0"
aria-label="Approvals"
title="Approvals"
>
<ShieldCheck className="h-4 w-4" />
{pendingApprovals.length > 0 ? (
<span className="absolute -right-1 -top-1 inline-flex min-w-[18px] items-center justify-center rounded-full bg-slate-900 px-1.5 py-0.5 text-[10px] font-semibold text-white">
{pendingApprovals.length}
</span>
) : null}
</Button>
{isOrgAdmin ? (
<Button
variant="outline"
onClick={() =>
openAgentsControlDialog(
isAgentsPaused ? "resume" : "pause",
)
}
disabled={
!isSignedIn ||
!boardId ||
isAgentsControlSending ||
!canWrite
}
className={cn(
"h-9 w-9 p-0",
isAgentsPaused
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-800"
: "",
)}
aria-label={
isAgentsPaused ? "Resume agents" : "Pause agents"
}
title={
canWrite
? isAgentsPaused
? "Resume agents"
: "Pause agents"
: "Read-only access"
}
>
{isAgentsPaused ? (
<Play className="h-4 w-4" />
) : (
<Pause className="h-4 w-4" />
)}
</Button>
) : null}
<Button
variant="outline"
onClick={openBoardChat}
className="h-9 w-9 p-0"
aria-label="Board chat"
title="Board chat"
>
<MessageSquare className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={openLiveFeed}
className="h-9 w-9 p-0"
aria-label="Live feed"
title="Live feed"
>
<Activity className="h-4 w-4" />
</Button>
{isOrgAdmin ? (
<button
type="button"
onClick={() => router.push(`/boards/${boardId}/edit`)}
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition hover:border-slate-300 hover:bg-slate-50"
aria-label="Board settings"
title="Board settings"
>
<Settings className="h-4 w-4" />
</button>
) : null}
</div>
</div>
</div>
</div>
{canWrite && boardId ? (
<div className="w-full">
<BoardForgejoRepositoryLinks boardId={boardId} canWrite={canWrite} />
</div>
) : null}
<div className="relative flex flex-col gap-4 p-4 md:flex-row md:gap-6 md:p-6">
{isOrgAdmin ? (
<aside className="flex w-full flex-col rounded-xl border border-slate-200 bg-white shadow-sm md:h-full md:w-64">
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Agents
</p>
<p className="text-xs text-slate-400">
{sortedAgents.length} total
</p>
</div>
<button
type="button"
onClick={() => router.push("/agents/new")}
className="rounded-md border border-slate-200 px-2.5 py-1 text-xs font-semibold text-slate-600 transition hover:border-slate-300 hover:bg-slate-50"
>
Add
</button>
</div>
<div className="flex-1 space-y-2 overflow-y-auto p-3">
{sortedAgents.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-200 p-3 text-xs text-slate-500">
No agents assigned yet.
</div>
) : (
sortedAgents.map((agent) => {
const isWorking = workingAgentIds.has(agent.id);
return (
<button
key={agent.id}
type="button"
className={cn(
"flex w-full items-center gap-3 rounded-lg border border-transparent px-2 py-2 text-left transition hover:border-slate-200 hover:bg-slate-50",
)}
onClick={() => router.push(`/agents/${agent.id}`)}
>
<div className="relative flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-700">
{agentAvatarLabel(agent)}
<StatusDot
status={agent.status}
variant="agent"
className={cn(
"absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full border-2 border-white",
isWorking && "ring-2 ring-emerald-200",
)}
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-900">
{agent.name}
</p>
<p className="text-[11px] text-slate-500">
{agentRoleLabel(agent)}
</p>
</div>
</button>
);
})
)}
</div>
</aside>
) : null}
<div className="min-w-0 flex-1 space-y-6">
{error && (
<div className="rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
{error}
</div>
)}
{isLoading ? (
<div className="flex min-h-[50vh] items-center justify-center text-sm text-slate-500">
Loading {titleLabel}
</div>
) : (
<>
{viewMode === "list" ? (
<>
{groupSnapshotError ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 shadow-sm">
{groupSnapshotError}
</div>
) : null}
{groupSnapshot?.group ? (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-200 px-5 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Related boards
</p>
<p className="mt-1 truncate text-sm font-semibold text-slate-900">
{groupSnapshot.group.name}
</p>
{groupSnapshot.group.description ? (
<p className="mt-1 max-w-3xl text-xs text-slate-500 line-clamp-2">
{groupSnapshot.group.description}
</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
router.push(
`/board-groups/${groupSnapshot.group?.id}`,
)
}
disabled={!groupSnapshot.group?.id}
>
View group
</Button>
{isOrgAdmin ? (
<Button
variant="ghost"
size="sm"
onClick={() =>
router.push(`/boards/${boardId}/edit`)
}
disabled={!boardId}
>
Settings
</Button>
) : null}
</div>
</div>
</div>
<div className="px-5 py-4">
{groupSnapshot.boards &&
groupSnapshot.boards.length ? (
<div className="grid gap-4 md:grid-cols-2">
{groupSnapshot.boards.map((item) => (
<div
key={item.board.id}
className="rounded-xl border border-slate-200 bg-slate-50/40 p-4"
>
<button
type="button"
className="group flex w-full items-start justify-between gap-3 text-left"
onClick={() =>
router.push(`/boards/${item.board.id}`)
}
>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-900 group-hover:text-blue-600">
{item.board.name}
</p>
<p className="mt-1 text-xs text-slate-500">
Updated{" "}
{formatTaskTimestamp(
item.board.updated_at,
)}
</p>
</div>
<ArrowUpRight className="mt-0.5 h-4 w-4 flex-shrink-0 text-slate-400 group-hover:text-blue-600" />
</button>
<div className="mt-3 flex flex-wrap gap-2 text-xs">
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-slate-700">
Inbox {item.task_counts?.inbox ?? 0}
</span>
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-slate-700">
In progress{" "}
{item.task_counts?.in_progress ?? 0}
</span>
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-slate-700">
Review {item.task_counts?.review ?? 0}
</span>
</div>
{item.tasks && item.tasks.length ? (
<ul className="mt-3 space-y-2">
{item.tasks.slice(0, 3).map((task) => (
<li
key={task.id}
className="rounded-lg border border-slate-200 bg-white 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(
"rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
statusBadgeClass(
task.status,
),
)}
>
{task.status.replace(
/_/g,
" ",
)}
</span>
<span
className={cn(
"rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
priorityBadgeClass(
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">
{formatTaskTimestamp(
task.updated_at,
)}
</p>
</div>
<p className="mt-2 truncate text-xs text-slate-600">
Assignee:{" "}
<span className="font-medium text-slate-900">
{task.assignee ?? "Unassigned"}
</span>
</p>
{task.tags?.length ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{task.tags
.slice(0, 3)
.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-semibold text-slate-700"
>
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: `#${normalizeTagColor(
tag.color,
)}`,
}}
/>
{tag.name}
</span>
))}
</div>
) : null}
</li>
))}
{item.tasks.length > 3 ? (
<li className="text-xs text-slate-500">
+{item.tasks.length - 3} more
</li>
) : null}
</ul>
) : (
<p className="mt-3 text-sm text-slate-500">
No tasks in this snapshot.
</p>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-slate-500">
No other boards in this group yet.
</p>
)}
</div>
</div>
) : groupSnapshot ? (
<div className="rounded-xl border border-slate-200 bg-white p-4 text-sm text-slate-600 shadow-sm">
<p className="font-semibold text-slate-900">
No board group configured
</p>
<p className="mt-1 text-sm text-slate-600">
Assign this board to a group to give agents
visibility into related work.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
router.push(`/boards/${boardId}/edit`)
}
disabled={!boardId}
>
Open settings
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/board-groups")}
>
View groups
</Button>
</div>
</div>
) : null}
</>
) : null}
{viewMode === "board" ? (
<TaskBoard
tasks={tasks}
onTaskSelect={openComments}
onTaskMove={canWrite ? handleTaskMove : undefined}
readOnly={!canWrite}
/>
) : (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-200 px-5 py-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-slate-900">
All tasks
</p>
<p className="text-xs text-slate-500">
{tasks.length} tasks in this board
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setIsDialogOpen(true)}
disabled={isCreating || !canWrite}
title={canWrite ? "New task" : "Read-only access"}
>
New task
</Button>
</div>
</div>
<div className="divide-y divide-slate-100">
{tasks.length === 0 ? (
<div className="px-5 py-8 text-sm text-slate-500">
No tasks yet. Create your first task to get started.
</div>
) : (
tasks.map((task) => (
<button
key={task.id}
type="button"
className="w-full px-5 py-4 text-left transition hover:bg-slate-50"
onClick={() => openComments(task)}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-900">
{task.title}
</p>
<p className="mt-1 text-xs text-slate-500">
{task.description
? task.description
.toString()
.trim()
.slice(0, 120)
: "No description"}
</p>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-500">
{task.approvals_pending_count ? (
<span className="inline-flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-amber-700">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
Approval needed ·{" "}
{task.approvals_pending_count}
</span>
) : null}
<span
className={cn(
"rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
statusBadgeClass(task.status),
)}
>
{task.status.replace(/_/g, " ")}
</span>
<span
className={cn(
"rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
priorityBadgeClass(task.priority),
)}
>
{task.priority}
</span>
{task.tags?.length ? (
<div className="flex flex-wrap items-center gap-1">
{task.tags.slice(0, 2).map((tag) => (
<span
key={tag.id}
className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-semibold text-slate-700"
>
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: `#${normalizeTagColor(
tag.color,
)}`,
}}
/>
{tag.name}
</span>
))}
{task.tags.length > 2 ? (
<span className="text-[10px] font-semibold text-slate-500">
+{task.tags.length - 2}
</span>
) : null}
</div>
) : null}
<span className="text-xs text-slate-500">
{task.assignee ?? "Unassigned"}
</span>
<span className="text-xs text-slate-500">
{formatTaskTimestamp(
task.updated_at ?? task.created_at,
)}
</span>
</div>
</div>
</button>
))
)}
</div>
</div>
)}
</>
)}
</div>
</div>
</main>
</SignedIn>
{isDetailOpen || isChatOpen || isLiveFeedOpen ? (
<div
className="fixed inset-0 z-40 bg-slate-900/20"
onClick={() => {
if (isChatOpen) {
closeBoardChat();
} else if (isLiveFeedOpen) {
closeLiveFeed();
} else {
closeComments();
}
}}
/>
) : null}
<aside
className={cn(
"fixed right-0 top-0 z-50 h-full w-full max-w-[99vw] transform bg-white shadow-2xl transition-transform md:w-[max(760px,45vw)]",
isDetailOpen ? "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-4 py-3 md:px-6 md:py-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Task detail
</p>
<p className="mt-1 text-sm font-medium text-slate-900">
{selectedTask?.title ?? "Task"}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setIsEditDialogOpen(true)}
className="rounded-lg border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-50"
disabled={!selectedTask || !canWrite}
title={canWrite ? "Edit task" : "Read-only access"}
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={closeComments}
className="rounded-lg border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-50"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-5">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Description
</p>
{selectedTask?.description ? (
<div className="prose prose-sm max-w-none text-slate-700">
<Markdown
content={selectedTask.description}
variant="description"
/>
</div>
) : (
<p className="text-sm text-slate-500">
No description provided.
</p>
)}
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Custom fields
</p>
{customFieldDefinitionsQuery.isLoading ? (
<p className="text-sm text-slate-500">Loading custom fields</p>
) : boardCustomFieldDefinitions.length > 0 ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3">
<dl className="space-y-2">
{boardCustomFieldDefinitions.map((definition) => {
const value =
selectedTaskCustomFieldValues[definition.field_key];
if (!isCustomFieldVisible(definition, value)) {
return null;
}
return (
<div
key={definition.id}
className="grid grid-cols-1 gap-2 sm:grid-cols-[160px_1fr] sm:gap-3"
>
<dt className="text-xs font-semibold text-slate-600">
{definition.label || definition.field_key}
{definition.required === true ? (
<span className="ml-1 text-rose-600">*</span>
) : null}
</dt>
<dd className="text-xs text-slate-800">
{formatCustomFieldDetailValue(definition, value)}
</dd>
</div>
);
})}
</dl>
</div>
) : (
<p className="text-sm text-slate-500">No custom fields.</p>
)}
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Tags
</p>
{selectedTask?.tags?.length ? (
<div className="flex flex-wrap gap-2">
{selectedTask.tags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-slate-700"
>
<span
className="h-2 w-2 rounded-full"
style={{
backgroundColor: `#${normalizeTagColor(tag.color)}`,
}}
/>
{tag.name}
</span>
))}
</div>
) : (
<p className="text-sm text-slate-500">No tags assigned.</p>
)}
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Dependencies
</p>
{(() => {
const hasDependencies =
(selectedTask?.depends_on_task_ids?.length ?? 0) > 0;
const hasResolvedDependencies =
selectedTaskResolvedDependencies.length > 0;
const isDependencyModeBlocked = hasDependencies
? selectedTask?.is_blocked === true
: false;
const bannerVariant =
hasDependencies || hasResolvedDependencies
? isDependencyModeBlocked
? "blocked"
: "resolved"
: "blocked";
const displayedDependencies =
hasDependencies && selectedTask
? selectedTaskDependencies
: selectedTaskResolvedDependencies;
const childrenMessage =
hasDependencies && selectedTask?.is_blocked
? "Blocked by incomplete dependencies."
: hasDependencies
? "Dependencies resolved."
: hasResolvedDependencies
? "This task resolves these tasks."
: null;
return (
<DependencyBanner
dependencies={displayedDependencies}
variant={bannerVariant}
emptyMessage="No dependencies."
>
{childrenMessage}
</DependencyBanner>
);
})()}
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Approvals
</p>
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/boards/${boardId}/approvals`)}
>
View all
</Button>
</div>
{approvalsError ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-500">
{approvalsError}
</div>
) : isApprovalsLoading ? (
<p className="text-sm text-slate-500">Loading approvals</p>
) : taskApprovals.length === 0 ? (
<p className="text-sm text-slate-500">
No approvals tied to this task.{" "}
{pendingApprovals.length > 0
? `${pendingApprovals.length} pending on this board.`
: "No pending approvals on this board."}
</p>
) : (
<div className="space-y-3">
{taskApprovals.map((approval) => (
<div
key={approval.id}
className="rounded-xl border border-slate-200 bg-white p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2 text-xs text-slate-500">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
{humanizeApprovalAction(approval.action_type)}
</p>
<p className="mt-1 text-xs text-slate-500">
Requested{" "}
{formatApprovalTimestamp(approval.created_at)}
</p>
</div>
<span className="text-xs font-semibold text-slate-700">
{approval.confidence}% confidence · {approval.status}
</span>
</div>
{approvalRows(approval).length > 0 ? (
<div className="mt-2 grid gap-2 text-xs text-slate-600 sm:grid-cols-2">
{approvalRows(approval).map((row) => (
<div key={`${approval.id}-${row.label}`}>
<p className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">
{row.label}
</p>
<p className="mt-1 text-xs text-slate-700">
{row.value}
</p>
</div>
))}
</div>
) : null}
{approvalReason(approval) ? (
<p className="mt-2 text-xs text-slate-600">
{approvalReason(approval)}
</p>
) : null}
{approval.status === "pending" ? (
<div className="mt-3 flex flex-wrap gap-2">
<Button
size="sm"
onClick={() =>
handleApprovalDecision(approval.id, "approved")
}
disabled={
approvalsUpdatingId === approval.id || !canWrite
}
title={canWrite ? "Approve" : "Read-only access"}
>
Approve
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
handleApprovalDecision(approval.id, "rejected")
}
disabled={
approvalsUpdatingId === approval.id || !canWrite
}
title={canWrite ? "Reject" : "Read-only access"}
className="border-slate-300 text-slate-700"
>
Reject
</Button>
</div>
) : null}
</div>
))}
</div>
)}
</div>
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Comments
</p>
<div className="space-y-2 rounded-xl border border-slate-200 bg-slate-50 p-3">
<BoardChatComposer
placeholder={
canWrite
? "Write a message for the assigned agent. Tag @lead or @name."
: "Read-only access. Comments are disabled."
}
isSending={isPostingComment}
onSend={handlePostComment}
disabled={!canWrite}
mentionSuggestions={boardChatMentionSuggestions}
/>
{postCommentError ? (
<p className="text-xs text-rose-600">{postCommentError}</p>
) : null}
{!canWrite ? (
<p className="text-xs text-slate-500">
Read-only access. You cannot post comments on this board.
</p>
) : null}
</div>
{isCommentsLoading ? (
<p className="text-sm text-slate-500">Loading comments</p>
) : commentsError ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-500">
{commentsError}
</div>
) : comments.length === 0 ? (
<p className="text-sm text-slate-500">No comments yet.</p>
) : (
<div className="space-y-3">
{comments.map((comment) => (
<TaskCommentCard
key={comment.id}
comment={comment}
isHighlighted={highlightedCommentId === comment.id}
authorLabel={
comment.agent_id
? (assigneeById.get(comment.agent_id) ?? "Agent")
: currentUserDisplayName
}
/>
))}
</div>
)}
</div>
</div>
</div>
</aside>
<aside
className={cn(
"fixed right-0 top-0 z-50 h-full w-full max-w-[96vw] transform border-l border-slate-200 bg-white shadow-2xl transition-transform md:w-[560px]",
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-4 py-3 md:px-6 md:py-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Board chat
</p>
<p className="mt-1 text-sm font-medium text-slate-900">
Talk to the lead agent. Tag others with @name.
</p>
</div>
<button
type="button"
onClick={closeBoardChat}
className="rounded-lg border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-50"
aria-label="Close board 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-1 space-y-4 overflow-y-auto rounded-2xl border border-slate-200 bg-white p-4">
{chatError ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{chatError}
</div>
) : null}
{chatMessages.length === 0 ? (
<p className="text-sm text-slate-500">
No messages yet. Start the conversation with your lead agent.
</p>
) : (
chatMessages.map((message) => (
<ChatMessageCard
key={message.id}
message={message}
fallbackSource={currentUserDisplayName}
/>
))
)}
<div ref={chatEndRef} />
</div>
<BoardChatComposer
isSending={isChatSending}
onSend={handleSendChat}
disabled={!canWrite}
mentionSuggestions={boardChatMentionSuggestions}
placeholder={
canWrite
? "Message the board lead. Tag agents with @name."
: "Read-only access. Chat is disabled."
}
/>
</div>
</div>
</aside>
<aside
className={cn(
"fixed right-0 top-0 z-50 h-full w-full max-w-[96vw] transform border-l border-slate-200 bg-white shadow-2xl transition-transform md:w-[520px]",
isLiveFeedOpen ? "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-4 py-3 md:px-6 md:py-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Live feed
</p>
<p className="mt-1 text-sm font-medium text-slate-900">
Realtime task, approval, agent, and board-chat activity.
</p>
</div>
<button
type="button"
onClick={closeLiveFeed}
className="rounded-lg border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-50"
aria-label="Close live feed"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
{isLiveFeedHistoryLoading && orderedLiveFeed.length === 0 ? (
<p className="text-sm text-slate-500">Loading feed</p>
) : liveFeedHistoryError ? (
<div className="rounded-lg border border-slate-200 bg-white p-4 text-sm text-slate-700 shadow-sm">
{liveFeedHistoryError}
</div>
) : orderedLiveFeed.length === 0 ? (
<p className="text-sm text-slate-500">
Waiting for new activity
</p>
) : (
<div className="space-y-3">
{orderedLiveFeed.map((item) => {
const taskId = item.task_id;
const authorAgent = item.agent_id
? (agents.find((agent) => agent.id === item.agent_id) ??
null)
: null;
const authorName =
authorAgent?.name ??
resolveHumanActorName(
item.actor_name,
currentUserDisplayName,
);
const authorRole = authorAgent
? agentRoleLabel(authorAgent)
: null;
const authorAvatar = authorAgent
? agentAvatarLabel(authorAgent)
: (authorName[0] ?? "A").toUpperCase();
return (
<LiveFeedCard
key={item.id}
item={item}
isNew={Boolean(liveFeedFlashIds[item.id])}
taskTitle={
item.title
? item.title
: taskId
? (taskTitleById.get(taskId) ?? "Unknown task")
: "Activity"
}
authorName={authorName}
authorRole={authorRole}
authorAvatar={authorAvatar}
onViewTask={
taskId ? () => openComments({ id: taskId }) : undefined
}
/>
);
})}
</div>
)}
</div>
</div>
</aside>
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent aria-label="Edit task">
<DialogHeader>
<DialogTitle>Edit task</DialogTitle>
<DialogDescription>
Update task details, priority, status, or assignment.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Title
</label>
<Input
value={editTitle}
onChange={(event) => setEditTitle(event.target.value)}
placeholder="Task title"
disabled={!selectedTask || isSavingTask || !canWrite}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Description
</label>
<Textarea
value={editDescription}
onChange={(event) => setEditDescription(event.target.value)}
placeholder="Task details"
className="min-h-[140px]"
disabled={!selectedTask || isSavingTask || !canWrite}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Custom fields
</label>
<TaskCustomFieldsEditor
definitions={boardCustomFieldDefinitions}
values={editCustomFieldValues}
setValues={setEditCustomFieldValues}
isLoading={customFieldDefinitionsQuery.isLoading}
disabled={!selectedTask || isSavingTask || !canWrite}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Status
</label>
<Select
value={editStatus}
onValueChange={(value) => setEditStatus(value as TaskStatus)}
disabled={!selectedTask || isSavingTask || !canWrite}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Priority
</label>
<Select
value={editPriority}
onValueChange={setEditPriority}
disabled={!selectedTask || isSavingTask || !canWrite}
>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
<SelectContent>
{priorities.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Due date
</label>
<Input
type="date"
value={editDueDate}
onChange={(event) => setEditDueDate(event.target.value)}
disabled={!selectedTask || isSavingTask || !canWrite}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Assignee
</label>
<Select
value={editAssigneeId || "unassigned"}
onValueChange={(value) =>
setEditAssigneeId(value === "unassigned" ? "" : value)
}
disabled={!selectedTask || isSavingTask || !canWrite}
>
<SelectTrigger>
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value="unassigned">Unassigned</SelectItem>
{assignableAgents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
{agent.name}
</SelectItem>
))}
</SelectContent>
</Select>
{assignableAgents.length === 0 ? (
<p className="text-xs text-slate-500">
Add agents to assign tasks.
</p>
) : null}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Tags
</label>
<button
type="button"
onClick={() => router.push("/tags")}
className="text-xs font-medium text-slate-500 underline underline-offset-2 transition hover:text-slate-700"
>
Manage tags
</button>
</div>
<DropdownSelect
ariaLabel="Add tag"
placeholder="Add tag"
options={editTagOptions}
onValueChange={addEditTag}
disabled={!selectedTask || isSavingTask || !canWrite}
emptyMessage="No tags configured."
/>
{editTagIds.length === 0 ? (
<p className="text-xs text-slate-500">No tags assigned.</p>
) : (
<div className="flex flex-wrap gap-2">
{editTagIds.map((tagId) => {
const tag = tagById.get(tagId);
const label = tag?.name ?? tagId;
const color = normalizeTagColor(tag?.color);
return (
<span
key={tagId}
className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs text-slate-700"
>
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: `#${color}` }}
/>
<span className="max-w-[16rem] truncate">{label}</span>
<button
type="button"
onClick={() => removeEditTag(tagId)}
className={cn(
"rounded-full p-0.5 text-slate-500 transition",
canWrite
? "hover:bg-white hover:text-slate-700"
: "opacity-50 cursor-not-allowed",
)}
aria-label="Remove tag"
disabled={!canWrite}
>
<X className="h-3 w-3" />
</button>
</span>
);
})}
</div>
)}
</div>
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Dependencies
</label>
<p className="text-xs text-slate-500">
Tasks stay blocked until every dependency is marked done.
</p>
<DropdownSelect
ariaLabel="Add dependency"
placeholder="Add dependency"
options={dependencyOptions}
onValueChange={addTaskDependency}
disabled={
!selectedTask ||
isSavingTask ||
selectedTask.status === "done" ||
!canWrite
}
emptyMessage="No other tasks found."
/>
{selectedTask?.status === "done" ? (
<p className="text-xs text-slate-500">
Dependencies can only be edited until the task is done.
</p>
) : null}
{editDependsOnTaskIds.length === 0 ? (
<p className="text-xs text-slate-500">No dependencies.</p>
) : (
<div className="flex flex-wrap gap-2">
{editDependsOnTaskIds.map((depId) => {
const depTask = taskById.get(depId);
const label = depTask?.title ?? depId;
const statusLabel = depTask?.status
? depTask.status.replace(/_/g, " ")
: null;
const isDone = depTask?.status === "done";
return (
<span
key={depId}
className={cn(
"inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs",
isDone
? "border-emerald-200 bg-emerald-50 text-emerald-800"
: "border-slate-200 bg-slate-50 text-slate-700",
)}
>
<span className="max-w-[18rem] truncate">{label}</span>
{statusLabel ? (
<span className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
{statusLabel}
</span>
) : null}
{selectedTask?.status !== "done" ? (
<button
type="button"
onClick={() => removeTaskDependency(depId)}
className={cn(
"rounded-full p-0.5 text-slate-500 transition",
canWrite
? "hover:bg-white hover:text-slate-700"
: "opacity-50 cursor-not-allowed",
)}
aria-label="Remove dependency"
disabled={!canWrite}
>
<X className="h-3 w-3" />
</button>
) : null}
</span>
);
})}
</div>
)}
</div>
{saveTaskError ? (
<div className="rounded-lg border border-slate-200 bg-white p-3 text-xs text-slate-600">
{saveTaskError}
</div>
) : null}
</div>
<DialogFooter className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => setIsDeleteDialogOpen(true)}
disabled={!selectedTask || isSavingTask || !canWrite}
className="border-rose-200 text-rose-600 hover:border-rose-300 hover:text-rose-700"
title={canWrite ? "Delete task" : "Read-only access"}
>
Delete task
</Button>
<Button
variant="outline"
onClick={handleTaskReset}
disabled={
!selectedTask || isSavingTask || !hasTaskChanges || !canWrite
}
>
Reset
</Button>
<Button
onClick={() => handleTaskSave(true)}
disabled={
!selectedTask || isSavingTask || !hasTaskChanges || !canWrite
}
>
{isSavingTask ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent aria-label="Delete task">
<DialogHeader>
<DialogTitle>Delete task</DialogTitle>
<DialogDescription>
This removes the task permanently. This action cannot be undone.
</DialogDescription>
</DialogHeader>
{deleteTaskError ? (
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-600">
{deleteTaskError}
</div>
) : null}
<DialogFooter className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => setIsDeleteDialogOpen(false)}
disabled={isDeletingTask}
>
Cancel
</Button>
<Button
onClick={handleDeleteTask}
disabled={isDeletingTask || !canWrite}
className="bg-rose-600 text-white hover:bg-rose-700"
>
{isDeletingTask ? "Deleting…" : "Delete task"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={isDialogOpen}
onOpenChange={(nextOpen) => {
setIsDialogOpen(nextOpen);
if (!nextOpen) {
resetForm();
}
}}
>
<DialogContent aria-label={titleLabel}>
<DialogHeader>
<DialogTitle>New task</DialogTitle>
<DialogDescription>
Add a task to the inbox and triage it when you are ready.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-strong">Title</label>
<Input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="e.g. Prepare launch notes"
disabled={!canWrite || isCreating}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Description
</label>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Optional details"
className="min-h-[120px]"
disabled={!canWrite || isCreating}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Custom fields
</label>
<TaskCustomFieldsEditor
definitions={boardCustomFieldDefinitions}
values={createCustomFieldValues}
setValues={setCreateCustomFieldValues}
isLoading={customFieldDefinitionsQuery.isLoading}
disabled={!canWrite || isCreating}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Priority
</label>
<Select
value={priority}
onValueChange={setPriority}
disabled={!canWrite || isCreating}
>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
<SelectContent>
{priorities.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Due date
</label>
<Input
type="date"
value={createDueDate}
onChange={(event) => setCreateDueDate(event.target.value)}
disabled={!canWrite || isCreating}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<label className="text-sm font-medium text-strong">Tags</label>
<button
type="button"
onClick={() => router.push("/tags")}
className="text-xs font-medium text-slate-500 underline underline-offset-2 transition hover:text-slate-700"
>
Manage tags
</button>
</div>
<DropdownSelect
ariaLabel="Add tag"
placeholder="Add tag"
options={createTagOptions}
onValueChange={addCreateTag}
disabled={!canWrite || isCreating}
emptyMessage="No tags configured."
/>
{createTagIds.length ? (
<div className="flex flex-wrap gap-2">
{createTagIds.map((tagId) => {
const tag = tagById.get(tagId);
const color = normalizeTagColor(tag?.color);
return (
<span
key={tagId}
className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs text-slate-700"
>
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: `#${color}` }}
/>
{tag?.name ?? tagId}
<button
type="button"
onClick={() => removeCreateTag(tagId)}
className="rounded-full p-0.5 text-slate-500 transition hover:bg-white hover:text-slate-700"
aria-label="Remove tag"
disabled={!canWrite || isCreating}
>
<X className="h-3 w-3" />
</button>
</span>
);
})}
</div>
) : (
<p className="text-xs text-slate-500">No tags assigned.</p>
)}
</div>
{createError ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{createError}
</div>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleCreateTask}
disabled={!canWrite || isCreating}
>
{isCreating ? "Creating…" : "Create task"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{isOrgAdmin ? (
<Dialog
open={isAgentsControlDialogOpen}
onOpenChange={(nextOpen) => {
setIsAgentsControlDialogOpen(nextOpen);
if (!nextOpen) {
setAgentsControlError(null);
}
}}
>
<DialogContent aria-label="Agent controls">
<DialogHeader>
<DialogTitle>
{agentsControlAction === "pause"
? "Pause agents"
: "Resume agents"}
</DialogTitle>
<DialogDescription>
{agentsControlAction === "pause"
? "Send /pause to every agent on this board."
: "Send /resume to every agent on this board."}
</DialogDescription>
</DialogHeader>
{agentsControlError ? (
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
{agentsControlError}
</div>
) : null}
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
<p className="font-semibold text-slate-900">What happens</p>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>
This posts{" "}
<span className="font-mono">
{agentsControlAction === "pause" ? "/pause" : "/resume"}
</span>{" "}
to board chat.
</li>
<li>Pipeline forwards it to all agents on this board.</li>
</ul>
</div>
<DialogFooter className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => setIsAgentsControlDialogOpen(false)}
disabled={isAgentsControlSending}
>
Cancel
</Button>
<Button
onClick={handleConfirmAgentsControl}
disabled={isAgentsControlSending}
>
{isAgentsControlSending
? "Sending…"
: agentsControlAction === "pause"
? "Pause agents"
: "Resume agents"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : null}
{toasts.length ? (
<div className="fixed bottom-6 right-6 z-[60] flex w-[320px] max-w-[90vw] flex-col gap-3">
{toasts.map((toast) => (
<div
key={toast.id}
className={cn(
"rounded-xl border bg-white px-4 py-3 text-sm shadow-lush",
toast.tone === "error"
? "border-rose-200 text-rose-700"
: "border-emerald-200 text-emerald-700",
)}
>
<div className="flex items-start gap-3">
<span
className={cn(
"mt-1 h-2 w-2 rounded-full",
toast.tone === "error" ? "bg-rose-500" : "bg-emerald-500",
)}
/>
<p className="flex-1 text-sm text-slate-700">{toast.message}</p>
<button
type="button"
className="text-xs text-slate-400 hover:text-slate-600"
onClick={() => dismissToast(toast.id)}
>
Dismiss
</button>
</div>
</div>
))}
</div>
) : null}
{/* onboarding moved to board settings */}
</DashboardShell>
);
}