improve(ui): polish sidebar scrolling and control alignment (#104586)

* fix(ui): make sidebar session scrollbar track transparent

* feat(ui): fade overflowing sidebar sessions

* style(ui): extend sidebar session fade

* style(ui): align pinned item checks right

* style(ui): align sidebar trailing controls

* refactor(ui): trim sidebar polish noise

* fix(ui): narrow sidebar resize observer target
This commit is contained in:
Vyctor H. Brzezowski
2026-07-11 14:54:30 -03:00
committed by GitHub
parent 554f253cb0
commit b14db9fc63
3 changed files with 130 additions and 6 deletions
+33
View File
@@ -244,6 +244,39 @@ describe("AppSidebar update card wiring", () => {
});
});
describe("AppSidebar session scroll fade", () => {
it("shows fades only toward additional session content", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
const scroller = sidebar.querySelector<HTMLElement>(".sidebar-recent-sessions");
if (!scroller) {
throw new Error("Expected sidebar session scroller");
}
let scrollHeight = 100;
Object.defineProperties(scroller, {
clientHeight: { configurable: true, value: 100 },
scrollHeight: { configurable: true, get: () => scrollHeight },
});
const expectScrollState = async (
scrollTop: number,
expected: "none" | "top" | "middle" | "bottom",
) => {
scroller.scrollTop = scrollTop;
scroller.dispatchEvent(new Event("scroll"));
await sidebar.updateComplete;
expect(scroller.classList.contains(`sidebar-recent-sessions--scroll-${expected}`)).toBe(true);
};
await expectScrollState(0, "none");
scrollHeight = 300;
await expectScrollState(0, "top");
await expectScrollState(80, "middle");
await expectScrollState(200, "bottom");
});
});
describe("AppSidebar lobster outcome wiring", () => {
it.each([
["panel", "failed", "error"],
+54 -6
View File
@@ -113,6 +113,7 @@ type SidebarSessionGroupMenuState = {
};
type SidebarSessionSortMode = "created" | "updated";
type SidebarSessionsScrollState = "none" | "top" | "middle" | "bottom";
type SidebarSessionGroupDropTarget = {
group: string;
position: "before" | "after";
@@ -229,6 +230,7 @@ class AppSidebar extends OpenClawLightDomContentsElement {
@state() private sessionsAgentId: string | null = null;
@state() private sessionsLoading = false;
@state() private nativeSessionSidebarReady = false;
@state() private sessionsScrollState: SidebarSessionsScrollState = "none";
private readonly subscriptions = new SubscriptionsController(this);
private customizeMenuTrigger: HTMLElement | null = null;
@@ -245,6 +247,8 @@ class AppSidebar extends OpenClawLightDomContentsElement {
private gatewaySource: ApplicationContext<RouteId>["gateway"] | null = null;
private gatewayClient: GatewayBrowserClient | null = null;
private nativeSessionSidebarLoadStarted = false;
private sessionsScrollElement: HTMLElement | null = null;
private sessionsScrollResizeObserver: ResizeObserver | null = null;
private readonly routePreloadTimers = new Map<
EventTarget,
ReturnType<typeof globalThis.setTimeout>
@@ -285,10 +289,14 @@ class AppSidebar extends OpenClawLightDomContentsElement {
globalThis.clearTimeout(timer);
}
this.routePreloadTimers.clear();
this.sessionsScrollResizeObserver?.disconnect();
this.sessionsScrollResizeObserver = null;
this.sessionsScrollElement = null;
super.disconnectedCallback();
}
override updated() {
this.syncSessionsScrollObserver();
const advertised = this.pluginTabs().some(
(tab) => tab.id === "sessions" && (tab.pluginId === "codex" || tab.pluginId === "anthropic"),
);
@@ -301,6 +309,41 @@ class AppSidebar extends OpenClawLightDomContentsElement {
});
}
private syncSessionsScrollObserver() {
const element = this.querySelector<HTMLElement>(".sidebar-recent-sessions");
if (element !== this.sessionsScrollElement) {
this.sessionsScrollResizeObserver?.disconnect();
this.sessionsScrollElement = element;
this.sessionsScrollResizeObserver = null;
if (element && typeof ResizeObserver === "function") {
this.sessionsScrollResizeObserver = new ResizeObserver(() =>
this.updateSessionsScrollState(element),
);
this.sessionsScrollResizeObserver.observe(element);
}
}
if (element) {
this.updateSessionsScrollState(element);
}
}
private updateSessionsScrollState(element: HTMLElement) {
const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
let nextState: SidebarSessionsScrollState = "none";
if (maxScrollTop > 1) {
if (element.scrollTop <= 1) {
nextState = "top";
} else if (element.scrollTop >= maxScrollTop - 1) {
nextState = "bottom";
} else {
nextState = "middle";
}
}
if (nextState !== this.sessionsScrollState) {
this.sessionsScrollState = nextState;
}
}
// The shell calls this before CSS hides the panel or drawer. Mounted menus
// keep their document-level shortcuts alive even when an ancestor is hidden.
dismissTransientMenus(): boolean {
@@ -1323,13 +1366,13 @@ class AppSidebar extends OpenClawLightDomContentsElement {
aria-checked=${String(pinned)}
@click=${() => this.togglePinnedRoute(routeId)}
>
<span class="sidebar-customize-menu__check" aria-hidden="true">
${pinned ? icons.check : nothing}
</span>
<span class="nav-item__icon" aria-hidden="true"
>${icons[navigationIconForRoute(routeId)]}</span
>
<span class="sidebar-customize-menu__text">${titleForRoute(routeId)}</span>
<span class="sidebar-customize-menu__check" aria-hidden="true">
${pinned ? icons.check : nothing}
</span>
</button>
`;
})}
@@ -1344,7 +1387,6 @@ class AppSidebar extends OpenClawLightDomContentsElement {
this.closeCustomizeMenu({ restoreFocus: true });
}}
>
<span class="sidebar-customize-menu__check" aria-hidden="true"></span>
<span class="nav-item__icon" aria-hidden="true">${icons.refresh}</span>
<span class="sidebar-customize-menu__text">${t("nav.customizeReset")}</span>
</button>
@@ -2009,7 +2051,13 @@ class AppSidebar extends OpenClawLightDomContentsElement {
const expandedAgentId = this.expandedAgentId();
return html`
<section class="sidebar-sessions">
<div class="sidebar-recent-sessions" aria-label=${titleForRoute("sessions")}>
<div
class="sidebar-recent-sessions sidebar-recent-sessions--scroll-${this
.sessionsScrollState}"
aria-label=${titleForRoute("sessions")}
@scroll=${(event: Event) =>
this.updateSessionsScrollState(event.currentTarget as HTMLElement)}
>
<div class="sidebar-recent-sessions__head sidebar-recent-sessions__head--root">
<span class="sidebar-recent-sessions__label-text">${t("sessionsView.title")}</span>
<button
@@ -2096,7 +2144,7 @@ class AppSidebar extends OpenClawLightDomContentsElement {
aria-expanded=${String(expanded)}
>
<span class="nav-section__label-text">${t("nav.more")}</span>
<span class="nav-section__chevron"> ${icons.chevronDown} </span>
<span class="nav-section__chevron">${icons.chevronDown}</span>
</button>
<div class="nav-section__items">
${moreRoutes.map((routeId) => this.renderRoute(routeId))}
+43
View File
@@ -996,12 +996,48 @@ html.openclaw-native-nav .shell-nav-expand:focus-visible {
margin: 0 -8px;
min-height: 0;
overflow-y: auto;
scrollbar-color: color-mix(in srgb, currentColor 8%, transparent) transparent;
scrollbar-width: thin;
/* Sessions are app chrome, not hyperlinks: the whole region uses the
default arrow cursor; the pointer hand is reserved for real links. */
cursor: default;
}
.sidebar-recent-sessions::-webkit-scrollbar,
.sidebar-recent-sessions::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-recent-sessions--scroll-top {
--sidebar-sessions-mask: linear-gradient(
to bottom,
black 0,
black calc(100% - 23px),
transparent 100%
);
}
.sidebar-recent-sessions--scroll-middle {
--sidebar-sessions-mask: linear-gradient(
to bottom,
transparent 0,
black 23px,
black calc(100% - 23px),
transparent 100%
);
}
.sidebar-recent-sessions--scroll-bottom {
--sidebar-sessions-mask: linear-gradient(to bottom, transparent 0, black 23px, black 100%);
}
.sidebar-recent-sessions--scroll-top,
.sidebar-recent-sessions--scroll-middle,
.sidebar-recent-sessions--scroll-bottom {
-webkit-mask-image: var(--sidebar-sessions-mask);
mask-image: var(--sidebar-sessions-mask);
}
/* The scroller's children must not flex-shrink: shrink squeezes each section
to its min-height while the fixed-height rows inside overflow and paint
over the following section. Fixed sizing makes the list scroll instead. */
@@ -1093,11 +1129,13 @@ html.openclaw-native-nav .shell-nav-expand:focus-visible {
}
.sidebar-session-group-count {
width: 26px;
flex: 0 0 auto;
color: var(--muted);
font-size: 10px;
font-variant-numeric: tabular-nums;
opacity: 0.7;
text-align: center;
}
.sidebar-session-group-drag-handle {
@@ -1402,6 +1440,10 @@ html.openclaw-native-nav .shell-nav-expand:focus-visible {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
min-width: 0;
flex: 0 0 26px;
opacity: 0.5;
transition: transform var(--duration-fast) ease;
}
@@ -1497,6 +1539,7 @@ html.openclaw-native-nav .shell-nav-expand:focus-visible {
}
.sidebar-customize-menu__text {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;