fix(web): scroll the sidebar nav with the session list, in both sidebar states (#119)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,9 +16,11 @@
|
||||
* search box then horizontally scrolling the whole draft page — and the workspace menu
|
||||
* ~143px off-screen right when the ownership pills share one row);
|
||||
* - no page grows the **document**: the app shell is height-constrained and each page scrolls
|
||||
* inside its own container, so a second scrollbar means an absolutely positioned descendant
|
||||
* escaped its scroller (the Traces tree and the Agent settings page both had one, visible
|
||||
* only with a second Agent below a long list);
|
||||
* inside its own container, so a second scrollbar means either an absolutely positioned
|
||||
* descendant escaped its scroller (the Traces tree and the Agent settings page both had one,
|
||||
* visible only with a second Agent below a long list) or something that cannot shrink no
|
||||
* longer fits — checked at 420/320/240px tall in both sidebar states, since the sidebar's
|
||||
* chrome used to stop fitting below ~412px;
|
||||
* - the sidebar's "New chat" button has no background fill (same gray-scale style as nav items);
|
||||
* - the collapsed rail shows, in product-specified order, last conversation / new chat /
|
||||
* Agents / Skills / Models / Costs / Traces / Benchmark with localized (en + zh) hover
|
||||
@@ -704,22 +706,42 @@ test("layout: no page grows the document (absolute descendants stay in their scr
|
||||
|
||||
// A short viewport puts the second Agent's node below the fold with a handful of Sessions
|
||||
// instead of dozens — the same geometry a full-height window reaches with a longer list.
|
||||
// Not shorter than 420: below ~412px the sidebar's own fixed chrome (project switcher, the
|
||||
// eight nav entries, the user row) no longer fits, which grows the document for its own
|
||||
// unrelated reason and would mask what this test is after.
|
||||
await page.setViewportSize({ width: 1440, height: 420 });
|
||||
const paths = ["/traces", "/chat", "/agents", "/agents/default_agent", "/skills", "/models"];
|
||||
const grewBy = (p) =>
|
||||
p.evaluate(() => {
|
||||
const de = document.documentElement;
|
||||
return de.scrollHeight - de.clientHeight;
|
||||
});
|
||||
const overflowing = [];
|
||||
for (const path of paths) {
|
||||
await page.goto(`${BASE}${path}`);
|
||||
await page.waitForTimeout(1200);
|
||||
const grew = await page.evaluate(() => {
|
||||
const de = document.documentElement;
|
||||
return de.scrollHeight - de.clientHeight;
|
||||
});
|
||||
const grew = await grewBy(page);
|
||||
if (grew > 0) overflowing.push(`${path} (+${grew}px)`);
|
||||
}
|
||||
expect(overflowing, "pages whose document scrolls").toEqual([]);
|
||||
|
||||
// The other way to grow the document: content that cannot shrink. The sidebar's own chrome
|
||||
// (Project switcher, New chat, eight nav entries, user row) used to be fixed height and
|
||||
// stopped fitting below ~412px — a window that short is reachable by browser zoom or docked
|
||||
// devtools. The nav now scrolls with the session list, and the collapsed rail scrolls its
|
||||
// icons the same way, so both states shrink to nothing instead of pushing the page out.
|
||||
const railToggle = page.getByRole("button", { name: "收起侧栏" });
|
||||
for (const height of [420, 320, 240]) {
|
||||
await page.setViewportSize({ width: 1440, height });
|
||||
await page.goto(`${BASE}/chat`);
|
||||
await page.waitForTimeout(900);
|
||||
expect(await grewBy(page), `pinned sidebar @${height}`).toBe(0);
|
||||
}
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto(`${BASE}/chat`);
|
||||
await railToggle.click();
|
||||
for (const height of [420, 320, 240]) {
|
||||
await page.setViewportSize({ width: 1440, height });
|
||||
await page.waitForTimeout(700);
|
||||
expect(await grewBy(page), `collapsed rail @${height}`).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("layout: login — blank start, non-crossing traces, lang/theme controls", async ({ page }) => {
|
||||
|
||||
@@ -77,11 +77,16 @@ function CollapsedRail({ onExpand }: { onExpand: () => void }) {
|
||||
title={S.nav.expandSidebar}
|
||||
aria-label={S.nav.expandSidebar}
|
||||
onClick={onExpand}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-gray-500 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
>
|
||||
<GlyphIcon d="M9 6l6 6-6 6M20 4v16" size={18} />
|
||||
</button>
|
||||
<nav className="mt-1 flex flex-col items-center gap-1">
|
||||
{/* The entries scroll as one block, like the pinned sidebar's nav + session list: the rail
|
||||
keeps only the expand control and the account avatar at fixed height, so a window too
|
||||
short for eight icons scrolls them here instead of pushing them out of the rail and
|
||||
growing the document. Scrollbar hidden — at 48px wide it would cost a third of the
|
||||
rail's width. */}
|
||||
<nav className="no-scrollbar mt-1 flex min-h-0 flex-1 flex-col items-center gap-1 overflow-y-auto">
|
||||
{/* 1. Last conversation: lit on any non-draft conversation. Dimmed/disabled (tooltip kept) only
|
||||
once the list has settled with no non-archived Session — while it is still loading the
|
||||
entry keeps its normal look (no flash) and a click is a graceful no-op. */}
|
||||
@@ -127,7 +132,7 @@ function CollapsedRail({ onExpand }: { onExpand: () => void }) {
|
||||
title={`${user?.userId ?? ""} · ${S.nav.expandSidebar}`}
|
||||
aria-label={user?.userId ?? S.auth.admin}
|
||||
onClick={onExpand}
|
||||
className="mt-auto flex h-8 w-8 items-center justify-center rounded-full bg-gray-900 text-xs font-bold text-white dark:bg-gray-200 dark:text-gray-900"
|
||||
className="mt-auto flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-900 text-xs font-bold text-white dark:bg-gray-200 dark:text-gray-900"
|
||||
>
|
||||
{(user?.userId ?? "?").slice(0, 1).toUpperCase()}
|
||||
</button>
|
||||
|
||||
@@ -773,10 +773,11 @@ export function Sidebar({
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
{/* Fixed nav (new chat pinned at top: default_agent draft): no background fill, shares the
|
||||
same gray hover/active styling as nav items, distinguished only by its top position and
|
||||
font-medium; shows the same gray active state while on the draft page. */}
|
||||
<nav className="shrink-0 space-y-0.5 px-2 pt-2">
|
||||
{/* New chat: the only pinned entry besides the Project switcher above and the user row
|
||||
below. No background fill, the same gray hover/active styling as the nav items,
|
||||
distinguished only by its position and font-medium; shows the same gray active state
|
||||
while on the draft page. */}
|
||||
<div className="shrink-0 px-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => newChat(defaultAgentId)}
|
||||
@@ -791,36 +792,46 @@ export function Sidebar({
|
||||
</span>
|
||||
{S.chat.newSessionMenu}
|
||||
</button>
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={() => onNavigate?.()}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors duration-150 ${
|
||||
isActive
|
||||
? "bg-gray-200/70 font-medium text-gray-900 dark:bg-gray-800 dark:text-gray-100"
|
||||
: "text-gray-600 hover:bg-gray-200/50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800/70 dark:hover:text-gray-200"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
<Icon d={item.icon} />
|
||||
</span>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Session area (scrollable): grouped by Workspace (default) or by Agent.
|
||||
{/* Scroll area: the page nav and the session list scroll together, so the nav rides up
|
||||
as the list is scrolled. It is the sidebar's only shrinkable block — with the nav
|
||||
pinned, the column's fixed height (Project switcher + New chat + eight nav entries +
|
||||
user row ≈ 412px) exceeded a short window, and the overflow, clipped by nothing,
|
||||
grew the document into a second scrollbar.
|
||||
relative: the scroller acts as its own containing block, so absolute descendants
|
||||
(each row's sr-only Agent name) anchor and scroll inside it — anchored to the
|
||||
initial containing block instead, rows past the fold would bypass this
|
||||
overflow-y-auto and stretch the **document**, so expanding "More" / a source
|
||||
folder made the whole page scroll (composer pushed up, blank space below). */}
|
||||
<div className="relative mt-3 min-h-0 flex-1 overflow-y-auto border-t border-gray-200 px-2 pb-2 dark:border-gray-800">
|
||||
{/* Section header: list label + grouping-mode toggle (the choice persists in localStorage) */}
|
||||
<div className="flex items-center justify-between px-1 pt-2">
|
||||
<div className="relative min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
||||
<nav className="space-y-0.5 pt-2">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={() => onNavigate?.()}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors duration-150 ${
|
||||
isActive
|
||||
? "bg-gray-200/70 font-medium text-gray-900 dark:bg-gray-800 dark:text-gray-100"
|
||||
: "text-gray-600 hover:bg-gray-200/50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800/70 dark:hover:text-gray-200"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
<Icon d={item.icon} />
|
||||
</span>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Section header: list label + grouping-mode toggle (the choice persists in localStorage).
|
||||
The separator above it spans the sidebar's full width (-mx-2 undoes the scroller's
|
||||
padding, px-3 puts the row's own inset back), as it did when it sat on the scroller's
|
||||
top edge — it now travels with the list instead of framing a pinned nav. */}
|
||||
<div className="-mx-2 mt-3 flex items-center justify-between border-t border-gray-200 px-3 pt-2 dark:border-gray-800">
|
||||
<span className="px-1 text-[11px] font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
|
||||
{S.chat.sessionList}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user