Merge pull request #327 from multica-ai/agent/j/53c0348f

feat(inbox): auto-scroll to comment and jump-to-bottom button
This commit is contained in:
LinYushen 2026-04-02 14:28:26 +08:00 committed by GitHub
commit eba2e7eacf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 99 additions and 21 deletions

View file

@ -40,6 +40,8 @@ interface CommentCardProps {
onEdit: (commentId: string, content: string) => Promise<void>;
onDelete: (commentId: string) => void;
onToggleReaction: (commentId: string, emoji: string) => void;
/** ID of the comment to highlight (flash animation). */
highlightedCommentId?: string | null;
}
// ---------------------------------------------------------------------------
@ -221,6 +223,7 @@ function CommentCard({
onEdit,
onDelete,
onToggleReaction,
highlightedCommentId,
}: CommentCardProps) {
const { getActorName } = useActorName();
const { uploadWithToast } = useFileUpload();
@ -275,8 +278,10 @@ function CommentCard({
const contentPreview = (entry.content ?? "").replace(/\n/g, " ").slice(0, 80);
const reactions = entry.reactions ?? [];
const isHighlighted = highlightedCommentId === entry.id;
return (
<Card className={`!py-0 !gap-0 overflow-hidden${isTemp ? " opacity-60" : ""}`}>
<Card className={cn("!py-0 !gap-0 overflow-hidden transition-colors duration-700", isTemp && "opacity-60", isHighlighted && "ring-2 ring-brand/50 bg-brand/5")}>
<Collapsible open={open} onOpenChange={setOpen}>
{/* Header — always visible, acts as toggle */}
<div className="px-4 py-3">
@ -404,7 +409,7 @@ function CommentCard({
{/* Replies */}
{allNestedReplies.map((reply) => (
<div key={reply.id} className="border-t border-border/50 px-4">
<div key={reply.id} id={`comment-${reply.id}`} className={cn("border-t border-border/50 px-4 transition-colors duration-700", highlightedCommentId === reply.id && "bg-brand/5")}>
<CommentRow
issueId={issueId}
entry={reply}

View file

@ -7,6 +7,7 @@ import { useRouter } from "next/navigation";
import {
Calendar,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Link2,
@ -160,13 +161,15 @@ interface IssueDetailProps {
onDelete?: () => void;
defaultSidebarOpen?: boolean;
layoutId?: string;
/** When set, the issue detail will auto-scroll to this comment and briefly highlight it. */
highlightCommentId?: string;
}
// ---------------------------------------------------------------------------
// IssueDetail
// ---------------------------------------------------------------------------
export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layoutId = "multica_issue_detail_layout" }: IssueDetailProps) {
export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layoutId = "multica_issue_detail_layout", highlightCommentId }: IssueDetailProps) {
const id = issueId;
const router = useRouter();
const user = useAuthStore((s) => s.user);
@ -191,6 +194,9 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [propertiesOpen, setPropertiesOpen] = useState(true);
const [detailsOpen, setDetailsOpen] = useState(true);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showScrollBottom, setShowScrollBottom] = useState(false);
const [highlightedId, setHighlightedId] = useState<string | null>(null);
// Single source of truth: read issue directly from global store
const issue = useIssueStore((s) => s.issues.find((i) => i.id === id)) ?? null;
@ -229,6 +235,40 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const loading = issueLoading;
// Scroll to highlighted comment once timeline loads
useEffect(() => {
if (!highlightCommentId || timeline.length === 0) return;
// Find the comment element — could be a top-level comment or a reply
const el = document.getElementById(`comment-${highlightCommentId}`);
if (el) {
// Small delay to ensure layout is settled
requestAnimationFrame(() => {
el.scrollIntoView({ behavior: "smooth", block: "center" });
setHighlightedId(highlightCommentId);
// Clear highlight after animation
const timer = setTimeout(() => setHighlightedId(null), 2000);
return () => clearTimeout(timer);
});
}
}, [highlightCommentId, timeline.length]);
// Track scroll position for jump-to-bottom button
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
const onScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
setShowScrollBottom(scrollHeight - scrollTop - clientHeight > 200);
};
container.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => container.removeEventListener("scroll", onScroll);
}, []);
const scrollToBottom = useCallback(() => {
scrollContainerRef.current?.scrollTo({ top: scrollContainerRef.current.scrollHeight, behavior: "smooth" });
}, []);
// Issue field updates — write directly to the global store (single source of truth)
const handleUpdateField = useCallback(
(updates: Partial<UpdateIssueRequest>) => {
@ -541,7 +581,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</div>
{/* Content — scrollable */}
<div className="flex-1 overflow-y-auto">
<div ref={scrollContainerRef} className="relative flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-4xl px-8 py-8">
<TitleEditor
key={`title-${id}`}
@ -733,17 +773,20 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
if (group.type === "comment") {
const entry = group.entries[0]!;
return (
<CommentCard
key={entry.id}
issueId={id}
entry={entry}
allReplies={repliesByParent}
currentUserId={user?.id}
onReply={submitReply}
onEdit={editComment}
onDelete={deleteComment}
onToggleReaction={handleToggleReaction}
/>
<div id={`comment-${entry.id}`}>
<CommentCard
key={entry.id}
issueId={id}
entry={entry}
allReplies={repliesByParent}
currentUserId={user?.id}
onReply={submitReply}
onEdit={editComment}
onDelete={deleteComment}
onToggleReaction={handleToggleReaction}
highlightedCommentId={highlightedId}
/>
</div>
);
}
@ -802,6 +845,20 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</div>
</div>
</div>
{/* Jump to bottom button */}
{showScrollBottom && (
<div className="sticky bottom-4 flex justify-center pointer-events-none">
<Button
variant="secondary"
size="sm"
className="pointer-events-auto shadow-md"
onClick={scrollToBottom}
>
<ChevronDown className="mr-1 h-3.5 w-3.5" />
Jump to bottom
</Button>
</div>
)}
</div>
</div>
</ResizablePanel>