feat(inbox): auto-scroll to comment from notification and add jump-to-bottom button

When clicking an inbox notification, the issue detail now scrolls to and
briefly highlights the relevant comment. Also adds a floating "Jump to
bottom" button on issue pages with long timelines.

Backend: store comment_id in inbox notification details for new_comment
and reaction_added events. Frontend: pass highlightCommentId through to
IssueDetail, add id attributes to comment elements, and track scroll
position for the jump-to-bottom button.
This commit is contained in:
Jiang Bohan 2026-04-02 13:43:05 +08:00
parent 57e48c1d6b
commit 575bbd7f60
5 changed files with 99 additions and 21 deletions

View file

@ -413,10 +413,11 @@ export default function InboxPage() {
<div className="flex flex-col min-h-0 h-full">
{selected?.issue_id ? (
<IssueDetail
key={selected.issue_id}
key={selected.id}
issueId={selected.issue_id}
defaultSidebarOpen={false}
layoutId="multica_inbox_issue_detail_layout"
highlightCommentId={selected.details?.comment_id ?? undefined}
onDelete={() => {
handleArchive(selected.id);
}}

View file

@ -39,6 +39,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;
}
// ---------------------------------------------------------------------------
@ -220,6 +222,7 @@ function CommentCard({
onEdit,
onDelete,
onToggleReaction,
highlightedCommentId,
}: CommentCardProps) {
const { getActorName } = useActorName();
const { uploadWithToast } = useFileUpload();
@ -274,8 +277,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">
@ -403,7 +408,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);
@ -190,6 +193,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;
@ -228,6 +234,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>) => {
@ -540,7 +580,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}`}
@ -729,17 +769,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>
);
}
@ -798,6 +841,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>

View file

@ -435,13 +435,15 @@ func registerNotificationListeners(bus *events.Bus, queries *db.Queries) {
// The comment payload can come as handler.CommentResponse from the
// HTTP handler, or as map[string]any from the agent comment path in
// task.go. Handle both.
var issueID, commentContent string
var issueID, commentID, commentContent string
switch c := payload["comment"].(type) {
case handler.CommentResponse:
issueID = c.IssueID
commentID = c.ID
commentContent = c.Content
case map[string]any:
issueID, _ = c["issue_id"].(string)
commentID, _ = c["id"].(string)
commentContent, _ = c["content"].(string)
default:
return
@ -450,17 +452,24 @@ func registerNotificationListeners(bus *events.Bus, queries *db.Queries) {
issueTitle, _ := payload["issue_title"].(string)
issueStatus, _ := payload["issue_status"].(string)
commentDetails := emptyDetails
if commentID != "" {
commentDetails, _ = json.Marshal(map[string]string{
"comment_id": commentID,
})
}
notifySubscribers(ctx, queries, bus, issueID, issueStatus, e.WorkspaceID, e,
nil, "new_comment", "info",
issueTitle, commentContent,
emptyDetails)
commentDetails)
// Notify @mentions in comment content.
mentions := parseMentions(commentContent)
if len(mentions) > 0 {
skip := map[string]bool{e.ActorID: true}
notifyMentionedMembers(bus, queries, e, mentions, issueID, issueTitle, issueStatus,
issueTitle, skip, emptyDetails)
issueTitle, skip, commentDetails)
}
})
@ -513,6 +522,7 @@ func registerNotificationListeners(bus *events.Bus, queries *db.Queries) {
commentAuthorType, _ := payload["comment_author_type"].(string)
commentAuthorID, _ := payload["comment_author_id"].(string)
commentID, _ := payload["comment_id"].(string)
issueID, _ := payload["issue_id"].(string)
issueTitle, _ := payload["issue_title"].(string)
issueStatus, _ := payload["issue_status"].(string)
@ -521,9 +531,13 @@ func registerNotificationListeners(bus *events.Bus, queries *db.Queries) {
return
}
details, _ := json.Marshal(map[string]string{
detailsMap := map[string]string{
"emoji": reaction.Emoji,
})
}
if commentID != "" {
detailsMap["comment_id"] = commentID
}
details, _ := json.Marshal(detailsMap)
notifyDirect(ctx, queries, bus,
commentAuthorType, commentAuthorID,

View file

@ -92,6 +92,7 @@ func (h *Handler) AddReaction(w http.ResponseWriter, r *http.Request) {
"issue_id": issueID,
"issue_title": issueTitle,
"issue_status": issueStatus,
"comment_id": uuidToString(comment.ID),
"comment_author_type": comment.AuthorType,
"comment_author_id": uuidToString(comment.AuthorID),
})