diff --git a/services/frontend/src/app/(dashboard)/messages/view.tsx b/services/frontend/src/app/(dashboard)/messages/view.tsx index f0e75cdb..8146a4ca 100644 --- a/services/frontend/src/app/(dashboard)/messages/view.tsx +++ b/services/frontend/src/app/(dashboard)/messages/view.tsx @@ -102,10 +102,14 @@ export function MessagesView({ channelId ?? undefined, initialMessages ?? undefined, ); - // Stream history one message per WS frame (replaces the 50-row batched fetch). - // Drives snapshots into the SWR list above as they arrive; falls back to the + // Stream history over WS. + // Drives snapshots into the SWR list above buffered by rAF; falls back to the // SSR `initialMessages` seed if WS is unavailable. - useMessagesStream(ws, guildId ?? "", channelId ?? undefined); + const { streaming } = useMessagesStream( + ws, + guildId ?? "", + channelId ?? undefined, + ); // Cursor to the next (older) page + whether more history exists. const { data: pageInfo } = useMessagesHasMore( guildId ?? "", @@ -218,10 +222,11 @@ export function MessagesView({ prevLen.current = list.length; }, [list.length, searching]); + // Depend on viewMode and initial load flag, NOT display.length, so streaming 200 items doesn't thrash animation const streamRef = useStaggerReveal(".msg-feed-card", { stagger: 0.02, y: 6, - dependencies: [display.length, viewMode], + dependencies: [viewMode], }); return ( @@ -233,6 +238,12 @@ export function MessagesView({

Chat Log Stream · Ingestion Stream

+ {streaming && ( + + + STREAMING + + )}
MODE: diff --git a/services/frontend/src/app/layout.tsx b/services/frontend/src/app/layout.tsx index b2094178..ff4f59c3 100644 --- a/services/frontend/src/app/layout.tsx +++ b/services/frontend/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next"; import { Bricolage_Grotesque, Inter, JetBrains_Mono } from "next/font/google"; import { ThemeProvider } from "next-themes"; import { Toaster } from "@/components/primitives/toast"; +import { SwrProvider } from "@/components/providers"; import "./globals.css"; const inter = Inter({ @@ -47,16 +48,18 @@ export default function RootLayout({ suppressHydrationWarning > - - {children} - - + + + {children} + + + ); diff --git a/services/frontend/src/components/providers.tsx b/services/frontend/src/components/providers.tsx new file mode 100644 index 00000000..ea2aa0cc --- /dev/null +++ b/services/frontend/src/components/providers.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { SWRConfig } from "swr"; +import { swrConfig } from "@/lib/swr-config"; + +/** + * Client-side SWR provider. Lives in its own client component so the config's + * callbacks (shouldRetryOnError / onErrorRetry) never cross the server→client + * boundary from the server-rendered root layout. + */ +export function SwrProvider({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/services/frontend/src/hooks/use-gsap-animation.ts b/services/frontend/src/hooks/use-gsap-animation.ts index 8ad70000..294a785c 100644 --- a/services/frontend/src/hooks/use-gsap-animation.ts +++ b/services/frontend/src/hooks/use-gsap-animation.ts @@ -2,7 +2,7 @@ import { useGSAP } from "@gsap/react"; import gsap from "gsap"; -import { type RefObject, useRef } from "react"; +import { useRef } from "react"; if (typeof window !== "undefined") { gsap.registerPlugin(useGSAP); @@ -69,64 +69,3 @@ export function useStaggerReveal( return containerRef; } - -/** - * Animated number counter using GSAP. - */ -export function useCounter( - targetValue: number, - ref: RefObject, - formatter?: (val: number) => string, -) { - useGSAP( - () => { - if (!ref.current) return; - const obj = { val: 0 }; - gsap.to(obj, { - val: targetValue, - duration: 0.75, - ease: "power2.out", - onUpdate: () => { - if (ref.current) { - ref.current.textContent = formatter - ? formatter(obj.val) - : Math.round(obj.val).toLocaleString(); - } - }, - }); - }, - { dependencies: [targetValue] }, - ); -} - -/** - * Micro-interaction hook for interactive elements (hover card tilt/glow, pulse) - */ -export function useLinearHover() { - const elementRef = useRef(null); - - useGSAP( - (_, contextSafe) => { - if (!elementRef.current || !contextSafe) return; - const el = elementRef.current; - - const onEnter = contextSafe(() => { - gsap.to(el, { y: -2, duration: 0.18, ease: "power2.out" }); - }); - const onLeave = contextSafe(() => { - gsap.to(el, { y: 0, duration: 0.22, ease: "power2.out" }); - }); - - el.addEventListener("mouseenter", onEnter); - el.addEventListener("mouseleave", onLeave); - - return () => { - el.removeEventListener("mouseenter", onEnter); - el.removeEventListener("mouseleave", onLeave); - }; - }, - { scope: elementRef }, - ); - - return elementRef; -} diff --git a/services/frontend/src/hooks/use-messages.ts b/services/frontend/src/hooks/use-messages.ts index 305295bd..ad59ed2b 100644 --- a/services/frontend/src/hooks/use-messages.ts +++ b/services/frontend/src/hooks/use-messages.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import useSWR, { useSWRConfig } from "swr"; import { useAction } from "@/hooks/use-action"; import { messagesApi, voiceApi } from "@/lib/api"; @@ -342,10 +342,10 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) { } /** - * Stream a channel/guild history ONE message per WS frame (no 50-row batch). - * Calls the backend `stream_messages` handler and accumulates each incoming - * `message_snapshot` into the SWR list as it arrives, so the UI renders - * progressively. Falls back to the batched `messagesApi.list` if WS is down. + * Stream a channel/guild history over WS. + * Buffers incoming `message_snapshot` frames with rAF/debounce and flushes in batches, + * preventing layout thrashing & SWR cascading re-renders during high frame counts (e.g. 200). + * Gates sending `stream_messages` on `ws.status !== "disconnected"` or sends when status is ready. * * Returns: { streaming, error }. */ @@ -358,32 +358,64 @@ export function useMessagesStream( const [streaming, setStreaming] = useState(false); const [error, setError] = useState(false); + const bufferRef = useRef([]); + const rafIdRef = useRef(null); + useEffect(() => { if (!guildId) return; let cancelled = false; - const key = msgKeys.list(guildId, channelId ?? undefined); - const unsubSnap = ws.on("message_snapshot", (data) => { - if (cancelled) return; - const msg = data as MessageRecord; - if (channelId && msg.channel_id !== channelId) return; - if (!channelId && msg.guild_id && msg.guild_id !== guildId) return; + + const flushBuffer = () => { + if (bufferRef.current.length === 0) return; + const incoming = bufferRef.current; + bufferRef.current = []; + void mutate( key, (old: MessagePage | undefined): MessagePage => { - const data2 = old?.data ?? []; - if (data2.some((m) => m.id === msg.id)) + const oldData = old?.data ?? []; + const existingIds = new Set(oldData.map((m) => m.id)); + const newItems = incoming.filter((m) => !existingIds.has(m.id)); + if (newItems.length === 0) return old ?? { data: [], nextCursor: null }; + return { - data: sortMessages([msg, ...data2]), + data: sortMessages([...newItems, ...oldData]), nextCursor: old?.nextCursor ?? null, }; }, { revalidate: false }, ); + }; + + const scheduleFlush = () => { + if (rafIdRef.current !== null) return; + rafIdRef.current = requestAnimationFrame(() => { + rafIdRef.current = null; + flushBuffer(); + }); + }; + + const unsubSnap = ws.on("message_snapshot", (data) => { + if (cancelled) return; + const msg = data as MessageRecord; + if (channelId && msg.channel_id !== channelId) return; + if (!channelId && msg.guild_id && msg.guild_id !== guildId) return; + + bufferRef.current.push(msg); + scheduleFlush(); }); + const unsubEnd = ws.on("message_snapshot_end", (data) => { if (cancelled) return; + // Flush any remaining buffered snapshots immediately + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = null; + } + flushBuffer(); + const end = data as { sent: number; nextCursor: string | null; @@ -391,6 +423,7 @@ export function useMessagesStream( }; setStreaming(false); setError(Boolean(end.error)); + // Persist the next-page cursor so "load older" still works after streaming. if (end.nextCursor) { void mutate( @@ -404,21 +437,31 @@ export function useMessagesStream( } }); - setStreaming(true); - setError(false); - ws.sendText( - JSON.stringify({ - type: "stream_messages", - payload: { guildId, channelId: channelId ?? undefined, limit: 200 }, - }), - ); + // Send stream request if ws.status is connected (or not provided/undefined) + if (ws.status === undefined || ws.status === "connected") { + setStreaming(true); + setError(false); + ws.sendText( + JSON.stringify({ + type: "stream_messages", + payload: { guildId, channelId: channelId ?? undefined, limit: 200 }, + }), + ); + } else { + setStreaming(false); + } return () => { cancelled = true; + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = null; + } + bufferRef.current = []; unsubSnap(); unsubEnd(); }; - }, [ws, guildId, channelId, mutate]); + }, [ws.status, ws.sendText, ws.on, guildId, channelId, mutate]); return { streaming, error }; } diff --git a/services/frontend/src/lib/utils.ts b/services/frontend/src/lib/utils.ts index 183cf42d..365058ce 100644 --- a/services/frontend/src/lib/utils.ts +++ b/services/frontend/src/lib/utils.ts @@ -4,16 +4,3 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } - -/** - * Returns an inline style that staggers a list item's entrance animation. - * Pair with the `animate-stagger` class. Caps the delay so long lists still - * appear promptly. - */ -export function staggerDelay( - index: number, - step = 45, - max = 600, -): React.CSSProperties { - return { animationDelay: `${Math.min(index * step, max)}ms` }; -} diff --git a/services/frontend/src/lib/ws-hook.ts b/services/frontend/src/lib/ws-hook.ts index d6f1dec0..452d3f9c 100644 --- a/services/frontend/src/lib/ws-hook.ts +++ b/services/frontend/src/lib/ws-hook.ts @@ -1,6 +1,7 @@ -import type { WsEventType } from "./ws/types"; +import type { WsEventType, WsStatus } from "./ws/types"; export type WsHook = { + status?: WsStatus; on: ( eventType: E, handler: (data: unknown) => void,