mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
feat: add thread visibility management using Jotai atoms in chat components
This commit is contained in:
parent
8e5a80fc19
commit
4e4ea6fba0
4 changed files with 76 additions and 37 deletions
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue