From 73d3a49a27c5dc833d3eb65605e5c92f6f36caca Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:19:18 +0800 Subject: [PATCH] style(webui): float in conversation highlight --- webui/src/components/ChatList.tsx | 46 ++++++++++++++++++++++++++---- webui/src/tests/chat-list.test.tsx | 35 +++++++++++++++++++---- 2 files changed, 70 insertions(+), 11 deletions(-) diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx index a0c067ece..f2a33b476 100644 --- a/webui/src/components/ChatList.tsx +++ b/webui/src/components/ChatList.tsx @@ -107,6 +107,8 @@ export const ChatList = memo(function ChatList({ const listContentRef = useRef(null); const activeRowRef = useRef(null); const activeHighlightRef = useRef(null); + const activeHighlightSurfaceRef = useRef(null); + const highlightVisibleRef = useRef(false); const labels = useMemo(() => ({ pinned: t("chat.groups.pinned"), all: t("chat.groups.all"), @@ -162,17 +164,27 @@ export const ChatList = memo(function ChatList({ }, [showArchived, sort]); useLayoutEffect(() => { + let resetTransitionFrame: number | null = null; + const updateHighlight = () => { const content = listContentRef.current; const row = activeRowRef.current; const highlight = activeHighlightRef.current; + const surface = activeHighlightSurfaceRef.current; - if (!highlight) return; + if (!highlight || !surface) return; if (!content || !row) { - highlight.style.opacity = "0"; + surface.style.opacity = "0"; + surface.style.transform = "scale(0.97)"; + highlightVisibleRef.current = false; return; } + const shouldFloatIn = !highlightVisibleRef.current; + if (shouldFloatIn) { + highlight.style.transitionProperty = "none"; + } + const contentRect = content.getBoundingClientRect(); const rowRect = row.getBoundingClientRect(); highlight.style.width = `${rowRect.width}px`; @@ -180,7 +192,21 @@ export const ChatList = memo(function ChatList({ highlight.style.transform = `translate3d(${rowRect.left - contentRect.left}px, ${ rowRect.top - contentRect.top }px, 0)`; - highlight.style.opacity = "1"; + + if (shouldFloatIn) { + void highlight.offsetWidth; + } + + surface.style.opacity = "1"; + surface.style.transform = "scale(1)"; + highlightVisibleRef.current = true; + + if (shouldFloatIn) { + resetTransitionFrame = window.requestAnimationFrame(() => { + highlight.style.removeProperty("transition-property"); + resetTransitionFrame = null; + }); + } }; updateHighlight(); @@ -196,6 +222,10 @@ export const ChatList = memo(function ChatList({ window.addEventListener("resize", updateHighlight); return () => { + if (resetTransitionFrame !== null) { + window.cancelAnimationFrame(resetTransitionFrame); + } + activeHighlightRef.current?.style.removeProperty("transition-property"); resizeObserver?.disconnect(); window.removeEventListener("resize", updateHighlight); }; @@ -449,8 +479,14 @@ export const ChatList = memo(function ChatList({ ref={activeHighlightRef} data-testid="active-chat-highlight" aria-hidden="true" - className="pointer-events-none absolute left-0 top-0 z-0 !mt-0 rounded-xl bg-sidebar-foreground/[0.055] opacity-0 transition-[transform,width,height,opacity] duration-300 ease-out will-change-transform motion-reduce:transition-none dark:bg-white/[0.07]" - /> + className="pointer-events-none absolute left-0 top-0 z-0 !mt-0 transition-[transform,width,height] duration-300 ease-out will-change-transform motion-reduce:transition-none" + > +
+
); diff --git a/webui/src/tests/chat-list.test.tsx b/webui/src/tests/chat-list.test.tsx index df9c20c2f..9afcf10da 100644 --- a/webui/src/tests/chat-list.test.tsx +++ b/webui/src/tests/chat-list.test.tsx @@ -220,7 +220,12 @@ describe("ChatList", () => { expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument(); }); - it("slides one borderless highlight between selected topics", () => { + it("floats a borderless highlight in, then slides it between selected topics", () => { + let revealFrame: FrameRequestCallback | null = null; + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + revealFrame = callback; + return 1; + }); vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( function (this: HTMLElement) { if (this.hasAttribute("data-chat-list-content")) { @@ -250,11 +255,26 @@ describe("ChatList", () => { const { rerender } = render( , ); const highlight = screen.getByTestId("active-chat-highlight"); + const surface = screen.getByTestId("active-chat-highlight-surface"); + expect(surface).toHaveClass( + "bg-sidebar-foreground/[0.055]", + "transition-[opacity,transform]", + "motion-reduce:transition-none", + ); + expect(surface).toHaveStyle("opacity: 0; transform: scale(0.97)"); + + rerender( + , + ); + const activeButton = screen.getByTitle("Active topic"); expect(activeButton).toHaveAttribute("aria-current", "page"); expect(activeButton.parentElement).not.toHaveClass( @@ -262,13 +282,16 @@ describe("ChatList", () => { "shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]", ); expect(highlight).toHaveClass( - "bg-sidebar-foreground/[0.055]", - "transition-[transform,width,height,opacity]", + "transition-[transform,width,height]", "motion-reduce:transition-none", ); expect(highlight).toHaveStyle( - "width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1", + "width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); transition-property: none", ); + expect(surface).toHaveStyle("opacity: 1; transform: scale(1)"); + + revealFrame?.(0); + expect(highlight.style.transitionProperty).toBe(""); rerender( { expect(screen.getByTitle("Active topic")).not.toHaveAttribute("aria-current"); expect(screen.getByTitle("Inactive topic")).toHaveAttribute("aria-current", "page"); expect(highlight).toHaveStyle( - "width: 284px; height: 40px; transform: translate3d(8px, 48px, 0); opacity: 1", + "width: 284px; height: 40px; transform: translate3d(8px, 48px, 0)", ); });