style(webui): slide active conversation highlight

This commit is contained in:
Xubin Ren 2026-07-28 15:12:30 +08:00
parent 962cdb968d
commit 0a8bc0ac29
2 changed files with 143 additions and 19 deletions

View File

@ -1,7 +1,9 @@
import {
memo,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import {
@ -102,6 +104,9 @@ 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 activeHighlightRef = useRef<HTMLDivElement>(null);
const labels = useMemo<ChatGroupLabels>(() => ({
pinned: t("chat.groups.pinned"),
all: t("chat.groups.all"),
@ -156,6 +161,46 @@ export const ChatList = memo(function ChatList({
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
}, [showArchived, sort]);
useLayoutEffect(() => {
const updateHighlight = () => {
const content = listContentRef.current;
const row = activeRowRef.current;
const highlight = activeHighlightRef.current;
if (!highlight) return;
if (!content || !row) {
highlight.style.opacity = "0";
return;
}
const contentRect = content.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
highlight.style.width = `${rowRect.width}px`;
highlight.style.height = `${rowRect.height}px`;
highlight.style.transform = `translate3d(${rowRect.left - contentRect.left}px, ${
rowRect.top - contentRect.top
}px, 0)`;
highlight.style.opacity = "1";
};
updateHighlight();
const resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(updateHighlight);
if (resizeObserver) {
if (listContentRef.current) resizeObserver.observe(listContentRef.current);
if (activeRowRef.current) resizeObserver.observe(activeRowRef.current);
}
window.addEventListener("resize", updateHighlight);
return () => {
resizeObserver?.disconnect();
window.removeEventListener("resize", updateHighlight);
};
}, [activeKey, density, limitedGroups, showPreviews, showTimestamps]);
if (loading && sessions.length === 0) {
return (
<div className="px-3 py-6 text-[12px] text-muted-foreground">
@ -181,7 +226,11 @@ 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 className="min-w-0 space-y-3 px-2 py-1.5">
<div
ref={listContentRef}
data-chat-list-content
className="relative min-w-0 space-y-3 px-2 py-1.5"
>
{limitedGroups.map((group, index) => {
const foldableChatsGroup = isFoldableChatsGroup(group);
const foldedChatsGroup = isFoldedChatsGroup(group, collapsedGroups);
@ -194,7 +243,7 @@ export const ChatList = memo(function ChatList({
const canToggleFold = group.sessions.length > COLLAPSED_CHATS_VISIBLE_COUNT;
return (
<section key={group.id} aria-label={group.label}>
<section key={group.id} aria-label={group.label} className="relative z-[1]">
{index === firstProjectGroupIndex ? (
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
{labels.projects}
@ -251,12 +300,14 @@ export const ChatList = memo(function ChatList({
return (
<li key={s.key} className="min-w-0">
<div
ref={active ? activeRowRef : undefined}
data-chat-row={s.key}
className={cn(
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
compact ? "min-h-7" : "min-h-8",
active
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]"
: "text-sidebar-foreground/82 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
? "text-sidebar-accent-foreground"
: "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
)}
>
<button
@ -380,7 +431,7 @@ export const ChatList = memo(function ChatList({
);
})}
{hiddenSessionCount > 0 ? (
<div className="px-2 pb-2 pt-1">
<div className="relative z-[1] px-2 pb-2 pt-1">
<button
type="button"
onClick={() =>
@ -394,6 +445,12 @@ export const ChatList = memo(function ChatList({
</button>
</div>
) : null}
<div
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]"
/>
</div>
</div>
);

View File

@ -1,5 +1,5 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ChatList } from "@/components/ChatList";
import type { ChatSummary } from "@/lib/types";
@ -17,7 +17,35 @@ function session(overrides: Partial<ChatSummary>): ChatSummary {
};
}
function rect({
left,
top,
width,
height,
}: {
left: number;
top: number;
width: number;
height: number;
}): DOMRect {
return {
x: left,
y: top,
left,
top,
width,
height,
right: left + width,
bottom: top + height,
toJSON: () => ({}),
} as DOMRect;
}
describe("ChatList", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("orders chats by latest session activity by default", () => {
const sessions = [
session({
@ -192,29 +220,68 @@ describe("ChatList", () => {
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
});
it("visually distinguishes the selected topic", () => {
render(
it("slides one borderless highlight between selected topics", () => {
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
function (this: HTMLElement) {
if (this.hasAttribute("data-chat-list-content")) {
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 });
}
if (this.getAttribute("data-chat-row") === "websocket:inactive") {
return rect({ left: 8, top: 48, width: 284, height: 40 });
}
return rect({ left: 0, top: 0, width: 0, height: 0 });
},
);
const props = {
sessions: [
session({ chatId: "active", title: "Active topic" }),
session({ chatId: "inactive", title: "Inactive topic" }),
],
onSelect: vi.fn(),
onRequestDelete: vi.fn(),
onTogglePin: vi.fn(),
onRequestRename: vi.fn(),
onToggleArchive: vi.fn(),
};
const { rerender } = render(
<ChatList
sessions={[
session({ chatId: "active", title: "Active topic" }),
session({ chatId: "inactive", title: "Inactive topic" }),
]}
{...props}
activeKey="websocket:active"
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
const highlight = screen.getByTestId("active-chat-highlight");
const activeButton = screen.getByTitle("Active topic");
expect(activeButton).toHaveAttribute("aria-current", "page");
expect(activeButton.parentElement).toHaveClass(
expect(activeButton.parentElement).not.toHaveClass(
"bg-sidebar-accent",
"shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
);
expect(screen.getByTitle("Inactive topic")).not.toHaveAttribute("aria-current");
expect(highlight).toHaveClass(
"bg-sidebar-foreground/[0.055]",
"transition-[transform,width,height,opacity]",
"motion-reduce:transition-none",
);
expect(highlight).toHaveStyle(
"width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1",
);
rerender(
<ChatList
{...props}
activeKey="websocket:inactive"
/>,
);
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",
);
});
it("can collapse a project group and keeps project rename separate from chat titles", async () => {