From c35f2a1cb4a8d1748aabf6978d31357203d47390 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 22 May 2026 09:34:39 +0300 Subject: [PATCH] feat(inbox): add a refresh button to the conversation header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A small icon button (lucide RefreshCw) in the message-thread header, sitting just left of the Status dropdown. Clicking it bumps the parent's `resyncToken` — the same channel that already drives the WS-reconnect and tab-visibility-resume refetches. So the button goes through the existing dedupe + fetch plumbing in ConversationList and MessageThread; no parallel code path to keep in sync. The icon spins for 700ms as click feedback (the refetch itself is fire-and-forget — the children just refire their fetch effects). The button is gated behind the optional `onRefresh` prop, so existing callers of MessageThread (and tests, if any are added later) keep working unchanged. Useful when the agent suspects realtime missed something — or just wants to be sure the list and thread reflect the latest DB state. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(dashboard)/inbox/page.tsx | 11 +++++ src/components/inbox/message-thread.tsx | 54 +++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/app/(dashboard)/inbox/page.tsx b/src/app/(dashboard)/inbox/page.tsx index 3d8bb1e..d81fdea 100644 --- a/src/app/(dashboard)/inbox/page.tsx +++ b/src/app/(dashboard)/inbox/page.tsx @@ -310,6 +310,16 @@ export default function InboxPage() { }; }, []); + /** + * Manual refresh trigger for the thread-header refresh button. + * Bumps the same resyncToken the reconnect / visibility paths use, + * so it goes through the existing dedupe & refetch plumbing — no + * separate code path to keep in sync. + */ + const handleManualRefresh = useCallback(() => { + setResyncToken((n) => n + 1); + }, []); + const handleConversationsLoaded = useCallback( (loaded: Conversation[]) => { setConversations(loaded); @@ -497,6 +507,7 @@ export default function InboxPage() { onAssignChange={handleAssignChange} onBack={handleCloseConversation} resyncToken={resyncToken} + onRefresh={handleManualRefresh} /> diff --git a/src/components/inbox/message-thread.tsx b/src/components/inbox/message-thread.tsx index fce4b03..1db70e6 100644 --- a/src/components/inbox/message-thread.tsx +++ b/src/components/inbox/message-thread.tsx @@ -20,6 +20,7 @@ import { Check, Clock, ArrowLeft, + RefreshCw, } from "lucide-react"; import { format, isToday, isYesterday, differenceInHours } from "date-fns"; import { Badge } from "@/components/ui/badge"; @@ -78,6 +79,14 @@ interface MessageThreadProps { * keep working. */ resyncToken?: number; + /** + * Fired by the manual-refresh button in the thread header. The parent + * typically bumps the same `resyncToken` it controls — this gives the + * user a way to force a refetch when they suspect realtime missed an + * event (or they're impatient). Optional so existing callers keep + * working; the button is only rendered when this is provided. + */ + onRefresh?: () => void; } function formatDateSeparator(dateStr: string): string { @@ -121,6 +130,7 @@ export function MessageThread({ onAssignChange, onBack, resyncToken = 0, + onRefresh, }: MessageThreadProps) { const { user } = useAuth(); const [loading, setLoading] = useState(false); @@ -128,6 +138,28 @@ export function MessageThread({ const [templateModalOpen, setTemplateModalOpen] = useState(false); const [profiles, setProfiles] = useState([]); const [reactions, setReactions] = useState([]); + // Purely visual spin state for the manual-refresh button. The actual + // refetch is fire-and-forget through `onRefresh` (which bumps the + // parent's resyncToken); the 700ms spin is just feedback so the click + // doesn't feel like a no-op. Cleared via the timer ref on unmount. + const [isRefreshing, setIsRefreshing] = useState(false); + const refreshTimerRef = useRef | null>(null); + useEffect(() => { + return () => { + if (refreshTimerRef.current !== null) { + clearTimeout(refreshTimerRef.current); + } + }; + }, []); + const handleRefreshClick = useCallback(() => { + if (isRefreshing || !onRefresh) return; + setIsRefreshing(true); + onRefresh(); + refreshTimerRef.current = setTimeout(() => { + setIsRefreshing(false); + refreshTimerRef.current = null; + }, 700); + }, [isRefreshing, onRefresh]); const [replyTo, setReplyTo] = useState(null); // Profiles are bounded by RLS to rows the current user is allowed to @@ -696,6 +728,28 @@ export function MessageThread({
+ {/* Manual refresh — forces a refetch of the messages + the + conversation list (the parent bumps its resyncToken). Useful + when realtime missed an event or the agent just wants to be + sure nothing's stale. Only rendered when the parent wires + up `onRefresh`. */} + {onRefresh && ( + + )} + {/* Status dropdown */}