diff --git a/src/app/(dashboard)/inbox/page.tsx b/src/app/(dashboard)/inbox/page.tsx index f40470c..ab50293 100644 --- a/src/app/(dashboard)/inbox/page.tsx +++ b/src/app/(dashboard)/inbox/page.tsx @@ -324,6 +324,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); @@ -538,6 +548,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 */}