From 0ce2e1e91ff44866aa3662659eff7ae62c220616 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sat, 7 Feb 2026 15:20:43 +0530 Subject: [PATCH] feat(activity): enhance live feed with new comment streaming and flash effects --- frontend/src/app/boards/[boardId]/page.tsx | 191 ++++++++++++++++++++- 1 file changed, 189 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index 84f3789a..9fd5b2d2 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -50,7 +50,10 @@ import { streamApprovalsApiV1BoardsBoardIdApprovalsStreamGet, updateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch, } from "@/api/generated/approvals/approvals"; -import { listTaskCommentFeedApiV1ActivityTaskCommentsGet } from "@/api/generated/activity/activity"; +import { + listTaskCommentFeedApiV1ActivityTaskCommentsGet, + streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet, +} from "@/api/generated/activity/activity"; import { getBoardSnapshotApiV1BoardsBoardIdSnapshotGet } from "@/api/generated/boards/boards"; import { createBoardMemoryApiV1BoardsBoardIdMemoryPost, @@ -219,6 +222,7 @@ const LiveFeedCard = memo(function LiveFeedCard({ authorRole, authorAvatar, onViewTask, + isNew, }: { comment: TaskComment; taskTitle: string; @@ -226,10 +230,18 @@ const LiveFeedCard = memo(function LiveFeedCard({ authorRole?: string | null; authorAvatar: string; onViewTask?: () => void; + isNew?: boolean; }) { const message = (comment.message ?? "").trim(); return ( -
+
{authorAvatar} @@ -316,6 +328,11 @@ export default function BoardDetailPage() { const openedTaskIdFromUrlRef = useRef(null); const [comments, setComments] = useState([]); const [liveFeed, setLiveFeed] = useState([]); + const liveFeedRef = useRef([]); + const liveFeedFlashTimersRef = useRef>({}); + const [liveFeedFlashIds, setLiveFeedFlashIds] = useState< + Record + >({}); const [isLiveFeedHistoryLoading, setIsLiveFeedHistoryLoading] = useState(false); const [liveFeedHistoryError, setLiveFeedHistoryError] = useState< @@ -359,7 +376,9 @@ export default function BoardDetailPage() { const [deleteTaskError, setDeleteTaskError] = useState(null); const [viewMode, setViewMode] = useState<"board" | "list">("board"); const [isLiveFeedOpen, setIsLiveFeedOpen] = useState(false); + const isLiveFeedOpenRef = useRef(false); const pushLiveFeed = useCallback((comment: TaskComment) => { + const alreadySeen = liveFeedRef.current.some((item) => item.id === comment.id); setLiveFeed((prev) => { if (prev.some((item) => item.id === comment.id)) { return prev; @@ -367,6 +386,28 @@ export default function BoardDetailPage() { const next = [comment, ...prev]; return next.slice(0, 50); }); + + if (alreadySeen) return; + if (!isLiveFeedOpenRef.current) return; + + setLiveFeedFlashIds((prev) => + prev[comment.id] ? prev : { ...prev, [comment.id]: true }, + ); + + if (typeof window === "undefined") return; + const existingTimer = liveFeedFlashTimersRef.current[comment.id]; + if (existingTimer !== undefined) { + window.clearTimeout(existingTimer); + } + liveFeedFlashTimersRef.current[comment.id] = window.setTimeout(() => { + delete liveFeedFlashTimersRef.current[comment.id]; + setLiveFeedFlashIds((prev) => { + if (!prev[comment.id]) return prev; + const next = { ...prev }; + delete next[comment.id]; + return next; + }); + }, 2200); }, []); useEffect(() => { @@ -374,8 +415,26 @@ export default function BoardDetailPage() { 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(() => { if (!isLiveFeedOpen) return; if (!isSignedIn || !boardId) return; @@ -576,6 +635,14 @@ export default function BoardDetailPage() { chatMessagesRef.current = chatMessages; }, [chatMessages]); + useEffect(() => { + liveFeedRef.current = liveFeed; + }, [liveFeed]); + + useEffect(() => { + isLiveFeedOpenRef.current = isLiveFeedOpen; + }, [isLiveFeedOpen]); + useEffect(() => { if (!isChatOpen) return; const timeout = window.setTimeout(() => { @@ -716,6 +783,125 @@ export default function BoardDetailPage() { }; }, [board, boardId, isChatOpen, isPageActive, isSignedIn]); + useEffect(() => { + if (!isPageActive) return; + if (!isLiveFeedOpen) return; + if (!isSignedIn || !boardId) return; + let isCancelled = false; + const abortController = new AbortController(); + const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF); + let reconnectTimeout: number | undefined; + + const connect = async () => { + try { + const since = (() => { + let latestTime = 0; + liveFeedRef.current.forEach((comment) => { + const time = apiDatetimeToMs(comment.created_at); + if (time !== null && time > latestTime) { + latestTime = time; + } + }); + return latestTime ? new Date(latestTime).toISOString() : null; + })(); + + const streamResult = + await streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet( + { + board_id: boardId, + since: since ?? null, + }, + { + headers: { Accept: "text/event-stream" }, + signal: abortController.signal, + }, + ); + if (streamResult.status !== 200) { + throw new Error("Unable to connect live feed stream."); + } + const response = streamResult.data as Response; + if (!(response instanceof Response) || !response.body) { + throw new Error("Unable to connect live 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?: { + id: string; + created_at: string; + message?: string | null; + agent_id?: string | null; + task_id?: string | null; + }; + }; + if (payload.comment) { + pushLiveFeed({ + id: payload.comment.id, + created_at: payload.comment.created_at, + message: payload.comment.message ?? null, + agent_id: payload.comment.agent_id ?? null, + task_id: payload.comment.task_id ?? null, + }); + } + } 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); + } + }; + }, [boardId, isLiveFeedOpen, isPageActive, isSignedIn, pushLiveFeed]); + useEffect(() => { if (!isPageActive) return; if (!isSignedIn || !boardId || !board) return; @@ -2573,6 +2759,7 @@ export default function BoardDetailPage() {