feat(ui): improve live feed cards

This commit is contained in:
Abhimanyu Saharan
2026-02-07 04:04:34 +05:30
parent f683eba02f
commit 9c965d0ff4

View File

@@ -4,7 +4,14 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { Activity, MessageSquare, Pencil, Settings, X } from "lucide-react";
import {
Activity,
ArrowUpRight,
MessageSquare,
Pencil,
Settings,
X,
} from "lucide-react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
@@ -140,10 +147,7 @@ const SSE_RECONNECT_BACKOFF = {
const MARKDOWN_TABLE_COMPONENTS: Components = {
table: ({ node: _node, className, ...props }) => (
<div className="my-3 overflow-x-auto">
<table
className={cn("w-full border-collapse", className)}
{...props}
/>
<table className={cn("w-full border-collapse", className)} {...props} />
</div>
),
thead: ({ node: _node, className, ...props }) => (
@@ -240,7 +244,9 @@ const Markdown = memo(function Markdown({
? MARKDOWN_REMARK_PLUGINS_WITH_BREAKS
: MARKDOWN_REMARK_PLUGINS_BASIC;
const components =
variant === "description" ? MARKDOWN_COMPONENTS_DESCRIPTION : MARKDOWN_COMPONENTS_BASIC;
variant === "description"
? MARKDOWN_COMPONENTS_DESCRIPTION
: MARKDOWN_COMPONENTS_BASIC;
return (
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
{trimmed}
@@ -296,7 +302,9 @@ const ChatMessageCard = memo(function ChatMessageCard({
return (
<div className="rounded-2xl border border-slate-200 bg-slate-50/60 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-slate-900">{message.source ?? "User"}</p>
<p className="text-sm font-semibold text-slate-900">
{message.source ?? "User"}
</p>
<span className="text-xs text-slate-400">
{formatShortTimestamp(message.created_at)}
</span>
@@ -313,56 +321,80 @@ ChatMessageCard.displayName = "ChatMessageCard";
const LiveFeedCard = memo(function LiveFeedCard({
comment,
taskTitle,
authorLabel,
authorName,
authorRole,
authorAvatar,
onViewTask,
}: {
comment: TaskComment;
taskTitle: string;
authorLabel: string;
authorName: string;
authorRole?: string | null;
authorAvatar: string;
onViewTask?: () => void;
}) {
const message = (comment.message ?? "").trim();
return (
<div className="rounded-xl border border-slate-200 bg-white p-3">
<div className="flex items-start justify-between gap-3 text-xs text-slate-500">
<div className="min-w-0">
<div className="rounded-xl border border-slate-200 bg-white p-3 transition 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(
"block truncate text-left text-xs font-semibold text-slate-700",
"text-left text-sm font-semibold leading-snug text-slate-900",
onViewTask
? "cursor-pointer transition hover:text-slate-900 hover:underline"
? "cursor-pointer transition hover:text-slate-950 hover:underline"
: "cursor-default",
)}
title={onViewTask ? "View task" : undefined}
title={taskTitle}
style={{
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{taskTitle}
</button>
<p className="mt-1 text-[11px] text-slate-400">{authorLabel}</p>
</div>
<div className="flex flex-col items-end gap-1 text-right">
<span className="text-[11px] text-slate-400">
{formatShortTimestamp(comment.created_at)}
</span>
{onViewTask ? (
<button
type="button"
onClick={onViewTask}
className="text-[11px] font-semibold text-slate-600 transition hover:text-slate-900"
className="inline-flex flex-shrink-0 items-center gap-1 rounded-md px-2 py-1 text-[11px] font-semibold text-slate-600 transition hover:bg-slate-50 hover:text-slate-900"
aria-label="View task"
>
View task
<ArrowUpRight className="h-3 w-3" />
</button>
) : null}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-slate-500">
<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(comment.created_at)}
</span>
</div>
</div>
</div>
{message ? (
<div className="mt-2 select-text cursor-text text-xs leading-relaxed text-slate-900 break-words">
<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-2 text-xs text-slate-500"></p>
<p className="mt-3 text-sm text-slate-500"></p>
)}
</div>
);
@@ -519,9 +551,8 @@ export default function BoardDetailPage() {
setApprovalsError(null);
setChatError(null);
try {
const snapshotResult = await getBoardSnapshotApiV1BoardsBoardIdSnapshotGet(
boardId,
);
const snapshotResult =
await getBoardSnapshotApiV1BoardsBoardIdSnapshotGet(boardId);
if (snapshotResult.status !== 200) {
throw new Error("Unable to load board snapshot.");
}
@@ -532,7 +563,8 @@ export default function BoardDetailPage() {
setApprovals((snapshot.approvals ?? []).map(normalizeApproval));
setChatMessages(snapshot.chat_messages ?? []);
} catch (err) {
const message = err instanceof Error ? err.message : "Something went wrong.";
const message =
err instanceof Error ? err.message : "Something went wrong.";
setError(message);
setApprovalsError(message);
setChatError(message);
@@ -641,7 +673,9 @@ export default function BoardDetailPage() {
}
if (eventType === "memory" && data) {
try {
const payload = JSON.parse(data) as { memory?: BoardChatMessage };
const payload = JSON.parse(data) as {
memory?: BoardChatMessage;
};
if (payload.memory?.tags?.includes("chat")) {
setChatMessages((prev) => {
const exists = prev.some(
@@ -900,17 +934,26 @@ export default function BoardDetailPage() {
task?: TaskRead;
comment?: TaskCommentRead;
};
if (payload.comment?.task_id && payload.type === "task.comment") {
if (
payload.comment?.task_id &&
payload.type === "task.comment"
) {
pushLiveFeed(payload.comment);
setComments((prev) => {
if (selectedTaskIdRef.current !== payload.comment?.task_id) {
if (
selectedTaskIdRef.current !== payload.comment?.task_id
) {
return prev;
}
const exists = prev.some((item) => item.id === payload.comment?.id);
const exists = prev.some(
(item) => item.id === payload.comment?.id,
);
if (exists) {
return prev;
}
const createdMs = apiDatetimeToMs(payload.comment?.created_at);
const createdMs = apiDatetimeToMs(
payload.comment?.created_at,
);
if (prev.length === 0 || createdMs === null) {
return [...prev, payload.comment as TaskComment];
}
@@ -929,12 +972,15 @@ export default function BoardDetailPage() {
});
} else if (payload.task) {
setTasks((prev) => {
const index = prev.findIndex((item) => item.id === payload.task?.id);
const index = prev.findIndex(
(item) => item.id === payload.task?.id,
);
if (index === -1) {
const assignee = payload.task?.assigned_agent_id
? agentsRef.current.find(
(agent) => agent.id === payload.task?.assigned_agent_id,
)?.name ?? null
? (agentsRef.current.find(
(agent) =>
agent.id === payload.task?.assigned_agent_id,
)?.name ?? null)
: null;
const created = normalizeTask({
...payload.task,
@@ -947,9 +993,10 @@ export default function BoardDetailPage() {
const next = [...prev];
const existing = next[index];
const assignee = payload.task?.assigned_agent_id
? agentsRef.current.find(
(agent) => agent.id === payload.task?.assigned_agent_id,
)?.name ?? null
? (agentsRef.current.find(
(agent) =>
agent.id === payload.task?.assigned_agent_id,
)?.name ?? null)
: null;
const updated = normalizeTask({
...existing,
@@ -1054,7 +1101,9 @@ export default function BoardDetailPage() {
if (payload.agent) {
const normalized = normalizeAgent(payload.agent);
setAgents((prev) => {
const index = prev.findIndex((item) => item.id === normalized.id);
const index = prev.findIndex(
(item) => item.id === normalized.id,
);
if (index === -1) {
return [normalized, ...prev];
}
@@ -1127,7 +1176,7 @@ export default function BoardDetailPage() {
const created = normalizeTask({
...result.data,
assignee: result.data.assigned_agent_id
? assigneeById.get(result.data.assigned_agent_id) ?? null
? (assigneeById.get(result.data.assigned_agent_id) ?? null)
: null,
approvals_count: 0,
approvals_pending_count: 0,
@@ -1136,23 +1185,29 @@ export default function BoardDetailPage() {
setIsDialogOpen(false);
resetForm();
} catch (err) {
setCreateError(err instanceof Error ? err.message : "Something went wrong.");
setCreateError(
err instanceof Error ? err.message : "Something went wrong.",
);
} finally {
setIsCreating(false);
}
};
const handleSendChat = useCallback(async (content: string): Promise<boolean> => {
const handleSendChat = useCallback(
async (content: string): Promise<boolean> => {
if (!isSignedIn || !boardId) return false;
const trimmed = content.trim();
if (!trimmed) return false;
setIsChatSending(true);
setChatError(null);
try {
const result = await createBoardMemoryApiV1BoardsBoardIdMemoryPost(boardId, {
const result = await createBoardMemoryApiV1BoardsBoardIdMemoryPost(
boardId,
{
content: trimmed,
tags: ["chat"],
});
},
);
if (result.status !== 200) {
throw new Error("Unable to send message.");
}
@@ -1179,7 +1234,9 @@ export default function BoardDetailPage() {
} finally {
setIsChatSending(false);
}
}, [boardId, isSignedIn]);
},
[boardId, isSignedIn],
);
const assigneeById = useMemo(() => {
const map = new Map<string, string>();
@@ -1307,7 +1364,8 @@ export default function BoardDetailPage() {
});
}, [agents, workingAgentIds]);
const loadComments = useCallback(async (taskId: string) => {
const loadComments = useCallback(
async (taskId: string) => {
if (!isSignedIn || !boardId) return;
setIsCommentsLoading(true);
setCommentsError(null);
@@ -1326,13 +1384,18 @@ export default function BoardDetailPage() {
});
setComments(items);
} catch (err) {
setCommentsError(err instanceof Error ? err.message : "Something went wrong.");
setCommentsError(
err instanceof Error ? err.message : "Something went wrong.",
);
} finally {
setIsCommentsLoading(false);
}
}, [boardId, isSignedIn]);
},
[boardId, isSignedIn],
);
const openComments = useCallback((task: { id: string }) => {
const openComments = useCallback(
(task: { id: string }) => {
setIsChatOpen(false);
setIsLiveFeedOpen(false);
const fullTask = tasksRef.current.find((item) => item.id === task.id);
@@ -1341,7 +1404,9 @@ export default function BoardDetailPage() {
setSelectedTask(fullTask);
setIsDetailOpen(true);
void loadComments(task.id);
}, [loadComments]);
},
[loadComments],
);
const closeComments = () => {
setIsDetailOpen(false);
@@ -1470,20 +1535,24 @@ export default function BoardDetailPage() {
...previous,
...result.data,
assignee: result.data.assigned_agent_id
? assigneeById.get(result.data.assigned_agent_id) ?? null
? (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)),
prev.map((task) =>
task.id === updated.id ? { ...task, ...updated } : task,
),
);
setSelectedTask(updated);
if (closeOnSuccess) {
setIsEditDialogOpen(false);
}
} catch (err) {
setSaveTaskError(err instanceof Error ? err.message : "Something went wrong.");
setSaveTaskError(
err instanceof Error ? err.message : "Something went wrong.",
);
} finally {
setIsSavingTask(false);
}
@@ -1522,7 +1591,8 @@ export default function BoardDetailPage() {
}
};
const handleTaskMove = useCallback(async (taskId: string, status: TaskStatus) => {
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;
@@ -1563,12 +1633,14 @@ export default function BoardDetailPage() {
}
if (result.status === 422) {
throw new Error(
result.data.detail?.[0]?.msg ?? "Validation error while moving task.",
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
? (agentsRef.current.find(
(agent) => agent.id === result.data.assigned_agent_id,
)?.name ?? null)
: null;
const updated = normalizeTask({
...currentTask,
@@ -1578,13 +1650,17 @@ export default function BoardDetailPage() {
approvals_pending_count: currentTask.approvals_pending_count,
} as TaskCardRead);
setTasks((prev) =>
prev.map((task) => (task.id === updated.id ? { ...task, ...updated } : task)),
prev.map((task) =>
task.id === updated.id ? { ...task, ...updated } : task,
),
);
} catch (err) {
setTasks(previousTasks);
setError(err instanceof Error ? err.message : "Unable to move task.");
}
}, [boardId, isSignedIn, taskTitleById]);
},
[boardId, isSignedIn, taskTitleById],
);
const agentInitials = (agent: Agent) =>
agent.name
@@ -1608,7 +1684,8 @@ export default function BoardDetailPage() {
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;
const rawEmoji = (agent.identity_profile as Record<string, unknown>)
.emoji;
emojiValue = typeof rawEmoji === "string" ? rawEmoji : null;
}
const emoji = resolveEmoji(emojiValue);
@@ -1683,16 +1760,11 @@ export default function BoardDetailPage() {
value
.split(".")
.map((part) =>
part
.replace(/_/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase())
part.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()),
)
.join(" · ");
const approvalPayloadValue = (
payload: Approval["payload"],
key: string,
) => {
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") {
@@ -2001,7 +2073,8 @@ export default function BoardDetailPage() {
{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}
Approval needed ·{" "}
{task.approvals_pending_count}
</span>
) : null}
<span
@@ -2097,10 +2170,15 @@ export default function BoardDetailPage() {
</p>
{selectedTask?.description ? (
<div className="prose prose-sm max-w-none text-slate-700">
<Markdown content={selectedTask.description} variant="description" />
<Markdown
content={selectedTask.description}
variant="description"
/>
</div>
) : (
<p className="text-sm text-slate-500">No description provided.</p>
<p className="text-sm text-slate-500">
No description provided.
</p>
)}
</div>
<div className="space-y-2">
@@ -2204,7 +2282,8 @@ export default function BoardDetailPage() {
{humanizeApprovalAction(approval.action_type)}
</p>
<p className="mt-1 text-xs text-slate-500">
Requested {formatApprovalTimestamp(approval.created_at)}
Requested{" "}
{formatApprovalTimestamp(approval.created_at)}
</p>
</div>
<span className="text-xs font-semibold text-slate-700">
@@ -2299,7 +2378,7 @@ export default function BoardDetailPage() {
comment={comment}
authorLabel={
comment.agent_id
? assigneeById.get(comment.agent_id) ?? "Agent"
? (assigneeById.get(comment.agent_id) ?? "Agent")
: "Admin"
}
/>
@@ -2354,7 +2433,10 @@ export default function BoardDetailPage() {
)}
<div ref={chatEndRef} />
</div>
<BoardChatComposer isSending={isChatSending} onSend={handleSendChat} />
<BoardChatComposer
isSending={isChatSending}
onSend={handleSendChat}
/>
</div>
</div>
</aside>
@@ -2393,18 +2475,27 @@ export default function BoardDetailPage() {
<div className="space-y-3">
{orderedLiveFeed.map((comment) => {
const taskId = comment.task_id;
const authorAgent = comment.agent_id
? (agents.find((agent) => agent.id === comment.agent_id) ??
null)
: null;
const authorName = authorAgent ? authorAgent.name : "Admin";
const authorRole = authorAgent
? agentRoleLabel(authorAgent)
: null;
const authorAvatar = authorAgent
? agentAvatarLabel(authorAgent)
: "A";
return (
<LiveFeedCard
key={comment.id}
comment={comment}
taskTitle={
taskId ? taskTitleById.get(taskId) ?? "Task" : "Task"
}
authorLabel={
comment.agent_id
? assigneeById.get(comment.agent_id) ?? "Agent"
: "Admin"
taskId ? (taskTitleById.get(taskId) ?? "Task") : "Task"
}
authorName={authorName}
authorRole={authorRole}
authorAvatar={authorAvatar}
onViewTask={
taskId ? () => openComments({ id: taskId }) : undefined
}
@@ -2713,16 +2804,10 @@ export default function BoardDetailPage() {
) : null}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDialogOpen(false)}
>
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleCreateTask}
disabled={isCreating}
>
<Button onClick={handleCreateTask} disabled={isCreating}>
{isCreating ? "Creating…" : "Create task"}
</Button>
</DialogFooter>