diff --git a/apps/client/src/features/comment/components/comment-hover-preview.test.tsx b/apps/client/src/features/comment/components/comment-hover-preview.test.tsx
index f35dbcdd..6f7289e8 100644
--- a/apps/client/src/features/comment/components/comment-hover-preview.test.tsx
+++ b/apps/client/src/features/comment/components/comment-hover-preview.test.tsx
@@ -169,8 +169,13 @@ describe("CommentHoverPreview — hover behaviour", () => {
vi.useRealTimers();
});
- it("shows the comment text after the open delay", () => {
- setComments([comment()]);
+ it("shows the parent comment text and author after the open delay", () => {
+ setComments([
+ comment({
+ content: doc("Hello world"),
+ creator: { id: "u-1", name: "Alice", avatarUrl: null } as any,
+ }),
+ ]);
render();
hoverMark();
@@ -178,27 +183,102 @@ describe("CommentHoverPreview — hover behaviour", () => {
expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
act(() => {
- vi.advanceTimersByTime(120);
+ vi.advanceTimersByTime(350);
});
const card = screen.getByTestId("comment-hover-preview");
- expect(card.textContent).toBe("Hello world");
+ // The line shows "Author: text" — both the author name and the comment text.
+ expect(card.textContent).toContain("Alice:");
+ expect(card.textContent).toContain("Hello world");
// The card MUST NOT intercept the mark's click (which opens the side panel):
// pointer-events:none is the single property guaranteeing that — lock it so
// a regression dropping it from the style object fails here.
expect(card.style.pointerEvents).toBe("none");
});
+ it("renders the whole thread: parent plus replies, each with its author", () => {
+ setComments([
+ comment({
+ id: "c-1",
+ content: doc("Parent comment"),
+ createdAt: new Date("2026-01-01T10:00:00Z"),
+ creator: { id: "u-1", name: "Alice", avatarUrl: null } as any,
+ }),
+ comment({
+ id: "c-3",
+ content: doc("Second reply"),
+ parentCommentId: "c-1",
+ createdAt: new Date("2026-01-01T12:00:00Z"),
+ creator: { id: "u-3", name: "Carol", avatarUrl: null } as any,
+ }),
+ comment({
+ id: "c-2",
+ content: doc("First reply"),
+ parentCommentId: "c-1",
+ createdAt: new Date("2026-01-01T11:00:00Z"),
+ creator: { id: "u-2", name: "Bob", avatarUrl: null } as any,
+ }),
+ ]);
+ render();
+
+ hoverMark();
+ act(() => {
+ vi.advanceTimersByTime(350);
+ });
+ const card = screen.getByTestId("comment-hover-preview");
+
+ // Parent and both replies are present, each as "Author: text".
+ const body = card.textContent ?? "";
+ expect(body).toContain("Alice: Parent comment");
+ expect(body).toContain("Bob: First reply");
+ expect(body).toContain("Carol: Second reply");
+
+ // Replies are ordered by createdAt ascending after the parent
+ // (Parent -> First reply -> Second reply), even though the input was
+ // out of order (Second reply's comment came before First reply's).
+ expect(body.indexOf("Parent comment")).toBeLessThan(
+ body.indexOf("First reply"),
+ );
+ expect(body.indexOf("First reply")).toBeLessThan(
+ body.indexOf("Second reply"),
+ );
+ });
+
+ it("shows the thread even when the parent text is empty but it has replies", () => {
+ setComments([
+ comment({
+ id: "c-1",
+ content: JSON.stringify({ type: "doc", content: [] }),
+ creator: { id: "u-1", name: "Alice", avatarUrl: null } as any,
+ }),
+ comment({
+ id: "c-2",
+ content: doc("A reply"),
+ parentCommentId: "c-1",
+ createdAt: new Date(),
+ creator: { id: "u-2", name: "Bob", avatarUrl: null } as any,
+ }),
+ ]);
+ render();
+
+ hoverMark();
+ act(() => {
+ vi.advanceTimersByTime(350);
+ });
+ const card = screen.getByTestId("comment-hover-preview");
+ expect(card.textContent).toContain("Bob: A reply");
+ });
+
it("hides on mouseout", () => {
setComments([comment()]);
render();
hoverMark();
act(() => {
- vi.advanceTimersByTime(120);
+ vi.advanceTimersByTime(350);
});
- expect(screen.getByTestId("comment-hover-preview").textContent).toBe(
- "Hello world",
- );
+ expect(
+ screen.getByTestId("comment-hover-preview").textContent,
+ ).toContain("Hello world");
leaveMark();
expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
@@ -258,11 +338,11 @@ describe("CommentHoverPreview — hover behaviour", () => {
hoverMark();
act(() => {
- vi.advanceTimersByTime(120);
+ vi.advanceTimersByTime(350);
});
- expect(screen.getByTestId("comment-hover-preview").textContent).toBe(
- "Hello world",
- );
+ expect(
+ screen.getByTestId("comment-hover-preview").textContent,
+ ).toContain("Hello world");
act(() => {
window.dispatchEvent(new Event("scroll"));
@@ -276,11 +356,11 @@ describe("CommentHoverPreview — hover behaviour", () => {
hoverMark();
act(() => {
- vi.advanceTimersByTime(120);
+ vi.advanceTimersByTime(350);
});
- expect(screen.getByTestId("comment-hover-preview").textContent).toBe(
- "Hello world",
- );
+ expect(
+ screen.getByTestId("comment-hover-preview").textContent,
+ ).toContain("Hello world");
const span = screen.getByTestId("mark");
act(() => {
@@ -295,7 +375,7 @@ describe("CommentHoverPreview — hover behaviour", () => {
hoverMark();
act(() => {
- vi.advanceTimersByTime(120);
+ vi.advanceTimersByTime(350);
});
expect(screen.queryByTestId("comment-hover-preview")).not.toBeNull();
@@ -315,7 +395,7 @@ describe("CommentHoverPreview — hover behaviour", () => {
hoverMark();
act(() => {
- vi.advanceTimersByTime(120);
+ vi.advanceTimersByTime(350);
});
expect(screen.queryByTestId("comment-hover-preview")).not.toBeNull();
diff --git a/apps/client/src/features/comment/components/comment-hover-preview.tsx b/apps/client/src/features/comment/components/comment-hover-preview.tsx
index ec6861df..5f68f5eb 100644
--- a/apps/client/src/features/comment/components/comment-hover-preview.tsx
+++ b/apps/client/src/features/comment/components/comment-hover-preview.tsx
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
-import { Paper } from "@mantine/core";
+import { Paper, Text } from "@mantine/core";
import { useCommentsQuery } from "@/features/comment/queries/comment-query";
import { IComment } from "@/features/comment/types/comment.types";
import { commentContentToText } from "@/features/comment/utils/comment-content-to-text";
@@ -11,15 +11,25 @@ interface CommentHoverPreviewProps {
}
// Delay before the card appears, to avoid flicker when the pointer quickly
-// passes over comment marks.
-const OPEN_DELAY_MS = 120;
-const CARD_MAX_WIDTH = 320;
+// passes over comment marks (kept generous so it does not pop up on a passing
+// glance).
+const OPEN_DELAY_MS = 350;
+const CARD_MAX_WIDTH = 360;
+const CARD_MAX_HEIGHT = 300;
const GAP = 6;
// Reserve roughly this much room below the span; flip above when it doesn't fit.
-const ESTIMATED_CARD_HEIGHT = 160;
+const ESTIMATED_CARD_HEIGHT = 200;
+
+// One rendered line of the thread: the author and the comment's plain text,
+// pre-computed at hover time so render stays cheap. Shown as "Author: text".
+interface ThreadRow {
+ id: string;
+ name: string;
+ text: string;
+}
interface HoverState {
- text: string;
+ thread: ThreadRow[];
rect: { top: number; bottom: number; left: number };
}
@@ -27,9 +37,32 @@ function isResolved(comment: IComment): boolean {
return comment.resolvedAt != null || comment.resolvedById != null;
}
+// Build the thread for a root (parent) comment: the root first, followed by its
+// replies sorted by createdAt ascending. Reads every comment from the map.
+function buildThread(
+ commentMap: Map,
+ root: IComment,
+): ThreadRow[] {
+ const replies: IComment[] = [];
+ commentMap.forEach((comment) => {
+ if (comment.parentCommentId === root.id) replies.push(comment);
+ });
+ replies.sort(
+ (a, b) =>
+ new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
+ );
+
+ return [root, ...replies].map((comment) => ({
+ id: comment.id,
+ name: comment.creator?.name ?? "",
+ text: commentContentToText(comment.content),
+ }));
+}
+
/**
- * Shows a small floating card with the plain text of the parent comment when
- * the user hovers a `.comment-mark` span in the main editor. Read-only:
+ * Shows a small floating card when the user hovers a `.comment-mark` span in the
+ * main editor: the parent comment plus all its replies, one per line as
+ * "Author: text" (plain — no avatars or timestamps). Read-only:
* `pointer-events: none` so it never intercepts the mark's click (which opens
* the side panel via ACTIVE_COMMENT_EVENT). Resolved/unknown marks show nothing.
*/
@@ -39,8 +72,8 @@ export default function CommentHoverPreview({
}: CommentHoverPreviewProps) {
const { data } = useCommentsQuery({ pageId });
- // Map of commentId -> comment. Only parent comments anchor marks, but indexing
- // every comment by id is harmless and keeps the lookup a single Map access.
+ // Map of commentId -> comment. The map indexes every comment (parents and
+ // replies) so a thread can be assembled from a single source.
const commentMap = useMemo(() => {
const map = new Map();
data?.items?.forEach((comment) => map.set(comment.id, comment));
@@ -102,12 +135,16 @@ export default function CommentHoverPreview({
return;
}
- // Already tracking this span: nothing to do (avoids re-parsing the
- // comment content on every intra-span mousemove).
+ // Already tracking this span: nothing to do (avoids re-building the thread
+ // on every intra-span mousemove).
if (span === activeSpanRef.current) return;
- const text = commentContentToText(comment.content);
- if (!text) return;
+ const thread = buildThread(commentMapRef.current, comment);
+ // Show the card when the root has text OR it has at least one reply.
+ // A thread of a single empty-text root carries nothing worth showing.
+ const hasContent =
+ thread.length > 1 || thread.some((row) => row.text.length > 0);
+ if (!hasContent) return;
activeSpanRef.current = span;
@@ -117,7 +154,7 @@ export default function CommentHoverPreview({
if (activeSpanRef.current !== span || !span.isConnected) return;
const rect = span.getBoundingClientRect();
setHover({
- text,
+ thread,
rect: { top: rect.top, bottom: rect.bottom, left: rect.left },
});
}, OPEN_DELAY_MS);
@@ -191,21 +228,36 @@ export default function CommentHoverPreview({
...positionStyle,
zIndex: 1000,
maxWidth: CARD_MAX_WIDTH,
- maxHeight: ESTIMATED_CARD_HEIGHT,
+ // The card is pointer-events:none, so it can't scroll; clamp long
+ // threads instead (most threads are short).
+ maxHeight: CARD_MAX_HEIGHT,
overflow: "hidden",
- padding: "6px 10px",
+ padding: "8px 10px",
fontSize: "13px",
lineHeight: 1.4,
// Never intercept clicks targeting the comment-mark span beneath.
pointerEvents: "none",
- whiteSpace: "pre-wrap",
wordBreak: "break-word",
- display: "-webkit-box",
- WebkitLineClamp: 6,
- WebkitBoxOrient: "vertical",
}}
>
- {hover.text}
+ {hover.thread
+ // A comment with no plain text (e.g. an image-only reply) adds nothing
+ // to a text preview — skip its line.
+ .filter((row) => row.text.length > 0)
+ .map((row) => (
+
+ {/* "Author: text" — one line per comment, parent then replies. */}
+
+ {row.name}:
+ {" "}
+ {row.text}
+
+ ))}
,
document.body,
);