fix(webui): position sidebar highlight on mount

This commit is contained in:
Xubin Ren 2026-08-01 22:54:32 +08:00
parent 0cb7dd5cc9
commit db6c9effc3
5 changed files with 77 additions and 56 deletions

View File

@ -107,7 +107,6 @@ export const ChatList = memo(function ChatList({
}: ChatListProps) {
const { t } = useTranslation();
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
const listContentRef = useRef<HTMLDivElement>(null);
const activeRowRef = useRef<HTMLDivElement>(null);
const labels = useMemo<ChatGroupLabels>(() => ({
pinned: t("chat.groups.pinned"),
@ -188,8 +187,10 @@ export const ChatList = memo(function ChatList({
return (
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
<div
ref={listContentRef}
<SidebarSelectionHighlight
targetRef={activeRowRef}
activeId={activeKey}
scope="sessions"
data-chat-list-content
className="relative min-w-0 space-y-3 px-2 py-1.5"
>
@ -408,13 +409,7 @@ export const ChatList = memo(function ChatList({
</button>
</div>
) : null}
<SidebarSelectionHighlight
containerRef={listContentRef}
targetRef={activeRowRef}
activeId={activeKey}
scope="sessions"
/>
</div>
</SidebarSelectionHighlight>
</div>
);
});

View File

@ -92,7 +92,6 @@ export function Sidebar(props: SidebarProps) {
const collapsed = Boolean(props.collapsed);
const toggleLabel = t("thread.header.toggleSidebar");
const newChatShortcut = newChatShortcutLabel();
const actionListRef = useRef<HTMLDivElement>(null);
const activeActionRef = useRef<HTMLButtonElement>(null);
const activeActionId = props.newChatActive
? "new-chat"
@ -150,8 +149,10 @@ export function Sidebar(props: SidebarProps) {
)}
</div>
<div
ref={actionListRef}
<SidebarSelectionHighlight
targetRef={activeActionRef}
activeId={activeActionId}
scope="actions"
className={cn(
"relative space-y-1.5 px-2 pb-2",
collapsed && "flex w-14 flex-col items-center px-0",
@ -208,13 +209,7 @@ export function Sidebar(props: SidebarProps) {
icon={<Archive className="h-4 w-4" />}
/>
) : null}
<SidebarSelectionHighlight
containerRef={actionListRef}
targetRef={activeActionRef}
activeId={activeActionId}
scope="actions"
/>
</div>
</SidebarSelectionHighlight>
<div
className={cn(
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden transition-opacity duration-200",

View File

@ -1,11 +1,11 @@
import {
type HTMLAttributes,
type RefObject,
useLayoutEffect,
useRef,
} from "react";
interface SidebarSelectionHighlightProps {
containerRef: RefObject<HTMLElement>;
interface SidebarSelectionHighlightProps extends HTMLAttributes<HTMLDivElement> {
targetRef: RefObject<HTMLElement>;
activeId: string | null;
scope: string;
@ -18,11 +18,13 @@ export const SIDEBAR_SELECTION_ACTION_ITEM_CLASS =
"relative z-[1] transition-[width,padding,color] [transition-duration:300ms,300ms,150ms] ease-out motion-reduce:transition-none";
export function SidebarSelectionHighlight({
containerRef,
targetRef,
activeId,
scope,
children,
...containerProps
}: SidebarSelectionHighlightProps) {
const containerRef = useRef<HTMLDivElement>(null);
const highlightRef = useRef<HTMLDivElement>(null);
const positionedRef = useRef(false);
@ -30,11 +32,19 @@ export function SidebarSelectionHighlight({
const highlight = highlightRef.current;
const container = containerRef.current;
const target = targetRef.current;
if (!highlight) return;
if (!activeId || !container || !target) {
highlight.style.opacity = "0";
positionedRef.current = false;
return;
}
let restoreTransitionFrame: number | null = null;
const position = () => {
if (!highlight) return;
if (!activeId || !container || !target) {
const containerRect = container.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
if (targetRect.width === 0 || targetRect.height === 0) {
highlight.style.opacity = "0";
positionedRef.current = false;
return;
@ -43,8 +53,6 @@ export function SidebarSelectionHighlight({
const firstPosition = !positionedRef.current;
if (firstPosition) highlight.style.transitionProperty = "none";
const containerRect = container.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
highlight.style.width = `${targetRect.width}px`;
highlight.style.height = `${targetRect.height}px`;
highlight.style.transform = `translate3d(${targetRect.left - containerRect.left}px, ${
@ -64,8 +72,8 @@ export function SidebarSelectionHighlight({
position();
const resizeObserver =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(position);
if (container) resizeObserver?.observe(container);
if (target) resizeObserver?.observe(target);
resizeObserver?.observe(container);
resizeObserver?.observe(target);
window.addEventListener("resize", position);
return () => {
@ -79,12 +87,15 @@ export function SidebarSelectionHighlight({
});
return (
<div
ref={highlightRef}
data-testid={`${scope}-selection-highlight`}
data-active-id={activeId ?? undefined}
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] duration-300 ease-out will-change-transform motion-reduce:transition-none dark:bg-white/[0.07]"
/>
<div {...containerProps} ref={containerRef}>
{children}
<div
ref={highlightRef}
data-testid={`${scope}-selection-highlight`}
data-active-id={activeId ?? undefined}
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] duration-300 ease-out will-change-transform motion-reduce:transition-none dark:bg-white/[0.07]"
/>
</div>
);
}

View File

@ -2501,7 +2501,6 @@ function SettingsSidebar({
hostChromeInset?: boolean;
}) {
const { t } = useTranslation();
const desktopNavRef = useRef<HTMLDivElement>(null);
const activeNavItemRef = useRef<HTMLButtonElement>(null);
const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection)
?? SETTINGS_NAV_ITEMS[0];
@ -2575,7 +2574,12 @@ function SettingsSidebar({
</DropdownMenuContent>
</DropdownMenu>
<div ref={desktopNavRef} className="relative hidden space-y-1 lg:block">
<SidebarSelectionHighlight
targetRef={activeNavItemRef}
activeId={activeSection}
scope="settings"
className="relative hidden space-y-1 lg:block"
>
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
const active = key === activeSection;
return (
@ -2600,13 +2604,7 @@ function SettingsSidebar({
</button>
);
})}
<SidebarSelectionHighlight
containerRef={desktopNavRef}
targetRef={activeNavItemRef}
activeId={activeSection}
scope="settings"
/>
</div>
</SidebarSelectionHighlight>
</nav>
<div className="hidden lg:mt-auto lg:block lg:pt-4">

View File

@ -44,6 +44,7 @@ function rect({
describe("ChatList", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("orders chats by latest session activity by default", () => {
@ -220,8 +221,20 @@ describe("ChatList", () => {
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
});
it("positions one background highlight, then slides it between selected topics", () => {
it("positions one background highlight and resets it across hidden targets", () => {
let revealFrame: FrameRequestCallback | null = null;
let resizeObserverCallback: ResizeObserverCallback | null = null;
let activeTargetVisible = true;
class MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeObserverCallback = callback;
}
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal("ResizeObserver", MockResizeObserver);
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
revealFrame = callback;
return 1;
@ -232,7 +245,9 @@ describe("ChatList", () => {
return rect({ left: 0, top: 0, width: 300, height: 200 });
}
if (this.getAttribute("data-chat-row") === "websocket:active") {
return rect({ left: 8, top: 12, width: 284, height: 32 });
return activeTargetVisible
? rect({ left: 8, top: 12, width: 284, height: 32 })
: rect({ left: 0, top: 0, width: 0, height: 0 });
}
if (this.getAttribute("data-chat-row") === "websocket:inactive") {
return rect({ left: 8, top: 48, width: 284, height: 40 });
@ -255,7 +270,7 @@ describe("ChatList", () => {
const { rerender } = render(
<ChatList
{...props}
activeKey={null}
activeKey="websocket:active"
/>,
);
@ -265,16 +280,9 @@ describe("ChatList", () => {
"transition-[transform,width,height]",
"motion-reduce:transition-none",
);
expect(highlight).toHaveStyle("opacity: 0");
expect(screen.queryByTestId("sessions-selection-highlight-surface"))
.not.toBeInTheDocument();
rerender(
<ChatList
{...props}
activeKey="websocket:active"
/>,
);
expect(resizeObserverCallback).not.toBeNull();
const activeButton = screen.getByTitle("Active topic");
expect(activeButton).toHaveAttribute("aria-current", "page");
@ -295,6 +303,17 @@ describe("ChatList", () => {
revealFrame?.(0);
expect(highlight.style.transitionProperty).toBe("");
activeTargetVisible = false;
resizeObserverCallback?.([], {} as ResizeObserver);
expect(highlight).toHaveStyle("opacity: 0");
activeTargetVisible = true;
resizeObserverCallback?.([], {} as ResizeObserver);
expect(highlight).toHaveStyle(
"width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1; transition-property: none",
);
revealFrame?.(0);
rerender(
<ChatList
{...props}
@ -307,6 +326,9 @@ describe("ChatList", () => {
expect(highlight).toHaveStyle(
"width: 284px; height: 40px; transform: translate3d(8px, 48px, 0)",
);
rerender(<ChatList {...props} activeKey={null} />);
expect(highlight).toHaveStyle("opacity: 0");
});
it("can collapse a project group and keeps project rename separate from chat titles", async () => {