Merge remote-tracking branch 'upstream/dev' into fix/SUR-86

This commit is contained in:
Anish Sarkar 2026-01-20 03:27:41 +05:30
commit e646343453
49 changed files with 3441 additions and 78 deletions

View file

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

View file

@ -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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -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}
/>
);
}

View file

@ -0,0 +1,5 @@
export interface CommentPanelContainerProps {
messageId: number;
isOpen: boolean;
maxHeight?: number;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
export interface CommentTriggerProps {
commentCount: number;
isOpen: boolean;
onClick: () => void;
disabled?: boolean;
}

View file

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

View file

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

View file

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

View file

@ -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>

View file

@ -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">