Merge pull request #716 from AnishSarkar22/fix/chat-ui

fix: Simiplify UI for chat
This commit is contained in:
Rohan Verma 2026-01-21 12:21:46 -08:00 committed by GitHub
commit 4fa71d2cd6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 703 additions and 281 deletions

View file

@ -315,6 +315,8 @@ async def create_comment(
thread_title=thread.title or "Untitled thread", thread_title=thread.title or "Untitled thread",
author_id=str(user.id), author_id=str(user.id),
author_name=author_name, author_name=author_name,
author_avatar_url=user.avatar_url,
author_email=user.email,
content_preview=content_preview[:200], content_preview=content_preview[:200],
search_space_id=search_space_id, search_space_id=search_space_id,
) )
@ -426,6 +428,8 @@ async def create_reply(
thread_title=thread.title or "Untitled thread", thread_title=thread.title or "Untitled thread",
author_id=str(user.id), author_id=str(user.id),
author_name=author_name, author_name=author_name,
author_avatar_url=user.avatar_url,
author_email=user.email,
content_preview=content_preview[:200], content_preview=content_preview[:200],
search_space_id=search_space_id, search_space_id=search_space_id,
) )
@ -565,6 +569,8 @@ async def update_comment(
thread_title=thread.title or "Untitled thread", thread_title=thread.title or "Untitled thread",
author_id=str(user.id), author_id=str(user.id),
author_name=author_name, author_name=author_name,
author_avatar_url=user.avatar_url,
author_email=user.email,
content_preview=content_preview[:200], content_preview=content_preview[:200],
search_space_id=search_space_id, search_space_id=search_space_id,
) )

View file

@ -634,6 +634,8 @@ class MentionNotificationHandler(BaseNotificationHandler):
thread_title: str, thread_title: str,
author_id: str, author_id: str,
author_name: str, author_name: str,
author_avatar_url: str | None,
author_email: str,
content_preview: str, content_preview: str,
search_space_id: int, search_space_id: int,
) -> Notification: ) -> Notification:
@ -650,6 +652,8 @@ class MentionNotificationHandler(BaseNotificationHandler):
thread_title: Title of the chat thread thread_title: Title of the chat thread
author_id: ID of the comment author author_id: ID of the comment author
author_name: Display name of the comment author author_name: Display name of the comment author
author_avatar_url: Avatar URL of the comment author
author_email: Email of the comment author (for fallback initials)
content_preview: First ~100 chars of the comment content_preview: First ~100 chars of the comment
search_space_id: Search space ID search_space_id: Search space ID
@ -667,6 +671,8 @@ class MentionNotificationHandler(BaseNotificationHandler):
"thread_title": thread_title, "thread_title": thread_title,
"author_id": author_id, "author_id": author_id,
"author_name": author_name, "author_name": author_name,
"author_avatar_url": author_avatar_url,
"author_email": author_email,
"content_preview": content_preview[:200], "content_preview": content_preview[:200],
} }

View file

@ -16,7 +16,6 @@ import {
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup"; import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb"; import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { LayoutDataProvider } from "@/components/layout"; import { LayoutDataProvider } from "@/components/layout";
import { OnboardingTour } from "@/components/onboarding-tour"; import { OnboardingTour } from "@/components/onboarding-tour";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@ -197,11 +196,7 @@ export function DashboardClientLayout({
return ( return (
<DocumentUploadDialogProvider> <DocumentUploadDialogProvider>
<OnboardingTour /> <OnboardingTour />
<LayoutDataProvider <LayoutDataProvider searchSpaceId={searchSpaceId} breadcrumb={<DashboardBreadcrumb />}>
searchSpaceId={searchSpaceId}
breadcrumb={<DashboardBreadcrumb />}
languageSwitcher={<LanguageSwitcher />}
>
{children} {children}
</LayoutDataProvider> </LayoutDataProvider>
</DocumentUploadDialogProvider> </DocumentUploadDialogProvider>

View file

@ -1060,13 +1060,7 @@ export default function NewChatPage() {
<div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden"> <div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden">
<Thread <Thread
messageThinkingSteps={messageThinkingSteps} messageThinkingSteps={messageThinkingSteps}
header={ header={<ChatHeader searchSpaceId={searchSpaceId} />}
<ChatHeader
searchSpaceId={searchSpaceId}
thread={currentThread}
onThreadVisibilityChange={handleVisibilityChange}
/>
}
/> />
</div> </div>
</AssistantRuntimeProvider> </AssistantRuntimeProvider>

View file

@ -778,8 +778,7 @@ function RolesTab({
role.name === "Owner" && "text-amber-600", role.name === "Owner" && "text-amber-600",
role.name === "Editor" && "text-blue-600", role.name === "Editor" && "text-blue-600",
role.name === "Viewer" && "text-gray-600", role.name === "Viewer" && "text-gray-600",
!["Owner", "Editor", "Viewer"].includes(role.name) && !["Owner", "Editor", "Viewer"].includes(role.name) && "text-primary"
"text-primary"
)} )}
/> />
</div> </div>
@ -1488,7 +1487,8 @@ function CreateRoleDialog({
</div> </div>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Use presets to quickly apply Editor (create/read/update) or Viewer (read-only) permissions Use presets to quickly apply Editor (create/read/update) or Viewer (read-only)
permissions
</p> </p>
<ScrollArea className="h-64 rounded-lg border p-4"> <ScrollArea className="h-64 rounded-lg border p-4">
<div className="space-y-4"> <div className="space-y-4">
@ -1500,9 +1500,7 @@ function CreateRoleDialog({
return ( return (
<div key={category} className="space-y-2"> <div key={category} className="space-y-2">
<label <label className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 p-1 rounded w-full text-left">
className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 p-1 rounded w-full text-left"
>
<Checkbox <Checkbox
checked={allSelected} checked={allSelected}
onCheckedChange={() => toggleCategory(category)} onCheckedChange={() => toggleCategory(category)}

View file

@ -113,6 +113,8 @@
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);

View file

@ -47,6 +47,11 @@ 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

@ -191,7 +191,7 @@ export const AssistantMessage: FC = () => {
{/* Mobile & Medium screen comment trigger - shown below lg breakpoint */} {/* Mobile & Medium screen comment trigger - shown below lg breakpoint */}
{showCommentTrigger && !isDesktop && ( {showCommentTrigger && !isDesktop && (
<div className="mt-2 flex justify-start"> <div className="ml-2 mt-1 flex justify-start">
<button <button
type="button" type="button"
onClick={handleCommentTriggerClick} onClick={handleCommentTriggerClick}
@ -234,7 +234,7 @@ const AssistantActionBar: FC = () => {
hideWhenRunning hideWhenRunning
autohide="not-last" autohide="not-last"
autohideFloat="single-branch" autohideFloat="single-branch"
className="aui-assistant-action-bar-root -ml-1 col-start-3 row-start-2 flex gap-1 text-muted-foreground data-floating:absolute data-floating:rounded-md data-floating:border data-floating:bg-background data-floating:p-1 data-floating:shadow-sm" className="aui-assistant-action-bar-root -ml-1 col-start-3 row-start-2 flex gap-1 text-muted-foreground md:data-floating:absolute md:data-floating:rounded-md md:data-floating:border md:data-floating:bg-background md:data-floating:p-1 md:data-floating:shadow-sm [&>button]:opacity-100 md:[&>button]:opacity-[var(--aui-button-opacity,1)]"
> >
<ActionBarPrimitive.Copy asChild> <ActionBarPrimitive.Copy asChild>
<TooltipIconButton tooltip="Copy"> <TooltipIconButton tooltip="Copy">

View file

@ -44,7 +44,6 @@ function findMentionTrigger(
return { isActive: false, query: "", startIndex: 0 }; return { isActive: false, query: "", startIndex: 0 };
} }
const fullMatch = mentionMatch[0];
const query = mentionMatch[1]; const query = mentionMatch[1];
const atIndex = cursorPos - query.length - 1; const atIndex = cursorPos - query.length - 1;
@ -80,7 +79,7 @@ function findMentionTrigger(
export function CommentComposer({ export function CommentComposer({
members, members,
membersLoading = false, membersLoading = false,
placeholder = "Write a comment...", placeholder = "Comment or @mention",
submitLabel = "Send", submitLabel = "Send",
isSubmitting = false, isSubmitting = false,
onSubmit, onSubmit,
@ -145,6 +144,13 @@ export function CommentComposer({
const cursorPos = e.target.selectionStart; const cursorPos = e.target.selectionStart;
setDisplayContent(value); setDisplayContent(value);
// Auto-resize textarea on content change
requestAnimationFrame(() => {
const textarea = e.target;
textarea.style.height = "auto";
textarea.style.height = `${textarea.scrollHeight}px`;
});
const triggerResult = findMentionTrigger(value, cursorPos, insertedMentions); const triggerResult = findMentionTrigger(value, cursorPos, insertedMentions);
if (triggerResult.isActive) { if (triggerResult.isActive) {
@ -208,9 +214,9 @@ export function CommentComposer({
const mentionPattern = /@([^\s@]+(?:\s+[^\s@]+)*?)(?=\s|$|[.,!?;:]|@)/g; const mentionPattern = /@([^\s@]+(?:\s+[^\s@]+)*?)(?=\s|$|[.,!?;:]|@)/g;
const foundMentions: InsertedMention[] = []; const foundMentions: InsertedMention[] = [];
let match: RegExpExecArray | null; const matches = initialValue.matchAll(mentionPattern);
while ((match = mentionPattern.exec(initialValue)) !== null) { for (const match of matches) {
const displayName = match[1]; const displayName = match[1];
const member = members.find( const member = members.find(
(m) => m.displayName === displayName || m.email.split("@")[0] === displayName (m) => m.displayName === displayName || m.email.split("@")[0] === displayName
@ -237,6 +243,19 @@ export function CommentComposer({
const canSubmit = displayContent.trim().length > 0 && !isSubmitting; const canSubmit = displayContent.trim().length > 0 && !isSubmitting;
// Auto-resize textarea
const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = "auto";
textarea.style.height = `${textarea.scrollHeight}px`;
}
}, []);
useEffect(() => {
adjustTextareaHeight();
}, [adjustTextareaHeight]);
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Popover <Popover
@ -251,7 +270,8 @@ export function CommentComposer({
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={placeholder} placeholder={placeholder}
className="min-h-[80px] resize-none" className="min-h-[40px] max-h-[200px] resize-none overflow-y-auto scrollbar-thin"
rows={1}
disabled={isSubmitting} disabled={isSubmitting}
/> />
</PopoverAnchor> </PopoverAnchor>

View file

@ -21,7 +21,7 @@ export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: Comment
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-7 opacity-0 group-hover:opacity-100 transition-opacity" className="size-7 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity"
> >
<MoreHorizontal className="size-4" /> <MoreHorizontal className="size-4" />
</Button> </Button>

View file

@ -1,8 +1,6 @@
"use client"; "use client";
import { useAtom } from "jotai";
import { MessageSquare } from "lucide-react"; import { MessageSquare } from "lucide-react";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -115,12 +113,8 @@ export function CommentItem({
members = [], members = [],
membersLoading = false, membersLoading = false,
}: CommentItemProps) { }: CommentItemProps) {
const [{ data: currentUser }] = useAtom(currentUserAtom); const displayName =
comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown";
const isCurrentUser = currentUser?.id === comment.author?.id;
const displayName = isCurrentUser
? "Me"
: comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown";
const email = comment.author?.email || ""; const email = comment.author?.email || "";
const handleEditSubmit = (content: string) => { const handleEditSubmit = (content: string) => {

View file

@ -1,13 +1,25 @@
"use client"; "use client";
import { MessageSquarePlus } from "lucide-react"; import { useAtom } from "jotai";
import { useState } from "react"; import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Button } from "@/components/ui/button"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CommentComposer } from "../comment-composer/comment-composer"; import { CommentComposer } from "../comment-composer/comment-composer";
import { CommentThread } from "../comment-thread/comment-thread"; import { CommentThread } from "../comment-thread/comment-thread";
import type { CommentPanelProps } from "./types"; import type { CommentPanelProps } from "./types";
function getInitials(name: string | null | undefined, email: string): string {
if (name) {
return name
.split(" ")
.map((part) => part[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
return email[0].toUpperCase();
}
export function CommentPanel({ export function CommentPanel({
threads, threads,
members, members,
@ -21,15 +33,10 @@ export function CommentPanel({
maxHeight, maxHeight,
variant = "desktop", variant = "desktop",
}: CommentPanelProps) { }: CommentPanelProps) {
const [isComposerOpen, setIsComposerOpen] = useState(false); const [{ data: currentUser }] = useAtom(currentUserAtom);
const handleCommentSubmit = (content: string) => { const handleCommentSubmit = (content: string) => {
onCreateComment(content); onCreateComment(content);
setIsComposerOpen(false);
};
const handleComposerCancel = () => {
setIsComposerOpen(false);
}; };
const isMobile = variant === "mobile"; const isMobile = variant === "mobile";
@ -51,7 +58,6 @@ export function CommentPanel({
} }
const hasThreads = threads.length > 0; const hasThreads = threads.length > 0;
const showEmptyState = !hasThreads && !isComposerOpen;
// Ensure minimum usable height for empty state + composer button // Ensure minimum usable height for empty state + composer button
const minHeight = 180; const minHeight = 180;
@ -81,38 +87,35 @@ export function CommentPanel({
</div> </div>
)} )}
{showEmptyState && ( {!hasThreads && currentUser && (
<div className="flex min-h-[120px] flex-col items-center justify-center gap-2 p-4 text-center"> <div className="flex items-center gap-3 px-4 pt-4 pb-1">
<MessageSquarePlus className="size-8 text-muted-foreground/50" /> <Avatar className="size-10">
<p className="text-sm text-muted-foreground">No comments yet</p> <AvatarImage
<p className="text-xs text-muted-foreground/70"> src={currentUser.avatar_url ?? undefined}
Start a conversation about this response alt={currentUser.display_name ?? currentUser.email}
</p> />
<AvatarFallback className="bg-primary/10 text-primary text-sm font-medium">
{getInitials(currentUser.display_name, currentUser.email)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-medium">
{currentUser.display_name ?? currentUser.email}
</span>
</div>
</div> </div>
)} )}
<div className={cn("p-3", showEmptyState && !isMobile && "border-t", isMobile && "border-t")}> <div className="p-3">
{isComposerOpen ? ( <CommentComposer
<CommentComposer members={members}
members={members} membersLoading={membersLoading}
membersLoading={membersLoading} placeholder="Comment or @mention"
placeholder="Write a comment..." submitLabel="Comment"
submitLabel="Comment" isSubmitting={isSubmitting}
isSubmitting={isSubmitting} onSubmit={handleCommentSubmit}
onSubmit={handleCommentSubmit} autoFocus={!hasThreads}
onCancel={handleComposerCancel} />
autoFocus
/>
) : (
<Button
variant="ghost"
className="w-full justify-start text-muted-foreground hover:text-foreground"
onClick={() => setIsComposerOpen(true)}
>
<MessageSquarePlus className="mr-2 size-4" />
Add a comment...
</Button>
)}
</div> </div>
</div> </div>
); );

View file

@ -20,7 +20,7 @@ export function CommentSheet({
<SheetContent <SheetContent
side={side} side={side}
className={cn( className={cn(
"flex flex-col p-0", "flex flex-col gap-0 overflow-hidden p-0",
isBottomSheet ? "h-[85vh] max-h-[85vh] rounded-t-xl" : "h-full w-full max-w-md" isBottomSheet ? "h-[85vh] max-h-[85vh] rounded-t-xl" : "h-full w-full max-w-md"
)} )}
> >
@ -41,7 +41,7 @@ export function CommentSheet({
)} )}
</SheetTitle> </SheetTitle>
</SheetHeader> </SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto"> <div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
<CommentPanelContainer messageId={messageId} isOpen={true} variant="mobile" /> <CommentPanelContainer messageId={messageId} isOpen={true} variant="mobile" />
</div> </div>
</SheetContent> </SheetContent>

View file

@ -128,23 +128,21 @@ export function CommentThread({
{/* Reply composer or button */} {/* Reply composer or button */}
{isReplyComposerOpen ? ( {isReplyComposerOpen ? (
<> <div className="pt-3">
<div className="pt-3"> <CommentComposer
<CommentComposer members={members}
members={members} membersLoading={membersLoading}
membersLoading={membersLoading} placeholder="Reply or @mention"
placeholder="Write a reply..." submitLabel="Reply"
submitLabel="Reply" isSubmitting={isSubmitting}
isSubmitting={isSubmitting} onSubmit={handleReplySubmit}
onSubmit={handleReplySubmit} onCancel={handleReplyCancel}
onCancel={handleReplyCancel} autoFocus
autoFocus />
/> </div>
</div>
</>
) : ( ) : (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleReply}> <Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleReply}>
<MessageSquare className="mr-1.5 size-3" /> <MessageSquare className="mr-1 size-3" />
Reply Reply
</Button> </Button>
)} )}
@ -156,7 +154,7 @@ export function CommentThread({
{!hasReplies && !isReplyComposerOpen && ( {!hasReplies && !isReplyComposerOpen && (
<div className="ml-7 mt-1"> <div className="ml-7 mt-1">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleReply}> <Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleReply}>
<MessageSquare className="mr-1.5 size-3" /> <MessageSquare className="mr-1 size-3" />
Reply Reply
</Button> </Button>
</div> </div>

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { MessageSquare } from "lucide-react"; import { MessageSquarePlus } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CommentTriggerProps } from "./types"; import type { CommentTriggerProps } from "./types";
@ -25,7 +25,7 @@ export function CommentTrigger({ commentCount, isOpen, onClick, disabled }: Comm
)} )}
onClick={onClick} onClick={onClick}
> >
<MessageSquare className={cn("size-5", (hasComments || isOpen) && "fill-current")} /> <MessageSquarePlus className={cn("size-5", (hasComments || isOpen) && "fill-current")} />
{hasComments && ( {hasComments && (
<span className="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground"> <span className="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
{commentCount > 9 ? "9+" : commentCount} {commentCount > 9 ? "9+" : commentCount}

View file

@ -76,10 +76,7 @@ export function DashboardBreadcrumb() {
const segments = path.split("/").filter(Boolean); const segments = path.split("/").filter(Boolean);
const breadcrumbs: BreadcrumbItemInterface[] = []; const breadcrumbs: BreadcrumbItemInterface[] = [];
// Always start with Dashboard // Handle search space (start directly with search space, skip "Dashboard")
breadcrumbs.push({ label: t("dashboard"), href: "/dashboard" });
// Handle search space
if (segments[0] === "dashboard" && segments[1]) { if (segments[0] === "dashboard" && segments[1]) {
// Use the actual search space name if available, otherwise fall back to the ID // Use the actual search space name if available, otherwise fall back to the ID
const searchSpaceLabel = searchSpace?.name || `${t("search_space")} ${segments[1]}`; const searchSpaceLabel = searchSpace?.name || `${t("search_space")} ${segments[1]}`;

View file

@ -34,14 +34,12 @@ interface LayoutDataProviderProps {
searchSpaceId: string; searchSpaceId: string;
children: React.ReactNode; children: React.ReactNode;
breadcrumb?: React.ReactNode; breadcrumb?: React.ReactNode;
languageSwitcher?: React.ReactNode;
} }
export function LayoutDataProvider({ export function LayoutDataProvider({
searchSpaceId, searchSpaceId,
children, children,
breadcrumb, breadcrumb,
languageSwitcher,
}: LayoutDataProviderProps) { }: LayoutDataProviderProps) {
const t = useTranslations("dashboard"); const t = useTranslations("dashboard");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
@ -302,10 +300,6 @@ export function LayoutDataProvider({
} }
}, [router]); }, [router]);
const handleToggleTheme = useCallback(() => {
setTheme(theme === "dark" ? "light" : "dark");
}, [theme, setTheme]);
const handleViewAllSharedChats = useCallback(() => { const handleViewAllSharedChats = useCallback(() => {
setIsAllSharedChatsSidebarOpen(true); setIsAllSharedChatsSidebarOpen(true);
}, []); }, []);
@ -375,9 +369,8 @@ export function LayoutDataProvider({
onLogout={handleLogout} onLogout={handleLogout}
pageUsage={pageUsage} pageUsage={pageUsage}
breadcrumb={breadcrumb} breadcrumb={breadcrumb}
languageSwitcher={languageSwitcher}
theme={theme} theme={theme}
onToggleTheme={handleToggleTheme} setTheme={setTheme}
isChatPage={isChatPage} isChatPage={isChatPage}
> >
{children} {children}

View file

@ -1,25 +1,50 @@
"use client"; "use client";
import { Moon, Sun } from "lucide-react"; import { useAtomValue } from "jotai";
import { usePathname } from "next/navigation";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
import { NotificationButton } from "@/components/notifications/NotificationButton"; import { NotificationButton } from "@/components/notifications/NotificationButton";
import { Button } from "@/components/ui/button"; import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
interface HeaderProps { interface HeaderProps {
breadcrumb?: React.ReactNode; breadcrumb?: React.ReactNode;
languageSwitcher?: React.ReactNode;
theme?: string;
onToggleTheme?: () => void;
mobileMenuTrigger?: React.ReactNode; mobileMenuTrigger?: React.ReactNode;
} }
export function Header({ export function Header({ breadcrumb, mobileMenuTrigger }: HeaderProps) {
breadcrumb, const pathname = usePathname();
languageSwitcher,
theme, // Check if we're on a chat page
onToggleTheme, const isChatPage = pathname?.includes("/new-chat") ?? false;
mobileMenuTrigger,
}: HeaderProps) { // Use Jotai atom for thread state (synced from chat page)
const currentThreadState = useAtomValue(currentThreadAtom);
// 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 && currentThreadState.id !== null
? {
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;
const handleVisibilityChange = (_visibility: ChatVisibility) => {
// Visibility change is handled by ChatShareButton internally via Jotai
// This callback can be used for additional side effects if needed
};
return ( return (
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4"> <header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4">
{/* Left side - Mobile menu trigger + Breadcrumb */} {/* Left side - Mobile menu trigger + Breadcrumb */}
@ -29,24 +54,13 @@ export function Header({
</div> </div>
{/* Right side - Actions */} {/* Right side - Actions */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-4">
{/* Notifications */} {/* Notifications */}
<NotificationButton /> <NotificationButton />
{/* Share button - only show on chat pages when thread exists */}
{/* Theme toggle */} {hasThread && (
{onToggleTheme && ( <ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" onClick={onToggleTheme} className="h-8 w-8">
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
<span className="sr-only">Toggle theme</span>
</Button>
</TooltipTrigger>
<TooltipContent>{theme === "dark" ? "Light mode" : "Dark mode"}</TooltipContent>
</Tooltip>
)} )}
{languageSwitcher}
</div> </div>
</header> </header>
); );

View file

@ -35,9 +35,8 @@ interface LayoutShellProps {
onLogout?: () => void; onLogout?: () => void;
pageUsage?: PageUsage; pageUsage?: PageUsage;
breadcrumb?: React.ReactNode; breadcrumb?: React.ReactNode;
languageSwitcher?: React.ReactNode;
theme?: string; theme?: string;
onToggleTheme?: () => void; setTheme?: (theme: "light" | "dark" | "system") => void;
defaultCollapsed?: boolean; defaultCollapsed?: boolean;
isChatPage?: boolean; isChatPage?: boolean;
children: React.ReactNode; children: React.ReactNode;
@ -69,9 +68,8 @@ export function LayoutShell({
onLogout, onLogout,
pageUsage, pageUsage,
breadcrumb, breadcrumb,
languageSwitcher,
theme, theme,
onToggleTheme, setTheme,
defaultCollapsed = false, defaultCollapsed = false,
isChatPage = false, isChatPage = false,
children, children,
@ -88,9 +86,6 @@ export function LayoutShell({
<div className={cn("flex h-screen w-full flex-col bg-background", className)}> <div className={cn("flex h-screen w-full flex-col bg-background", className)}>
<Header <Header
breadcrumb={breadcrumb} breadcrumb={breadcrumb}
languageSwitcher={languageSwitcher}
theme={theme}
onToggleTheme={onToggleTheme}
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />} mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
/> />
@ -120,6 +115,8 @@ export function LayoutShell({
onUserSettings={onUserSettings} onUserSettings={onUserSettings}
onLogout={onLogout} onLogout={onLogout}
pageUsage={pageUsage} pageUsage={pageUsage}
theme={theme}
setTheme={setTheme}
/> />
<main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}> <main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
@ -166,16 +163,13 @@ export function LayoutShell({
onUserSettings={onUserSettings} onUserSettings={onUserSettings}
onLogout={onLogout} onLogout={onLogout}
pageUsage={pageUsage} pageUsage={pageUsage}
theme={theme}
setTheme={setTheme}
className="hidden md:flex border-r shrink-0" className="hidden md:flex border-r shrink-0"
/> />
<main className="flex-1 flex flex-col min-w-0"> <main className="flex-1 flex flex-col min-w-0">
<Header <Header breadcrumb={breadcrumb} />
breadcrumb={breadcrumb}
languageSwitcher={languageSwitcher}
theme={theme}
onToggleTheme={onToggleTheme}
/>
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}> <div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
{children} {children}

View file

@ -5,12 +5,12 @@ import { format } from "date-fns";
import { import {
ArchiveIcon, ArchiveIcon,
Loader2, Loader2,
Lock,
MessageCircleMore, MessageCircleMore,
MoreHorizontal, MoreHorizontal,
RotateCcwIcon, RotateCcwIcon,
Search, Search,
Trash2, Trash2,
User,
X, X,
} from "lucide-react"; } from "lucide-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
@ -239,7 +239,7 @@ export function AllPrivateChatsSidebar({
<div className="shrink-0 p-4 pb-2 space-y-3"> <div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Lock className="h-5 w-5 text-primary" /> <User className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2> <h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
</div> </div>
<Button <Button
@ -419,7 +419,7 @@ export function AllPrivateChatsSidebar({
</div> </div>
) : ( ) : (
<div className="text-center py-8"> <div className="text-center py-8">
<Lock className="h-12 w-12 mx-auto text-muted-foreground mb-3" /> <User className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{showArchived {showArchived
? t("no_archived_chats") || "No archived chats" ? t("no_archived_chats") || "No archived chats"

View file

@ -33,6 +33,8 @@ interface MobileSidebarProps {
onUserSettings?: () => void; onUserSettings?: () => void;
onLogout?: () => void; onLogout?: () => void;
pageUsage?: PageUsage; pageUsage?: PageUsage;
theme?: string;
setTheme?: (theme: "light" | "dark" | "system") => void;
} }
export function MobileSidebarTrigger({ onClick }: { onClick: () => void }) { export function MobileSidebarTrigger({ onClick }: { onClick: () => void }) {
@ -70,6 +72,8 @@ export function MobileSidebar({
onUserSettings, onUserSettings,
onLogout, onLogout,
pageUsage, pageUsage,
theme,
setTheme,
}: MobileSidebarProps) { }: MobileSidebarProps) {
const handleSearchSpaceSelect = (id: number) => { const handleSearchSpaceSelect = (id: number) => {
onSearchSpaceSelect(id); onSearchSpaceSelect(id);
@ -145,6 +149,8 @@ export function MobileSidebar({
onUserSettings={onUserSettings} onUserSettings={onUserSettings}
onLogout={onLogout} onLogout={onLogout}
pageUsage={pageUsage} pageUsage={pageUsage}
theme={theme}
setTheme={setTheme}
className="w-full border-none" className="w-full border-none"
/> />
</div> </div>

View file

@ -35,6 +35,8 @@ interface SidebarProps {
onUserSettings?: () => void; onUserSettings?: () => void;
onLogout?: () => void; onLogout?: () => void;
pageUsage?: PageUsage; pageUsage?: PageUsage;
theme?: string;
setTheme?: (theme: "light" | "dark" | "system") => void;
className?: string; className?: string;
} }
@ -58,6 +60,8 @@ export function Sidebar({
onUserSettings, onUserSettings,
onLogout, onLogout,
pageUsage, pageUsage,
theme,
setTheme,
className, className,
}: SidebarProps) { }: SidebarProps) {
const t = useTranslations("sidebar"); const t = useTranslations("sidebar");
@ -241,6 +245,8 @@ export function Sidebar({
onUserSettings={onUserSettings} onUserSettings={onUserSettings}
onLogout={onLogout} onLogout={onLogout}
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
theme={theme}
setTheme={setTheme}
/> />
</div> </div>
</div> </div>

View file

@ -1,24 +1,44 @@
"use client"; "use client";
import { ChevronUp, LogOut, Settings } from "lucide-react"; import { ChevronUp, Laptop, Languages, LogOut, Moon, Settings, Sun } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useLocaleContext } from "@/contexts/LocaleContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { User } from "../../types/layout.types"; import type { User } from "../../types/layout.types";
// Supported languages configuration
const LANGUAGES = [
{ code: "en" as const, name: "English", flag: "🇺🇸" },
{ code: "zh" as const, name: "简体中文", flag: "🇨🇳" },
];
// Supported themes configuration
const THEMES = [
{ value: "light" as const, name: "Light", icon: Sun },
{ value: "dark" as const, name: "Dark", icon: Moon },
{ value: "system" as const, name: "System", icon: Laptop },
];
interface SidebarUserProfileProps { interface SidebarUserProfileProps {
user: User; user: User;
onUserSettings?: () => void; onUserSettings?: () => void;
onLogout?: () => void; onLogout?: () => void;
isCollapsed?: boolean; isCollapsed?: boolean;
theme?: string;
setTheme?: (theme: "light" | "dark" | "system") => void;
} }
/** /**
@ -99,12 +119,23 @@ export function SidebarUserProfile({
onUserSettings, onUserSettings,
onLogout, onLogout,
isCollapsed = false, isCollapsed = false,
theme,
setTheme,
}: SidebarUserProfileProps) { }: SidebarUserProfileProps) {
const t = useTranslations("sidebar"); const t = useTranslations("sidebar");
const { locale, setLocale } = useLocaleContext();
const bgColor = stringToColor(user.email); const bgColor = stringToColor(user.email);
const initials = getInitials(user.email); const initials = getInitials(user.email);
const displayName = user.name || user.email.split("@")[0]; const displayName = user.name || user.email.split("@")[0];
const handleLanguageChange = (newLocale: "en" | "zh") => {
setLocale(newLocale);
};
const handleThemeChange = (newTheme: "light" | "dark" | "system") => {
setTheme?.(newTheme);
};
// Collapsed view - just show avatar with dropdown // Collapsed view - just show avatar with dropdown
if (isCollapsed) { if (isCollapsed) {
return ( return (
@ -118,7 +149,8 @@ export function SidebarUserProfile({
className={cn( className={cn(
"flex h-10 w-full items-center justify-center rounded-md", "flex h-10 w-full items-center justify-center rounded-md",
"hover:bg-accent transition-colors", "hover:bg-accent transition-colors",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" "focus:outline-none focus-visible:outline-none",
"data-[state=open]:bg-transparent"
)} )}
> >
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} /> <UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
@ -129,7 +161,7 @@ export function SidebarUserProfile({
<TooltipContent side="right">{displayName}</TooltipContent> <TooltipContent side="right">{displayName}</TooltipContent>
</Tooltip> </Tooltip>
<DropdownMenuContent className="w-56" side="right" align="end" sideOffset={8}> <DropdownMenuContent className="w-56" side="right" align="center" sideOffset={8}>
<DropdownMenuLabel className="font-normal"> <DropdownMenuLabel className="font-normal">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} /> <UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
@ -147,6 +179,65 @@ export function SidebarUserProfile({
{t("user_settings")} {t("user_settings")}
</DropdownMenuItem> </DropdownMenuItem>
{setTheme && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Sun className="mr-2 h-4 w-4" />
{t("theme")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="gap-1">
{THEMES.map((themeOption) => {
const Icon = themeOption.icon;
const isSelected = theme === themeOption.value;
return (
<DropdownMenuItem
key={themeOption.value}
onClick={() => handleThemeChange(themeOption.value)}
className={cn(
"mb-1 last:mb-0 transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80"
)}
>
<Icon className="mr-2 h-4 w-4" />
<span className="flex-1">{t(themeOption.value)}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
)}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Languages className="mr-2 h-4 w-4" />
{t("language")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="gap-1">
{LANGUAGES.map((language) => {
const isSelected = locale === language.code;
return (
<DropdownMenuItem
key={language.code}
onClick={() => handleLanguageChange(language.code)}
className={cn(
"mb-1 last:mb-0 transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80"
)}
>
<span className="mr-2">{language.flag}</span>
<span className="flex-1">{language.name}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout}> <DropdownMenuItem onClick={onLogout}>
@ -169,7 +260,8 @@ export function SidebarUserProfile({
className={cn( className={cn(
"flex w-full items-center gap-2 px-2 py-3 text-left", "flex w-full items-center gap-2 px-2 py-3 text-left",
"hover:bg-accent transition-colors", "hover:bg-accent transition-colors",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" "focus:outline-none focus-visible:outline-none",
"data-[state=open]:bg-transparent"
)} )}
> >
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} /> <UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
@ -185,7 +277,7 @@ export function SidebarUserProfile({
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-56" side="top" align="start" sideOffset={4}> <DropdownMenuContent className="w-56" side="top" align="center" sideOffset={4}>
<DropdownMenuLabel className="font-normal"> <DropdownMenuLabel className="font-normal">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} /> <UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
@ -203,6 +295,65 @@ export function SidebarUserProfile({
{t("user_settings")} {t("user_settings")}
</DropdownMenuItem> </DropdownMenuItem>
{setTheme && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Sun className="mr-2 h-4 w-4" />
{t("theme")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="gap-1">
{THEMES.map((themeOption) => {
const Icon = themeOption.icon;
const isSelected = theme === themeOption.value;
return (
<DropdownMenuItem
key={themeOption.value}
onClick={() => handleThemeChange(themeOption.value)}
className={cn(
"mb-1 last:mb-0 transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80"
)}
>
<Icon className="mr-2 h-4 w-4" />
<span className="flex-1">{t(themeOption.value)}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
)}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Languages className="mr-2 h-4 w-4" />
{t("language")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="gap-1">
{LANGUAGES.map((language) => {
const isSelected = locale === language.code;
return (
<DropdownMenuItem
key={language.code}
onClick={() => handleLanguageChange(language.code)}
className={cn(
"mb-1 last:mb-0 transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80"
)}
>
<span className="mr-2">{language.flag}</span>
<span className="flex-1">{language.name}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout}> <DropdownMenuItem onClick={onLogout}>

View file

@ -5,18 +5,14 @@ import type {
GlobalNewLLMConfig, GlobalNewLLMConfig,
NewLLMConfigPublic, NewLLMConfigPublic,
} from "@/contracts/types/new-llm-config.types"; } from "@/contracts/types/new-llm-config.types";
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
import { ChatShareButton } from "./chat-share-button";
import { ModelConfigSidebar } from "./model-config-sidebar"; import { ModelConfigSidebar } from "./model-config-sidebar";
import { ModelSelector } from "./model-selector"; import { ModelSelector } from "./model-selector";
interface ChatHeaderProps { interface ChatHeaderProps {
searchSpaceId: number; searchSpaceId: number;
thread?: ThreadRecord | null;
onThreadVisibilityChange?: (visibility: ChatVisibility) => void;
} }
export function ChatHeader({ searchSpaceId, thread, onThreadVisibilityChange }: ChatHeaderProps) { export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [selectedConfig, setSelectedConfig] = useState< const [selectedConfig, setSelectedConfig] = useState<
NewLLMConfigPublic | GlobalNewLLMConfig | null NewLLMConfigPublic | GlobalNewLLMConfig | null
@ -52,7 +48,6 @@ export function ChatHeader({ searchSpaceId, thread, onThreadVisibilityChange }:
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ModelSelector onEdit={handleEditConfig} onAddNew={handleAddNew} /> <ModelSelector onEdit={handleEditConfig} onAddNew={handleAddNew} />
<ChatShareButton thread={thread ?? null} onVisibilityChange={onThreadVisibilityChange} />
<ModelConfigSidebar <ModelConfigSidebar
open={sidebarOpen} open={sidebarOpen}
onOpenChange={handleSidebarClose} onOpenChange={handleSidebarClose}

View file

@ -1,11 +1,14 @@
"use client"; "use client";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Loader2, Lock, Users } from "lucide-react"; import { useAtomValue, useSetAtom } from "jotai";
import { Loader2, User, 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { import {
type ChatVisibility, type ChatVisibility,
type ThreadRecord, type ThreadRecord,
@ -23,13 +26,13 @@ const visibilityOptions: {
value: ChatVisibility; value: ChatVisibility;
label: string; label: string;
description: string; description: string;
icon: typeof Lock; icon: typeof User;
}[] = [ }[] = [
{ {
value: "PRIVATE", value: "PRIVATE",
label: "Private", label: "Private",
description: "Only you can access this chat", description: "Only you can access this chat",
icon: Lock, icon: User,
}, },
{ {
value: "SEARCH_SPACE", value: "SEARCH_SPACE",
@ -44,7 +47,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 +63,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 +81,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)
@ -83,38 +96,41 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
return null; return null;
} }
const CurrentIcon = currentVisibility === "PRIVATE" ? Lock : Users; const CurrentIcon = currentVisibility === "PRIVATE" ? User : Users;
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <Tooltip>
<Button <TooltipTrigger asChild>
variant="ghost" <PopoverTrigger asChild>
size="sm" <Button
className={cn( variant="outline"
"h-7 md:h-9 gap-1 md:gap-2 px-2 md:px-3 rounded-lg md:rounded-xl border border-border/80 bg-background/50 backdrop-blur-sm", size="icon"
"hover:bg-muted/80 hover:border-border/30 transition-all duration-200", className={cn(
"text-xs md:text-sm font-medium text-foreground", "h-8 w-8 md:w-auto md:px-3 md:gap-2 relative bg-muted hover:bg-muted/80 border-0",
"focus-visible:ring-0 focus-visible:ring-offset-0", className
className )}
)} >
> <CurrentIcon className="h-4 w-4" />
<CurrentIcon className="size-3.5 md:size-4 text-muted-foreground" /> <span className="hidden md:inline text-sm">
<span className="hidden md:inline"> {currentVisibility === "PRIVATE" ? "Private" : "Shared"}
{currentVisibility === "PRIVATE" ? "Private" : "Shared"} </span>
</span> </Button>
</Button> </PopoverTrigger>
</PopoverTrigger> </TooltipTrigger>
<TooltipContent>Share settings</TooltipContent>
</Tooltip>
<PopoverContent <PopoverContent
className="w-[280px] md:w-[320px] p-0 rounded-lg md:rounded-xl shadow-lg border-border/60" className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg border-border/60"
align="end" align="end"
sideOffset={8} sideOffset={8}
onCloseAutoFocus={(e) => e.preventDefault()}
> >
<div className="p-1.5 space-y-1"> <div className="p-1.5 space-y-1">
{/* Updating overlay */} {/* Updating overlay */}
{isUpdating && ( {isUpdating && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-xl"> <div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-lg">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
<span>Updating</span> <span>Updating</span>
@ -133,7 +149,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
onClick={() => handleVisibilityChange(option.value)} onClick={() => handleVisibilityChange(option.value)}
disabled={isUpdating} disabled={isUpdating}
className={cn( className={cn(
"w-full flex items-start gap-2.5 px-2.5 py-2 rounded-md transition-all", "w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
"hover:bg-accent/50 cursor-pointer", "hover:bg-accent/50 cursor-pointer",
"focus:outline-none", "focus:outline-none",
isSelected && "bg-accent/80" isSelected && "bg-accent/80"
@ -141,13 +157,13 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
> >
<div <div
className={cn( className={cn(
"mt-0.5 p-1.5 rounded-md shrink-0", "size-7 rounded-md shrink-0 grid place-items-center",
isSelected ? "bg-primary/10" : "bg-muted" isSelected ? "bg-primary/10" : "bg-muted"
)} )}
> >
<Icon <Icon
className={cn( className={cn(
"size-3.5", "size-4 block",
isSelected ? "text-primary" : "text-muted-foreground" isSelected ? "text-primary" : "text-muted-foreground"
)} )}
/> />
@ -157,11 +173,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
<span className={cn("text-sm font-medium", isSelected && "text-primary")}> <span className={cn("text-sm font-medium", isSelected && "text-primary")}>
{option.label} {option.label}
</span> </span>
{isSelected && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
Current
</span>
)}
</div> </div>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug"> <p className="text-xs text-muted-foreground mt-0.5 leading-snug">
{option.description} {option.description}

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;
} }

View file

@ -170,59 +170,55 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="ghost" variant="outline"
size="sm" size="sm"
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
className={cn( className={cn("h-8 gap-2 px-3 text-sm border-border/60", className)}
"h-7 md:h-9 gap-1 md:gap-2 px-2 md:px-3 rounded-lg md:rounded-xl border border-border/80 bg-background/50 backdrop-blur-sm",
"hover:bg-muted/80 hover:border-border/30 transition-all duration-200",
"text-xs md:text-sm font-medium text-foreground",
"focus-visible:ring-0 focus-visible:ring-offset-0",
className
)}
> >
{isLoading ? ( {isLoading ? (
<> <>
<Loader2 className="size-3.5 md:size-4 animate-spin text-muted-foreground" /> <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<span className="text-muted-foreground hidden md:inline">Loading...</span> <span className="text-muted-foreground hidden md:inline">Loading</span>
<span className="text-muted-foreground md:hidden">Load...</span>
</> </>
) : currentConfig ? ( ) : currentConfig ? (
<> <>
{getProviderIcon(currentConfig.provider)} {getProviderIcon(currentConfig.provider)}
<span className="max-w-[80px] md:max-w-[150px] truncate">{currentConfig.name}</span> <span className="max-w-[100px] md:max-w-[150px] truncate hidden md:inline">
<Badge {currentConfig.name}
variant="secondary" </span>
className="ml-0.5 md:ml-1 text-[9px] md:text-[10px] px-1 md:px-1.5 py-0 h-3.5 md:h-4 bg-muted/80" <Badge variant="secondary" className="ml-1 text-[10px] px-1.5 py-0 h-4 bg-muted/80">
>
{currentConfig.model_name.split("/").pop()?.slice(0, 10) || {currentConfig.model_name.split("/").pop()?.slice(0, 10) ||
currentConfig.model_name.slice(0, 10)} currentConfig.model_name.slice(0, 10)}
</Badge> </Badge>
</> </>
) : ( ) : (
<> <>
<Bot className="size-3.5 md:size-4 text-muted-foreground" /> <Bot className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground hidden md:inline">Select Model</span> <span className="text-muted-foreground hidden md:inline">Select Model</span>
<span className="text-muted-foreground md:hidden">Model</span>
</> </>
)} )}
<ChevronDown className="size-3 md:size-3.5 text-muted-foreground ml-1 shrink-0" /> <ChevronDown
className={cn(
"h-3.5 w-3.5 text-muted-foreground ml-1 shrink-0 transition-transform duration-200",
open && "rotate-180"
)}
/>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className="w-[280px] md:w-[360px] p-0 rounded-lg md:rounded-xl shadow-lg border-border/60" className="w-[280px] md:w-[360px] p-0 rounded-lg shadow-lg border-border/60"
align="start" align="start"
sideOffset={8} sideOffset={8}
> >
<Command <Command
shouldFilter={false} shouldFilter={false}
className="rounded-xl relative [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2" className="rounded-lg relative [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
> >
{/* Switching overlay */} {/* Switching overlay */}
{isSwitching && ( {isSwitching && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-xl"> <div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-lg">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
<span>Switching model...</span> <span>Switching model...</span>
@ -230,9 +226,9 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
</div> </div>
)} )}
<div className="flex items-center gap-1 md:gap-2 border-b border-border/30 px-2 md:px-3 py-1.5 md:py-2"> <div className="flex items-center gap-1 md:gap-2 px-2 md:px-3 py-1.5 md:py-2">
<CommandInput <CommandInput
placeholder="Search models..." placeholder="Search models"
value={searchQuery} value={searchQuery}
onValueChange={setSearchQuery} onValueChange={setSearchQuery}
className="h-7 md:h-8 text-xs md:text-sm border-0 bg-transparent focus:ring-0 placeholder:text-muted-foreground/60" className="h-7 md:h-8 text-xs md:text-sm border-0 bg-transparent focus:ring-0 placeholder:text-muted-foreground/60"
@ -243,7 +239,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
<CommandList className="max-h-[300px] md:max-h-[400px] overflow-y-auto"> <CommandList className="max-h-[300px] md:max-h-[400px] overflow-y-auto">
<CommandEmpty className="py-8 text-center"> <CommandEmpty className="py-8 text-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<Bot className="size-8 text-muted-foreground/40" /> <Bot className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No models found</p> <p className="text-sm text-muted-foreground">No models found</p>
<p className="text-xs text-muted-foreground/60">Try a different search term</p> <p className="text-xs text-muted-foreground/60">Try a different search term</p>
</div> </div>
@ -264,8 +260,8 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
value={`global-${config.id}`} value={`global-${config.id}`}
onSelect={() => handleSelectConfig(config)} onSelect={() => handleSelectConfig(config)}
className={cn( className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer", "mx-2 rounded-lg mb-1 cursor-pointer transition-all",
"aria-selected:bg-accent/50", "hover:bg-accent/50",
isSelected && "bg-accent/80" isSelected && "bg-accent/80"
)} )}
> >
@ -326,8 +322,8 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
value={`user-${config.id}`} value={`user-${config.id}`}
onSelect={() => handleSelectConfig(config)} onSelect={() => handleSelectConfig(config)}
className={cn( className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer", "mx-2 rounded-lg mb-1 cursor-pointer transition-all",
"aria-selected:bg-accent/50", "hover:bg-accent/50",
isSelected && "bg-accent/80" isSelected && "bg-accent/80"
)} )}
> >

View file

@ -4,7 +4,6 @@ import { useQuery } from "@tanstack/react-query";
import { import {
BookOpen, BookOpen,
ChevronDown, ChevronDown,
ChevronUp,
ExternalLink, ExternalLink,
FileText, FileText,
Hash, Hash,
@ -387,7 +386,7 @@ export function SourceDetailPanel({
<div className="absolute inset-0 rounded-full bg-primary/20 blur-xl" /> <div className="absolute inset-0 rounded-full bg-primary/20 blur-xl" />
<Loader2 className="h-12 w-12 animate-spin text-primary relative" /> <Loader2 className="h-12 w-12 animate-spin text-primary relative" />
</div> </div>
<p className="text-sm text-muted-foreground font-medium">Loading document...</p> <p className="text-sm text-muted-foreground font-medium">Loading document</p>
</motion.div> </motion.div>
</div> </div>
)} )}

View file

@ -3,27 +3,64 @@
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { Bell } from "lucide-react"; import { Bell } from "lucide-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms";
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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useNotifications } from "@/hooks/use-notifications"; import { useNotifications, type NotificationTypeEnum } from "@/hooks/use-notifications";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { NotificationPopup } from "./NotificationPopup"; import { NotificationPopup } from "./NotificationPopup";
const NOTIFICATION_FILTER_STORAGE_KEY = "surfsense_notification_filter";
export function NotificationButton() { export function NotificationButton() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { data: user } = useAtomValue(currentUserAtom); const { data: user } = useAtomValue(currentUserAtom);
const params = useParams(); const params = useParams();
// Filter state - null means show all, otherwise filter by type
const [activeFilter, setActiveFilter] = useState<NotificationTypeEnum | null>(null);
// Load filter from localStorage on mount
useEffect(() => {
try {
const stored = localStorage.getItem(NOTIFICATION_FILTER_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (
parsed === null ||
["new_mention", "connector_indexing", "document_processing"].includes(parsed)
) {
setActiveFilter(parsed);
}
}
} catch {
// Ignore localStorage errors
}
}, []);
// Handle filter toggle - clicking same pill again shows all
const handleFilterChange = useCallback((filter: NotificationTypeEnum | null) => {
setActiveFilter((current) => {
const newFilter = current === filter ? null : filter;
try {
localStorage.setItem(NOTIFICATION_FILTER_STORAGE_KEY, JSON.stringify(newFilter));
} catch {
// Ignore localStorage errors
}
return newFilter;
});
}, []);
const userId = user?.id ? String(user.id) : null; const userId = user?.id ? String(user.id) : null;
// Get searchSpaceId from URL params - the component is rendered within /dashboard/[search_space_id]/ // Get searchSpaceId from URL params - the component is rendered within /dashboard/[search_space_id]/
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null; const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
const { notifications, unreadCount, loading, markAsRead, markAllAsRead } = useNotifications( const { notifications, unreadCount, loading, markAsRead, markAllAsRead } = useNotifications(
userId, userId,
searchSpaceId searchSpaceId,
activeFilter
); );
return ( return (
@ -31,7 +68,7 @@ export function NotificationButton() {
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8 relative"> <Button variant="outline" size="icon" className="h-8 w-8 relative border-0">
<Bell className="h-4 w-4" /> <Bell className="h-4 w-4" />
{unreadCount > 0 && ( {unreadCount > 0 && (
<span <span
@ -57,6 +94,8 @@ export function NotificationButton() {
markAsRead={markAsRead} markAsRead={markAsRead}
markAllAsRead={markAllAsRead} markAllAsRead={markAllAsRead}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
activeFilter={activeFilter}
onFilterChange={handleFilterChange}
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>

View file

@ -1,15 +1,53 @@
"use client"; "use client";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { AlertCircle, Bell, CheckCheck, CheckCircle2, Loader2 } from "lucide-react"; import {
AlertCircle,
AtSign,
Bell,
Cable,
CheckCheck,
CheckCircle2,
FileText,
Loader2,
} from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item"; import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import type { Notification } from "@/hooks/use-notifications"; import type { Notification, NotificationTypeEnum } from "@/hooks/use-notifications";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/**
* Filter configuration for notification types
*/
const NOTIFICATION_FILTERS = {
new_mention: { label: "Mentions", icon: AtSign },
connector_indexing: { label: "Connectors", icon: Cable },
document_processing: { label: "Documents", icon: FileText },
} as const;
/**
* Get initials from name or email for avatar fallback
*/
function getInitials(name: string | null | undefined, email: string | null | undefined): string {
if (name) {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
if (email) {
const localPart = email.split("@")[0];
return localPart.slice(0, 2).toUpperCase();
}
return "U";
}
interface NotificationPopupProps { interface NotificationPopupProps {
notifications: Notification[]; notifications: Notification[];
unreadCount: number; unreadCount: number;
@ -17,6 +55,8 @@ interface NotificationPopupProps {
markAsRead: (id: number) => Promise<boolean>; markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>; markAllAsRead: () => Promise<boolean>;
onClose?: () => void; onClose?: () => void;
activeFilter: NotificationTypeEnum | null;
onFilterChange: (filter: NotificationTypeEnum | null) => void;
} }
export function NotificationPopup({ export function NotificationPopup({
@ -26,6 +66,8 @@ export function NotificationPopup({
markAsRead, markAsRead,
markAllAsRead, markAllAsRead,
onClose, onClose,
activeFilter,
onFilterChange,
}: NotificationPopupProps) { }: NotificationPopupProps) {
const router = useRouter(); const router = useRouter();
@ -66,6 +108,28 @@ export function NotificationPopup({
}; };
const getStatusIcon = (notification: Notification) => { const getStatusIcon = (notification: Notification) => {
// For mentions, show the author's avatar with initials fallback
if (notification.type === "new_mention") {
const metadata = notification.metadata as {
author_name?: string;
author_avatar_url?: string | null;
author_email?: string;
};
const authorName = metadata?.author_name;
const avatarUrl = metadata?.author_avatar_url;
const authorEmail = metadata?.author_email;
return (
<Avatar className="h-6 w-6">
{avatarUrl && <AvatarImage src={avatarUrl} alt={authorName || "User"} />}
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
{getInitials(authorName, authorEmail)}
</AvatarFallback>
</Avatar>
);
}
// For other notification types, show status icons
const status = notification.metadata?.status as string | undefined; const status = notification.metadata?.status as string | undefined;
switch (status) { switch (status) {
@ -83,7 +147,7 @@ export function NotificationPopup({
return ( return (
<div className="flex flex-col w-80 max-w-[calc(100vw-2rem)]"> <div className="flex flex-col w-80 max-w-[calc(100vw-2rem)]">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b"> <div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="font-semibold text-sm">Notifications</h3> <h3 className="font-semibold text-sm">Notifications</h3>
</div> </div>
@ -95,6 +159,35 @@ export function NotificationPopup({
)} )}
</div> </div>
{/* Filter Pills */}
<div className="flex items-center gap-1.5 px-4 py-2 overflow-x-auto">
{(
Object.entries(NOTIFICATION_FILTERS) as [
NotificationTypeEnum,
(typeof NOTIFICATION_FILTERS)[keyof typeof NOTIFICATION_FILTERS],
][]
).map(([key, { label, icon: Icon }]) => {
const isActive = activeFilter === key;
return (
<button
key={key}
type="button"
onClick={() => onFilterChange(key)}
className={cn(
"inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-medium transition-colors whitespace-nowrap",
"border focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
isActive
? "bg-primary text-primary-foreground border-primary"
: "bg-transparent text-muted-foreground border-border hover:bg-accent hover:text-accent-foreground"
)}
>
<Icon className="h-3 w-3" />
{label}
</button>
);
})}
</div>
{/* Notifications List */} {/* Notifications List */}
<ScrollArea className="h-[400px]"> <ScrollArea className="h-[400px]">
{loading ? ( {loading ? (

View file

@ -182,13 +182,13 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger" data-slot="dropdown-menu-sub-trigger"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8", "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRightIcon className="ml-auto size-4" /> <ChevronRightIcon className="ml-auto size-4 text-muted-foreground" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
); );
} }

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import * as SelectPrimitive from "@radix-ui/react-select"; import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import type * as React from "react"; import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -94,16 +94,11 @@ function SelectItem({
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", "focus:bg-accent/50 focus:text-accent-foreground hover:bg-accent/50 [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 px-2 text-sm outline-hidden select-none transition-all data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 data-[highlighted]:bg-accent/50",
className className
)} )}
{...props} {...props}
> >
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
); );

View file

@ -83,6 +83,8 @@ export const newMentionMetadata = z.object({
thread_title: z.string(), thread_title: z.string(),
author_id: z.string(), author_id: z.string(),
author_name: z.string(), author_name: z.string(),
author_avatar_url: z.string().nullable().optional(),
author_email: z.string().optional(),
content_preview: z.string(), content_preview: z.string(),
}); });

View file

@ -1,12 +1,12 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { Notification } from "@/contracts/types/notification.types"; import type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import type { SyncHandle } from "@/lib/electric/client"; import type { SyncHandle } from "@/lib/electric/client";
import { useElectricClient } from "@/lib/electric/context"; import { useElectricClient } from "@/lib/electric/context";
export type { Notification } from "@/contracts/types/notification.types"; export type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types";
/** /**
* Hook for managing notifications with Electric SQL real-time sync * Hook for managing notifications with Electric SQL real-time sync
@ -22,16 +22,23 @@ export type { Notification } from "@/contracts/types/notification.types";
* *
* @param userId - The user ID to fetch notifications for * @param userId - The user ID to fetch notifications for
* @param searchSpaceId - The search space ID to filter notifications (null shows global notifications only) * @param searchSpaceId - The search space ID to filter notifications (null shows global notifications only)
* @param typeFilter - Optional notification type to filter by (null shows all types)
*/ */
export function useNotifications(userId: string | null, searchSpaceId: number | null) { export function useNotifications(
userId: string | null,
searchSpaceId: number | null,
typeFilter: NotificationTypeEnum | null = null
) {
// Get Electric client from context - ElectricProvider handles initialization // Get Electric client from context - ElectricProvider handles initialization
const electricClient = useElectricClient(); const electricClient = useElectricClient();
const [notifications, setNotifications] = useState<Notification[]>([]); const [notifications, setNotifications] = useState<Notification[]>([]);
const [totalUnreadCount, setTotalUnreadCount] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const syncHandleRef = useRef<SyncHandle | null>(null); const syncHandleRef = useRef<SyncHandle | null>(null);
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
// Track user-level sync key to prevent duplicate sync subscriptions // Track user-level sync key to prevent duplicate sync subscriptions
const userSyncKeyRef = useRef<string | null>(null); const userSyncKeyRef = useRef<string | null>(null);
@ -108,7 +115,7 @@ export function useNotifications(userId: string | null, searchSpaceId: number |
}; };
}, [userId, electricClient]); }, [userId, electricClient]);
// EFFECT 2: Search-space-level query - updates when searchSpaceId changes // EFFECT 2: Search-space-level query - updates when searchSpaceId or typeFilter changes
// This runs independently of sync, allowing smooth transitions between search spaces // This runs independently of sync, allowing smooth transitions between search spaces
useEffect(() => { useEffect(() => {
if (!userId || !electricClient) { if (!userId || !electricClient) {
@ -125,16 +132,24 @@ export function useNotifications(userId: string | null, searchSpaceId: number |
} }
try { try {
console.log("[useNotifications] Updating query for searchSpace:", searchSpaceId); console.log(
"[useNotifications] Updating query for searchSpace:",
searchSpaceId,
"typeFilter:",
typeFilter
);
// Build query with optional type filter
const baseQuery = `SELECT * FROM notifications
WHERE user_id = $1
AND (search_space_id = $2 OR search_space_id IS NULL)`;
const typeClause = typeFilter ? ` AND type = $3` : "";
const orderClause = ` ORDER BY created_at DESC`;
const fullQuery = baseQuery + typeClause + orderClause;
const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
// Fetch notifications for current search space immediately // Fetch notifications for current search space immediately
const result = await electricClient.db.query<Notification>( const result = await electricClient.db.query<Notification>(fullQuery, params);
`SELECT * FROM notifications
WHERE user_id = $1
AND (search_space_id = $2 OR search_space_id IS NULL)
ORDER BY created_at DESC`,
[userId, searchSpaceId]
);
if (mounted) { if (mounted) {
setNotifications(result.rows || []); setNotifications(result.rows || []);
@ -145,13 +160,7 @@ export function useNotifications(userId: string | null, searchSpaceId: number |
const db = electricClient.db as any; const db = electricClient.db as any;
if (db.live?.query && typeof db.live.query === "function") { if (db.live?.query && typeof db.live.query === "function") {
const liveQuery = await db.live.query( const liveQuery = await db.live.query(fullQuery, params);
`SELECT * FROM notifications
WHERE user_id = $1
AND (search_space_id = $2 OR search_space_id IS NULL)
ORDER BY created_at DESC`,
[userId, searchSpaceId]
);
if (!mounted) { if (!mounted) {
liveQuery.unsubscribe?.(); liveQuery.unsubscribe?.();
@ -192,6 +201,86 @@ export function useNotifications(userId: string | null, searchSpaceId: number |
liveQueryRef.current = null; liveQueryRef.current = null;
} }
}; };
}, [userId, searchSpaceId, typeFilter, electricClient]);
// EFFECT 3: Total unread count - independent of type filter
// This ensures the badge count stays consistent regardless of active filter
useEffect(() => {
if (!userId || !electricClient) {
return;
}
let mounted = true;
async function updateUnreadCount() {
// Clean up previous live query
if (unreadCountLiveQueryRef.current) {
unreadCountLiveQueryRef.current.unsubscribe();
unreadCountLiveQueryRef.current = null;
}
try {
const countQuery = `SELECT COUNT(*) as count FROM notifications
WHERE user_id = $1
AND (search_space_id = $2 OR search_space_id IS NULL)
AND read = false`;
// Fetch initial count
const result = await electricClient.db.query<{ count: number }>(countQuery, [
userId,
searchSpaceId,
]);
if (mounted && result.rows?.[0]) {
setTotalUnreadCount(Number(result.rows[0].count) || 0);
}
// Set up live query for real-time updates
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = electricClient.db as any;
if (db.live?.query && typeof db.live.query === "function") {
const liveQuery = await db.live.query(countQuery, [userId, searchSpaceId]);
if (!mounted) {
liveQuery.unsubscribe?.();
return;
}
// Set initial results from live query
if (liveQuery.initialResults?.rows?.[0]) {
setTotalUnreadCount(Number(liveQuery.initialResults.rows[0].count) || 0);
} else if (liveQuery.rows?.[0]) {
setTotalUnreadCount(Number(liveQuery.rows[0].count) || 0);
}
// Subscribe to changes
if (typeof liveQuery.subscribe === "function") {
liveQuery.subscribe((result: { rows: { count: number }[] }) => {
if (mounted && result.rows?.[0]) {
setTotalUnreadCount(Number(result.rows[0].count) || 0);
}
});
}
if (typeof liveQuery.unsubscribe === "function") {
unreadCountLiveQueryRef.current = liveQuery;
}
}
} catch (err) {
console.error("[useNotifications] Failed to update unread count:", err);
}
}
updateUnreadCount();
return () => {
mounted = false;
if (unreadCountLiveQueryRef.current) {
unreadCountLiveQueryRef.current.unsubscribe();
unreadCountLiveQueryRef.current = null;
}
};
}, [userId, searchSpaceId, electricClient]); }, [userId, searchSpaceId, electricClient]);
// Mark notification as read via backend API // Mark notification as read via backend API
@ -234,12 +323,9 @@ export function useNotifications(userId: string | null, searchSpaceId: number |
} }
}, []); }, []);
// Get unread count
const unreadCount = notifications.filter((n) => !n.read).length;
return { return {
notifications, notifications,
unreadCount, unreadCount: totalUnreadCount,
markAsRead, markAsRead,
markAllAsRead, markAllAsRead,
loading, loading,

View file

@ -687,6 +687,11 @@
"expand_sidebar": "Expand sidebar", "expand_sidebar": "Expand sidebar",
"collapse_sidebar": "Collapse sidebar", "collapse_sidebar": "Collapse sidebar",
"user_settings": "User settings", "user_settings": "User settings",
"language": "Language",
"theme": "Theme",
"light": "Light",
"dark": "Dark",
"system": "System",
"logout": "Logout" "logout": "Logout"
}, },
"errors": { "errors": {

View file

@ -672,6 +672,11 @@
"expand_sidebar": "展开侧边栏", "expand_sidebar": "展开侧边栏",
"collapse_sidebar": "收起侧边栏", "collapse_sidebar": "收起侧边栏",
"user_settings": "用户设置", "user_settings": "用户设置",
"language": "语言",
"theme": "主题",
"light": "浅色",
"dark": "深色",
"system": "系统",
"logout": "退出登录" "logout": "退出登录"
}, },
"errors": { "errors": {

View file

@ -52,6 +52,9 @@ module.exports = {
}, },
}, },
borderRadius: { borderRadius: {
"3xl": "calc(var(--radius) + 12px)",
"2xl": "calc(var(--radius) + 8px)",
xl: "calc(var(--radius) + 4px)",
lg: "var(--radius)", lg: "var(--radius)",
md: "calc(var(--radius) - 2px)", md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)", sm: "calc(var(--radius) - 4px)",