feat: implement responsive comment UI with mobile and desktop variants

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-01-19 16:50:51 -08:00
parent d83e9aa52d
commit ab91cbd148
8 changed files with 229 additions and 9 deletions

View file

@ -6,7 +6,7 @@ import {
useAssistantState,
} from "@assistant-ui/react";
import { useAtom, useAtomValue } from "jotai";
import { CheckIcon, CopyIcon, DownloadIcon, RefreshCwIcon } from "lucide-react";
import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
import type { FC } from "react";
import { useContext, useEffect, useRef, useState } from "react";
import {
@ -23,8 +23,11 @@ import {
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 { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet";
import { CommentTrigger } from "@/components/chat-comments/comment-trigger/comment-trigger";
import { useComments } from "@/hooks/use-comments";
import { useMediaQuery } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
export const MessageError: FC = () => {
return (
@ -93,6 +96,7 @@ function parseMessageId(assistantUiMessageId: string | undefined): number | null
export const AssistantMessage: FC = () => {
const [messageHeight, setMessageHeight] = useState<number | undefined>(undefined);
const [isSheetOpen, setIsSheetOpen] = useState(false);
const messageRef = useRef<HTMLDivElement>(null);
const messageId = useAssistantState(({ message }) => message?.id);
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
@ -102,6 +106,11 @@ export const AssistantMessage: FC = () => {
addingCommentToMessageIdAtom
);
// 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)");
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false);
const isMessageStreaming = isThreadRunning && isLastMessage;
@ -111,7 +120,8 @@ export const AssistantMessage: FC = () => {
enabled: !!dbMessageId,
});
const hasComments = (commentsData?.total_count ?? 0) > 0;
const commentCount = commentsData?.total_count ?? 0;
const hasComments = commentCount > 0;
const isAddingComment = dbMessageId !== null && addingCommentToMessageId === dbMessageId;
const showCommentPanel = hasComments || isAddingComment;
@ -120,6 +130,10 @@ export const AssistantMessage: FC = () => {
setAddingCommentToMessageId(isAddingComment ? null : dbMessageId);
};
const handleCommentTriggerClick = () => {
setIsSheetOpen(true);
};
useEffect(() => {
if (!messageRef.current) return;
const el = messageRef.current;
@ -130,6 +144,11 @@ export const AssistantMessage: FC = () => {
return () => observer.disconnect();
}, []);
const showCommentTrigger = searchSpaceId && commentsEnabled && !isMessageStreaming && dbMessageId;
// Determine sheet side based on screen size
const sheetSide = isMediumScreen ? "right" : "bottom";
return (
<MessagePrimitive.Root
ref={messageRef}
@ -138,6 +157,7 @@ export const AssistantMessage: FC = () => {
>
<AssistantMessageInner />
{/* Desktop comment panel - only on lg screens and above */}
{searchSpaceId && commentsEnabled && !isMessageStreaming && (
<div className="absolute left-full top-0 ml-4 hidden lg:block w-72">
<div
@ -168,6 +188,40 @@ export const AssistantMessage: FC = () => {
</div>
</div>
)}
{/* Mobile & Medium screen comment trigger - shown below lg breakpoint */}
{showCommentTrigger && !isDesktop && (
<div className="mt-2 flex justify-start">
<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 ? (
<span>{commentCount} {commentCount === 1 ? "comment" : "comments"}</span>
) : (
<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}
/>
)}
</MessagePrimitive.Root>
);
};

View file

@ -19,6 +19,7 @@ export function CommentPanelContainer({
messageId,
isOpen,
maxHeight,
variant = "desktop",
}: CommentPanelContainerProps) {
const { data: commentsData, isLoading: isCommentsLoading } = useComments({
messageId,
@ -80,6 +81,7 @@ export function CommentPanelContainer({
onDeleteComment={handleDeleteComment}
isSubmitting={isSubmitting}
maxHeight={maxHeight}
variant={variant}
/>
);
}

View file

@ -2,4 +2,6 @@ export interface CommentPanelContainerProps {
messageId: number;
isOpen: boolean;
maxHeight?: number;
/** Variant for responsive styling - desktop shows border/bg, mobile is plain */
variant?: "desktop" | "mobile";
}

View file

@ -3,6 +3,7 @@
import { MessageSquarePlus } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { CommentComposer } from "../comment-composer/comment-composer";
import { CommentThread } from "../comment-thread/comment-thread";
import type { CommentPanelProps } from "./types";
@ -18,6 +19,7 @@ export function CommentPanel({
onDeleteComment,
isSubmitting = false,
maxHeight,
variant = "desktop",
}: CommentPanelProps) {
const [isComposerOpen, setIsComposerOpen] = useState(false);
@ -30,9 +32,14 @@ export function CommentPanel({
setIsComposerOpen(false);
};
const isMobile = variant === "mobile";
if (isLoading) {
return (
<div className="flex min-h-[120px] w-96 items-center justify-center rounded-lg border bg-card p-4">
<div className={cn(
"flex min-h-[120px] items-center justify-center p-4",
!isMobile && "w-96 rounded-lg border bg-card"
)}>
<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...
@ -50,8 +57,11 @@ export function CommentPanel({
return (
<div
className="flex w-85 flex-col rounded-lg border bg-card"
style={effectiveMaxHeight ? { maxHeight: effectiveMaxHeight } : undefined}
className={cn(
"flex flex-col",
isMobile ? "w-full" : "w-85 rounded-lg border bg-card"
)}
style={!isMobile && effectiveMaxHeight ? { maxHeight: effectiveMaxHeight } : undefined}
>
{hasThreads && (
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
@ -82,7 +92,11 @@ export function CommentPanel({
</div>
)}
<div className={showEmptyState ? "border-t p-3" : "p-3"}>
<div className={cn(
"p-3",
showEmptyState && !isMobile && "border-t",
isMobile && "border-t"
)}>
{isComposerOpen ? (
<CommentComposer
members={members}

View file

@ -12,4 +12,6 @@ export interface CommentPanelProps {
onDeleteComment: (commentId: number) => void;
isSubmitting?: boolean;
maxHeight?: number;
/** Variant for responsive styling - desktop shows border/bg, mobile is plain */
variant?: "desktop" | "mobile";
}

View file

@ -0,0 +1,64 @@
"use client";
import { MessageSquare } from "lucide-react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { CommentPanelContainer } from "../comment-panel-container/comment-panel-container";
import type { CommentSheetProps } from "./types";
export function CommentSheet({
messageId,
isOpen,
onOpenChange,
commentCount = 0,
side = "bottom",
}: CommentSheetProps) {
const isBottomSheet = side === "bottom";
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent
side={side}
className={cn(
"flex flex-col p-0",
isBottomSheet
? "h-[85vh] max-h-[85vh] rounded-t-xl"
: "h-full w-full max-w-md"
)}
>
{/* Drag handle indicator - only for bottom sheet */}
{isBottomSheet && (
<div className="flex justify-center pt-3 pb-1">
<div className="h-1 w-10 rounded-full bg-muted-foreground/30" />
</div>
)}
<SheetHeader className={cn(
"flex-shrink-0 border-b px-4",
isBottomSheet ? "pb-3" : "py-4"
)}>
<SheetTitle className="flex items-center gap-2 text-base font-semibold">
<MessageSquare className="size-5" />
Comments
{commentCount > 0 && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{commentCount}
</span>
)}
</SheetTitle>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto">
<CommentPanelContainer
messageId={messageId}
isOpen={true}
variant="mobile"
/>
</div>
</SheetContent>
</Sheet>
);
}

View file

@ -0,0 +1,8 @@
export interface CommentSheetProps {
messageId: number;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
commentCount?: number;
/** Side to open the sheet from - bottom for mobile, right for medium screens */
side?: "bottom" | "right";
}