mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-24 21:38:09 +02:00
Merge remote-tracking branch 'upstream/dev' into fix/SUR-86
This commit is contained in:
commit
e646343453
49 changed files with 3441 additions and 78 deletions
|
|
@ -8,10 +8,11 @@ import {
|
|||
} from "@assistant-ui/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import {
|
||||
type MentionedDocumentInfo,
|
||||
mentionedDocumentIdsAtom,
|
||||
|
|
@ -251,6 +252,7 @@ export default function NewChatPage() {
|
|||
const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom);
|
||||
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
|
||||
const hydratePlanState = useSetAtom(hydratePlanStateAtom);
|
||||
const setCurrentThreadState = useSetAtom(currentThreadAtom);
|
||||
|
||||
// Get current user for author info in shared chats
|
||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||
|
|
@ -365,6 +367,48 @@ export default function NewChatPage() {
|
|||
initializeThread();
|
||||
}, [initializeThread]);
|
||||
|
||||
// Handle scroll to comment from URL query params (e.g., from notification click)
|
||||
const searchParams = useSearchParams();
|
||||
const targetCommentId = searchParams.get("commentId");
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetCommentId || isInitializing || messages.length === 0) return;
|
||||
|
||||
const tryScroll = () => {
|
||||
const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Try immediately
|
||||
if (tryScroll()) return;
|
||||
|
||||
// Retry every 200ms for up to 10 seconds
|
||||
const intervalId = setInterval(() => {
|
||||
if (tryScroll()) clearInterval(intervalId);
|
||||
}, 200);
|
||||
|
||||
const timeoutId = setTimeout(() => clearInterval(intervalId), 10000);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [targetCommentId, isInitializing, messages.length]);
|
||||
|
||||
// Sync current thread state to atom
|
||||
useEffect(() => {
|
||||
setCurrentThreadState({
|
||||
id: currentThread?.id ?? null,
|
||||
visibility: currentThread?.visibility ?? null,
|
||||
hasComments: currentThread?.has_comments ?? false,
|
||||
addingCommentToMessageId: null,
|
||||
});
|
||||
}, [currentThread, setCurrentThreadState]);
|
||||
|
||||
// Cancel ongoing request
|
||||
const cancelRun = useCallback(async () => {
|
||||
if (abortControllerRef.current) {
|
||||
|
|
@ -842,10 +886,32 @@ export default function NewChatPage() {
|
|||
// Persist assistant message (with thinking steps for restoration on refresh)
|
||||
const finalContent = buildContentForPersistence();
|
||||
if (contentParts.length > 0) {
|
||||
appendMessage(currentThreadId, {
|
||||
role: "assistant",
|
||||
content: finalContent,
|
||||
}).catch((err) => console.error("Failed to persist assistant message:", err));
|
||||
try {
|
||||
const savedMessage = await appendMessage(currentThreadId, {
|
||||
role: "assistant",
|
||||
content: finalContent,
|
||||
});
|
||||
|
||||
// Update message ID from temporary to database ID so comments work immediately
|
||||
const newMsgId = `msg-${savedMessage.id}`;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
|
||||
);
|
||||
|
||||
// Also update thinking steps map with new ID
|
||||
setMessageThinkingSteps((prev) => {
|
||||
const steps = prev.get(assistantMsgId);
|
||||
if (steps) {
|
||||
const newMap = new Map(prev);
|
||||
newMap.delete(assistantMsgId);
|
||||
newMap.set(newMsgId, steps);
|
||||
return newMap;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to persist assistant message:", err);
|
||||
}
|
||||
|
||||
// Track successful response
|
||||
trackChatResponseReceived(searchSpaceId, currentThreadId);
|
||||
|
|
@ -860,10 +926,20 @@ export default function NewChatPage() {
|
|||
);
|
||||
if (hasContent && currentThreadId) {
|
||||
const partialContent = buildContentForPersistence();
|
||||
appendMessage(currentThreadId, {
|
||||
role: "assistant",
|
||||
content: partialContent,
|
||||
}).catch((err) => console.error("Failed to persist partial assistant message:", err));
|
||||
try {
|
||||
const savedMessage = await appendMessage(currentThreadId, {
|
||||
role: "assistant",
|
||||
content: partialContent,
|
||||
});
|
||||
|
||||
// Update message ID from temporary to database ID
|
||||
const newMsgId = `msg-${savedMessage.id}`;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to persist partial assistant message:", err);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,5 +157,33 @@ button {
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Custom scrollbar styles */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--muted-foreground) / 0.2) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin:hover {
|
||||
scrollbar-color: hsl(var(--muted-foreground) / 0.4) transparent;
|
||||
}
|
||||
|
||||
/* Webkit scrollbar styles */
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--muted-foreground) / 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--muted-foreground) / 0.4);
|
||||
}
|
||||
|
||||
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}';
|
||||
@source '../node_modules/streamdown/dist/*.js';
|
||||
|
|
|
|||
72
surfsense_web/atoms/chat-comments/comments-mutation.atoms.ts
Normal file
72
surfsense_web/atoms/chat-comments/comments-mutation.atoms.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { atomWithMutation } from "jotai-tanstack-query";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
CreateCommentRequest,
|
||||
CreateReplyRequest,
|
||||
DeleteCommentRequest,
|
||||
UpdateCommentRequest,
|
||||
} from "@/contracts/types/chat-comments.types";
|
||||
import { chatCommentsApiService } from "@/lib/apis/chat-comments-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
|
||||
export const createCommentMutationAtom = atomWithMutation(() => ({
|
||||
mutationFn: async (request: CreateCommentRequest) => {
|
||||
return chatCommentsApiService.createComment(request);
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.comments.byMessage(variables.message_id),
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Error creating comment:", error);
|
||||
toast.error("Failed to create comment");
|
||||
},
|
||||
}));
|
||||
|
||||
export const createReplyMutationAtom = atomWithMutation(() => ({
|
||||
mutationFn: async (request: CreateReplyRequest & { message_id: number }) => {
|
||||
return chatCommentsApiService.createReply(request);
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.comments.byMessage(variables.message_id),
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Error creating reply:", error);
|
||||
toast.error("Failed to create reply");
|
||||
},
|
||||
}));
|
||||
|
||||
export const updateCommentMutationAtom = atomWithMutation(() => ({
|
||||
mutationFn: async (request: UpdateCommentRequest & { message_id: number }) => {
|
||||
return chatCommentsApiService.updateComment(request);
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.comments.byMessage(variables.message_id),
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Error updating comment:", error);
|
||||
toast.error("Failed to update comment");
|
||||
},
|
||||
}));
|
||||
|
||||
export const deleteCommentMutationAtom = atomWithMutation(() => ({
|
||||
mutationFn: async (request: DeleteCommentRequest & { message_id: number }) => {
|
||||
return chatCommentsApiService.deleteComment(request);
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.comments.byMessage(variables.message_id),
|
||||
});
|
||||
toast.success("Comment deleted");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Error deleting comment:", error);
|
||||
toast.error("Failed to delete comment");
|
||||
},
|
||||
}));
|
||||
52
surfsense_web/atoms/chat/current-thread.atom.ts
Normal file
52
surfsense_web/atoms/chat/current-thread.atom.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { atom } from "jotai";
|
||||
import type { ChatVisibility } from "@/lib/chat/thread-persistence";
|
||||
|
||||
// TODO: Update `hasComments` to true when the first comment is created on a thread.
|
||||
// Currently it only updates on thread load. The gutter still works because
|
||||
// `addingCommentToMessageId` keeps it open, but the state is technically stale.
|
||||
|
||||
// TODO: Reset `addingCommentToMessageId` to null after a comment is successfully created.
|
||||
// Currently it stays set until navigation or clicking another message's bubble.
|
||||
// Not causing issues since panel visibility is driven by per-message comment count.
|
||||
|
||||
// TODO: Consider calling `resetCurrentThreadAtom` when unmounting the chat page
|
||||
// for explicit cleanup, though React navigation handles this implicitly.
|
||||
|
||||
interface CurrentThreadState {
|
||||
id: number | null;
|
||||
visibility: ChatVisibility | null;
|
||||
hasComments: boolean;
|
||||
addingCommentToMessageId: number | null;
|
||||
}
|
||||
|
||||
const initialState: CurrentThreadState = {
|
||||
id: null,
|
||||
visibility: null,
|
||||
hasComments: false,
|
||||
addingCommentToMessageId: null,
|
||||
};
|
||||
|
||||
export const currentThreadAtom = atom<CurrentThreadState>(initialState);
|
||||
|
||||
export const commentsEnabledAtom = atom(
|
||||
(get) => get(currentThreadAtom).visibility === "SEARCH_SPACE"
|
||||
);
|
||||
|
||||
export const showCommentsGutterAtom = atom((get) => {
|
||||
const thread = get(currentThreadAtom);
|
||||
return (
|
||||
thread.visibility === "SEARCH_SPACE" &&
|
||||
(thread.hasComments || thread.addingCommentToMessageId !== null)
|
||||
);
|
||||
});
|
||||
|
||||
export const addingCommentToMessageIdAtom = atom(
|
||||
(get) => get(currentThreadAtom).addingCommentToMessageId,
|
||||
(get, set, messageId: number | null) => {
|
||||
set(currentThreadAtom, { ...get(currentThreadAtom), addingCommentToMessageId: messageId });
|
||||
}
|
||||
);
|
||||
|
||||
export const resetCurrentThreadAtom = atom(null, (_, set) => {
|
||||
set(currentThreadAtom, initialState);
|
||||
});
|
||||
|
|
@ -5,9 +5,15 @@ import {
|
|||
MessagePrimitive,
|
||||
useAssistantState,
|
||||
} from "@assistant-ui/react";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { CheckIcon, CopyIcon, DownloadIcon, RefreshCwIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useContext } from "react";
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
addingCommentToMessageIdAtom,
|
||||
commentsEnabledAtom,
|
||||
} from "@/atoms/chat/current-thread.atom";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { BranchPicker } from "@/components/assistant-ui/branch-picker";
|
||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||
import {
|
||||
|
|
@ -16,6 +22,9 @@ import {
|
|||
} from "@/components/assistant-ui/thinking-steps";
|
||||
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
|
||||
import { CommentTrigger } from "@/components/chat-comments/comment-trigger/comment-trigger";
|
||||
import { useComments } from "@/hooks/use-comments";
|
||||
|
||||
export const MessageError: FC = () => {
|
||||
return (
|
||||
|
|
@ -76,13 +85,89 @@ const AssistantMessageInner: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
function parseMessageId(assistantUiMessageId: string | undefined): number | null {
|
||||
if (!assistantUiMessageId) return null;
|
||||
const match = assistantUiMessageId.match(/^msg-(\d+)$/);
|
||||
return match ? Number.parseInt(match[1], 10) : null;
|
||||
}
|
||||
|
||||
export const AssistantMessage: FC = () => {
|
||||
const [messageHeight, setMessageHeight] = useState<number | undefined>(undefined);
|
||||
const messageRef = useRef<HTMLDivElement>(null);
|
||||
const messageId = useAssistantState(({ message }) => message?.id);
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const dbMessageId = parseMessageId(messageId);
|
||||
const commentsEnabled = useAtomValue(commentsEnabledAtom);
|
||||
const [addingCommentToMessageId, setAddingCommentToMessageId] = useAtom(
|
||||
addingCommentToMessageIdAtom
|
||||
);
|
||||
|
||||
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
||||
const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false);
|
||||
const isMessageStreaming = isThreadRunning && isLastMessage;
|
||||
|
||||
const { data: commentsData } = useComments({
|
||||
messageId: dbMessageId ?? 0,
|
||||
enabled: !!dbMessageId,
|
||||
});
|
||||
|
||||
const hasComments = (commentsData?.total_count ?? 0) > 0;
|
||||
const isAddingComment = dbMessageId !== null && addingCommentToMessageId === dbMessageId;
|
||||
const showCommentPanel = hasComments || isAddingComment;
|
||||
|
||||
const handleToggleAddComment = () => {
|
||||
if (!dbMessageId) return;
|
||||
setAddingCommentToMessageId(isAddingComment ? null : dbMessageId);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!messageRef.current) return;
|
||||
const el = messageRef.current;
|
||||
const update = () => setMessageHeight(el.offsetHeight);
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
|
||||
ref={messageRef}
|
||||
className="aui-assistant-message-root group fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
|
||||
data-role="assistant"
|
||||
>
|
||||
<AssistantMessageInner />
|
||||
|
||||
{searchSpaceId && commentsEnabled && !isMessageStreaming && (
|
||||
<div className="absolute left-full top-0 ml-4 hidden lg:block w-72">
|
||||
<div
|
||||
className={`sticky top-3 ${showCommentPanel ? "opacity-100" : "opacity-0 group-hover:opacity-100"} transition-opacity`}
|
||||
>
|
||||
{!hasComments && (
|
||||
<CommentTrigger
|
||||
commentCount={0}
|
||||
isOpen={isAddingComment}
|
||||
onClick={handleToggleAddComment}
|
||||
disabled={!dbMessageId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCommentPanel && dbMessageId && (
|
||||
<div
|
||||
className={
|
||||
hasComments ? "" : "mt-2 animate-in fade-in slide-in-from-top-2 duration-200"
|
||||
}
|
||||
>
|
||||
<CommentPanelContainer
|
||||
messageId={dbMessageId}
|
||||
isOpen={true}
|
||||
maxHeight={messageHeight}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
import { useParams } from "next/navigation";
|
||||
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import {
|
||||
mentionedDocumentIdsAtom,
|
||||
mentionedDocumentsAtom,
|
||||
|
|
@ -36,6 +37,7 @@ import {
|
|||
newLLMConfigsAtom,
|
||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
|
||||
import { ComposerAddAttachment, ComposerAttachments } from "@/components/assistant-ui/attachment";
|
||||
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
||||
import {
|
||||
|
|
@ -59,57 +61,63 @@ import { Button } from "@/components/ui/button";
|
|||
import type { Document } from "@/contracts/types/document.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Props for the Thread component
|
||||
*/
|
||||
interface ThreadProps {
|
||||
messageThinkingSteps?: Map<string, ThinkingStep[]>;
|
||||
/** Optional header component to render at the top of the viewport (sticky) */
|
||||
header?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map(), header }) => {
|
||||
return (
|
||||
<ThinkingStepsContext.Provider value={messageThinkingSteps}>
|
||||
<ThreadPrimitive.Root
|
||||
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-background"
|
||||
style={{
|
||||
["--thread-max-width" as string]: "44rem",
|
||||
}}
|
||||
>
|
||||
<ThreadPrimitive.Viewport
|
||||
turnAnchor="top"
|
||||
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4"
|
||||
>
|
||||
{/* Optional sticky header for model selector etc. */}
|
||||
{header && <div className="sticky top-0 z-10 mb-4">{header}</div>}
|
||||
|
||||
<AssistantIf condition={({ thread }) => thread.isEmpty}>
|
||||
<ThreadWelcome />
|
||||
</AssistantIf>
|
||||
|
||||
<ThreadPrimitive.Messages
|
||||
components={{
|
||||
UserMessage,
|
||||
EditComposer,
|
||||
AssistantMessage,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 z-20 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4 md:pb-6">
|
||||
<ThreadScrollToBottom />
|
||||
<AssistantIf condition={({ thread }) => !thread.isEmpty}>
|
||||
<div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both">
|
||||
<Composer />
|
||||
</div>
|
||||
</AssistantIf>
|
||||
</ThreadPrimitive.ViewportFooter>
|
||||
</ThreadPrimitive.Viewport>
|
||||
</ThreadPrimitive.Root>
|
||||
<ThreadContent header={header} />
|
||||
</ThinkingStepsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
|
||||
const showGutter = useAtomValue(showCommentsGutterAtom);
|
||||
|
||||
return (
|
||||
<ThreadPrimitive.Root
|
||||
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-background"
|
||||
style={{
|
||||
["--thread-max-width" as string]: "44rem",
|
||||
}}
|
||||
>
|
||||
<ThreadPrimitive.Viewport
|
||||
turnAnchor="top"
|
||||
className={cn(
|
||||
"aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4 transition-[padding] duration-300 ease-out",
|
||||
showGutter && "lg:pr-30"
|
||||
)}
|
||||
>
|
||||
{header && <div className="sticky top-0 z-10 mb-4">{header}</div>}
|
||||
|
||||
<AssistantIf condition={({ thread }) => thread.isEmpty}>
|
||||
<ThreadWelcome />
|
||||
</AssistantIf>
|
||||
|
||||
<ThreadPrimitive.Messages
|
||||
components={{
|
||||
UserMessage,
|
||||
EditComposer,
|
||||
AssistantMessage,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4 md:pb-6">
|
||||
<ThreadScrollToBottom />
|
||||
<AssistantIf condition={({ thread }) => !thread.isEmpty}>
|
||||
<div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both">
|
||||
<Composer />
|
||||
</div>
|
||||
</AssistantIf>
|
||||
</ThreadPrimitive.ViewportFooter>
|
||||
</ThreadPrimitive.Viewport>
|
||||
</ThreadPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadScrollToBottom: FC = () => {
|
||||
return (
|
||||
<ThreadPrimitive.ScrollToBottom asChild>
|
||||
|
|
@ -579,17 +587,6 @@ const AssistantMessageInner: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const AssistantMessage: FC = () => {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
|
||||
data-role="assistant"
|
||||
>
|
||||
<AssistantMessageInner />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const AssistantActionBar: FC = () => {
|
||||
return (
|
||||
<ActionBarPrimitive.Root
|
||||
|
|
|
|||
|
|
@ -0,0 +1,303 @@
|
|||
"use client";
|
||||
|
||||
import { Send, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MemberMentionPicker } from "../member-mention-picker/member-mention-picker";
|
||||
import type { MemberOption } from "../member-mention-picker/types";
|
||||
import type { CommentComposerProps, InsertedMention, MentionState } from "./types";
|
||||
|
||||
function convertDisplayToData(displayContent: string, mentions: InsertedMention[]): string {
|
||||
let result = displayContent;
|
||||
|
||||
const sortedMentions = [...mentions].sort((a, b) => b.displayName.length - a.displayName.length);
|
||||
|
||||
for (const mention of sortedMentions) {
|
||||
const displayPattern = new RegExp(
|
||||
`@${escapeRegExp(mention.displayName)}(?=\\s|$|[.,!?;:])`,
|
||||
"g"
|
||||
);
|
||||
const dataFormat = `@[${mention.id}]`;
|
||||
result = result.replace(displayPattern, dataFormat);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function escapeRegExp(string: string): string {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function findMentionTrigger(
|
||||
text: string,
|
||||
cursorPos: number,
|
||||
insertedMentions: InsertedMention[]
|
||||
): { isActive: boolean; query: string; startIndex: number } {
|
||||
const textBeforeCursor = text.slice(0, cursorPos);
|
||||
|
||||
const mentionMatch = textBeforeCursor.match(/(?:^|[\s])@([^\s]*)$/);
|
||||
|
||||
if (!mentionMatch) {
|
||||
return { isActive: false, query: "", startIndex: 0 };
|
||||
}
|
||||
|
||||
const fullMatch = mentionMatch[0];
|
||||
const query = mentionMatch[1];
|
||||
const atIndex = cursorPos - query.length - 1;
|
||||
|
||||
if (atIndex > 0) {
|
||||
const charBefore = text[atIndex - 1];
|
||||
if (charBefore && !/[\s]/.test(charBefore)) {
|
||||
return { isActive: false, query: "", startIndex: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
const textFromAt = text.slice(atIndex);
|
||||
|
||||
for (const mention of insertedMentions) {
|
||||
const mentionPattern = `@${mention.displayName}`;
|
||||
|
||||
if (textFromAt.startsWith(mentionPattern)) {
|
||||
const charAfterMention = text[atIndex + mentionPattern.length];
|
||||
if (!charAfterMention || /[\s.,!?;:]/.test(charAfterMention)) {
|
||||
if (cursorPos <= atIndex + mentionPattern.length) {
|
||||
return { isActive: false, query: "", startIndex: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (query.length > 50) {
|
||||
return { isActive: false, query: "", startIndex: 0 };
|
||||
}
|
||||
|
||||
return { isActive: true, query, startIndex: atIndex };
|
||||
}
|
||||
|
||||
export function CommentComposer({
|
||||
members,
|
||||
membersLoading = false,
|
||||
placeholder = "Write a comment...",
|
||||
submitLabel = "Send",
|
||||
isSubmitting = false,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
autoFocus = false,
|
||||
initialValue = "",
|
||||
}: CommentComposerProps) {
|
||||
const [displayContent, setDisplayContent] = useState(initialValue);
|
||||
const [insertedMentions, setInsertedMentions] = useState<InsertedMention[]>([]);
|
||||
const [mentionsInitialized, setMentionsInitialized] = useState(false);
|
||||
const [mentionState, setMentionState] = useState<MentionState>({
|
||||
isActive: false,
|
||||
query: "",
|
||||
startIndex: 0,
|
||||
});
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const filteredMembers = mentionState.query
|
||||
? members.filter(
|
||||
(member) =>
|
||||
member.displayName?.toLowerCase().includes(mentionState.query.toLowerCase()) ||
|
||||
member.email.toLowerCase().includes(mentionState.query.toLowerCase())
|
||||
)
|
||||
: members;
|
||||
|
||||
const closeMentionPicker = useCallback(() => {
|
||||
setMentionState({ isActive: false, query: "", startIndex: 0 });
|
||||
setHighlightedIndex(0);
|
||||
}, []);
|
||||
|
||||
const insertMention = useCallback(
|
||||
(member: MemberOption) => {
|
||||
const displayName = member.displayName || member.email.split("@")[0];
|
||||
const before = displayContent.slice(0, mentionState.startIndex);
|
||||
const cursorPos = textareaRef.current?.selectionStart ?? displayContent.length;
|
||||
const after = displayContent.slice(cursorPos);
|
||||
const mentionText = `@${displayName} `;
|
||||
const newContent = before + mentionText + after;
|
||||
|
||||
setDisplayContent(newContent);
|
||||
setInsertedMentions((prev) => {
|
||||
const exists = prev.some((m) => m.id === member.id && m.displayName === displayName);
|
||||
if (exists) return prev;
|
||||
return [...prev, { id: member.id, displayName }];
|
||||
});
|
||||
closeMentionPicker();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaRef.current) {
|
||||
const cursorPos = before.length + mentionText.length;
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.setSelectionRange(cursorPos, cursorPos);
|
||||
}
|
||||
});
|
||||
},
|
||||
[displayContent, mentionState.startIndex, closeMentionPicker]
|
||||
);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
const cursorPos = e.target.selectionStart;
|
||||
setDisplayContent(value);
|
||||
|
||||
const triggerResult = findMentionTrigger(value, cursorPos, insertedMentions);
|
||||
|
||||
if (triggerResult.isActive) {
|
||||
setMentionState(triggerResult);
|
||||
setHighlightedIndex(0);
|
||||
} else if (mentionState.isActive) {
|
||||
closeMentionPicker();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!mentionState.isActive) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
case "Tab":
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) => (prev < filteredMembers.length - 1 ? prev + 1 : 0));
|
||||
} else if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filteredMembers.length - 1));
|
||||
}
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filteredMembers.length - 1));
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (filteredMembers[highlightedIndex]) {
|
||||
insertMention(filteredMembers[highlightedIndex]);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
closeMentionPicker();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = displayContent.trim();
|
||||
if (!trimmed || isSubmitting) return;
|
||||
|
||||
const dataContent = convertDisplayToData(trimmed, insertedMentions);
|
||||
onSubmit(dataContent);
|
||||
setDisplayContent("");
|
||||
setInsertedMentions([]);
|
||||
};
|
||||
|
||||
// Pre-populate insertedMentions from initialValue when members are loaded
|
||||
useEffect(() => {
|
||||
if (mentionsInitialized || !initialValue || members.length === 0) return;
|
||||
|
||||
const mentionPattern = /@([^\s@]+(?:\s+[^\s@]+)*?)(?=\s|$|[.,!?;:]|@)/g;
|
||||
const foundMentions: InsertedMention[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = mentionPattern.exec(initialValue)) !== null) {
|
||||
const displayName = match[1];
|
||||
const member = members.find(
|
||||
(m) => m.displayName === displayName || m.email.split("@")[0] === displayName
|
||||
);
|
||||
if (member) {
|
||||
const exists = foundMentions.some((m) => m.id === member.id);
|
||||
if (!exists) {
|
||||
foundMentions.push({ id: member.id, displayName });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundMentions.length > 0) {
|
||||
setInsertedMentions(foundMentions);
|
||||
}
|
||||
setMentionsInitialized(true);
|
||||
}, [initialValue, members, mentionsInitialized]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, [autoFocus]);
|
||||
|
||||
const canSubmit = displayContent.trim().length > 0 && !isSubmitting;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Popover
|
||||
open={mentionState.isActive}
|
||||
onOpenChange={(open) => !open && closeMentionPicker()}
|
||||
modal={false}
|
||||
>
|
||||
<PopoverAnchor asChild>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={displayContent}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="min-h-[80px] resize-none"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
collisionPadding={8}
|
||||
className="w-72 p-0"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<MemberMentionPicker
|
||||
members={members}
|
||||
query={mentionState.query}
|
||||
highlightedIndex={highlightedIndex}
|
||||
isLoading={membersLoading}
|
||||
onSelect={insertMention}
|
||||
onHighlightChange={setHighlightedIndex}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<X className="mr-1 size-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className={cn(!canSubmit && "opacity-50")}
|
||||
>
|
||||
<Send className="mr-1 size-4" />
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import type { MemberOption } from "../member-mention-picker/types";
|
||||
|
||||
export interface CommentComposerProps {
|
||||
members: MemberOption[];
|
||||
membersLoading?: boolean;
|
||||
placeholder?: string;
|
||||
submitLabel?: string;
|
||||
isSubmitting?: boolean;
|
||||
onSubmit: (content: string) => void;
|
||||
onCancel?: () => void;
|
||||
autoFocus?: boolean;
|
||||
initialValue?: string;
|
||||
}
|
||||
|
||||
export interface MentionState {
|
||||
isActive: boolean;
|
||||
query: string;
|
||||
startIndex: number;
|
||||
}
|
||||
|
||||
export interface InsertedMention {
|
||||
id: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { CommentActionsProps } from "./types";
|
||||
|
||||
export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: CommentActionsProps) {
|
||||
if (!canEdit && !canDelete) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canEdit && (
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem onClick={onDelete} className="text-destructive">
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CommentComposer } from "../comment-composer/comment-composer";
|
||||
import { CommentActions } from "./comment-actions";
|
||||
import type { CommentItemProps } from "./types";
|
||||
|
||||
function getInitials(name: string | null, email: string): string {
|
||||
if (name) {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
return email[0].toUpperCase();
|
||||
}
|
||||
|
||||
function formatTimestamp(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
const timeStr = date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
|
||||
if (diffMins < 1) {
|
||||
return "Just now";
|
||||
}
|
||||
|
||||
if (diffMins < 60) {
|
||||
return `${diffMins}m ago`;
|
||||
}
|
||||
|
||||
if (diffHours < 24 && date.getDate() === now.getDate()) {
|
||||
return `Today at ${timeStr}`;
|
||||
}
|
||||
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (date.getDate() === yesterday.getDate() && diffDays < 2) {
|
||||
return `Yesterday at ${timeStr}`;
|
||||
}
|
||||
|
||||
if (diffDays < 7) {
|
||||
const dayName = date.toLocaleDateString("en-US", { weekday: "long" });
|
||||
return `${dayName} at ${timeStr}`;
|
||||
}
|
||||
|
||||
return (
|
||||
date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
}) + ` at ${timeStr}`
|
||||
);
|
||||
}
|
||||
|
||||
export function convertRenderedToDisplay(contentRendered: string): string {
|
||||
// Convert @{DisplayName} format to @DisplayName for editing
|
||||
return contentRendered.replace(/@\{([^}]+)\}/g, "@$1");
|
||||
}
|
||||
|
||||
function renderMentions(content: string): React.ReactNode {
|
||||
// Match @{DisplayName} format from backend
|
||||
const mentionPattern = /@\{([^}]+)\}/g;
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = mentionPattern.exec(content)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(content.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
// Display as @DisplayName (without curly braces)
|
||||
parts.push(
|
||||
<span key={match.index} className="rounded bg-primary/10 px-1 font-medium text-primary">
|
||||
@{match[1]}
|
||||
</span>
|
||||
);
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < content.length) {
|
||||
parts.push(content.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : content;
|
||||
}
|
||||
|
||||
export function CommentItem({
|
||||
comment,
|
||||
onEdit,
|
||||
onEditSubmit,
|
||||
onEditCancel,
|
||||
onDelete,
|
||||
onReply,
|
||||
isReply = false,
|
||||
isEditing = false,
|
||||
isSubmitting = false,
|
||||
members = [],
|
||||
membersLoading = false,
|
||||
}: CommentItemProps) {
|
||||
const [{ data: currentUser }] = useAtom(currentUserAtom);
|
||||
|
||||
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 handleEditSubmit = (content: string) => {
|
||||
onEditSubmit?.(comment.id, content);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("group flex gap-3")} data-comment-id={comment.id}>
|
||||
<Avatar className="size-8 shrink-0">
|
||||
{comment.author?.avatarUrl && (
|
||||
<AvatarImage src={comment.author.avatarUrl} alt={displayName} />
|
||||
)}
|
||||
<AvatarFallback className="text-xs">
|
||||
{getInitials(comment.author?.displayName ?? null, email || "U")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{displayName}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatTimestamp(comment.createdAt)}
|
||||
</span>
|
||||
{comment.isEdited && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">(edited)</span>
|
||||
)}
|
||||
{!isEditing && (
|
||||
<div className="ml-auto">
|
||||
<CommentActions
|
||||
canEdit={comment.canEdit}
|
||||
canDelete={comment.canDelete}
|
||||
onEdit={() => onEdit?.(comment.id)}
|
||||
onDelete={() => onDelete?.(comment.id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="mt-1">
|
||||
<CommentComposer
|
||||
members={members}
|
||||
membersLoading={membersLoading}
|
||||
placeholder="Edit your comment..."
|
||||
submitLabel="Save"
|
||||
isSubmitting={isSubmitting}
|
||||
onSubmit={handleEditSubmit}
|
||||
onCancel={onEditCancel}
|
||||
initialValue={convertRenderedToDisplay(comment.contentRendered)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 text-sm text-foreground whitespace-pre-wrap wrap-break-word">
|
||||
{renderMentions(comment.contentRendered)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isReply && onReply && !isEditing && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-1 h-7 w-fit px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onReply(comment.id)}
|
||||
>
|
||||
<MessageSquare className="mr-1 size-3" />
|
||||
Reply
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
surfsense_web/components/chat-comments/comment-item/types.ts
Normal file
44
surfsense_web/components/chat-comments/comment-item/types.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
export interface CommentAuthor {
|
||||
id: string;
|
||||
displayName: string | null;
|
||||
email: string;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface CommentData {
|
||||
id: number;
|
||||
content: string;
|
||||
contentRendered: string;
|
||||
author: CommentAuthor | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isEdited: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
export interface CommentItemProps {
|
||||
comment: CommentData;
|
||||
onEdit?: (commentId: number) => void;
|
||||
onEditSubmit?: (commentId: number, content: string) => void;
|
||||
onEditCancel?: () => void;
|
||||
onDelete?: (commentId: number) => void;
|
||||
onReply?: (commentId: number) => void;
|
||||
isReply?: boolean;
|
||||
isEditing?: boolean;
|
||||
isSubmitting?: boolean;
|
||||
members?: Array<{
|
||||
id: string;
|
||||
displayName: string | null;
|
||||
email: string;
|
||||
avatarUrl?: string | null;
|
||||
}>;
|
||||
membersLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface CommentActionsProps {
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
createCommentMutationAtom,
|
||||
createReplyMutationAtom,
|
||||
deleteCommentMutationAtom,
|
||||
updateCommentMutationAtom,
|
||||
} from "@/atoms/chat-comments/comments-mutation.atoms";
|
||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { useComments } from "@/hooks/use-comments";
|
||||
import { CommentPanel } from "../comment-panel/comment-panel";
|
||||
import type { CommentPanelContainerProps } from "./types";
|
||||
import { transformComment, transformMember } from "./utils";
|
||||
|
||||
export function CommentPanelContainer({
|
||||
messageId,
|
||||
isOpen,
|
||||
maxHeight,
|
||||
}: CommentPanelContainerProps) {
|
||||
const { data: commentsData, isLoading: isCommentsLoading } = useComments({
|
||||
messageId,
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const [{ data: membersData, isLoading: isMembersLoading }] = useAtom(membersAtom);
|
||||
const [{ data: currentUser }] = useAtom(currentUserAtom);
|
||||
|
||||
const [{ mutate: createComment, isPending: isCreating }] = useAtom(createCommentMutationAtom);
|
||||
const [{ mutate: createReply, isPending: isCreatingReply }] = useAtom(createReplyMutationAtom);
|
||||
const [{ mutate: updateComment, isPending: isUpdating }] = useAtom(updateCommentMutationAtom);
|
||||
const [{ mutate: deleteComment, isPending: isDeleting }] = useAtom(deleteCommentMutationAtom);
|
||||
|
||||
const commentThreads = useMemo(() => {
|
||||
if (!commentsData?.comments) return [];
|
||||
return commentsData.comments.map(transformComment);
|
||||
}, [commentsData]);
|
||||
|
||||
const members = useMemo(() => {
|
||||
if (!membersData) return [];
|
||||
const allMembers = membersData.map(transformMember);
|
||||
// Filter out current user from mention picker
|
||||
if (currentUser?.id) {
|
||||
return allMembers.filter((member) => member.id !== currentUser.id);
|
||||
}
|
||||
return allMembers;
|
||||
}, [membersData, currentUser?.id]);
|
||||
|
||||
const isSubmitting = isCreating || isCreatingReply || isUpdating || isDeleting;
|
||||
|
||||
const handleCreateComment = (content: string) => {
|
||||
createComment({ message_id: messageId, content });
|
||||
};
|
||||
|
||||
const handleCreateReply = (commentId: number, content: string) => {
|
||||
createReply({ comment_id: commentId, content, message_id: messageId });
|
||||
};
|
||||
|
||||
const handleEditComment = (commentId: number, content: string) => {
|
||||
updateComment({ comment_id: commentId, content, message_id: messageId });
|
||||
};
|
||||
|
||||
const handleDeleteComment = (commentId: number) => {
|
||||
deleteComment({ comment_id: commentId, message_id: messageId });
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<CommentPanel
|
||||
threads={commentThreads}
|
||||
members={members}
|
||||
membersLoading={isMembersLoading}
|
||||
isLoading={isCommentsLoading}
|
||||
onCreateComment={handleCreateComment}
|
||||
onCreateReply={handleCreateReply}
|
||||
onEditComment={handleEditComment}
|
||||
onDeleteComment={handleDeleteComment}
|
||||
isSubmitting={isSubmitting}
|
||||
maxHeight={maxHeight}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export interface CommentPanelContainerProps {
|
||||
messageId: number;
|
||||
isOpen: boolean;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import type { Comment, CommentReply } from "@/contracts/types/chat-comments.types";
|
||||
import type { Membership } from "@/contracts/types/members.types";
|
||||
import type { CommentData } from "../comment-item/types";
|
||||
import type { CommentThreadData } from "../comment-thread/types";
|
||||
import type { MemberOption } from "../member-mention-picker/types";
|
||||
|
||||
export function transformAuthor(author: Comment["author"]): CommentData["author"] {
|
||||
if (!author) return null;
|
||||
return {
|
||||
id: author.id,
|
||||
displayName: author.display_name,
|
||||
email: author.email,
|
||||
avatarUrl: author.avatar_url,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformReply(reply: CommentReply): CommentData {
|
||||
return {
|
||||
id: reply.id,
|
||||
content: reply.content,
|
||||
contentRendered: reply.content_rendered,
|
||||
author: transformAuthor(reply.author),
|
||||
createdAt: reply.created_at,
|
||||
updatedAt: reply.updated_at,
|
||||
isEdited: reply.is_edited,
|
||||
canEdit: reply.can_edit,
|
||||
canDelete: reply.can_delete,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformComment(comment: Comment): CommentThreadData {
|
||||
return {
|
||||
id: comment.id,
|
||||
messageId: comment.message_id,
|
||||
content: comment.content,
|
||||
contentRendered: comment.content_rendered,
|
||||
author: transformAuthor(comment.author),
|
||||
createdAt: comment.created_at,
|
||||
updatedAt: comment.updated_at,
|
||||
isEdited: comment.is_edited,
|
||||
canEdit: comment.can_edit,
|
||||
canDelete: comment.can_delete,
|
||||
replyCount: comment.reply_count,
|
||||
replies: comment.replies.map(transformReply),
|
||||
};
|
||||
}
|
||||
|
||||
export function transformMember(membership: Membership): MemberOption {
|
||||
return {
|
||||
id: membership.user_id,
|
||||
displayName: membership.user_display_name ?? null,
|
||||
email: membership.user_email ?? "",
|
||||
avatarUrl: membership.user_avatar_url ?? null,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
"use client";
|
||||
|
||||
import { MessageSquarePlus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CommentComposer } from "../comment-composer/comment-composer";
|
||||
import { CommentThread } from "../comment-thread/comment-thread";
|
||||
import type { CommentPanelProps } from "./types";
|
||||
|
||||
export function CommentPanel({
|
||||
threads,
|
||||
members,
|
||||
membersLoading = false,
|
||||
isLoading = false,
|
||||
onCreateComment,
|
||||
onCreateReply,
|
||||
onEditComment,
|
||||
onDeleteComment,
|
||||
isSubmitting = false,
|
||||
maxHeight,
|
||||
}: CommentPanelProps) {
|
||||
const [isComposerOpen, setIsComposerOpen] = useState(false);
|
||||
|
||||
const handleCommentSubmit = (content: string) => {
|
||||
onCreateComment(content);
|
||||
setIsComposerOpen(false);
|
||||
};
|
||||
|
||||
const handleComposerCancel = () => {
|
||||
setIsComposerOpen(false);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[120px] w-96 items-center justify-center rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="size-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Loading comments...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasThreads = threads.length > 0;
|
||||
const showEmptyState = !hasThreads && !isComposerOpen;
|
||||
|
||||
// Ensure minimum usable height for empty state + composer button
|
||||
const minHeight = 180;
|
||||
const effectiveMaxHeight = maxHeight ? Math.max(maxHeight, minHeight) : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-85 flex-col rounded-lg border bg-card"
|
||||
style={effectiveMaxHeight ? { maxHeight: effectiveMaxHeight } : undefined}
|
||||
>
|
||||
{hasThreads && (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
|
||||
<div className="space-y-4 p-4">
|
||||
{threads.map((thread) => (
|
||||
<CommentThread
|
||||
key={thread.id}
|
||||
thread={thread}
|
||||
members={members}
|
||||
membersLoading={membersLoading}
|
||||
onCreateReply={onCreateReply}
|
||||
onEditComment={onEditComment}
|
||||
onDeleteComment={onDeleteComment}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEmptyState && (
|
||||
<div className="flex min-h-[120px] flex-col items-center justify-center gap-2 p-4 text-center">
|
||||
<MessageSquarePlus className="size-8 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">No comments yet</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
Start a conversation about this response
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={showEmptyState ? "border-t p-3" : "p-3"}>
|
||||
{isComposerOpen ? (
|
||||
<CommentComposer
|
||||
members={members}
|
||||
membersLoading={membersLoading}
|
||||
placeholder="Write a comment..."
|
||||
submitLabel="Comment"
|
||||
isSubmitting={isSubmitting}
|
||||
onSubmit={handleCommentSubmit}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import type { CommentThreadData } from "../comment-thread/types";
|
||||
import type { MemberOption } from "../member-mention-picker/types";
|
||||
|
||||
export interface CommentPanelProps {
|
||||
threads: CommentThreadData[];
|
||||
members: MemberOption[];
|
||||
membersLoading?: boolean;
|
||||
isLoading?: boolean;
|
||||
onCreateComment: (content: string) => void;
|
||||
onCreateReply: (commentId: number, content: string) => void;
|
||||
onEditComment: (commentId: number, content: string) => void;
|
||||
onDeleteComment: (commentId: number) => void;
|
||||
isSubmitting?: boolean;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight, MessageSquare } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CommentComposer } from "../comment-composer/comment-composer";
|
||||
import { CommentItem } from "../comment-item/comment-item";
|
||||
import type { CommentThreadProps } from "./types";
|
||||
|
||||
export function CommentThread({
|
||||
thread,
|
||||
members,
|
||||
membersLoading = false,
|
||||
onCreateReply,
|
||||
onEditComment,
|
||||
onDeleteComment,
|
||||
isSubmitting = false,
|
||||
}: CommentThreadProps) {
|
||||
const [isRepliesExpanded, setIsRepliesExpanded] = useState(true);
|
||||
const [isReplyComposerOpen, setIsReplyComposerOpen] = useState(false);
|
||||
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
|
||||
|
||||
const parentComment = {
|
||||
id: thread.id,
|
||||
content: thread.content,
|
||||
contentRendered: thread.contentRendered,
|
||||
author: thread.author,
|
||||
createdAt: thread.createdAt,
|
||||
updatedAt: thread.updatedAt,
|
||||
isEdited: thread.isEdited,
|
||||
canEdit: thread.canEdit,
|
||||
canDelete: thread.canDelete,
|
||||
};
|
||||
|
||||
const handleReply = () => {
|
||||
setIsReplyComposerOpen(true);
|
||||
setIsRepliesExpanded(true);
|
||||
};
|
||||
|
||||
const handleReplySubmit = (content: string) => {
|
||||
onCreateReply(thread.id, content);
|
||||
setIsReplyComposerOpen(false);
|
||||
};
|
||||
|
||||
const handleReplyCancel = () => {
|
||||
setIsReplyComposerOpen(false);
|
||||
};
|
||||
|
||||
const handleEditStart = (commentId: number) => {
|
||||
setEditingCommentId(commentId);
|
||||
};
|
||||
|
||||
const handleEditSubmit = (commentId: number, content: string) => {
|
||||
onEditComment(commentId, content);
|
||||
setEditingCommentId(null);
|
||||
};
|
||||
|
||||
const handleEditCancel = () => {
|
||||
setEditingCommentId(null);
|
||||
};
|
||||
|
||||
const hasReplies = thread.replies.length > 0;
|
||||
const showReplies = thread.replies.length === 1 || isRepliesExpanded;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Parent comment */}
|
||||
<CommentItem
|
||||
comment={parentComment}
|
||||
onEdit={handleEditStart}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onEditCancel={handleEditCancel}
|
||||
onDelete={onDeleteComment}
|
||||
isEditing={editingCommentId === parentComment.id}
|
||||
isSubmitting={isSubmitting}
|
||||
members={members}
|
||||
membersLoading={membersLoading}
|
||||
/>
|
||||
|
||||
{/* Replies and actions - using flex layout with connector */}
|
||||
{(hasReplies || isReplyComposerOpen) && (
|
||||
<div className="flex">
|
||||
{/* Connector column - vertical line */}
|
||||
<div className="flex w-7 flex-col items-center">
|
||||
<div className="w-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
{/* Content column */}
|
||||
<div className="min-w-0 flex-1 space-y-2 pb-1">
|
||||
{/* Expand/collapse for multiple replies */}
|
||||
{thread.replies.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setIsRepliesExpanded(!isRepliesExpanded)}
|
||||
>
|
||||
{isRepliesExpanded ? (
|
||||
<ChevronDown className="mr-1 size-3" />
|
||||
) : (
|
||||
<ChevronRight className="mr-1 size-3" />
|
||||
)}
|
||||
{thread.replies.length} replies
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Reply items */}
|
||||
{showReplies && hasReplies && (
|
||||
<div className="space-y-3 pt-2">
|
||||
{thread.replies.map((reply) => (
|
||||
<CommentItem
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
isReply
|
||||
onEdit={handleEditStart}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onEditCancel={handleEditCancel}
|
||||
onDelete={onDeleteComment}
|
||||
isEditing={editingCommentId === reply.id}
|
||||
isSubmitting={isSubmitting}
|
||||
members={members}
|
||||
membersLoading={membersLoading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reply composer or button */}
|
||||
|
||||
{isReplyComposerOpen ? (
|
||||
<>
|
||||
<div className="pt-3">
|
||||
<CommentComposer
|
||||
members={members}
|
||||
membersLoading={membersLoading}
|
||||
placeholder="Write a reply..."
|
||||
submitLabel="Reply"
|
||||
isSubmitting={isSubmitting}
|
||||
onSubmit={handleReplySubmit}
|
||||
onCancel={handleReplyCancel}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleReply}>
|
||||
<MessageSquare className="mr-1.5 size-3" />
|
||||
Reply
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reply button when no replies yet */}
|
||||
{!hasReplies && !isReplyComposerOpen && (
|
||||
<div className="ml-7 mt-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleReply}>
|
||||
<MessageSquare className="mr-1.5 size-3" />
|
||||
Reply
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import type { CommentData } from "../comment-item/types";
|
||||
import type { MemberOption } from "../member-mention-picker/types";
|
||||
|
||||
export interface CommentThreadData {
|
||||
id: number;
|
||||
messageId: number;
|
||||
content: string;
|
||||
contentRendered: string;
|
||||
author: CommentData["author"];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isEdited: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
replyCount: number;
|
||||
replies: CommentData[];
|
||||
}
|
||||
|
||||
export interface CommentThreadProps {
|
||||
thread: CommentThreadData;
|
||||
members: MemberOption[];
|
||||
membersLoading?: boolean;
|
||||
onCreateReply: (commentId: number, content: string) => void;
|
||||
onEditComment: (commentId: number, content: string) => void;
|
||||
onDeleteComment: (commentId: number) => void;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CommentTriggerProps } from "./types";
|
||||
|
||||
export function CommentTrigger({ commentCount, isOpen, onClick, disabled }: CommentTriggerProps) {
|
||||
const hasComments = commentCount > 0;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={hasComments ? "outline" : isOpen ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"relative size-10 rounded-full transition-all duration-200",
|
||||
hasComments
|
||||
? "border-primary/50 bg-primary/5 text-primary hover:bg-primary/10 hover:border-primary"
|
||||
: isOpen
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
!hasComments && !isOpen && "opacity-0 group-hover:opacity-100",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<MessageSquare className={cn("size-5", (hasComments || isOpen) && "fill-current")} />
|
||||
{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">
|
||||
{commentCount > 9 ? "9+" : commentCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export interface CommentTriggerProps {
|
||||
commentCount: number;
|
||||
isOpen: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MemberMentionItemProps } from "./types";
|
||||
|
||||
function getInitials(name: string | null, email: string): string {
|
||||
if (name) {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
return email[0]?.toUpperCase() ?? "?";
|
||||
}
|
||||
|
||||
export function MemberMentionItem({
|
||||
member,
|
||||
isHighlighted,
|
||||
onSelect,
|
||||
onMouseEnter,
|
||||
}: MemberMentionItemProps) {
|
||||
const displayName = member.displayName || member.email.split("@")[0];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-3 py-2 text-left transition-colors",
|
||||
isHighlighted ? "bg-accent" : "hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => onSelect(member)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
>
|
||||
<Avatar className="size-7">
|
||||
{member.avatarUrl && <AvatarImage src={member.avatarUrl} alt={displayName} />}
|
||||
<AvatarFallback className="text-xs">
|
||||
{getInitials(member.displayName, member.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm font-medium">{displayName}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{member.email}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { MemberMentionItem } from "./member-mention-item";
|
||||
import type { MemberMentionPickerProps } from "./types";
|
||||
|
||||
export function MemberMentionPicker({
|
||||
members,
|
||||
query,
|
||||
highlightedIndex,
|
||||
isLoading = false,
|
||||
onSelect,
|
||||
onHighlightChange,
|
||||
}: MemberMentionPickerProps) {
|
||||
const filteredMembers = query
|
||||
? members.filter(
|
||||
(member) =>
|
||||
member.displayName?.toLowerCase().includes(query.toLowerCase()) ||
|
||||
member.email.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
: members;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredMembers.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
{query ? "No members found" : "No members available"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="max-h-64">
|
||||
<div className="py-1">
|
||||
{filteredMembers.map((member, index) => (
|
||||
<MemberMentionItem
|
||||
key={member.id}
|
||||
member={member}
|
||||
isHighlighted={index === highlightedIndex}
|
||||
onSelect={onSelect}
|
||||
onMouseEnter={() => onHighlightChange(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
export interface MemberOption {
|
||||
id: string;
|
||||
displayName: string | null;
|
||||
email: string;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface MemberMentionPickerProps {
|
||||
members: MemberOption[];
|
||||
query: string;
|
||||
highlightedIndex: number;
|
||||
isLoading?: boolean;
|
||||
onSelect: (member: MemberOption) => void;
|
||||
onHighlightChange: (index: number) => void;
|
||||
}
|
||||
|
||||
export interface MemberMentionItemProps {
|
||||
member: MemberOption;
|
||||
isHighlighted: boolean;
|
||||
onSelect: (member: MemberOption) => void;
|
||||
onMouseEnter: () => void;
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Bell } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
|
@ -12,6 +13,7 @@ import { cn } from "@/lib/utils";
|
|||
import { useParams } from "next/navigation";
|
||||
|
||||
export function NotificationButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: user } = useAtomValue(currentUserAtom);
|
||||
const params = useParams();
|
||||
|
||||
|
|
@ -25,7 +27,7 @@ export function NotificationButton() {
|
|||
);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
|
|
@ -54,6 +56,7 @@ export function NotificationButton() {
|
|||
loading={loading}
|
||||
markAsRead={markAsRead}
|
||||
markAllAsRead={markAllAsRead}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { Bell, CheckCheck, Loader2, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import type { Notification } from "@/hooks/use-notifications";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
||||
|
||||
interface NotificationPopupProps {
|
||||
notifications: Notification[];
|
||||
|
|
@ -14,6 +16,7 @@ interface NotificationPopupProps {
|
|||
loading: boolean;
|
||||
markAsRead: (id: number) => Promise<boolean>;
|
||||
markAllAsRead: () => Promise<boolean>;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function NotificationPopup({
|
||||
|
|
@ -22,15 +25,38 @@ export function NotificationPopup({
|
|||
loading,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
onClose,
|
||||
}: NotificationPopupProps) {
|
||||
const handleMarkAsRead = async (id: number) => {
|
||||
await markAsRead(id);
|
||||
};
|
||||
const router = useRouter();
|
||||
|
||||
const handleMarkAllAsRead = async () => {
|
||||
await markAllAsRead();
|
||||
};
|
||||
|
||||
const handleNotificationClick = async (notification: Notification) => {
|
||||
if (!notification.read) {
|
||||
await markAsRead(notification.id);
|
||||
}
|
||||
|
||||
if (notification.type === "new_mention") {
|
||||
const metadata = notification.metadata as {
|
||||
thread_id?: number;
|
||||
comment_id?: number;
|
||||
};
|
||||
const searchSpaceId = notification.search_space_id;
|
||||
const threadId = metadata?.thread_id;
|
||||
const commentId = metadata?.comment_id;
|
||||
|
||||
if (searchSpaceId && threadId) {
|
||||
const url = commentId
|
||||
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
|
||||
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
|
||||
onClose?.();
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
try {
|
||||
return formatDistanceToNow(new Date(dateString), { addSuffix: true });
|
||||
|
|
@ -86,7 +112,7 @@ export function NotificationPopup({
|
|||
<div key={notification.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !notification.read && handleMarkAsRead(notification.id)}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
className={cn(
|
||||
"w-full px-4 py-3 text-left hover:bg-accent transition-colors",
|
||||
!notification.read && "bg-accent/50"
|
||||
|
|
@ -106,7 +132,7 @@ export function NotificationPopup({
|
|||
</p>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground break-all line-clamp-2">
|
||||
{notification.message}
|
||||
{convertRenderedToDisplay(notification.message)}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
|
|
|
|||
142
surfsense_web/contracts/types/chat-comments.types.ts
Normal file
142
surfsense_web/contracts/types/chat-comments.types.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const author = z.object({
|
||||
id: z.string().uuid(),
|
||||
display_name: z.string().nullable(),
|
||||
avatar_url: z.string().nullable(),
|
||||
email: z.string(),
|
||||
});
|
||||
|
||||
export const commentReply = z.object({
|
||||
id: z.number(),
|
||||
content: z.string(),
|
||||
content_rendered: z.string(),
|
||||
author: author.nullable(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
is_edited: z.boolean(),
|
||||
can_edit: z.boolean(),
|
||||
can_delete: z.boolean(),
|
||||
});
|
||||
|
||||
export const comment = z.object({
|
||||
id: z.number(),
|
||||
message_id: z.number(),
|
||||
content: z.string(),
|
||||
content_rendered: z.string(),
|
||||
author: author.nullable(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
is_edited: z.boolean(),
|
||||
can_edit: z.boolean(),
|
||||
can_delete: z.boolean(),
|
||||
reply_count: z.number(),
|
||||
replies: z.array(commentReply),
|
||||
});
|
||||
|
||||
export const mentionContext = z.object({
|
||||
thread_id: z.number(),
|
||||
thread_title: z.string(),
|
||||
message_id: z.number(),
|
||||
search_space_id: z.number(),
|
||||
search_space_name: z.string(),
|
||||
});
|
||||
|
||||
export const mentionComment = z.object({
|
||||
id: z.number(),
|
||||
content_preview: z.string(),
|
||||
author: author.nullable(),
|
||||
created_at: z.string(),
|
||||
});
|
||||
|
||||
export const mention = z.object({
|
||||
id: z.number(),
|
||||
created_at: z.string(),
|
||||
comment: mentionComment,
|
||||
context: mentionContext,
|
||||
});
|
||||
|
||||
/**
|
||||
* Get comments for a message
|
||||
*/
|
||||
export const getCommentsRequest = z.object({
|
||||
message_id: z.number(),
|
||||
});
|
||||
|
||||
export const getCommentsResponse = z.object({
|
||||
comments: z.array(comment),
|
||||
total_count: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create comment
|
||||
*/
|
||||
export const createCommentRequest = z.object({
|
||||
message_id: z.number(),
|
||||
content: z.string().min(1).max(5000),
|
||||
});
|
||||
|
||||
export const createCommentResponse = comment;
|
||||
|
||||
/**
|
||||
* Create reply
|
||||
*/
|
||||
export const createReplyRequest = z.object({
|
||||
comment_id: z.number(),
|
||||
content: z.string().min(1).max(5000),
|
||||
});
|
||||
|
||||
export const createReplyResponse = commentReply;
|
||||
|
||||
/**
|
||||
* Update comment
|
||||
*/
|
||||
export const updateCommentRequest = z.object({
|
||||
comment_id: z.number(),
|
||||
content: z.string().min(1).max(5000),
|
||||
});
|
||||
|
||||
export const updateCommentResponse = commentReply;
|
||||
|
||||
/**
|
||||
* Delete comment
|
||||
*/
|
||||
export const deleteCommentRequest = z.object({
|
||||
comment_id: z.number(),
|
||||
});
|
||||
|
||||
export const deleteCommentResponse = z.object({
|
||||
message: z.string(),
|
||||
comment_id: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Get mentions
|
||||
*/
|
||||
export const getMentionsRequest = z.object({
|
||||
search_space_id: z.number().optional(),
|
||||
});
|
||||
|
||||
export const getMentionsResponse = z.object({
|
||||
mentions: z.array(mention),
|
||||
total_count: z.number(),
|
||||
});
|
||||
|
||||
export type Author = z.infer<typeof author>;
|
||||
export type CommentReply = z.infer<typeof commentReply>;
|
||||
export type Comment = z.infer<typeof comment>;
|
||||
export type MentionContext = z.infer<typeof mentionContext>;
|
||||
export type MentionComment = z.infer<typeof mentionComment>;
|
||||
export type Mention = z.infer<typeof mention>;
|
||||
export type GetCommentsRequest = z.infer<typeof getCommentsRequest>;
|
||||
export type GetCommentsResponse = z.infer<typeof getCommentsResponse>;
|
||||
export type CreateCommentRequest = z.infer<typeof createCommentRequest>;
|
||||
export type CreateCommentResponse = z.infer<typeof createCommentResponse>;
|
||||
export type CreateReplyRequest = z.infer<typeof createReplyRequest>;
|
||||
export type CreateReplyResponse = z.infer<typeof createReplyResponse>;
|
||||
export type UpdateCommentRequest = z.infer<typeof updateCommentRequest>;
|
||||
export type UpdateCommentResponse = z.infer<typeof updateCommentResponse>;
|
||||
export type DeleteCommentRequest = z.infer<typeof deleteCommentRequest>;
|
||||
export type DeleteCommentResponse = z.infer<typeof deleteCommentResponse>;
|
||||
export type GetMentionsRequest = z.infer<typeof getMentionsRequest>;
|
||||
export type GetMentionsResponse = z.infer<typeof getMentionsResponse>;
|
||||
|
|
@ -11,6 +11,8 @@ export const membership = z.object({
|
|||
created_at: z.string(),
|
||||
role: role.nullable().optional(),
|
||||
user_email: z.string().nullable().optional(),
|
||||
user_display_name: z.string().nullable().optional(),
|
||||
user_avatar_url: z.string().nullable().optional(),
|
||||
user_is_active: z.boolean().nullable().optional(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { documentTypeEnum } from "./document.types";
|
|||
/**
|
||||
* Notification type enum - matches backend notification types
|
||||
*/
|
||||
export const notificationTypeEnum = z.enum(["connector_indexing", "document_processing"]);
|
||||
export const notificationTypeEnum = z.enum(["connector_indexing", "document_processing", "new_mention"]);
|
||||
|
||||
/**
|
||||
* Notification status enum - used in metadata
|
||||
|
|
@ -68,6 +68,20 @@ export const documentProcessingMetadata = baseNotificationMetadata.extend({
|
|||
error_message: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* New mention metadata schema
|
||||
*/
|
||||
export const newMentionMetadata = z.object({
|
||||
mention_id: z.number(),
|
||||
comment_id: z.number(),
|
||||
message_id: z.number(),
|
||||
thread_id: z.number(),
|
||||
thread_title: z.string(),
|
||||
author_id: z.string(),
|
||||
author_name: z.string(),
|
||||
content_preview: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Union of all notification metadata types
|
||||
* Use this when the notification type is unknown
|
||||
|
|
@ -75,6 +89,7 @@ export const documentProcessingMetadata = baseNotificationMetadata.extend({
|
|||
export const notificationMetadata = z.union([
|
||||
connectorIndexingMetadata,
|
||||
documentProcessingMetadata,
|
||||
newMentionMetadata,
|
||||
baseNotificationMetadata,
|
||||
]);
|
||||
|
||||
|
|
@ -107,6 +122,11 @@ export const documentProcessingNotification = notification.extend({
|
|||
metadata: documentProcessingMetadata,
|
||||
});
|
||||
|
||||
export const newMentionNotification = notification.extend({
|
||||
type: z.literal("new_mention"),
|
||||
metadata: newMentionMetadata,
|
||||
});
|
||||
|
||||
// Inferred types
|
||||
export type NotificationTypeEnum = z.infer<typeof notificationTypeEnum>;
|
||||
export type NotificationStatusEnum = z.infer<typeof notificationStatusEnum>;
|
||||
|
|
@ -114,7 +134,9 @@ export type DocumentProcessingStageEnum = z.infer<typeof documentProcessingStage
|
|||
export type BaseNotificationMetadata = z.infer<typeof baseNotificationMetadata>;
|
||||
export type ConnectorIndexingMetadata = z.infer<typeof connectorIndexingMetadata>;
|
||||
export type DocumentProcessingMetadata = z.infer<typeof documentProcessingMetadata>;
|
||||
export type NewMentionMetadata = z.infer<typeof newMentionMetadata>;
|
||||
export type NotificationMetadata = z.infer<typeof notificationMetadata>;
|
||||
export type Notification = z.infer<typeof notification>;
|
||||
export type ConnectorIndexingNotification = z.infer<typeof connectorIndexingNotification>;
|
||||
export type DocumentProcessingNotification = z.infer<typeof documentProcessingNotification>;
|
||||
export type NewMentionNotification = z.infer<typeof newMentionNotification>;
|
||||
|
|
|
|||
18
surfsense_web/hooks/use-comments.ts
Normal file
18
surfsense_web/hooks/use-comments.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { chatCommentsApiService } from "@/lib/apis/chat-comments-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
interface UseCommentsOptions {
|
||||
messageId: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useComments({ messageId, enabled = true }: UseCommentsOptions) {
|
||||
return useQuery({
|
||||
queryKey: cacheKeys.comments.byMessage(messageId),
|
||||
queryFn: async () => {
|
||||
return chatCommentsApiService.getComments({ message_id: messageId });
|
||||
},
|
||||
enabled: enabled && !!messageId,
|
||||
});
|
||||
}
|
||||
134
surfsense_web/lib/apis/chat-comments-api.service.ts
Normal file
134
surfsense_web/lib/apis/chat-comments-api.service.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import {
|
||||
type CreateCommentRequest,
|
||||
type CreateReplyRequest,
|
||||
createCommentRequest,
|
||||
createCommentResponse,
|
||||
createReplyRequest,
|
||||
createReplyResponse,
|
||||
type DeleteCommentRequest,
|
||||
deleteCommentRequest,
|
||||
deleteCommentResponse,
|
||||
type GetCommentsRequest,
|
||||
type GetMentionsRequest,
|
||||
getCommentsRequest,
|
||||
getCommentsResponse,
|
||||
getMentionsRequest,
|
||||
getMentionsResponse,
|
||||
type UpdateCommentRequest,
|
||||
updateCommentRequest,
|
||||
updateCommentResponse,
|
||||
} from "@/contracts/types/chat-comments.types";
|
||||
import { ValidationError } from "@/lib/error";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
class ChatCommentsApiService {
|
||||
/**
|
||||
* Get comments for a message
|
||||
*/
|
||||
getComments = async (request: GetCommentsRequest) => {
|
||||
const parsed = getCommentsRequest.safeParse(request);
|
||||
|
||||
if (!parsed.success) {
|
||||
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return baseApiService.get(
|
||||
`/api/v1/messages/${parsed.data.message_id}/comments`,
|
||||
getCommentsResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a top-level comment
|
||||
*/
|
||||
createComment = async (request: CreateCommentRequest) => {
|
||||
const parsed = createCommentRequest.safeParse(request);
|
||||
|
||||
if (!parsed.success) {
|
||||
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return baseApiService.post(
|
||||
`/api/v1/messages/${parsed.data.message_id}/comments`,
|
||||
createCommentResponse,
|
||||
{ body: { content: parsed.data.content } }
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a reply to a comment
|
||||
*/
|
||||
createReply = async (request: CreateReplyRequest) => {
|
||||
const parsed = createReplyRequest.safeParse(request);
|
||||
|
||||
if (!parsed.success) {
|
||||
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return baseApiService.post(
|
||||
`/api/v1/comments/${parsed.data.comment_id}/replies`,
|
||||
createReplyResponse,
|
||||
{ body: { content: parsed.data.content } }
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a comment
|
||||
*/
|
||||
updateComment = async (request: UpdateCommentRequest) => {
|
||||
const parsed = updateCommentRequest.safeParse(request);
|
||||
|
||||
if (!parsed.success) {
|
||||
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return baseApiService.put(`/api/v1/comments/${parsed.data.comment_id}`, updateCommentResponse, {
|
||||
body: { content: parsed.data.content },
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a comment
|
||||
*/
|
||||
deleteComment = async (request: DeleteCommentRequest) => {
|
||||
const parsed = deleteCommentRequest.safeParse(request);
|
||||
|
||||
if (!parsed.success) {
|
||||
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return baseApiService.delete(
|
||||
`/api/v1/comments/${parsed.data.comment_id}`,
|
||||
deleteCommentResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get mentions for current user
|
||||
*/
|
||||
getMentions = async (request?: GetMentionsRequest) => {
|
||||
const parsed = getMentionsRequest.safeParse(request ?? {});
|
||||
|
||||
if (!parsed.success) {
|
||||
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (parsed.data.search_space_id !== undefined) {
|
||||
params.set("search_space_id", String(parsed.data.search_space_id));
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = queryString ? `/api/v1/mentions?${queryString}` : "/api/v1/mentions";
|
||||
|
||||
return baseApiService.get(url, getMentionsResponse);
|
||||
};
|
||||
}
|
||||
|
||||
export const chatCommentsApiService = new ChatCommentsApiService();
|
||||
|
|
@ -23,6 +23,7 @@ export interface ThreadRecord {
|
|||
search_space_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
has_comments?: boolean;
|
||||
}
|
||||
|
||||
export interface MessageRecord {
|
||||
|
|
|
|||
|
|
@ -224,6 +224,19 @@ export async function initElectric(userId: string): Promise<ElectricClient> {
|
|||
CREATE INDEX IF NOT EXISTS idx_documents_search_space_type ON documents(search_space_id, document_type);
|
||||
`);
|
||||
|
||||
// Create the chat_comment_mentions table schema in PGlite
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chat_comment_mentions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
comment_id INTEGER NOT NULL,
|
||||
mentioned_user_id TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_user_id ON chat_comment_mentions(mentioned_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_comment_id ON chat_comment_mentions(comment_id);
|
||||
`);
|
||||
|
||||
const electricUrl = getElectricUrl();
|
||||
|
||||
// STEP 4: Create the client wrapper
|
||||
|
|
|
|||
|
|
@ -72,4 +72,7 @@ export const cacheKeys = {
|
|||
["connectors", "google-drive", connectorId, "folders", parentId] as const,
|
||||
},
|
||||
},
|
||||
comments: {
|
||||
byMessage: (messageId: number) => ["comments", "message", messageId] as const,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue