feat: add thread visibility management using Jotai atoms in chat components

This commit is contained in:
Anish Sarkar 2026-01-20 16:33:42 +05:30
parent 8e5a80fc19
commit 4e4ea6fba0
4 changed files with 76 additions and 37 deletions

View file

@ -47,6 +47,14 @@ export const addingCommentToMessageIdAtom = atom(
} }
); );
// Setter atom for updating thread visibility
export const setThreadVisibilityAtom = atom(
null,
(get, set, newVisibility: ChatVisibility) => {
set(currentThreadAtom, { ...get(currentThreadAtom), visibility: newVisibility });
}
);
export const resetCurrentThreadAtom = atom(null, (_, set) => { export const resetCurrentThreadAtom = atom(null, (_, set) => {
set(currentThreadAtom, initialState); set(currentThreadAtom, initialState);
}); });

View file

@ -1,11 +1,11 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai";
import { useParams, usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { NotificationButton } from "@/components/notifications/NotificationButton"; import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { ChatShareButton } from "@/components/new-chat/chat-share-button"; import { ChatShareButton } from "@/components/new-chat/chat-share-button";
import { getThreadFull } from "@/lib/chat/thread-persistence"; import { NotificationButton } from "@/components/notifications/NotificationButton";
import type { ChatVisibility } from "@/lib/chat/thread-persistence"; import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
interface HeaderProps { interface HeaderProps {
breadcrumb?: React.ReactNode; breadcrumb?: React.ReactNode;
@ -16,26 +16,34 @@ export function Header({
breadcrumb, breadcrumb,
mobileMenuTrigger, mobileMenuTrigger,
}: HeaderProps) { }: HeaderProps) {
const params = useParams();
const pathname = usePathname(); const pathname = usePathname();
// Check if we're on a chat page // Check if we're on a chat page
const isChatPage = pathname?.includes("/new-chat") ?? false; const isChatPage = pathname?.includes("/new-chat") ?? false;
// Get chat_id from URL params // Use Jotai atom for thread state (synced from chat page)
const chatId = params?.chat_id const currentThreadState = useAtomValue(currentThreadAtom);
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
// Show button only when we have a thread id (thread exists and is synced to Jotai)
const hasThread = isChatPage && currentThreadState.id !== null;
// Create minimal thread object for ChatShareButton (used for API calls)
const threadForButton: ThreadRecord | null = hasThread
? {
id: currentThreadState.id!,
visibility: currentThreadState.visibility ?? "PRIVATE",
// These fields are not used by ChatShareButton for display, only for checks
created_by_id: null,
search_space_id: 0,
title: "",
archived: false,
created_at: "",
updated_at: "",
}
: null; : null;
// Fetch current thread if on chat page and chat_id exists
const { data: currentThread } = useQuery({
queryKey: ["thread", chatId],
queryFn: () => getThreadFull(chatId!),
enabled: isChatPage && chatId !== null && chatId > 0,
});
const handleVisibilityChange = (visibility: ChatVisibility) => { const handleVisibilityChange = (visibility: ChatVisibility) => {
// Visibility change is handled by ChatShareButton internally // Visibility change is handled by ChatShareButton internally via Jotai
// This callback can be used for additional side effects if needed // This callback can be used for additional side effects if needed
}; };
@ -52,9 +60,9 @@ export function Header({
{/* Notifications */} {/* Notifications */}
<NotificationButton /> <NotificationButton />
{/* Share button - only show on chat pages when thread exists */} {/* Share button - only show on chat pages when thread exists */}
{isChatPage && currentThread && ( {hasThread && (
<ChatShareButton <ChatShareButton
thread={currentThread} thread={threadForButton}
onVisibilityChange={handleVisibilityChange} onVisibilityChange={handleVisibilityChange}
/> />
)} )}

View file

@ -1,9 +1,11 @@
"use client"; "use client";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { Loader2, Lock, Users } from "lucide-react"; import { Loader2, Lock, Users } from "lucide-react";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { import {
@ -44,7 +46,12 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const currentVisibility = thread?.visibility ?? "PRIVATE"; // Use Jotai atom for visibility (single source of truth)
const currentThreadState = useAtomValue(currentThreadAtom);
const setThreadVisibility = useSetAtom(setThreadVisibilityAtom);
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
const isOwnThread = thread?.created_by_id !== null; // If we have the thread, we can modify it const isOwnThread = thread?.created_by_id !== null; // If we have the thread, we can modify it
const handleVisibilityChange = useCallback( const handleVisibilityChange = useCallback(
@ -55,10 +62,13 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
} }
setIsUpdating(true); setIsUpdating(true);
// Update Jotai atom immediately for instant UI feedback
setThreadVisibility(newVisibility);
try { try {
await updateThreadVisibility(thread.id, newVisibility); await updateThreadVisibility(thread.id, newVisibility);
// Refetch all thread queries to update sidebar immediately // Refetch threads list to update sidebar
await queryClient.refetchQueries({ await queryClient.refetchQueries({
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "threads", predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "threads",
}); });
@ -70,12 +80,14 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
setOpen(false); setOpen(false);
} catch (error) { } catch (error) {
console.error("Failed to update visibility:", error); console.error("Failed to update visibility:", error);
// Revert Jotai state on error
setThreadVisibility(thread.visibility ?? "PRIVATE");
toast.error("Failed to update sharing settings"); toast.error("Failed to update sharing settings");
} finally { } finally {
setIsUpdating(false); setIsUpdating(false);
} }
}, },
[thread, currentVisibility, onVisibilityChange, queryClient] [thread, currentVisibility, onVisibilityChange, queryClient, setThreadVisibility]
); );
// Don't show if no thread (new chat that hasn't been created yet) // Don't show if no thread (new chat that hasn't been created yet)

View file

@ -4,6 +4,7 @@ import { useAtomValue } from "jotai";
import { AlertCircle, Bot, ChevronRight, Globe, User, X } from "lucide-react"; import { AlertCircle, Bot, ChevronRight, Globe, User, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
createNewLLMConfigMutationAtom, createNewLLMConfigMutationAtom,
@ -38,6 +39,12 @@ export function ModelConfigSidebar({
mode, mode,
}: ModelConfigSidebarProps) { }: ModelConfigSidebarProps) {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [mounted, setMounted] = useState(false);
// Handle SSR - only render portal on client
useEffect(() => {
setMounted(true);
}, []);
// Mutations - use mutateAsync from the atom value // Mutations - use mutateAsync from the atom value
const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom); const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
@ -147,7 +154,9 @@ export function ModelConfigSidebar({
} }
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]); }, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
return ( if (!mounted) return null;
const sidebarContent = (
<AnimatePresence> <AnimatePresence>
{open && ( {open && (
<> <>
@ -157,7 +166,7 @@ export function ModelConfigSidebar({
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm" className="fixed inset-0 z-[24] bg-black/20 backdrop-blur-sm"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
/> />
@ -172,7 +181,7 @@ export function ModelConfigSidebar({
stiffness: 300, stiffness: 300,
}} }}
className={cn( className={cn(
"fixed right-0 top-0 z-50 h-full w-full sm:w-[480px] lg:w-[540px]", "fixed right-0 top-0 z-[25] h-full w-full sm:w-[480px] lg:w-[540px]",
"bg-background border-l border-border/50 shadow-2xl", "bg-background border-l border-border/50 shadow-2xl",
"flex flex-col" "flex flex-col"
)} )}
@ -245,16 +254,16 @@ export function ModelConfigSidebar({
<div className="space-y-4"> <div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> <div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Configuration Name Configuration Name
</label> </div>
<p className="text-sm font-medium">{config.name}</p> <p className="text-sm font-medium">{config.name}</p>
</div> </div>
{config.description && ( {config.description && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> <div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Description Description
</label> </div>
<p className="text-sm text-muted-foreground">{config.description}</p> <p className="text-sm text-muted-foreground">{config.description}</p>
</div> </div>
)} )}
@ -264,15 +273,15 @@ export function ModelConfigSidebar({
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> <div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Provider Provider
</label> </div>
<p className="text-sm font-medium">{config.provider}</p> <p className="text-sm font-medium">{config.provider}</p>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> <div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Model Model
</label> </div>
<p className="text-sm font-medium font-mono">{config.model_name}</p> <p className="text-sm font-medium font-mono">{config.model_name}</p>
</div> </div>
</div> </div>
@ -281,9 +290,9 @@ export function ModelConfigSidebar({
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> <div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Citations Citations
</label> </div>
<Badge <Badge
variant={config.citations_enabled ? "default" : "secondary"} variant={config.citations_enabled ? "default" : "secondary"}
className="w-fit" className="w-fit"
@ -297,9 +306,9 @@ export function ModelConfigSidebar({
<> <>
<div className="h-px bg-border/50" /> <div className="h-px bg-border/50" />
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> <div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
System Instructions System Instructions
</label> </div>
<div className="p-3 rounded-lg bg-muted/50 border border-border/50"> <div className="p-3 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs font-mono text-muted-foreground whitespace-pre-wrap line-clamp-10"> <p className="text-xs font-mono text-muted-foreground whitespace-pre-wrap line-clamp-10">
{config.system_instructions} {config.system_instructions}
@ -367,4 +376,6 @@ export function ModelConfigSidebar({
)} )}
</AnimatePresence> </AnimatePresence>
); );
return typeof document !== "undefined" ? createPortal(sidebarContent, document.body) : null;
} }