mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
6ee0974098
commit
89ef6632de
7 changed files with 212 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
48
ui/src/lib/hover-marquee.test.ts
Normal file
48
ui/src/lib/hover-marquee.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
39
ui/src/lib/hover-marquee.ts
Normal file
39
ui/src/lib/hover-marquee.ts
Normal 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");
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue