diff --git a/apps/web/components/task/chat/messages/inline-code.test.tsx b/apps/web/components/task/chat/messages/inline-code.test.tsx new file mode 100644 index 0000000000..4f64b58b69 --- /dev/null +++ b/apps/web/components/task/chat/messages/inline-code.test.tsx @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { InlineCode } from "./inline-code"; + +const { copyMock } = vi.hoisted(() => ({ + copyMock: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/hooks/use-copy-to-clipboard", () => ({ + useCopyToClipboard: () => ({ copy: copyMock }), +})); + +describe("InlineCode", () => { + beforeEach(() => { + copyMock.mockClear(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders the inline code content", () => { + render(npm install); + expect(screen.getByText("npm install")).toBeDefined(); + }); + + it("portals the hover tooltip to document.body so overflow-hidden ancestors cannot clip it", () => { + // Mirrors the user-message bubble with nested overflow-hidden layers that previously clipped the tooltip. + const { container } = render( +
+
+ git status +
+
, + ); + + fireEvent.mouseEnter(screen.getByText("git status")); + + const tooltip = screen.getByRole("tooltip"); + expect(tooltip.textContent).toBe("Copy to clipboard"); + // Tooltip must escape the clipping wrapper: lives on document.body, not inside the container. + expect(container.contains(tooltip)).toBe(false); + expect(document.body.contains(tooltip)).toBe(true); + }); + + it("hides the tooltip when the pointer leaves", () => { + render(ls); + const code = screen.getByText("ls"); + + fireEvent.mouseEnter(code); + expect(screen.queryByRole("tooltip")).not.toBeNull(); + + fireEvent.mouseLeave(code); + expect(screen.queryByRole("tooltip")).toBeNull(); + }); + + it("copies the content and shows a Copied! confirmation on click", async () => { + render(echo hi); + + await act(async () => { + fireEvent.click(screen.getByText("echo hi")); + }); + + expect(copyMock).toHaveBeenCalledWith("echo hi"); + expect(screen.getByRole("tooltip").textContent).toBe("Copied!"); + }); + + it("wires the trigger to the tooltip via aria-describedby only while visible", () => { + render(pwd); + const code = screen.getByText("pwd"); + + expect(code.getAttribute("aria-describedby")).toBeNull(); + + fireEvent.mouseEnter(code); + const tooltip = screen.getByRole("tooltip"); + expect(code.getAttribute("aria-describedby")).toBe(tooltip.id); + expect(tooltip.id).not.toBe(""); + + fireEvent.mouseLeave(code); + expect(code.getAttribute("aria-describedby")).toBeNull(); + }); + + it("dismisses the tooltip on scroll so it cannot drift from the anchored code", () => { + render(cd); + + fireEvent.mouseEnter(screen.getByText("cd")); + expect(screen.queryByRole("tooltip")).not.toBeNull(); + + act(() => { + window.dispatchEvent(new Event("scroll")); + }); + + expect(screen.queryByRole("tooltip")).toBeNull(); + }); +}); diff --git a/apps/web/components/task/chat/messages/inline-code.tsx b/apps/web/components/task/chat/messages/inline-code.tsx index 100da253c7..52f9c124eb 100644 --- a/apps/web/components/task/chat/messages/inline-code.tsx +++ b/apps/web/components/task/chat/messages/inline-code.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { cn } from "@/lib/utils"; import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; @@ -8,45 +9,89 @@ type InlineCodeProps = { children: React.ReactNode; }; +const COPIED_RESET_MS = 1500; + +type TooltipAnchor = { left: number; top: number }; + +// Tooltip is portaled to document.body so overflow-hidden ancestors (e.g. the user-message bubble) can't clip it. export function InlineCode({ children }: InlineCodeProps) { const { copy } = useCopyToClipboard(); - const [showTooltip, setShowTooltip] = useState(false); - const [tooltipText, setTooltipText] = useState("Copy to clipboard"); + const [anchor, setAnchor] = useState(null); + const [hovered, setHovered] = useState(false); + const [copied, setCopied] = useState(false); + const resetTimer = useRef | null>(null); + const tooltipId = useId(); - const handleClick = async () => { - const text = String(children); - await copy(text); - setTooltipText("Copied!"); - setShowTooltip(true); + useEffect( + () => () => { + if (resetTimer.current) clearTimeout(resetTimer.current); + }, + [], + ); - setTimeout(() => { - setShowTooltip(false); - setTimeout(() => setTooltipText("Copy to clipboard"), 200); - }, 1500); + // Viewport snapshot; dismiss on scroll/resize rather than repositioning. + const visible = hovered || copied; + useEffect(() => { + if (!visible) return; + const dismiss = () => { + setHovered(false); + setCopied(false); + }; + window.addEventListener("scroll", dismiss, true); + window.addEventListener("resize", dismiss); + return () => { + window.removeEventListener("scroll", dismiss, true); + window.removeEventListener("resize", dismiss); + }; + }, [visible]); + + const anchorTo = (el: HTMLElement) => { + const rect = el.getBoundingClientRect(); + setAnchor({ left: rect.left + rect.width / 2, top: rect.top }); + }; + + const handleClick = async (event: React.MouseEvent) => { + anchorTo(event.currentTarget); + await copy(String(children)); + setCopied(true); + if (resetTimer.current) clearTimeout(resetTimer.current); + resetTimer.current = setTimeout(() => setCopied(false), COPIED_RESET_MS); }; return ( - + <> { + anchorTo(event.currentTarget); + setHovered(true); + }} + onMouseLeave={() => setHovered(false)} + aria-describedby={visible ? tooltipId : undefined} className="cursor-pointer hover:bg-foreground/10 transition-colors" > {children} - {/* Tooltip */} - + {copied ? "Copied!" : "Copy to clipboard"} + , + document.body, )} - > - {tooltipText} - - - + ); }