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 6f7289e8..235fc141 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 @@ -268,6 +268,33 @@ describe("CommentHoverPreview — hover behaviour", () => { expect(card.textContent).toContain("Bob: A reply"); }); + it("shows nothing when neither the parent nor its reply has any text", () => { + // The card is gated on rows-with-text (not thread length), so a text-less + // root whose only reply is also text-less must NOT open an empty card. + const emptyDoc = JSON.stringify({ type: "doc", content: [] }); + setComments([ + comment({ + id: "c-1", + content: emptyDoc, + creator: { id: "u-1", name: "Alice", avatarUrl: null } as any, + }), + comment({ + id: "c-2", + content: emptyDoc, + parentCommentId: "c-1", + createdAt: new Date(), + creator: { id: "u-2", name: "Bob", avatarUrl: null } as any, + }), + ]); + render(); + + hoverMark(); + act(() => { + vi.advanceTimersByTime(350); + }); + expect(screen.queryByTestId("comment-hover-preview")).toBeNull(); + }); + it("hides on mouseout", () => { setComments([comment()]); render(); 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 5f68f5eb..7ebcc066 100644 --- a/apps/client/src/features/comment/components/comment-hover-preview.tsx +++ b/apps/client/src/features/comment/components/comment-hover-preview.tsx @@ -18,7 +18,10 @@ 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 = 200; +// Match CARD_MAX_HEIGHT so the flip-above decision reserves the real worst-case +// height — otherwise a tall thread placed below near the viewport bottom passes +// the "fits below" check and then overflows off-screen (clipped, no scroll). +const ESTIMATED_CARD_HEIGHT = 300; // 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". @@ -140,10 +143,10 @@ export default function CommentHoverPreview({ if (span === activeSpanRef.current) 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); + // Show the card only when SOME comment has text. Gating on thread length + // could open an empty card (a text-less root whose only reply is also + // text-less), since the render filters out empty-text rows. + const hasContent = thread.some((row) => row.text.length > 0); if (!hasContent) return; activeSpanRef.current = span;