feat(activity): enhance live feed with new comment streaming and flash effects

This commit is contained in:
Abhimanyu Saharan
2026-02-07 15:20:43 +05:30
parent a32c01e4a8
commit 0ce2e1e91f

View File

@@ -50,7 +50,10 @@ import {
streamApprovalsApiV1BoardsBoardIdApprovalsStreamGet, streamApprovalsApiV1BoardsBoardIdApprovalsStreamGet,
updateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch, updateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch,
} from "@/api/generated/approvals/approvals"; } 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 { getBoardSnapshotApiV1BoardsBoardIdSnapshotGet } from "@/api/generated/boards/boards";
import { import {
createBoardMemoryApiV1BoardsBoardIdMemoryPost, createBoardMemoryApiV1BoardsBoardIdMemoryPost,
@@ -219,6 +222,7 @@ const LiveFeedCard = memo(function LiveFeedCard({
authorRole, authorRole,
authorAvatar, authorAvatar,
onViewTask, onViewTask,
isNew,
}: { }: {
comment: TaskComment; comment: TaskComment;
taskTitle: string; taskTitle: string;
@@ -226,10 +230,18 @@ const LiveFeedCard = memo(function LiveFeedCard({
authorRole?: string | null; authorRole?: string | null;
authorAvatar: string; authorAvatar: string;
onViewTask?: () => void; onViewTask?: () => void;
isNew?: boolean;
}) { }) {
const message = (comment.message ?? "").trim(); const message = (comment.message ?? "").trim();
return ( return (
<div className="rounded-xl border border-slate-200 bg-white p-3 transition hover:border-slate-300"> <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 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"> <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} {authorAvatar}
@@ -316,6 +328,11 @@ export default function BoardDetailPage() {
const openedTaskIdFromUrlRef = useRef<string | null>(null); const openedTaskIdFromUrlRef = useRef<string | null>(null);
const [comments, setComments] = useState<TaskComment[]>([]); const [comments, setComments] = useState<TaskComment[]>([]);
const [liveFeed, setLiveFeed] = useState<TaskComment[]>([]); const [liveFeed, setLiveFeed] = useState<TaskComment[]>([]);
const liveFeedRef = useRef<TaskComment[]>([]);
const liveFeedFlashTimersRef = useRef<Record<string, number>>({});
const [liveFeedFlashIds, setLiveFeedFlashIds] = useState<
Record<string, boolean>
>({});
const [isLiveFeedHistoryLoading, setIsLiveFeedHistoryLoading] = const [isLiveFeedHistoryLoading, setIsLiveFeedHistoryLoading] =
useState(false); useState(false);
const [liveFeedHistoryError, setLiveFeedHistoryError] = useState< const [liveFeedHistoryError, setLiveFeedHistoryError] = useState<
@@ -359,7 +376,9 @@ export default function BoardDetailPage() {
const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null); const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"board" | "list">("board"); const [viewMode, setViewMode] = useState<"board" | "list">("board");
const [isLiveFeedOpen, setIsLiveFeedOpen] = useState(false); const [isLiveFeedOpen, setIsLiveFeedOpen] = useState(false);
const isLiveFeedOpenRef = useRef(false);
const pushLiveFeed = useCallback((comment: TaskComment) => { const pushLiveFeed = useCallback((comment: TaskComment) => {
const alreadySeen = liveFeedRef.current.some((item) => item.id === comment.id);
setLiveFeed((prev) => { setLiveFeed((prev) => {
if (prev.some((item) => item.id === comment.id)) { if (prev.some((item) => item.id === comment.id)) {
return prev; return prev;
@@ -367,6 +386,28 @@ export default function BoardDetailPage() {
const next = [comment, ...prev]; const next = [comment, ...prev];
return next.slice(0, 50); 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(() => { useEffect(() => {
@@ -374,8 +415,26 @@ export default function BoardDetailPage() {
setIsLiveFeedHistoryLoading(false); setIsLiveFeedHistoryLoading(false);
setLiveFeedHistoryError(null); setLiveFeedHistoryError(null);
setLiveFeed([]); setLiveFeed([]);
setLiveFeedFlashIds({});
if (typeof window !== "undefined") {
Object.values(liveFeedFlashTimersRef.current).forEach((timerId) => {
window.clearTimeout(timerId);
});
}
liveFeedFlashTimersRef.current = {};
}, [boardId]); }, [boardId]);
useEffect(() => {
return () => {
if (typeof window !== "undefined") {
Object.values(liveFeedFlashTimersRef.current).forEach((timerId) => {
window.clearTimeout(timerId);
});
}
liveFeedFlashTimersRef.current = {};
};
}, []);
useEffect(() => { useEffect(() => {
if (!isLiveFeedOpen) return; if (!isLiveFeedOpen) return;
if (!isSignedIn || !boardId) return; if (!isSignedIn || !boardId) return;
@@ -576,6 +635,14 @@ export default function BoardDetailPage() {
chatMessagesRef.current = chatMessages; chatMessagesRef.current = chatMessages;
}, [chatMessages]); }, [chatMessages]);
useEffect(() => {
liveFeedRef.current = liveFeed;
}, [liveFeed]);
useEffect(() => {
isLiveFeedOpenRef.current = isLiveFeedOpen;
}, [isLiveFeedOpen]);
useEffect(() => { useEffect(() => {
if (!isChatOpen) return; if (!isChatOpen) return;
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
@@ -716,6 +783,125 @@ export default function BoardDetailPage() {
}; };
}, [board, boardId, isChatOpen, isPageActive, isSignedIn]); }, [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(() => { useEffect(() => {
if (!isPageActive) return; if (!isPageActive) return;
if (!isSignedIn || !boardId || !board) return; if (!isSignedIn || !boardId || !board) return;
@@ -2573,6 +2759,7 @@ export default function BoardDetailPage() {
<LiveFeedCard <LiveFeedCard
key={comment.id} key={comment.id}
comment={comment} comment={comment}
isNew={Boolean(liveFeedFlashIds[comment.id])}
taskTitle={ taskTitle={
taskId ? (taskTitleById.get(taskId) ?? "Task") : "Task" taskId ? (taskTitleById.get(taskId) ?? "Task") : "Task"
} }