From 7dbbcbfab02d52f9ea29be059f005ab863c19e86 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 21:46:08 +0000 Subject: [PATCH 1/5] test(web): file path tooltips stay open for selection until the pointer leaves Co-Authored-By: Claude Opus 5.5 --- .../components/ChatMarkdown.browser.test.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index e30b81c5e5f93..85d2d0d56356a 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -2,6 +2,7 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; import { type ReactElement } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; +import { commands, page, userEvent } from "vite-plus/test/browser"; import { act, render } from "../../test/render"; import "../index.css"; @@ -230,6 +231,26 @@ describe("ChatMarkdown workspace file opens", () => { }); }); +describe("ChatMarkdown file path tooltips", () => { + it("stays open while the pointer selects the path, then closes when it leaves", async () => { + setup(); + await using screen = await renderText("[notes](/tmp/project/docs/deeply/nested/notes.md)"); + await userEvent.hover(screen.container.querySelector(".chat-markdown-file-link")!); + const path = page.getByText("/tmp/project/docs/deeply/nested/notes.md"); + await expect.element(path).toBeVisible(); + + // Forced so the pointer lands where the user would put it even if the popup ignores it. + await userEvent.tripleClick(path, { force: true }); + expect(window.getSelection()?.toString().trim()).toBe( + "/tmp/project/docs/deeply/nested/notes.md", + ); + await expect.element(path).toBeVisible(); + + await commands.resetPointer(); + await expect.element(path).not.toBeInTheDocument(); + }); +}); + describe("ChatMarkdown headings", () => { it("never gives two headings the same id, even when a suffix matches another heading", async () => { setup(); From 8adedccae51d6c520db2d827d9bffbcd7199a383 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 21:47:29 +0000 Subject: [PATCH 2/5] fix(web): tooltips stay open while the pointer moves onto them Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ui/tooltip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index e7751a8e41288..a8917083895a5 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -138,7 +138,7 @@ function TooltipPopup({ Date: Sat, 24 Oct 2026 21:55:18 +0000 Subject: [PATCH 3/5] fix(web): scrolling a long file path no longer closes its tooltip Popup scrolls bubble through the React portal into TooltipScrollDismissArea, so only scrolls from inside the area's own DOM dismiss hovered tooltips. Co-Authored-By: Claude Opus 5.5 --- .../components/ChatMarkdown.browser.test.tsx | 31 +++++++++++++++++++ apps/web/src/components/ui/tooltip.tsx | 5 ++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index 85d2d0d56356a..f4e49570450bc 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -74,6 +74,7 @@ vi.mock(import("../hooks/useSettings"), async (original) => { }); import ChatMarkdown from "./ChatMarkdown"; +import { TooltipScrollDismissArea } from "./ui/tooltip"; function setup() { settingsOverrides.mathRenderingEnabled = false; @@ -249,6 +250,36 @@ describe("ChatMarkdown file path tooltips", () => { await commands.resetPointer(); await expect.element(path).not.toBeInTheDocument(); }); + + it("stays open while its long path scrolls, then closes when the timeline scrolls", async () => { + setup(); + const longPath = `/tmp/project/${"deeply/nested/".repeat(20)}notes.md`; + await using screen = await render( + + +
+ , + ); + const area = screen.container.firstElementChild as HTMLElement; + const trigger = area.querySelector(".chat-markdown-file-link")!; + await userEvent.hover(trigger); + const path = page.getByText(longPath); + await expect.element(path).toBeVisible(); + + const scrollOnce = async (element: HTMLElement, scroll: () => void) => { + const scrolled = new Promise((resolve) => + element.addEventListener("scroll", resolve, { once: true }), + ); + scroll(); + await scrolled; + }; + const pathScroller = path.element().closest(".overflow-x-auto")!; + await scrollOnce(pathScroller, () => (pathScroller.scrollLeft = 80)); + expect(trigger).toHaveAttribute("data-popup-open"); + + await scrollOnce(area, () => (area.scrollTop = 40)); + await expect.element(path).not.toBeInTheDocument(); + }); }); describe("ChatMarkdown headings", () => { diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index a8917083895a5..54bd0a209bf80 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -54,7 +54,10 @@ function TooltipScrollDismissArea({ onScrollCapture, ...props }: ComponentProps< {...props} onScrollCapture={(event) => { onScrollCapture?.(event); - tracker.dismissUnfocused(); + // Portaled popups bubble their own scrolls here through React, such as a long path. + if (event.target instanceof Node && event.currentTarget.contains(event.target)) { + tracker.dismissUnfocused(); + } }} /> From efbb71aa999d8ef34892da24d15ed4a8bcff9e2b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 07:05:36 +0000 Subject: [PATCH 4/5] test(web): cover tooltip text selection and inner scroll at the tooltip layer File paths in tooltips now wrap on main, so the regressions move from ChatMarkdown to the shared tooltip they exercise. Co-Authored-By: Claude Opus 5.5 --- .../components/ui/tooltip.browser.test.tsx | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/tooltip.browser.test.tsx b/apps/web/src/components/ui/tooltip.browser.test.tsx index 24d76784e8a5a..2be3f72292c38 100644 --- a/apps/web/src/components/ui/tooltip.browser.test.tsx +++ b/apps/web/src/components/ui/tooltip.browser.test.tsx @@ -1,5 +1,5 @@ import { expect, it, vi } from "vite-plus/test"; -import { page, userEvent } from "vite-plus/test/browser"; +import { commands, page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; import { render } from "../../../test/render"; @@ -41,3 +41,54 @@ it.each([ if (c.keptOpen) await expect.element(page.getByText("Description")).toBeVisible(); else await expect.element(page.getByText("Description")).not.toBeInTheDocument(); }); + +it("stays open while the pointer selects its text, then closes when the pointer leaves", async () => { + await using screen = await render( + + File + /tmp/project/docs/notes.md + , + ); + await userEvent.hover(screen.getByRole("button", { name: "File" })); + const text = page.getByText("/tmp/project/docs/notes.md"); + await expect.element(text).toBeVisible(); + + // Forced so the pointer lands on the popup even when it ignores pointer events. + await userEvent.tripleClick(text, { force: true }); + expect(window.getSelection()?.toString().trim()).toBe("/tmp/project/docs/notes.md"); + await expect.element(text).toBeVisible(); + + await commands.resetPointer(); + await expect.element(text).not.toBeInTheDocument(); +}); + +it("stays open when its own content scrolls inside a scroll dismiss area", async () => { + await using screen = await render( + + + Pull requests + +
+
Pull request list
+
+
+
+
+ , + ); + const trigger = screen.getByRole("button", { name: "Pull requests" }); + await userEvent.hover(trigger); + const list = page.getByTestId("list"); + await expect.element(list).toBeVisible(); + + const listElement = list.element(); + const listScrolled = new Promise((resolve) => + listElement.addEventListener("scroll", resolve, { once: true }), + ); + listElement.scrollTop = 20; + await listScrolled; + await expect.element(trigger).toHaveAttribute("data-popup-open"); + + screen.getByTestId("timeline").element().scrollTop = 20; + await expect.element(list).not.toBeInTheDocument(); +}); From 0e06830fefa11ec95563f664c6f9fee6f57aae79 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 07:18:20 +0000 Subject: [PATCH 5/5] fix(web): only file path tooltips take the pointer Making every tooltip popup take the pointer let open tooltips block the controls under them. Restore the shared pass-through default and opt the file path tooltips in, as ThreadStatusIndicators already does. Drop the popup scroll guard: wrapped file paths no longer scroll. Co-Authored-By: Claude Opus 5.5 --- .../components/ChatMarkdown.browser.test.tsx | 18 ++++++- .../components/ComposerPromptEditorTiptap.tsx | 2 +- .../src/components/chat/MarkdownFileLink.tsx | 2 +- .../chat/UserMessageContextChips.tsx | 2 +- .../components/ui/tooltip.browser.test.tsx | 53 +------------------ apps/web/src/components/ui/tooltip.tsx | 7 +-- 6 files changed, 23 insertions(+), 61 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index 66dc5c150a651..ae678a02c8346 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -3,7 +3,7 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; import { type ReactElement } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; -import { page, userEvent } from "vite-plus/test/browser"; +import { commands, page, userEvent } from "vite-plus/test/browser"; import { act, render } from "../../test/render"; import "../index.css"; @@ -682,6 +682,22 @@ describe("ChatMarkdown alert copy", () => { }); describe("ChatMarkdown file chip tooltips", () => { + it("stays open while the pointer selects the path, then closes when it leaves", async () => { + setup(); + await using screen = await renderText("[notes](/tmp/project/docs/notes.md)"); + await userEvent.hover(screen.container.querySelector(".chat-markdown-file-link")!); + const path = page.getByText("/tmp/project/docs/notes.md"); + await expect.element(path).toBeVisible(); + + // Forced so the pointer lands on the popup even when it ignores pointer events. + await userEvent.tripleClick(path, { force: true }); + expect(window.getSelection()?.toString().trim()).toBe("/tmp/project/docs/notes.md"); + await expect.element(path).toBeVisible(); + + await commands.resetPointer(); + await expect.element(path).not.toBeInTheDocument(); + }); + it("wraps a long path so its file name stays inside the tooltip", async () => { setup(); const path = diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 5a036e34611ed..d6354080e8327 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -300,7 +300,7 @@ function ComposerMentionNodeView({ node }: NodeViewProps) { - + - + diff --git a/apps/web/src/components/chat/UserMessageContextChips.tsx b/apps/web/src/components/chat/UserMessageContextChips.tsx index 39801a908b952..d6c320d090540 100644 --- a/apps/web/src/components/chat/UserMessageContextChips.tsx +++ b/apps/web/src/components/chat/UserMessageContextChips.tsx @@ -92,7 +92,7 @@ function UserMessageMentionChip(props: { } /> - + { - await using screen = await render( - - File - /tmp/project/docs/notes.md - , - ); - await userEvent.hover(screen.getByRole("button", { name: "File" })); - const text = page.getByText("/tmp/project/docs/notes.md"); - await expect.element(text).toBeVisible(); - - // Forced so the pointer lands on the popup even when it ignores pointer events. - await userEvent.tripleClick(text, { force: true }); - expect(window.getSelection()?.toString().trim()).toBe("/tmp/project/docs/notes.md"); - await expect.element(text).toBeVisible(); - - await commands.resetPointer(); - await expect.element(text).not.toBeInTheDocument(); -}); - -it("stays open when its own content scrolls inside a scroll dismiss area", async () => { - await using screen = await render( - - - Pull requests - -
-
Pull request list
-
-
-
-
- , - ); - const trigger = screen.getByRole("button", { name: "Pull requests" }); - await userEvent.hover(trigger); - const list = page.getByTestId("list"); - await expect.element(list).toBeVisible(); - - const listElement = list.element(); - const listScrolled = new Promise((resolve) => - listElement.addEventListener("scroll", resolve, { once: true }), - ); - listElement.scrollTop = 20; - await listScrolled; - await expect.element(trigger).toHaveAttribute("data-popup-open"); - - screen.getByTestId("timeline").element().scrollTop = 20; - await expect.element(list).not.toBeInTheDocument(); -}); diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 1563dc2286d7a..614943364b1e1 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -55,10 +55,7 @@ function TooltipScrollDismissArea({ onScrollCapture, ...props }: ComponentProps< {...props} onScrollCapture={(event) => { onScrollCapture?.(event); - // React bubbles scrolls from portaled popups here; only this area's own scrolls dismiss. - if (event.target instanceof Node && event.currentTarget.contains(event.target)) { - tracker.dismissUnfocused(); - } + tracker.dismissUnfocused(); }} /> @@ -143,7 +140,7 @@ function TooltipPopup({