Add global live feed for task comments

This commit is contained in:
Abhimanyu Saharan
2026-02-07 05:26:15 +05:30
parent 844b521d00
commit b2109da88b
16 changed files with 1518 additions and 122 deletions

View File

@@ -0,0 +1,356 @@
"use client";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { ArrowUpRight, Activity as ActivityIcon } from "lucide-react";
import { ApiError } from "@/api/mutator";
import {
type listTaskCommentFeedApiV1ActivityTaskCommentsGetResponse,
streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet,
useListTaskCommentFeedApiV1ActivityTaskCommentsGet,
} from "@/api/generated/activity/activity";
import type { ActivityTaskCommentFeedItemRead } from "@/api/generated/model";
import { Markdown } from "@/components/atoms/Markdown";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import { createExponentialBackoff } from "@/lib/backoff";
import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime";
import { cn } from "@/lib/utils";
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 latestTimestamp = (items: ActivityTaskCommentFeedItemRead[]) => {
let latest = 0;
for (const item of items) {
const time = apiDatetimeToMs(item.created_at) ?? 0;
latest = Math.max(latest, time);
}
return latest ? new Date(latest).toISOString() : null;
};
const FeedCard = memo(function FeedCard({
item,
}: {
item: ActivityTaskCommentFeedItemRead;
}) {
const message = (item.message ?? "").trim();
const authorName = item.agent_name?.trim() || "Admin";
const authorRole = item.agent_role?.trim() || null;
const authorAvatar = (authorName[0] ?? "A").toUpperCase();
const taskHref = `/boards/${item.board_id}?taskId=${item.task_id}`;
const boardHref = `/boards/${item.board_id}`;
return (
<div className="rounded-xl border border-slate-200 bg-white p-4 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">
<div className="min-w-0">
<Link
href={taskHref}
className={cn(
"block text-sm font-semibold leading-snug text-slate-900 transition hover:text-slate-950 hover:underline",
)}
title={item.task_title}
style={{
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{item.task_title}
</Link>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-slate-500">
<Link
href={boardHref}
className="font-semibold text-slate-700 hover:text-slate-900 hover:underline"
>
{item.board_name}
</Link>
<span className="text-slate-300">·</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>
<Link
href={taskHref}
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" />
</Link>
</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>
);
});
FeedCard.displayName = "FeedCard";
export default function ActivityPage() {
const { isSignedIn } = useAuth();
const feedQuery = useListTaskCommentFeedApiV1ActivityTaskCommentsGet<
listTaskCommentFeedApiV1ActivityTaskCommentsGetResponse,
ApiError
>(
{ limit: 200 },
{
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
refetchOnWindowFocus: false,
retry: false,
},
},
);
const [feedItems, setFeedItems] = useState<ActivityTaskCommentFeedItemRead[]>(
[],
);
const feedItemsRef = useRef<ActivityTaskCommentFeedItemRead[]>([]);
const seenIdsRef = useRef<Set<string>>(new Set());
const initializedRef = useRef(false);
useEffect(() => {
feedItemsRef.current = feedItems;
}, [feedItems]);
useEffect(() => {
if (initializedRef.current) return;
if (feedQuery.data?.status !== 200) return;
const items = feedQuery.data.data.items ?? [];
initializedRef.current = true;
setFeedItems((prev) => {
const map = new Map<string, ActivityTaskCommentFeedItemRead>();
[...prev, ...items].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;
});
const next = merged.slice(0, 200);
seenIdsRef.current = new Set(next.map((item) => item.id));
return next;
});
}, [feedQuery.data]);
const pushFeedItem = useCallback((item: ActivityTaskCommentFeedItemRead) => {
setFeedItems((prev) => {
if (seenIdsRef.current.has(item.id)) return prev;
seenIdsRef.current.add(item.id);
const next = [item, ...prev];
return next.slice(0, 200);
});
}, []);
useEffect(() => {
if (!isSignedIn) return;
let isCancelled = false;
const abortController = new AbortController();
const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF);
let reconnectTimeout: number | undefined;
const connect = async () => {
try {
const since = latestTimestamp(feedItemsRef.current);
const streamResult =
await streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet(
since ? { since } : undefined,
{
headers: { Accept: "text/event-stream" },
signal: abortController.signal,
},
);
if (streamResult.status !== 200) {
throw new Error("Unable to connect task comment feed stream.");
}
const response = streamResult.data as Response;
if (!(response instanceof Response) || !response.body) {
throw new Error("Unable to connect task comment feed 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 === "comment" && data) {
try {
const payload = JSON.parse(data) as {
comment?: ActivityTaskCommentFeedItemRead;
};
if (payload.comment) {
pushFeedItem(payload.comment);
}
} 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);
}
};
}, [isSignedIn, pushFeedItem]);
const orderedFeed = useMemo(() => {
return [...feedItems].sort((a, b) => {
const aTime = apiDatetimeToMs(a.created_at) ?? 0;
const bTime = apiDatetimeToMs(b.created_at) ?? 0;
return bTime - aTime;
});
}, [feedItems]);
return (
<DashboardShell>
<SignedOut>
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
<p className="text-sm text-slate-600">Sign in to view the feed.</p>
<SignInButton
mode="modal"
forceRedirectUrl="/activity"
signUpForceRedirectUrl="/activity"
>
<Button className="mt-4">Sign in</Button>
</SignInButton>
</div>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<main className="flex-1 overflow-y-auto bg-slate-50">
<div className="sticky top-0 z-30 border-b border-slate-200 bg-white">
<div className="px-8 py-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<ActivityIcon className="h-5 w-5 text-slate-600" />
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">
Live feed
</h1>
</div>
<p className="mt-1 text-sm text-slate-500">
Realtime task comments across all boards.
</p>
</div>
</div>
</div>
</div>
<div className="p-8">
{feedQuery.isLoading && feedItems.length === 0 ? (
<p className="text-sm text-slate-500">Loading feed</p>
) : feedQuery.error ? (
<div className="rounded-lg border border-slate-200 bg-white p-4 text-sm text-slate-700 shadow-sm">
{feedQuery.error.message || "Unable to load feed."}
</div>
) : orderedFeed.length === 0 ? (
<div className="rounded-xl border border-slate-200 bg-white p-10 text-center shadow-sm">
<p className="text-sm font-medium text-slate-900">
Waiting for new comments
</p>
<p className="mt-1 text-sm text-slate-500">
When agents post updates, they will show up here.
</p>
</div>
) : (
<div className="space-y-4">
{orderedFeed.map((item) => (
<FeedCard key={item.id} item={item} />
))}
</div>
)}
</div>
</main>
</SignedIn>
</DashboardShell>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import {
@@ -12,10 +12,8 @@ import {
Settings,
X,
} from "lucide-react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import { Markdown } from "@/components/atoms/Markdown";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { TaskBoard } from "@/components/organisms/TaskBoard";
import { DashboardShell } from "@/components/templates/DashboardShell";
@@ -144,118 +142,6 @@ const SSE_RECONNECT_BACKOFF = {
maxMs: 5 * 60_000,
} as const;
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} />
</div>
),
thead: ({ node: _node, className, ...props }) => (
<thead className={cn("bg-slate-50", className)} {...props} />
),
tbody: ({ node: _node, className, ...props }) => (
<tbody className={cn("divide-y divide-slate-100", className)} {...props} />
),
tr: ({ node: _node, className, ...props }) => (
<tr className={cn("align-top", className)} {...props} />
),
th: ({ node: _node, className, ...props }) => (
<th
className={cn(
"border border-slate-200 px-3 py-2 text-left text-xs font-semibold",
className,
)}
{...props}
/>
),
td: ({ node: _node, className, ...props }) => (
<td
className={cn("border border-slate-200 px-3 py-2 align-top", className)}
{...props}
/>
),
};
const MARKDOWN_COMPONENTS_BASIC: Components = {
...MARKDOWN_TABLE_COMPONENTS,
p: ({ node: _node, className, ...props }) => (
<p className={cn("mb-2 last:mb-0", className)} {...props} />
),
ul: ({ node: _node, className, ...props }) => (
<ul className={cn("mb-2 list-disc pl-5", className)} {...props} />
),
ol: ({ node: _node, className, ...props }) => (
<ol className={cn("mb-2 list-decimal pl-5", className)} {...props} />
),
li: ({ node: _node, className, ...props }) => (
<li className={cn("mb-1", className)} {...props} />
),
strong: ({ node: _node, className, ...props }) => (
<strong className={cn("font-semibold", className)} {...props} />
),
};
const MARKDOWN_COMPONENTS_DESCRIPTION: Components = {
...MARKDOWN_COMPONENTS_BASIC,
p: ({ node: _node, className, ...props }) => (
<p className={cn("mb-3 last:mb-0", className)} {...props} />
),
h1: ({ node: _node, className, ...props }) => (
<h1 className={cn("mb-2 text-base font-semibold", className)} {...props} />
),
h2: ({ node: _node, className, ...props }) => (
<h2 className={cn("mb-2 text-sm font-semibold", className)} {...props} />
),
h3: ({ node: _node, className, ...props }) => (
<h3 className={cn("mb-2 text-sm font-semibold", className)} {...props} />
),
code: ({ node: _node, className, ...props }) => (
<code
className={cn("rounded bg-slate-100 px-1 py-0.5 text-xs", className)}
{...props}
/>
),
pre: ({ node: _node, className, ...props }) => (
<pre
className={cn(
"overflow-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100",
className,
)}
{...props}
/>
),
};
const MARKDOWN_REMARK_PLUGINS_BASIC = [remarkGfm];
const MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [remarkGfm, remarkBreaks];
type MarkdownVariant = "basic" | "comment" | "description";
const Markdown = memo(function Markdown({
content,
variant,
}: {
content: string;
variant: MarkdownVariant;
}) {
const trimmed = content.trim();
const remarkPlugins =
variant === "comment"
? MARKDOWN_REMARK_PLUGINS_WITH_BREAKS
: MARKDOWN_REMARK_PLUGINS_BASIC;
const components =
variant === "description"
? MARKDOWN_COMPONENTS_DESCRIPTION
: MARKDOWN_COMPONENTS_BASIC;
return (
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
{trimmed}
</ReactMarkdown>
);
});
Markdown.displayName = "Markdown";
const formatShortTimestamp = (value: string) => {
const date = parseApiDatetime(value);
if (!date) return "—";
@@ -405,9 +291,11 @@ LiveFeedCard.displayName = "LiveFeedCard";
export default function BoardDetailPage() {
const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
const boardIdParam = params?.boardId;
const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam;
const { isSignedIn } = useAuth();
const taskIdFromUrl = searchParams.get("taskId");
const [board, setBoard] = useState<Board | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
@@ -416,6 +304,7 @@ export default function BoardDetailPage() {
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 [comments, setComments] = useState<TaskComment[]>([]);
const [liveFeed, setLiveFeed] = useState<TaskComment[]>([]);
const [isCommentsLoading, setIsCommentsLoading] = useState(false);
@@ -1408,6 +1297,15 @@ export default function BoardDetailPage() {
[loadComments],
);
useEffect(() => {
if (!taskIdFromUrl) return;
if (openedTaskIdFromUrlRef.current === taskIdFromUrl) return;
const exists = tasks.some((task) => task.id === taskIdFromUrl);
if (!exists) return;
openedTaskIdFromUrlRef.current = taskIdFromUrl;
openComments({ id: taskIdFromUrl });
}, [openComments, taskIdFromUrl, tasks]);
const closeComments = () => {
setIsDetailOpen(false);
selectedTaskIdRef.current = null;