mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 17:38:35 +00:00
fix(webui): position sidebar highlight on mount
This commit is contained in:
parent
0cb7dd5cc9
commit
db6c9effc3
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user