2025-12-30 16:38:57 +02:00
|
|
|
import {
|
|
|
|
|
ActionBarPrimitive,
|
|
|
|
|
AssistantIf,
|
|
|
|
|
ErrorPrimitive,
|
|
|
|
|
MessagePrimitive,
|
|
|
|
|
useAssistantState,
|
2026-02-04 18:04:53 +02:00
|
|
|
useMessage,
|
2025-12-30 16:38:57 +02:00
|
|
|
} from "@assistant-ui/react";
|
2026-02-04 18:04:53 +02:00
|
|
|
import { useAtom, useAtomValue } from "jotai";
|
2026-01-19 16:50:51 -08:00
|
|
|
import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
|
2025-12-30 16:38:57 +02:00
|
|
|
import type { FC } from "react";
|
2026-01-27 22:14:02 +05:30
|
|
|
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
2026-01-19 14:37:06 +02:00
|
|
|
import {
|
|
|
|
|
addingCommentToMessageIdAtom,
|
2026-01-27 20:59:03 +05:30
|
|
|
commentsCollapsedAtom,
|
2026-01-19 14:37:06 +02:00
|
|
|
commentsEnabledAtom,
|
2026-01-27 22:14:02 +05:30
|
|
|
targetCommentIdAtom,
|
2026-01-19 14:37:06 +02:00
|
|
|
} from "@/atoms/chat/current-thread.atom";
|
2026-01-16 15:09:51 +02:00
|
|
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
2025-12-30 16:38:57 +02:00
|
|
|
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
2026-01-01 22:24:42 +05:30
|
|
|
import {
|
|
|
|
|
ThinkingStepsContext,
|
|
|
|
|
ThinkingStepsDisplay,
|
|
|
|
|
} from "@/components/assistant-ui/thinking-steps";
|
2025-12-30 16:38:57 +02:00
|
|
|
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
|
|
|
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
2026-01-16 15:09:51 +02:00
|
|
|
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
|
2026-01-19 16:50:51 -08:00
|
|
|
import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet";
|
2026-01-16 15:09:51 +02:00
|
|
|
import { CommentTrigger } from "@/components/chat-comments/comment-trigger/comment-trigger";
|
|
|
|
|
import { useComments } from "@/hooks/use-comments";
|
2026-01-19 16:50:51 -08:00
|
|
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
2025-12-30 16:38:57 +02:00
|
|
|
|
|
|
|
|
export const MessageError: FC = () => {
|
|
|
|
|
return (
|
|
|
|
|
<MessagePrimitive.Error>
|
|
|
|
|
<ErrorPrimitive.Root className="aui-message-error-root mt-2 rounded-md border border-destructive bg-destructive/10 p-3 text-destructive text-sm dark:bg-destructive/5 dark:text-red-200">
|
|
|
|
|
<ErrorPrimitive.Message className="aui-message-error-message line-clamp-2" />
|
|
|
|
|
</ErrorPrimitive.Root>
|
|
|
|
|
</MessagePrimitive.Error>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Custom component to render thinking steps from Context
|
|
|
|
|
*/
|
|
|
|
|
const ThinkingStepsPart: FC = () => {
|
|
|
|
|
const thinkingStepsMap = useContext(ThinkingStepsContext);
|
|
|
|
|
|
|
|
|
|
// Get the current message ID to look up thinking steps
|
|
|
|
|
const messageId = useAssistantState(({ message }) => message?.id);
|
|
|
|
|
const thinkingSteps = thinkingStepsMap.get(messageId) || [];
|
|
|
|
|
|
|
|
|
|
// Check if this specific message is currently streaming
|
|
|
|
|
// A message is streaming if: thread is running AND this is the last assistant message
|
|
|
|
|
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
|
|
|
|
const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false);
|
|
|
|
|
const isMessageStreaming = isThreadRunning && isLastMessage;
|
|
|
|
|
|
|
|
|
|
if (thinkingSteps.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="mb-3">
|
|
|
|
|
<ThinkingStepsDisplay steps={thinkingSteps} isThreadRunning={isMessageStreaming} />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const AssistantMessageInner: FC = () => {
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{/* Render thinking steps from message content - this ensures proper scroll tracking */}
|
|
|
|
|
<ThinkingStepsPart />
|
|
|
|
|
|
|
|
|
|
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
|
|
|
|
|
<MessagePrimitive.Parts
|
|
|
|
|
components={{
|
|
|
|
|
Text: MarkdownText,
|
|
|
|
|
tools: { Fallback: ToolFallback },
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<MessageError />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex">
|
|
|
|
|
<AssistantActionBar />
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-16 15:09:51 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 16:38:57 +02:00
|
|
|
export const AssistantMessage: FC = () => {
|
2026-01-19 14:37:06 +02:00
|
|
|
const [messageHeight, setMessageHeight] = useState<number | undefined>(undefined);
|
2026-01-19 16:50:51 -08:00
|
|
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
2026-01-19 14:37:06 +02:00
|
|
|
const messageRef = useRef<HTMLDivElement>(null);
|
2026-01-16 15:09:51 +02:00
|
|
|
const messageId = useAssistantState(({ message }) => message?.id);
|
|
|
|
|
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
|
|
|
|
const dbMessageId = parseMessageId(messageId);
|
2026-01-19 14:37:06 +02:00
|
|
|
const commentsEnabled = useAtomValue(commentsEnabledAtom);
|
2026-01-27 20:59:03 +05:30
|
|
|
const commentsCollapsed = useAtomValue(commentsCollapsedAtom);
|
2026-01-19 14:37:06 +02:00
|
|
|
const [addingCommentToMessageId, setAddingCommentToMessageId] = useAtom(
|
|
|
|
|
addingCommentToMessageIdAtom
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-19 16:50:51 -08:00
|
|
|
// Screen size detection for responsive comment UI
|
|
|
|
|
// Mobile: < 768px (bottom sheet), Medium: 768px - 1024px (right sheet), Desktop: >= 1024px (inline panel)
|
|
|
|
|
const isMediumScreen = useMediaQuery("(min-width: 768px) and (max-width: 1023px)");
|
|
|
|
|
const isDesktop = useMediaQuery("(min-width: 1024px)");
|
|
|
|
|
|
2026-01-19 14:37:06 +02:00
|
|
|
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
|
|
|
|
const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false);
|
|
|
|
|
const isMessageStreaming = isThreadRunning && isLastMessage;
|
2026-01-16 15:09:51 +02:00
|
|
|
|
2026-01-27 22:14:02 +05:30
|
|
|
const { data: commentsData, isSuccess: commentsLoaded } = useComments({
|
2026-01-16 15:09:51 +02:00
|
|
|
messageId: dbMessageId ?? 0,
|
|
|
|
|
enabled: !!dbMessageId,
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-27 22:14:02 +05:30
|
|
|
// Target comment navigation - read target from global atom
|
|
|
|
|
const targetCommentId = useAtomValue(targetCommentIdAtom);
|
|
|
|
|
|
|
|
|
|
// Check if target comment belongs to this message (including replies)
|
|
|
|
|
const hasTargetComment = useMemo(() => {
|
|
|
|
|
if (!targetCommentId || !commentsData?.comments) return false;
|
|
|
|
|
return commentsData.comments.some(
|
|
|
|
|
(c) => c.id === targetCommentId || c.replies?.some((r) => r.id === targetCommentId)
|
|
|
|
|
);
|
|
|
|
|
}, [targetCommentId, commentsData]);
|
|
|
|
|
|
2026-01-19 16:50:51 -08:00
|
|
|
const commentCount = commentsData?.total_count ?? 0;
|
|
|
|
|
const hasComments = commentCount > 0;
|
2026-01-19 14:37:06 +02:00
|
|
|
const isAddingComment = dbMessageId !== null && addingCommentToMessageId === dbMessageId;
|
|
|
|
|
const showCommentPanel = hasComments || isAddingComment;
|
|
|
|
|
|
|
|
|
|
const handleToggleAddComment = () => {
|
|
|
|
|
if (!dbMessageId) return;
|
|
|
|
|
setAddingCommentToMessageId(isAddingComment ? null : dbMessageId);
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-19 16:50:51 -08:00
|
|
|
const handleCommentTriggerClick = () => {
|
|
|
|
|
setIsSheetOpen(true);
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-19 14:37:06 +02:00
|
|
|
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();
|
|
|
|
|
}, []);
|
2026-01-16 15:09:51 +02:00
|
|
|
|
2026-01-27 22:14:02 +05:30
|
|
|
// Auto-open sheet on mobile/tablet when this message has the target comment
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (hasTargetComment && !isDesktop && commentsLoaded) {
|
|
|
|
|
setIsSheetOpen(true);
|
|
|
|
|
}
|
|
|
|
|
}, [hasTargetComment, isDesktop, commentsLoaded]);
|
|
|
|
|
|
|
|
|
|
// Scroll message into view when it contains target comment (desktop)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (hasTargetComment && isDesktop && commentsLoaded && messageRef.current) {
|
|
|
|
|
// Small delay to ensure DOM is ready after comments render
|
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
|
|
|
messageRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
|
|
|
}, 100);
|
|
|
|
|
return () => clearTimeout(timeoutId);
|
|
|
|
|
}
|
|
|
|
|
}, [hasTargetComment, isDesktop, commentsLoaded]);
|
|
|
|
|
|
2026-01-19 16:50:51 -08:00
|
|
|
const showCommentTrigger = searchSpaceId && commentsEnabled && !isMessageStreaming && dbMessageId;
|
|
|
|
|
|
|
|
|
|
// Determine sheet side based on screen size
|
|
|
|
|
const sheetSide = isMediumScreen ? "right" : "bottom";
|
|
|
|
|
|
2025-12-30 16:38:57 +02:00
|
|
|
return (
|
|
|
|
|
<MessagePrimitive.Root
|
2026-01-19 14:37:06 +02:00
|
|
|
ref={messageRef}
|
2026-01-16 15:09:51 +02:00
|
|
|
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"
|
2025-12-30 16:38:57 +02:00
|
|
|
data-role="assistant"
|
|
|
|
|
>
|
|
|
|
|
<AssistantMessageInner />
|
2026-01-16 15:09:51 +02:00
|
|
|
|
2026-01-27 20:59:03 +05:30
|
|
|
{/* Desktop comment panel - only on lg screens and above, hidden when collapsed */}
|
|
|
|
|
{searchSpaceId && commentsEnabled && !isMessageStreaming && !commentsCollapsed && (
|
2026-01-19 14:37:06 +02:00
|
|
|
<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}
|
2026-01-16 15:09:51 +02:00
|
|
|
/>
|
2026-01-19 14:37:06 +02:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{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>
|
2026-01-16 15:09:51 +02:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-01-19 16:50:51 -08:00
|
|
|
|
|
|
|
|
{/* Mobile & Medium screen comment trigger - shown below lg breakpoint */}
|
|
|
|
|
{showCommentTrigger && !isDesktop && (
|
2026-01-20 19:49:34 +05:30
|
|
|
<div className="ml-2 mt-1 flex justify-start">
|
2026-01-19 16:50:51 -08:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={handleCommentTriggerClick}
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex items-center gap-2 rounded-full px-3 py-1.5 text-sm transition-colors",
|
|
|
|
|
hasComments
|
|
|
|
|
? "border border-primary/50 bg-primary/5 text-primary hover:bg-primary/10"
|
|
|
|
|
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<MessageSquare className={cn("size-4", hasComments && "fill-current")} />
|
|
|
|
|
{hasComments ? (
|
2026-01-20 00:32:31 -08:00
|
|
|
<span>
|
|
|
|
|
{commentCount} {commentCount === 1 ? "comment" : "comments"}
|
|
|
|
|
</span>
|
2026-01-19 16:50:51 -08:00
|
|
|
) : (
|
|
|
|
|
<span>Add comment</span>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Comment sheet - bottom for mobile, right for medium screens */}
|
|
|
|
|
{showCommentTrigger && !isDesktop && (
|
|
|
|
|
<CommentSheet
|
|
|
|
|
messageId={dbMessageId}
|
|
|
|
|
isOpen={isSheetOpen}
|
|
|
|
|
onOpenChange={setIsSheetOpen}
|
|
|
|
|
commentCount={commentCount}
|
|
|
|
|
side={sheetSide}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2025-12-30 16:38:57 +02:00
|
|
|
</MessagePrimitive.Root>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const AssistantActionBar: FC = () => {
|
2026-02-04 18:04:53 +02:00
|
|
|
const { isLast } = useMessage();
|
|
|
|
|
|
2025-12-30 16:38:57 +02:00
|
|
|
return (
|
|
|
|
|
<ActionBarPrimitive.Root
|
|
|
|
|
hideWhenRunning
|
|
|
|
|
autohide="not-last"
|
|
|
|
|
autohideFloat="single-branch"
|
2026-01-20 19:49:34 +05:30
|
|
|
className="aui-assistant-action-bar-root -ml-1 col-start-3 row-start-2 flex gap-1 text-muted-foreground md:data-floating:absolute md:data-floating:rounded-md md:data-floating:border md:data-floating:bg-background md:data-floating:p-1 md:data-floating:shadow-sm [&>button]:opacity-100 md:[&>button]:opacity-[var(--aui-button-opacity,1)]"
|
2025-12-30 16:38:57 +02:00
|
|
|
>
|
|
|
|
|
<ActionBarPrimitive.Copy asChild>
|
|
|
|
|
<TooltipIconButton tooltip="Copy">
|
|
|
|
|
<AssistantIf condition={({ message }) => message.isCopied}>
|
|
|
|
|
<CheckIcon />
|
|
|
|
|
</AssistantIf>
|
|
|
|
|
<AssistantIf condition={({ message }) => !message.isCopied}>
|
|
|
|
|
<CopyIcon />
|
|
|
|
|
</AssistantIf>
|
|
|
|
|
</TooltipIconButton>
|
|
|
|
|
</ActionBarPrimitive.Copy>
|
|
|
|
|
<ActionBarPrimitive.ExportMarkdown asChild>
|
|
|
|
|
<TooltipIconButton tooltip="Export as Markdown">
|
|
|
|
|
<DownloadIcon />
|
|
|
|
|
</TooltipIconButton>
|
|
|
|
|
</ActionBarPrimitive.ExportMarkdown>
|
2026-02-04 18:04:53 +02:00
|
|
|
{/* Only allow regenerating the last assistant message */}
|
|
|
|
|
{isLast && (
|
|
|
|
|
<ActionBarPrimitive.Reload asChild>
|
|
|
|
|
<TooltipIconButton tooltip="Refresh">
|
|
|
|
|
<RefreshCwIcon />
|
|
|
|
|
</TooltipIconButton>
|
|
|
|
|
</ActionBarPrimitive.Reload>
|
|
|
|
|
)}
|
2025-12-30 16:38:57 +02:00
|
|
|
</ActionBarPrimitive.Root>
|
|
|
|
|
);
|
|
|
|
|
};
|