diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx index 65b3191b..98e5677d 100644 --- a/apps/web/app/(dashboard)/inbox/page.tsx +++ b/apps/web/app/(dashboard)/inbox/page.tsx @@ -413,10 +413,11 @@ export default function InboxPage() {
{selected?.issue_id ? ( { handleArchive(selected.id); }} diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx index b08b582a..23753e03 100644 --- a/apps/web/features/issues/components/comment-card.tsx +++ b/apps/web/features/issues/components/comment-card.tsx @@ -39,6 +39,8 @@ interface CommentCardProps { onEdit: (commentId: string, content: string) => Promise; 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 ( - + {/* Header — always visible, acts as toggle */}
@@ -403,7 +408,7 @@ function CommentCard({ {/* Replies */} {allNestedReplies.map((reply) => ( -
+
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(null); + const [showScrollBottom, setShowScrollBottom] = useState(false); + const [highlightedId, setHighlightedId] = useState(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) => { @@ -540,7 +580,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{/* Content — scrollable */} -
+
+
+ +
); } @@ -798,6 +841,20 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
+ {/* Jump to bottom button */} + {showScrollBottom && ( +
+ +
+ )}
diff --git a/server/cmd/server/notification_listeners.go b/server/cmd/server/notification_listeners.go index 1ad0ed92..fdd83ec3 100644 --- a/server/cmd/server/notification_listeners.go +++ b/server/cmd/server/notification_listeners.go @@ -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, diff --git a/server/internal/handler/reaction.go b/server/internal/handler/reaction.go index c3665120..2dae83e6 100644 --- a/server/internal/handler/reaction.go +++ b/server/internal/handler/reaction.go @@ -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), })