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:
Yaowei Zheng
2026-07-29 23:30:06 +08:00
committed by GitHub
parent a4a434cfaf
commit ed1ea509d3
3 changed files with 79 additions and 41 deletions
+32 -10
View File
@@ -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>
+39 -28
View File
@@ -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>