diff --git a/ui/src/components/app-sidebar.test.ts b/ui/src/components/app-sidebar.test.ts index 80049c8f520c..a7bed50b283b 100644 --- a/ui/src/components/app-sidebar.test.ts +++ b/ui/src/components/app-sidebar.test.ts @@ -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(".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"], diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index c97116fde828..a8774cd1e067 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -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["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 @@ -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(".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)} > - ${titleForRoute(routeId)} + `; })} @@ -1344,7 +1387,6 @@ class AppSidebar extends OpenClawLightDomContentsElement { this.closeCustomizeMenu({ restoreFocus: true }); }} > - ${t("nav.customizeReset")} @@ -2009,7 +2051,13 @@ class AppSidebar extends OpenClawLightDomContentsElement { const expandedAgentId = this.expandedAgentId(); return html`