From 2ebc912d3e3465bc35c12fe3cfd056f763ea466f Mon Sep 17 00:00:00 2001
From: youxikexue <5642921+youxikexue@users.noreply.github.com>
Date: Tue, 14 Jul 2026 16:58:30 +0800
Subject: [PATCH] fix(messages): hide pointer focus ring on thread
---
.../virtualized-message-thread.test.tsx | 106 ++++++++++++++++++
.../message/virtualized-message-thread.tsx | 13 ++-
2 files changed, 117 insertions(+), 2 deletions(-)
create mode 100644 src/components/message/virtualized-message-thread.test.tsx
diff --git a/src/components/message/virtualized-message-thread.test.tsx b/src/components/message/virtualized-message-thread.test.tsx
new file mode 100644
index 000000000..7ac6a53fe
--- /dev/null
+++ b/src/components/message/virtualized-message-thread.test.tsx
@@ -0,0 +1,106 @@
+import type { ReactNode } from "react"
+import { fireEvent, render, screen } from "@testing-library/react"
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+const testState = vi.hoisted(() => ({
+ scrollRef: { current: null as HTMLDivElement | null },
+}))
+
+vi.mock("use-stick-to-bottom", () => ({
+ useStickToBottomContext: () => ({ scrollRef: testState.scrollRef }),
+}))
+
+vi.mock("virtua", () => ({
+ Virtualizer: ({ children }: { children: ReactNode }) => <>{children}>,
+}))
+
+vi.mock("@/components/ai-elements/message-thread", () => ({
+ MessageThreadContent: ({
+ children,
+ scrollClassName,
+ }: {
+ children: ReactNode
+ scrollClassName?: string
+ }) => (
+
{
+ testState.scrollRef.current = element
+ }}
+ className={scrollClassName}
+ data-testid="viewport"
+ >
+ {children}
+
+ ),
+}))
+
+import { VirtualizedMessageThread } from "@/components/message/virtualized-message-thread"
+
+function renderThread(
+ content: ReactNode = text
+) {
+ return render(
+ item.id}
+ renderItem={() => content}
+ />
+ )
+}
+
+function pointerDown(element: HTMLElement, button: number) {
+ fireEvent(element, new MouseEvent("pointerdown", { bubbles: true, button }))
+}
+
+beforeEach(() => {
+ testState.scrollRef.current = null
+})
+
+describe("VirtualizedMessageThread focus origin", () => {
+ it("marks pointer-origin focus and clears it on blur", () => {
+ renderThread()
+ const viewport = screen.getByTestId("viewport")
+
+ pointerDown(screen.getByTestId("content"), 0)
+
+ expect(document.activeElement).toBe(viewport)
+ expect(viewport).toHaveAttribute("data-focus-origin", "pointer")
+ expect(viewport.className).toContain(
+ "data-[focus-origin=pointer]:focus-visible:ring-0"
+ )
+
+ fireEvent.blur(viewport)
+ expect(viewport).not.toHaveAttribute("data-focus-origin")
+ })
+
+ it("keeps keyboard-origin focus distinguishable", () => {
+ renderThread()
+ const viewport = screen.getByTestId("viewport")
+
+ viewport.focus()
+
+ expect(document.activeElement).toBe(viewport)
+ expect(viewport).not.toHaveAttribute("data-focus-origin")
+ expect(viewport.className).toContain("focus-visible:ring-2")
+ })
+
+ it("does not mark focus when an interactive control is clicked", () => {
+ renderThread()
+ const viewport = screen.getByTestId("viewport")
+
+ pointerDown(screen.getByTestId("action"), 0)
+
+ expect(viewport).not.toHaveAttribute("data-focus-origin")
+ expect(document.activeElement).not.toBe(viewport)
+ })
+
+ it("does not mark focus for a right click", () => {
+ renderThread()
+ const viewport = screen.getByTestId("viewport")
+
+ pointerDown(screen.getByTestId("content"), 2)
+
+ expect(viewport).not.toHaveAttribute("data-focus-origin")
+ expect(document.activeElement).not.toBe(viewport)
+ })
+})
diff --git a/src/components/message/virtualized-message-thread.tsx b/src/components/message/virtualized-message-thread.tsx
index e1987203f..ced6583ab 100644
--- a/src/components/message/virtualized-message-thread.tsx
+++ b/src/components/message/virtualized-message-thread.tsx
@@ -103,6 +103,9 @@ function VirtualizedMessageThreadImpl({
const el = scrollRef.current
if (!el) return
el.tabIndex = 0
+ const clearPointerFocus = () => {
+ el.removeAttribute("data-focus-origin")
+ }
const onPointerDown = (e: PointerEvent) => {
// Ignore right-click and macOS ctrl-click (both open the context menu).
if (e.button !== 0 || e.ctrlKey) return
@@ -117,10 +120,16 @@ function VirtualizedMessageThreadImpl({
)
)
return
+ el.setAttribute("data-focus-origin", "pointer")
el.focus({ preventScroll: true })
}
el.addEventListener("pointerdown", onPointerDown)
- return () => el.removeEventListener("pointerdown", onPointerDown)
+ el.addEventListener("blur", clearPointerFocus)
+ return () => {
+ el.removeEventListener("pointerdown", onPointerDown)
+ el.removeEventListener("blur", clearPointerFocus)
+ clearPointerFocus()
+ }
}, [scrollRef])
// Pre-compute the three possible padding styles so every render reuses
@@ -146,7 +155,7 @@ function VirtualizedMessageThreadImpl({
{items.length === 0 ? (