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

View File

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

View File

@ -1,11 +1,11 @@
import { import {
type HTMLAttributes,
type RefObject, type RefObject,
useLayoutEffect, useLayoutEffect,
useRef, useRef,
} from "react"; } from "react";
interface SidebarSelectionHighlightProps { interface SidebarSelectionHighlightProps extends HTMLAttributes<HTMLDivElement> {
containerRef: RefObject<HTMLElement>;
targetRef: RefObject<HTMLElement>; targetRef: RefObject<HTMLElement>;
activeId: string | null; activeId: string | null;
scope: string; 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"; "relative z-[1] transition-[width,padding,color] [transition-duration:300ms,300ms,150ms] ease-out motion-reduce:transition-none";
export function SidebarSelectionHighlight({ export function SidebarSelectionHighlight({
containerRef,
targetRef, targetRef,
activeId, activeId,
scope, scope,
children,
...containerProps
}: SidebarSelectionHighlightProps) { }: SidebarSelectionHighlightProps) {
const containerRef = useRef<HTMLDivElement>(null);
const highlightRef = useRef<HTMLDivElement>(null); const highlightRef = useRef<HTMLDivElement>(null);
const positionedRef = useRef(false); const positionedRef = useRef(false);
@ -30,11 +32,19 @@ export function SidebarSelectionHighlight({
const highlight = highlightRef.current; const highlight = highlightRef.current;
const container = containerRef.current; const container = containerRef.current;
const target = targetRef.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; let restoreTransitionFrame: number | null = null;
const position = () => { const position = () => {
if (!highlight) return; const containerRect = container.getBoundingClientRect();
if (!activeId || !container || !target) { const targetRect = target.getBoundingClientRect();
if (targetRect.width === 0 || targetRect.height === 0) {
highlight.style.opacity = "0"; highlight.style.opacity = "0";
positionedRef.current = false; positionedRef.current = false;
return; return;
@ -43,8 +53,6 @@ export function SidebarSelectionHighlight({
const firstPosition = !positionedRef.current; const firstPosition = !positionedRef.current;
if (firstPosition) highlight.style.transitionProperty = "none"; if (firstPosition) highlight.style.transitionProperty = "none";
const containerRect = container.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
highlight.style.width = `${targetRect.width}px`; highlight.style.width = `${targetRect.width}px`;
highlight.style.height = `${targetRect.height}px`; highlight.style.height = `${targetRect.height}px`;
highlight.style.transform = `translate3d(${targetRect.left - containerRect.left}px, ${ highlight.style.transform = `translate3d(${targetRect.left - containerRect.left}px, ${
@ -64,8 +72,8 @@ export function SidebarSelectionHighlight({
position(); position();
const resizeObserver = const resizeObserver =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(position); typeof ResizeObserver === "undefined" ? null : new ResizeObserver(position);
if (container) resizeObserver?.observe(container); resizeObserver?.observe(container);
if (target) resizeObserver?.observe(target); resizeObserver?.observe(target);
window.addEventListener("resize", position); window.addEventListener("resize", position);
return () => { return () => {
@ -79,12 +87,15 @@ export function SidebarSelectionHighlight({
}); });
return ( return (
<div <div {...containerProps} ref={containerRef}>
ref={highlightRef} {children}
data-testid={`${scope}-selection-highlight`} <div
data-active-id={activeId ?? undefined} ref={highlightRef}
aria-hidden="true" data-testid={`${scope}-selection-highlight`}
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]" 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; hostChromeInset?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const desktopNavRef = useRef<HTMLDivElement>(null);
const activeNavItemRef = useRef<HTMLButtonElement>(null); const activeNavItemRef = useRef<HTMLButtonElement>(null);
const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection) const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection)
?? SETTINGS_NAV_ITEMS[0]; ?? SETTINGS_NAV_ITEMS[0];
@ -2575,7 +2574,12 @@ function SettingsSidebar({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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 }) => { {SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
const active = key === activeSection; const active = key === activeSection;
return ( return (
@ -2600,13 +2604,7 @@ function SettingsSidebar({
</button> </button>
); );
})} })}
<SidebarSelectionHighlight </SidebarSelectionHighlight>
containerRef={desktopNavRef}
targetRef={activeNavItemRef}
activeId={activeSection}
scope="settings"
/>
</div>
</nav> </nav>
<div className="hidden lg:mt-auto lg:block lg:pt-4"> <div className="hidden lg:mt-auto lg:block lg:pt-4">

View File

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