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:
commit
eba2e7eacf
5 changed files with 99 additions and 21 deletions
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -460,13 +460,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
|
||||
|
|
@ -475,17 +477,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)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -538,6 +547,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)
|
||||
|
|
@ -546,9 +556,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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue