Skip to content

Commit 14f46d2

Browse files
committed
fix(webui): preserve sidebar highlight easing during resize
Coalesce highlight measurements per animation frame and stop animating the measured action width during a drag. This keeps navigation and session highlights moving toward the current sidebar width with the same easing instead of lagging until the drag stops. Cover target refs, selector targets, frame cleanup, and all drag termination paths.
1 parent 1640784 commit 14f46d2

4 files changed

Lines changed: 151 additions & 6 deletions

File tree

webui/src/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2603,9 +2603,10 @@ function Shell({
26032603
{showMainSidebar ? (
26042604
<aside
26052605
data-testid="host-sidebar-flow"
2606+
data-resizing={sidebarDragging || undefined}
26062607
id="main-sidebar"
26072608
className={cn(
2608-
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
2609+
"group/sidebar relative z-20 hidden shrink-0 overflow-hidden lg:block",
26092610
sidebarDragging ? "select-none" : "transition-[width] duration-300 ease-out motion-reduce:transition-none",
26102611
)}
26112612
style={{

webui/src/components/SidebarSelectionHighlight.tsx

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ interface SidebarSelectionHighlightProps extends HTMLAttributes<HTMLDivElement>
1515
export const SIDEBAR_SELECTION_ITEM_CLASS =
1616
"relative z-[1] transition-[color] duration-150 ease-out motion-reduce:transition-none";
1717

18+
// During a drag, animate only the shared highlight, not its measured target as well.
1819
export const SIDEBAR_SELECTION_ACTION_ITEM_CLASS =
19-
"relative z-[1] transition-[width,padding,color] [transition-duration:300ms,300ms,150ms] ease-out motion-reduce:transition-none";
20+
"relative z-[1] transition-[width,padding,color] [transition-duration:300ms,300ms,150ms] ease-out group-data-[resizing=true]/sidebar:transition-none motion-reduce:transition-none";
2021

2122
export function SidebarSelectionHighlight({
2223
targetRef,
@@ -44,6 +45,7 @@ export function SidebarSelectionHighlight({
4445
}
4546

4647
let restoreTransitionFrame: number | null = null;
48+
let positionFrame: number | null = null;
4749

4850
const position = () => {
4951
const containerRect = container.getBoundingClientRect();
@@ -73,20 +75,34 @@ export function SidebarSelectionHighlight({
7375
}
7476
};
7577

76-
position();
78+
// Measure once per animation frame so React rerenders do not repeatedly
79+
// retarget a transition before the browser has advanced its current frame.
80+
const schedulePosition = () => {
81+
if (positionFrame !== null) return;
82+
positionFrame = window.requestAnimationFrame(() => {
83+
positionFrame = null;
84+
position();
85+
});
86+
};
87+
88+
if (!positionedRef.current) position();
89+
else schedulePosition();
7790
const resizeObserver =
78-
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(position);
91+
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(schedulePosition);
7992
resizeObserver?.observe(container);
8093
resizeObserver?.observe(target);
81-
window.addEventListener("resize", position);
94+
window.addEventListener("resize", schedulePosition);
8295

8396
return () => {
8497
if (restoreTransitionFrame !== null) {
8598
window.cancelAnimationFrame(restoreTransitionFrame);
8699
}
100+
if (positionFrame !== null) {
101+
window.cancelAnimationFrame(positionFrame);
102+
}
87103
highlight?.style.removeProperty("transition-property");
88104
resizeObserver?.disconnect();
89-
window.removeEventListener("resize", position);
105+
window.removeEventListener("resize", schedulePosition);
90106
};
91107
});
92108

webui/src/tests/app-layout.test.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1741,9 +1741,13 @@ describe("App layout", () => {
17411741
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
17421742
const handle = screen.getByRole("separator", { name: "Resize sidebar" });
17431743
const sidebar = screen.getByTestId("host-sidebar-flow");
1744+
expect(sidebar).toHaveClass("group/sidebar");
17441745
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 272 });
1746+
expect(sidebar).toHaveAttribute("data-resizing", "true");
17451747
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 360 });
1748+
expect(sidebar).toHaveStyle({ width: "360px" });
17461749
fireEvent.pointerUp(handle, { pointerId: 1, clientX: 360 });
1750+
expect(sidebar).not.toHaveAttribute("data-resizing");
17471751
expect(sidebar).toHaveStyle({ width: "360px" });
17481752
expect(localStorage.getItem("nanobot-webui.sidebar.width")).toBe("360");
17491753
fireEvent.pointerDown(handle, { button: 0, pointerId: 2, clientX: 360 });
@@ -1765,6 +1769,43 @@ describe("App layout", () => {
17651769
expect(screen.getByTestId("host-sidebar-flow")).toHaveStyle({ width: "272px" });
17661770
});
17671771

1772+
it.each(["pointerUp", "pointerCancel", "lostPointerCapture"] as const)("keeps highlight easing while disabling navigation target lag until %s", async (finish) => {
1773+
mockSessions = [{
1774+
key: "websocket:resize-test", channel: "websocket", chatId: "resize-test",
1775+
createdAt: "2026-04-16T10:00:00Z", updatedAt: "2026-04-16T10:00:00Z",
1776+
title: "Resize test", preview: "",
1777+
}];
1778+
render(<App />);
1779+
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
1780+
const sidebar = screen.getByTestId("host-sidebar-flow");
1781+
await within(sidebar).findByTestId("chats-selection-highlight");
1782+
const handle = within(sidebar).getByRole("separator", { name: "Resize sidebar" });
1783+
for (const label of ["Apps", "Skills", "Automations", "Channels"]) {
1784+
expect(within(sidebar).getByRole("button", { name: label })).toHaveClass(
1785+
"group-data-[resizing=true]/sidebar:transition-none",
1786+
);
1787+
}
1788+
for (const scope of ["actions", "chats"]) {
1789+
const highlight = within(sidebar).getByTestId(`${scope}-selection-highlight`);
1790+
expect(highlight).not.toHaveClass(
1791+
"group-data-[resizing=true]/sidebar:transition-none",
1792+
);
1793+
expect(highlight).toHaveClass(
1794+
"transition-[transform,width,height]",
1795+
"duration-300",
1796+
"ease-out",
1797+
"motion-reduce:transition-none",
1798+
);
1799+
}
1800+
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 272 });
1801+
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 400 });
1802+
expect(sidebar).toHaveAttribute("data-resizing", "true");
1803+
expect(sidebar).toHaveStyle({ width: "400px" });
1804+
fireEvent[finish](handle, { pointerId: 1, clientX: 400 });
1805+
expect(sidebar).not.toHaveAttribute("data-resizing");
1806+
expect(sidebar).toHaveClass("transition-[width]");
1807+
});
1808+
17681809
it("uses the shared sidebar controls and rail on the native host", async () => {
17691810
mockSessions = [
17701811
{
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { useRef } from "react";
2+
import { act, cleanup, render, screen } from "@testing-library/react";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
4+
5+
import { SidebarSelectionHighlight } from "@/components/SidebarSelectionHighlight";
6+
7+
afterEach(() => {
8+
cleanup();
9+
vi.restoreAllMocks();
10+
vi.unstubAllGlobals();
11+
});
12+
13+
function Harness({ activeId, targetByRef }: { activeId: string | null; targetByRef: boolean }) {
14+
const target = useRef<HTMLButtonElement>(null);
15+
return <SidebarSelectionHighlight activeId={activeId} scope="test" data-testid="highlight-container"
16+
targetRef={targetByRef ? target : undefined}
17+
targetSelector={targetByRef ? undefined : "[aria-current=page]"}>
18+
<button ref={activeId === "first" ? target : undefined} aria-current={activeId === "first" ? "page" : undefined}>First</button>
19+
<button ref={activeId === "second" ? target : undefined} aria-current={activeId === "second" ? "page" : undefined}>Second</button>
20+
</SidebarSelectionHighlight>;
21+
}
22+
23+
describe("sidebar selection highlight geometry", () => {
24+
it.each([true, false])("updates the animated width as its target resizes (target by ref: %s)", (targetByRef) => {
25+
let width = 272;
26+
let frameId = 0;
27+
const frames = new Map<number, FrameRequestCallback>();
28+
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
29+
frames.set(++frameId, callback);
30+
return frameId;
31+
});
32+
vi.spyOn(window, "cancelAnimationFrame").mockImplementation((id) => {
33+
frames.delete(id);
34+
});
35+
const paint = () => act(() => {
36+
const callbacks = [...frames.values()];
37+
frames.clear();
38+
callbacks.forEach((callback) => callback(performance.now()));
39+
});
40+
const resizes: Array<() => void> = [];
41+
vi.stubGlobal("ResizeObserver", class {
42+
constructor(callback: () => void) { resizes.push(callback); }
43+
observe() {}
44+
disconnect() {}
45+
});
46+
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
47+
const container = this.dataset.testid === "highlight-container";
48+
const second = this.textContent === "Second";
49+
const inset = container ? 0 : second ? 24 : 8;
50+
const x = 100 + inset;
51+
const y = container ? 20 : second ? 100 : 60;
52+
const w = container ? width : width - inset - 8;
53+
return { x, y, left: x, top: y, right: x + w, bottom: y + 32, width: w, height: 32, toJSON() {} };
54+
});
55+
const { rerender } = render(<Harness activeId="first" targetByRef={targetByRef} />);
56+
const highlight = screen.getByTestId("test-selection-highlight");
57+
expect(highlight).toHaveStyle({ width: "256px", height: "32px", transform: "translate3d(8px, 40px, 0)" });
58+
paint();
59+
60+
// After initial placement, every observer update changes the animation's
61+
// destination rather than waiting for a pointer-up or resize-end event.
62+
rerender(<Harness activeId="first" targetByRef={targetByRef} />);
63+
width = 300;
64+
act(() => resizes.at(-1)?.());
65+
width = 420;
66+
act(() => resizes.at(-1)?.());
67+
expect(frames.size).toBe(1);
68+
expect(highlight).toHaveStyle({ width: "256px" });
69+
paint();
70+
expect(highlight).toHaveStyle({ width: "404px" });
71+
expect(highlight.style.transitionProperty).not.toBe("none");
72+
width = 320;
73+
act(() => resizes.at(-1)?.());
74+
paint();
75+
expect(highlight).toHaveStyle({ width: "304px" });
76+
expect(highlight.style.transitionProperty).not.toBe("none");
77+
rerender(<Harness activeId="second" targetByRef={targetByRef} />);
78+
paint();
79+
expect(highlight).toHaveStyle({ width: "288px", transform: "translate3d(24px, 80px, 0)" });
80+
expect(highlight.style.transitionProperty).not.toBe("none");
81+
act(() => resizes.at(-1)?.());
82+
expect(frames.size).toBe(1);
83+
rerender(<Harness activeId={null} targetByRef={targetByRef} />);
84+
expect(frames.size).toBe(0);
85+
expect(highlight).toHaveStyle({ opacity: "0" });
86+
});
87+
});

0 commit comments

Comments
 (0)