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(
+
,
+ );
+
+ 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}
-
-
-
+ >
);
}