diff --git a/services/frontend/src/components/shared/error-boundary.tsx b/services/frontend/src/components/shared/error-boundary.tsx
new file mode 100644
index 00000000..a251ffd7
--- /dev/null
+++ b/services/frontend/src/components/shared/error-boundary.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { Component, type ReactNode } from "react";
+import { ErrorState } from "./states";
+
+/**
+ * React error boundary — catches render/exception crashes in child trees
+ * (e.g. third-party lib throwing on unexpected payload shape) and surfaces
+ * a consistent ErrorState instead of unmounting the whole app shell.
+ *
+ * Usage: wrap leaf views in
.
+ */
+interface ErrorBoundaryProps {
+ children: ReactNode;
+ fallback?: (error: Error, reset: () => void) => ReactNode;
+ onReset?: () => void;
+}
+
+interface ErrorBoundaryState {
+ error: Error | null;
+}
+
+export class ErrorBoundary extends Component<
+ ErrorBoundaryProps,
+ ErrorBoundaryState
+> {
+ constructor(props: ErrorBoundaryProps) {
+ super(props);
+ this.state = { error: null };
+ }
+
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
+ return { error };
+ }
+
+ reset = () => {
+ this.setState({ error: null });
+ this.props.onReset?.();
+ };
+
+ override render() {
+ if (!this.state.error) return this.props.children;
+
+ if (this.props.fallback) {
+ return this.props.fallback(this.state.error, this.reset);
+ }
+
+ return (
+
+ );
+ }
+}
diff --git a/services/frontend/src/components/shared/index.ts b/services/frontend/src/components/shared/index.ts
index 3d5c4992..3a4864dd 100644
--- a/services/frontend/src/components/shared/index.ts
+++ b/services/frontend/src/components/shared/index.ts
@@ -2,6 +2,7 @@ export { GuildChannelPicker } from "./guild-picker";
export { MarkdownLite } from "./markdown";
export { PageTransition } from "./page-transition";
export { MetricTile, SectionHeader } from "./section";
+export { ErrorBoundary } from "./error-boundary";
export {
EmptyState,
ErrorState,
diff --git a/services/frontend/src/hooks/index.ts b/services/frontend/src/hooks/index.ts
index 093c3014..3b2afc2c 100644
--- a/services/frontend/src/hooks/index.ts
+++ b/services/frontend/src/hooks/index.ts
@@ -31,6 +31,7 @@ export {
useMessagesWsSync,
useRecentEdits,
useReview,
+ useReviewWsSync,
useSemanticSearch,
useTextChannels,
} from "./use-messages";
diff --git a/services/frontend/src/hooks/use-action.ts b/services/frontend/src/hooks/use-action.ts
index 9659f5c2..08296ee7 100644
--- a/services/frontend/src/hooks/use-action.ts
+++ b/services/frontend/src/hooks/use-action.ts
@@ -7,17 +7,23 @@ export interface UseActionState {
/**
* A lightweight mutation hook with a TanStack-compatible surface
- * ({ mutate, mutateAsync, isPending, error }) built on plain state —
+ * ({ mutate, mutateAsync, isPending, error, resetError }) built on plain state —
* the SWR replacement for useMutation. Fire-and-forget via `mutate`,
* await the result via `mutateAsync`.
*
* `onSuccess` receives (data, args) and may perform SWR cache updates
* (e.g. `mutate(key, data, { revalidate: false })`).
+ *
+ * `onError` receives the caught error and args — use it for side-effect
+ * logging without throwing (the error is also surfaced via `state.error`).
+ *
+ * `resetError` clears the error state without re-running the action.
*/
export function useAction
(
fn: (args: TArgs) => Promise,
options?: {
onSuccess?: (data: TResult, args: TArgs) => void | Promise;
+ onError?: (error: Error, args: TArgs) => void | Promise;
},
) {
const [state, setState] = useState({
@@ -29,6 +35,8 @@ export function useAction(
fnRef.current = fn;
const onSuccessRef = useRef(options?.onSuccess);
onSuccessRef.current = options?.onSuccess;
+ const onErrorRef = useRef(options?.onError);
+ onErrorRef.current = options?.onError;
const run = useCallback(async (args?: TArgs): Promise => {
setState({ isPending: true, error: null });
@@ -38,8 +46,10 @@ export function useAction(
setState({ isPending: false, error: null });
return data;
} catch (err) {
- setState({ isPending: false, error: err as Error });
- throw err;
+ const error = err as Error;
+ setState({ isPending: false, error });
+ await onErrorRef.current?.(error, args as TArgs);
+ throw error;
}
}, []);
@@ -50,6 +60,6 @@ export function useAction(
mutateAsync: run,
isPending: state.isPending,
error: state.error,
- reset: () => setState({ isPending: false, error: null }),
+ resetError: useCallback(() => setState((s) => ({ ...s, error: null })), []),
};
}
diff --git a/services/frontend/src/hooks/use-messages.ts b/services/frontend/src/hooks/use-messages.ts
index d254d45b..305295bd 100644
--- a/services/frontend/src/hooks/use-messages.ts
+++ b/services/frontend/src/hooks/use-messages.ts
@@ -124,21 +124,43 @@ export function useImages(guildId: string) {
);
}
-// ── Review ───────────────────────────────────────
+// ── Review ────────────────────────────────────────
+// WS-driven now (was: refreshInterval:15000 polling). The backend broadcasts
+// `moderation_action` events over WS whenever a moderation action is created;
+// the review list is revalidated on that event instead of polling.
export function useReview(channelId?: string) {
+ const key = msgKeys.review(channelId);
return useSWR(
- msgKeys.review(channelId),
+ key,
async () => {
const result = await messagesApi.getReview(50, channelId || undefined);
return result.results;
},
{
- refreshInterval: 15_000,
+ revalidateOnFocus: true,
+ // No more polling — WS sync handles real-time updates.
},
);
}
+/**
+ * Subscribe to WS `moderation_action` events and invalidate the review
+ * SWR cache. Replaces the old 15-second polling interval that caused
+ * duplicate requests and race conditions with WS updates.
+ */
+export function useReviewWsSync(ws: WsHook, channelId?: string) {
+ const { mutate } = useSWRConfig();
+ const key = msgKeys.review(channelId);
+
+ useEffect(() => {
+ const unsub = ws.on("moderation_action", () => {
+ void mutate(key, undefined, { revalidate: true });
+ });
+ return unsub;
+ }, [ws, key, mutate]);
+}
+
// ── Detail ───────────────────────────────────────
export function useMessageDetail(id: string | null) {
@@ -168,7 +190,11 @@ export function useMessageDetail(id: string | null) {
message: detail.data ?? null,
attachments: attachments.data ?? [],
loading: detail.isLoading || attachments.isLoading,
- error: detail.error,
+ error: detail.error ?? attachments.error,
+ refetch: () => {
+ void detail.mutate();
+ void attachments.mutate();
+ },
};
}
@@ -216,7 +242,7 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) {
const { mutate } = useSWRConfig();
useEffect(() => {
if (!guildId) return;
- // Patch every message-list key for this guild (all channels + "**filtered**").
+ // Patch every message-list key for this guild (all channels + "__all__").
// The updater receives the SWR key so we can honor its channel filter:
// a live `message_created`/updated for channel B must NOT be prepended to
// a list that is filtered down to channel A.
@@ -255,13 +281,13 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) {
});
const unsub2 = ws.on("message_updated", (data) => {
const msg = data as Partial & { id: string };
- // The gateway broadcasts a PARTIAL update ({ id, edited_content,
- // edited_at, ... }) — merge it over the existing record instead of
- // replacing it, or the card would lose username/content/channel/etc.
+ // The gateway broadcasts a PARTIAL update ({ id } plus the changed fields
+ // — merge it over the existing record instead of replacing it, or the
+ // card would lose username/content/channel/etc.
patchLists(
(_k, m) =>
(m as Partial).channel_id === undefined ||
- matchesFilter(_k as unknown[], m),
+ matchesFilter(_k as unknown[], m as { channel_id?: string }),
(old) =>
old
? {
@@ -271,7 +297,7 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) {
),
}
: old,
- msg,
+ msg as { channel_id?: string },
);
void mutate(
msgKeys.detail(msg.id),
@@ -321,7 +347,7 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) {
* `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.
*
- * Returns: { streaming, streamed, error }.
+ * Returns: { streaming, error }.
*/
export function useMessagesStream(
ws: WsHook,
@@ -414,3 +440,5 @@ export function useRecentEdits(
{ fallbackData: initialData },
);
}
+
+export { msgKeys };
diff --git a/services/frontend/src/hooks/use-voice.ts b/services/frontend/src/hooks/use-voice.ts
index 6ef2ac10..342543fe 100644
--- a/services/frontend/src/hooks/use-voice.ts
+++ b/services/frontend/src/hooks/use-voice.ts
@@ -4,19 +4,13 @@ import { useAction } from "@/hooks/use-action";
import { voiceApi } from "@/lib/api";
import { MicTransmitter } from "@/lib/audio/mic-transmit";
import { PcmPlayer } from "@/lib/audio/pcm-player";
+import { hashUserId } from "@/lib/hash";
import type { ActiveSpeaker, Channel, VoiceStatus } from "@/lib/types";
import type { PcmChunk } from "@/lib/ws/types";
import type { WsHook } from "@/lib/ws-hook";
-/** FNV-1a 32-bit — same hash the gateway uses to tag PCM frames. */
-export function hashUserId(userId: string): number {
- let hash = 0x811c9dc5;
- for (let i = 0; i < userId.length; i++) {
- hash ^= userId.charCodeAt(i);
- hash = Math.imul(hash, 0x01000193);
- }
- return hash >>> 0;
-}
+// Re-export for components that still import hashUserId from this module.
+export { hashUserId };
const STATUS_KEY = ["voice-status"] as const;
@@ -34,6 +28,8 @@ export function useVoiceChannels(guildId: string) {
);
}
+const SPEAKERS_KEY = ["voice-speakers"] as const;
+
/**
* Live shared speaker state.
*
@@ -44,48 +40,66 @@ export function useVoiceChannels(guildId: string) {
* every client with the same list);
* - `voice_active_user` → incremental upsert of a single speaker delta.
*
+ * Now backed by SWR (consistent with all other hooks) so cache, revalidation,
+ * and deduping apply. WS events mutate the SWR cache directly
+ * ({ revalidate: false }) to avoid refetching the full status.
+ *
* This replaces the old per-browser model where each tab accumulated speakers
* only from events it happened to receive while mounted.
*/
export function useSpeakers(initialStatusActive?: ActiveSpeaker[]) {
- const [speakers, setSpeakers] = useState(
- initialStatusActive ?? [],
+ const {
+ data: speakers,
+ error,
+ mutate,
+ isValidating,
+ } = useSWR(SPEAKERS_KEY, () => Promise.resolve([]), {
+ fallbackData: initialStatusActive ?? [],
+ revalidateOnMount: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ });
+
+ const subscribe = useCallback(
+ (ws: WsHook) => {
+ const unsubSnapshot = ws.on("voice_state", (data) => {
+ const state = data as { activeSpeakers?: ActiveSpeaker[] };
+ if (Array.isArray(state?.activeSpeakers)) {
+ void mutate(state.activeSpeakers, { revalidate: false });
+ }
+ });
+ const unsub = ws.on("voice_active_user", (data) => {
+ const speaker = data as ActiveSpeaker;
+ void mutate(
+ (prev: ActiveSpeaker[] | undefined) => {
+ const arr = prev ?? [];
+ const idx = arr.findIndex((s) => s.userId === speaker.userId);
+ if (idx >= 0) {
+ const next = [...arr];
+ next[idx] = speaker;
+ return next;
+ }
+ return [...arr, speaker];
+ },
+ { revalidate: false },
+ );
+ });
+ return () => {
+ unsubSnapshot();
+ unsub();
+ };
+ },
+ [mutate],
);
- const subscribe = useCallback((ws: WsHook) => {
- const unsubSnapshot = ws.on("voice_state", (data) => {
- const state = data as { activeSpeakers?: ActiveSpeaker[] };
- if (Array.isArray(state?.activeSpeakers)) {
- setSpeakers(state.activeSpeakers);
- }
- });
- const unsub = ws.on("voice_active_user", (data) => {
- const speaker = data as ActiveSpeaker;
- setSpeakers((prev) => {
- const idx = prev.findIndex((s) => s.userId === speaker.userId);
- if (idx >= 0) {
- const next = [...prev];
- next[idx] = speaker;
- return next;
- }
- return [...prev, speaker];
- });
- });
- return () => {
- unsubSnapshot();
- unsub();
- setSpeakers([]);
- };
- }, []);
-
- return { speakers, subscribe };
+ return { speakers: speakers ?? [], subscribe, error, isValidating };
}
function useStatusInvalidator() {
const { mutate } = useSWRConfig();
- return () => {
+ return useCallback(() => {
void mutate(STATUS_KEY);
- };
+ }, [mutate]);
}
export function useVoiceConnect() {
diff --git a/services/frontend/src/lib/hash.ts b/services/frontend/src/lib/hash.ts
new file mode 100644
index 00000000..fb7985d3
--- /dev/null
+++ b/services/frontend/src/lib/hash.ts
@@ -0,0 +1,12 @@
+/**
+ * FNV-1a 32-bit hash — same hash the gateway uses to tag PCM frames.
+ * Shared between use-voice hooks and ambient-canvas.
+ */
+export function hashUserId(userId: string): number {
+ let hash = 0x811c9dc5;
+ for (let i = 0; i < userId.length; i++) {
+ hash ^= userId.charCodeAt(i);
+ hash = Math.imul(hash, 0x01000193);
+ }
+ return hash >>> 0;
+}
diff --git a/services/frontend/src/lib/swr-config.ts b/services/frontend/src/lib/swr-config.ts
new file mode 100644
index 00000000..6852d97a
--- /dev/null
+++ b/services/frontend/src/lib/swr-config.ts
@@ -0,0 +1,47 @@
+/**
+ * Global SWR configuration for the GMW frontend.
+ *
+ * Centralizes revalidation policy, error retry with exponential backoff, and
+ * deduplication so every `useSWR` call across the app gets consistent behaviour
+ * without each hook re-declaring the same options.
+ *
+ * Import this in the root layout (or any component mounted once) via
+ * `SWRConfig` from "swr".
+ */
+
+import type { SWRConfiguration } from "swr";
+
+/**
+ * Exponential backoff retry — starts at ~1s, doubles up to 30s, then flatlines.
+ * Matches the existing WsConnection reconnection philosophy.
+ */
+export const swrConfig: SWRConfiguration = {
+ // Re-validate on focus (tab switch) but not on every interval by default.
+ revalidateOnFocus: true,
+ // Dedupe rapid identical requests within 2s.
+ dedupingInterval: 2000,
+ // Never throw unhandled rejections — every hook handles `error` gracefully.
+ shouldRetryOnError: (error) => {
+ // Don't retry on 404 (NotFound) — it's a client expectation, not transient.
+ if (error?.statusCode === 404 || error?.code === "NOT_FOUND") return false;
+ // Retry network errors, 5xx, and oRPC transport failures.
+ return true;
+ },
+ onErrorRetry: (_error, _key, _config, revalidate, opts) => {
+ const attemptCount = (opts as { attemptCount?: number }).attemptCount ?? 0;
+ // Stop retrying after 3 attempts (≈ 1+2+4 = 7s max backoff).
+ if (attemptCount >= 3) return;
+
+ // Exponential backoff: 1000 * 2^attempt, capped at 30000ms.
+ const baseDelay = 1000 * 2 ** attemptCount;
+ const delay = Math.min(baseDelay, 30000);
+
+ // Jitter ±25% to avoid thundering herd on shared endpoints.
+ const jitter = delay * 0.25 * (Math.random() * 2 - 1);
+ const timeout = Math.round(delay + jitter);
+
+ setTimeout(revalidate, timeout);
+ },
+ // Sensible defaults for the "loading" state — most data loads in <500ms.
+ loadingTimeout: 15000,
+};
diff --git a/services/frontend/src/lib/ws/connection.ts b/services/frontend/src/lib/ws/connection.ts
index aab3c974..8bd16b40 100644
--- a/services/frontend/src/lib/ws/connection.ts
+++ b/services/frontend/src/lib/ws/connection.ts
@@ -37,6 +37,11 @@ export class WsConnection {
return this._status;
}
+ /** Current reconnect attempt count (0 = connected/first attempt). */
+ get reconnectAttemptCount(): number {
+ return this.reconnectAttempt;
+ }
+
onStatusChange(listener: (status: WsStatus) => void): () => void {
this.statusListeners.push(listener);
return () => {
diff --git a/services/frontend/src/lib/ws/context.tsx b/services/frontend/src/lib/ws/context.tsx
index 8581fd66..833f23a3 100644
--- a/services/frontend/src/lib/ws/context.tsx
+++ b/services/frontend/src/lib/ws/context.tsx
@@ -9,6 +9,7 @@ import {
useRef,
useState,
} from "react";
+import { toast } from "@/components/primitives";
import { WsConnection } from "./connection";
import type { PcmChunk, WsEventHandler, WsEventType, WsStatus } from "./types";
@@ -29,16 +30,6 @@ interface WsContextValue {
const WsContext = createContext(null);
-/** FNV-1a 32-bit hash matching the backend's hashUserId function */
-function _hashUserId(userId: string): number {
- let hash = 0x811c9dc5;
- for (let i = 0; i < userId.length; i++) {
- hash ^= userId.charCodeAt(i);
- hash = Math.imul(hash, 0x01000193);
- }
- return hash >>> 0;
-}
-
export function WsProvider({
children,
url,
@@ -48,6 +39,9 @@ export function WsProvider({
}) {
const connRef = useRef(null);
const [status, setStatus] = useState("disconnected");
+ // Tracks whether we've ever been connected — used to suppress the
+ // "reconnecting" toast on initial page load.
+ const wasConnected = useRef(false);
// Event handler registry — Ref so listeners survive re-renders without reconnect
// Using unknown as internal store; typed at the subscribe interface
@@ -85,7 +79,31 @@ export function WsProvider({
const conn = new WsConnection(url);
connRef.current = conn;
- const unsubStatus = conn.onStatusChange(setStatus);
+ const unsubStatus = conn.onStatusChange((s) => {
+ setStatus(s);
+ // User feedback on WS lifecycle transitions.
+ if (s === "connecting") {
+ // Only toast if we were previously connected (i.e. a disconnect,
+ // not the initial connect on page load).
+ if (wasConnected.current) {
+ toast({
+ title: "Reconnecting…",
+ description: "WebSocket connection lost. Attempting to reconnect.",
+ tone: "neutral",
+ });
+ }
+ wasConnected.current = false;
+ } else if (s === "connected") {
+ wasConnected.current = true;
+ } else if (s === "error" && !wasConnected.current) {
+ toast({
+ title: "Connection error",
+ description:
+ "WebSocket failed to connect. Retrying in the background.",
+ tone: "vermilion",
+ });
+ }
+ });
const unsubEvent = conn.onEvent((event) => {
if (event.type === "text") {
handleJsonEvent(event.data);