mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-30 19:36:25 +02:00
Merge pull request #716 from AnishSarkar22/fix/chat-ui
fix: Simiplify UI for chat
This commit is contained in:
commit
4fa71d2cd6
37 changed files with 703 additions and 281 deletions
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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]}`;
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue