feat(ui): scroll truncated sidebar session names on hover (#100276)

* feat(ui): scroll truncated sidebar session names on hover

Sidebar recent-session labels clip long titles with an ellipsis and there
was no way to read the tail without opening the session. Hovering a row
now animates the label's text-indent to slide the clipped tail into view
(speed-proportional duration, 300ms minimum) and snaps back quickly on
mouse leave. Resting ellipsis rendering is preserved by animating
text-indent instead of transforming an inner wrapper, since text-overflow
draws no ellipsis for atomic inline children. Respects
prefers-reduced-motion.

* fix(ui): harden sidebar session marquee
This commit is contained in:
Peter Steinberger 2026-07-05 10:29:46 -07:00 committed by GitHub
parent 6ee0974098
commit 89ef6632de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 212 additions and 3 deletions

View file

@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- **Control UI session titles:** reveal truncated recent-session names with a reduced-motion-safe hover animation.
- **Control UI sidebar usage:** remove the provider usage quota row from the expanded sidebar while keeping usage details available in the chat composer and Usage page. Thanks @shakkernerd.
### Fixes

View file

@ -1,6 +1,7 @@
import { consume } from "@lit/context";
import { LitElement, html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { keyed } from "lit/directives/keyed.js";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { SessionsListResult } from "../api/types.ts";
import {
@ -26,6 +27,7 @@ import type { ThemeMode } from "../app/theme.ts";
import { t } from "../i18n/index.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../lib/external-link.ts";
import { formatRelativeTimestamp } from "../lib/format.ts";
import { startHoverMarquee, stopHoverMarquee } from "../lib/hover-marquee.ts";
import { resolveSessionDisplayName } from "../lib/session-display.ts";
import {
compareSessionRowsByUpdatedAt,
@ -422,8 +424,13 @@ export class AppSidebar extends LitElement {
]
.filter(Boolean)
.join(" ");
return html`
<div class=${rowClass} data-session-key=${session.key}>
const row = html`
<div
class=${rowClass}
data-session-key=${session.key}
@mouseenter=${(event: MouseEvent) => startHoverMarquee(event.currentTarget as HTMLElement)}
@mouseleave=${(event: MouseEvent) => stopHoverMarquee(event.currentTarget as HTMLElement)}
>
<a
href=${session.href}
class="sidebar-recent-session__link"
@ -436,7 +443,7 @@ export class AppSidebar extends LitElement {
this.selectSession(session.key);
}}
>
<span class="sidebar-recent-session__name">${session.label}</span>
<span class="sidebar-recent-session__name hover-marquee">${session.label}</span>
</a>
<span class="sidebar-recent-session__aside session-row-aside">
<span class="session-row-trail">
@ -480,6 +487,9 @@ export class AppSidebar extends LitElement {
</span>
</div>
`;
// Hover marquee state mutates the row DOM. Keying prevents that state from
// leaking when Lit reuses this slot for another session after navigation.
return keyed(session.key, row);
}
private renderSessions() {

View file

@ -1256,6 +1256,82 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
}
});
it("clears hover marquee state when a session switch reshuffles recent rows", async () => {
const context = await newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const sessions = chatSessionListResponse();
sessions.sessions[0].label = "Short";
sessions.sessions[1].label =
"Review and repair the intentionally overlong sidebar session title before navigation ".repeat(
4,
);
await installMockGateway(page, {
methodResponses: { "sessions.list": sessions },
sessionKey: "agent:main:session-a",
});
try {
await page.goto(`${server.baseUrl}chat`);
const recentRow = page.locator(
'.sidebar-recent-session[data-session-key="agent:main:session-b"]',
);
const recentLabel = recentRow.locator(".sidebar-recent-session__name");
await recentLabel.waitFor({ state: "visible", timeout: 10_000 });
const layout = await recentLabel.evaluate((label) => ({
clientWidth: label.clientWidth,
linkWidth: label.parentElement?.clientWidth ?? 0,
rowWidth: label.closest<HTMLElement>(".sidebar-recent-session")?.clientWidth ?? 0,
scrollWidth: label.scrollWidth,
text: label.textContent,
}));
expect(layout.scrollWidth, JSON.stringify(layout)).toBeGreaterThan(layout.clientWidth);
await recentRow.dispatchEvent("mouseenter");
await expect
.poll(() => recentLabel.evaluate((label) => getComputedStyle(label).textIndent))
.not.toBe("0px");
await recentRow.locator("a.sidebar-recent-session__link").dispatchEvent("click", {
button: 0,
});
await page
.locator(".sidebar-recent-session--active")
.getByText(sessions.sessions[1].label)
.waitFor({
timeout: 10_000,
});
const reshuffledLabel = page
.locator('.sidebar-recent-session[data-session-key="agent:main:session-a"]')
.getByText("Short");
await reshuffledLabel.waitFor({ state: "visible", timeout: 10_000 });
expect(await reshuffledLabel.evaluate((label) => getComputedStyle(label).textIndent)).toBe(
"0px",
);
expect(await reshuffledLabel.evaluate((label) => getComputedStyle(label).textOverflow)).toBe(
"ellipsis",
);
await page.emulateMedia({ reducedMotion: "reduce" });
const activeRow = page.locator(
'.sidebar-recent-session[data-session-key="agent:main:session-b"]',
);
await activeRow.dispatchEvent("mouseenter");
expect(
await activeRow.locator(".sidebar-recent-session__name").evaluate((label) => ({
textIndent: getComputedStyle(label).textIndent,
textOverflow: getComputedStyle(label).textOverflow,
})),
).toEqual({ textIndent: "0px", textOverflow: "ellipsis" });
} finally {
await closeBrowserContext(context);
}
});
it("shows a pending send while a model override save is still pending", async () => {
const context = await newBrowserContext({
locale: "en-US",

View file

@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { startHoverMarquee, stopHoverMarquee } from "./hover-marquee.ts";
function buildRow(params: { textWidth: number; labelWidth: number }) {
const row = document.createElement("div");
const label = document.createElement("span");
label.className = "hover-marquee";
label.textContent = "Fix stale iMessage group-allowlist warning copy";
row.append(label);
document.body.append(row);
Object.defineProperty(label, "clientWidth", { value: params.labelWidth });
Object.defineProperty(label, "scrollWidth", { value: params.textWidth });
return { row, label };
}
describe("hover marquee", () => {
it("scrolls overflowing labels by the clipped distance and restores on leave", () => {
const { row, label } = buildRow({ textWidth: 320, labelWidth: 180 });
startHoverMarquee(row);
expect(label.classList.contains("hover-marquee--scrolling")).toBe(true);
expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("-140px");
expect(label.style.getPropertyValue("--hover-marquee-duration")).toBe("1750ms");
stopHoverMarquee(row);
expect(label.classList.contains("hover-marquee--scrolling")).toBe(false);
});
it("keeps short scroll distances readable with a minimum duration", () => {
const { row, label } = buildRow({ textWidth: 190, labelWidth: 180 });
startHoverMarquee(row);
expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("-10px");
expect(label.style.getPropertyValue("--hover-marquee-duration")).toBe("300ms");
});
it("leaves labels that fit untouched", () => {
const { row, label } = buildRow({ textWidth: 120, labelWidth: 180 });
startHoverMarquee(row);
expect(label.classList.contains("hover-marquee--scrolling")).toBe(false);
expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("");
});
it("ignores hosts without a marquee label", () => {
const row = document.createElement("div");
expect(() => {
startHoverMarquee(row);
stopHoverMarquee(row);
}).not.toThrow();
});
});

View file

@ -0,0 +1,39 @@
// Hover marquee for truncated single-line labels: on pointer enter, animate
// text-indent to slide the clipped tail into view; on leave, the base
// transition in styles/components.css (.hover-marquee) snaps it back quickly.
// text-indent (not an inner transform wrapper) because text-overflow renders
// no ellipsis for atomic inline children, which would lose the resting "…".
const MARQUEE_SPEED_PX_PER_SEC = 80;
const MARQUEE_MIN_DURATION_MS = 300;
function findMarqueeLabel(host: HTMLElement): HTMLElement | null {
return host.classList.contains("hover-marquee")
? host
: host.querySelector<HTMLElement>(".hover-marquee");
}
export function startHoverMarquee(host: HTMLElement): void {
const label = findMarqueeLabel(host);
if (!label) {
return;
}
// Measure at hover time: labels resize with the sidebar and with hover-only
// row actions, so a cached width would drift. A negative mid-transition
// indent (re-hover while snapping back) shrinks scrollWidth; add it back.
const indent = Number.parseFloat(getComputedStyle(label).textIndent) || 0;
const shift = label.scrollWidth - indent - label.clientWidth;
if (shift <= 1) {
return;
}
const durationMs = Math.max(
MARQUEE_MIN_DURATION_MS,
Math.round((shift / MARQUEE_SPEED_PX_PER_SEC) * 1000),
);
label.style.setProperty("--hover-marquee-shift", `${-shift}px`);
label.style.setProperty("--hover-marquee-duration", `${durationMs}ms`);
label.classList.add("hover-marquee--scrolling");
}
export function stopHoverMarquee(host: HTMLElement): void {
findMarqueeLabel(host)?.classList.remove("hover-marquee--scrolling");
}

View file

@ -6748,3 +6748,37 @@ details[open] > .ov-expandable-toggle::after {
border-color: var(--accent);
}
}
/* ===========================================
Hover Marquee
=========================================== */
/* Truncated single-line labels that slide on hover to reveal the clipped
tail. lib/hover-marquee.ts measures the overflow and sets the custom
properties; without it the label stays a plain ellipsized span. Animates
text-indent so the resting ellipsis keeps rendering (text-overflow shows
no "…" for atomic inline children, ruling out a transformed wrapper). */
.hover-marquee {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: text-indent 180ms ease-out;
}
.hover-marquee--scrolling {
text-overflow: clip;
text-indent: var(--hover-marquee-shift, 0);
transition-duration: var(--hover-marquee-duration, 600ms);
transition-timing-function: linear;
}
@media (prefers-reduced-motion: reduce) {
.hover-marquee {
transition: none;
}
.hover-marquee--scrolling {
text-overflow: ellipsis;
text-indent: 0;
}
}

View file

@ -778,6 +778,7 @@
.sidebar-recent-session {
display: flex;
align-items: center;
min-width: 0;
min-height: 34px;
padding-right: 4px;
border: 1px solid transparent;