improve(ui): reveal only clipped session titles

This commit is contained in:
vyctorbrzezowski
2026-08-20 10:38:37 -03:00
parent e71fc9420c
commit 13e8829206
9 changed files with 286 additions and 242 deletions
@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
import { ref } from "lit/directives/ref.js";
import type {
SessionCatalog,
SessionCatalogHost,
@@ -11,6 +12,7 @@ import { t } from "../i18n/index.ts";
import { formatUiError } from "../lib/format-error.ts";
import { handleContextMenuEvent } from "../lib/keyboard-shortcuts.ts";
import { shouldHandleNavigationClick } from "../lib/navigation-click.ts";
import { createOverflowFadeRef } from "../lib/overflow-fade.ts";
import type { CatalogSessionKey } from "../lib/sessions/catalog-key.ts";
import { buildCatalogSessionKey } from "../lib/sessions/catalog-key.ts";
import {
@@ -483,7 +485,9 @@ function renderCatalogSessionRow(
>
<span class="sidebar-session-indicator"></span>
<span class="sidebar-recent-session__text">
<span class="sidebar-recent-session__name hover-marquee">${label}</span>
<span class="sidebar-recent-session__name" ${ref(createOverflowFadeRef())}
><span class="sidebar-recent-session__name-content">${label}</span></span
>
<span class="sidebar-recent-session__details">
<span class="sidebar-recent-session__details-endcap">
${renderSessionRowBadges({
@@ -1,6 +1,7 @@
import { html, nothing, type TemplateResult } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import { keyed } from "lit/directives/keyed.js";
import { ref } from "lit/directives/ref.js";
import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js";
import type { NavigationRouteId } from "../app-navigation.ts";
import { sessionHasPendingApproval } from "../app/approval-presentation.ts";
@@ -9,8 +10,8 @@ import { resolveControlUiAuthCandidates } from "../app/control-ui-auth.ts";
import { t } from "../i18n/index.ts";
import { sessionHasBoard } from "../lib/board/provider.ts";
import { formatDurationCompact } from "../lib/format.ts";
import { startHoverMarquee, stopHoverMarquee } from "../lib/hover-marquee.ts";
import { handleContextMenuEvent } from "../lib/keyboard-shortcuts.ts";
import { createOverflowFadeRef } from "../lib/overflow-fade.ts";
import { projectPresencePayload } from "../lib/presence-users.ts";
import { writeSessionDragData } from "../lib/sessions/drag.ts";
import type { SidebarSessionsGrouping } from "../lib/sessions/grouping.ts";
@@ -287,8 +288,6 @@ export function renderRecentSession(params: {
}}
@contextmenu=${openMenuFromEvent}
@keydown=${openMenuFromEvent}
@mouseenter=${(event: MouseEvent) => startHoverMarquee(event.currentTarget as HTMLElement)}
@mouseleave=${(event: MouseEvent) => stopHoverMarquee(event.currentTarget as HTMLElement)}
>
<a
href=${session.href}
@@ -307,22 +306,24 @@ export function renderRecentSession(params: {
: nothing}</span
>
<span class="sidebar-recent-session__text">
<span class="sidebar-recent-session__name hover-marquee"
>${session.archived
? html`<span
class="sidebar-session__archive-glyph"
aria-label=${t("sessionsView.archived")}
title=${t("sessionsView.archived")}
>${icons.archive}</span
>`
: nothing}${session.forkSource
? html`<span
class="sidebar-session-fork-indicator"
role="img"
aria-label=${t("sessionsView.forkedSession")}
>${icons.gitFork}</span
>`
: nothing}${label}</span
<span class="sidebar-recent-session__name" ${ref(createOverflowFadeRef())}
><span class="sidebar-recent-session__name-content"
>${session.archived
? html`<span
class="sidebar-session__archive-glyph"
aria-label=${t("sessionsView.archived")}
title=${t("sessionsView.archived")}
>${icons.archive}</span
>`
: nothing}${session.forkSource
? html`<span
class="sidebar-session-fork-indicator"
role="img"
aria-label=${t("sessionsView.forkedSession")}
>${icons.gitFork}</span
>`
: nothing}${label}</span
></span
>
<span class="sidebar-recent-session__details">
${renderSidebarSessionSubtitle({ subtitle, narration })}
@@ -446,7 +447,7 @@ export function renderRecentSession(params: {
</span>
</div>
`;
// Marquee state mutates the row DOM; keying prevents cross-session reuse.
// The overflow ref owns per-title measurement state, so DOM cannot be reused across sessions.
return keyed(session.key, row);
}
@@ -7,7 +7,6 @@ import {
createChatFlowE2eSuite,
expectDefined,
installMockGateway,
pauseVirtualClock,
requireRecord,
} from "./chat-flow.test-support.ts";
@@ -226,14 +225,13 @@ suite.define(() => {
}
});
it("keeps long sidebar labels clipped after a session switch", async () => {
it("reveals only clipped sidebar titles without moving row chrome", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
await page.clock.install();
const sessions = chatSessionListResponse();
const firstSession = expectDefined(sessions.sessions[0], "first chat session fixture");
const secondSession = expectDefined(sessions.sessions[1], "second chat session fixture");
@@ -242,6 +240,8 @@ suite.define(() => {
"Review and repair the intentionally overlong sidebar session title before navigation ".repeat(
4,
);
secondSession.hasAutomation = true;
secondSession.pinned = true;
await installMockGateway(page, {
methodResponses: { "sessions.list": sessions },
sessionKey: "agent:main:session-a",
@@ -252,51 +252,98 @@ suite.define(() => {
const recentRow = page.locator(
'.sidebar-recent-session[data-session-key="agent:main:session-b"]',
);
const shortRow = page.locator(
'.sidebar-recent-session[data-session-key="agent:main:session-a"]',
);
const recentLabel = recentRow.locator(".sidebar-recent-session__name");
const recentContent = recentLabel.locator(".sidebar-recent-session__name-content");
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 shortRow.locator(".sidebar-recent-session__name").waitFor({ state: "visible" });
await expect
.poll(() => page.locator(".shell").evaluate((shell) => getComputedStyle(shell).transform))
.toBe("none");
// Freeze the clock so the 500ms hover-intent delay elapses only via
// runFor; a ticking clock let slow runners start the marquee before the
// "not yet scrolling" asserts below.
await pauseVirtualClock(page);
await recentRow.dispatchEvent("mouseenter");
await page.clock.runFor(250);
expect(await recentLabel.evaluate((label) => label.classList.value)).not.toContain(
"hover-marquee--scrolling",
const shortTitle = shortRow.locator(".sidebar-recent-session__name");
const shortContent = shortTitle.locator(".sidebar-recent-session__name-content");
const shortTransform = await shortContent.evaluate(
(content) => getComputedStyle(content).transform,
);
await recentRow.dispatchEvent("mouseleave");
// 250 + 300 exceeds the hover delay: only the leave-cancel keeps it off.
await page.clock.runFor(300);
expect(await recentLabel.evaluate((label) => label.classList.value)).not.toContain(
"hover-marquee--scrolling",
expect(await shortTitle.getAttribute("data-overflow-fade")).toBeNull();
expect(await shortTitle.getAttribute("data-overflow-reveal")).toBeNull();
await shortRow.hover();
expect(await shortContent.evaluate((content) => getComputedStyle(content).transform)).toBe(
shortTransform,
);
await recentRow.dispatchEvent("mouseenter");
await page.clock.runFor(500);
const readLongTitleState = () =>
recentRow.evaluate((row) => {
const rect = (selector: string) => {
const element = row.querySelector<HTMLElement>(selector);
if (!element) {
throw new Error(`Missing clipped-title fixture ${selector}`);
}
const box = element.getBoundingClientRect();
return { bottom: box.bottom, left: box.left, right: box.right, top: box.top };
};
const title = row.querySelector<HTMLElement>(".sidebar-recent-session__name");
const content = row.querySelector<HTMLElement>(".sidebar-recent-session__name-content");
if (!title || !content) {
throw new Error("Missing clipped-title content");
}
return {
badge: rect(".session-row-badges"),
contentTransform: getComputedStyle(content).transform,
fade: title.hasAttribute("data-overflow-fade"),
menu: rect("[data-session-menu]"),
pin: rect("[data-sidebar-session-pin]"),
reveal: title.hasAttribute("data-overflow-reveal"),
revealTranslate: title.style.getPropertyValue("--overflow-reveal-translate"),
title: rect(".sidebar-recent-session__name"),
titleClientWidth: title.clientWidth,
titleScrollWidth: content.scrollWidth,
};
});
const resting = await readLongTitleState();
expect(resting.titleScrollWidth).toBeGreaterThan(resting.titleClientWidth);
expect(resting.fade).toBe(true);
expect(resting.reveal).toBe(true);
expect(Number.parseFloat(resting.revealTranslate)).toBeLessThan(0);
await recentRow.hover();
await expect
.poll(() => recentLabel.evaluate((label) => label.classList.value), { timeout: 1_500 })
.toContain("hover-marquee--scrolling");
// Resume real time: the snap-back below is a compositor-driven CSS
// transition, not a fake-timer callback.
await page.clock.resume();
await recentRow.dispatchEvent("mouseleave");
.poll(() => recentContent.evaluate((content) => getComputedStyle(content).transform))
.not.toBe(resting.contentTransform);
const hovered = await readLongTitleState();
expect({
badge: hovered.badge,
menu: hovered.menu,
pin: hovered.pin,
title: hovered.title,
}).toEqual({
badge: resting.badge,
menu: resting.menu,
pin: resting.pin,
title: resting.title,
});
await recentRow.locator("a.sidebar-recent-session__link").focus();
await page.mouse.move(1_000, 100);
await expect
.poll(
() =>
recentLabel.evaluate((label) => ({
textIndent: getComputedStyle(label).textIndent,
textOverflow: getComputedStyle(label).textOverflow,
})),
{ timeout: 1_500 },
)
.toEqual({ textIndent: "0px", textOverflow: "ellipsis" });
.poll(() => recentContent.evaluate((content) => getComputedStyle(content).transform))
.not.toBe(resting.contentTransform);
const focused = await readLongTitleState();
expect({
badge: focused.badge,
menu: focused.menu,
pin: focused.pin,
title: focused.title,
}).toEqual({
badge: resting.badge,
menu: resting.menu,
pin: resting.pin,
title: resting.title,
});
await recentRow.locator("a.sidebar-recent-session__link").dispatchEvent("click", {
button: 0,
@@ -308,12 +355,13 @@ suite.define(() => {
const activeRow = page.locator(
'.sidebar-recent-session[data-session-key="agent:main:session-b"]',
);
expect(
await activeRow.locator(".sidebar-recent-session__name").evaluate((label) => ({
textIndent: getComputedStyle(label).textIndent,
textOverflow: getComputedStyle(label).textOverflow,
})),
).toEqual({ textIndent: "0px", textOverflow: "ellipsis" });
await expect
.poll(() =>
activeRow
.locator(".sidebar-recent-session__name")
.evaluate((title) => title.hasAttribute("data-overflow-fade")),
)
.toBe(true);
} finally {
await suite.closeBrowserContext(context);
}
-67
View File
@@ -1,67 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } 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", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
vi.useRealTimers();
document.body.replaceChildren();
});
it("waits before scrolling overflowing labels by the clipped distance", () => {
const { row, label } = buildRow({ textWidth: 320, labelWidth: 180 });
startHoverMarquee(row);
expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("-140px");
expect(label.style.getPropertyValue("--hover-marquee-duration")).toBe("1750ms");
vi.advanceTimersByTime(499);
expect(label.classList.contains("hover-marquee--scrolling")).toBe(false);
vi.advanceTimersByTime(1);
expect(label.classList.contains("hover-marquee--scrolling")).toBe(true);
stopHoverMarquee(row);
expect(label.classList.contains("hover-marquee--scrolling")).toBe(false);
});
it("cancels the delayed scroll when hover ends early", () => {
const { row, label } = buildRow({ textWidth: 320, labelWidth: 180 });
startHoverMarquee(row);
vi.advanceTimersByTime(250);
stopHoverMarquee(row);
vi.advanceTimersByTime(250);
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();
});
});
-63
View File
@@ -1,63 +0,0 @@
// 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;
const MARQUEE_HOVER_DELAY_MS = 500;
const pendingMarquees = new WeakMap<HTMLElement, number>();
function findMarqueeLabel(host: HTMLElement): HTMLElement | null {
return host.classList.contains("hover-marquee")
? host
: host.querySelector<HTMLElement>(".hover-marquee");
}
function clearPendingMarquee(label: HTMLElement): void {
const pending = pendingMarquees.get(label);
if (pending === undefined) {
return;
}
window.clearTimeout(pending);
pendingMarquees.delete(label);
}
export function startHoverMarquee(host: HTMLElement): void {
const label = findMarqueeLabel(host);
if (!label || label.classList.contains("hover-marquee--scrolling")) {
return;
}
clearPendingMarquee(label);
// 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`);
// Keep quick pointer passes quiet; leaving before the timer fires cancels it.
pendingMarquees.set(
label,
window.setTimeout(() => {
pendingMarquees.delete(label);
label.classList.add("hover-marquee--scrolling");
}, MARQUEE_HOVER_DELAY_MS),
);
}
export function stopHoverMarquee(host: HTMLElement): void {
const label = findMarqueeLabel(host);
if (!label) {
return;
}
clearPendingMarquee(label);
label.classList.remove("hover-marquee--scrolling");
}
+70
View File
@@ -0,0 +1,70 @@
import { afterEach, describe, expect, it } from "vitest";
import { createOverflowFadeRef } from "./overflow-fade.ts";
function buildRow(params: { textWidth: number; titleWidth: number; direction?: "ltr" | "rtl" }) {
const row = document.createElement("div");
const title = document.createElement("span");
title.className = "sidebar-recent-session__name";
title.style.direction = params.direction ?? "ltr";
const content = document.createElement("span");
content.className = "sidebar-recent-session__name-content";
content.textContent = "Fix stale iMessage group-allowlist warning copy";
const badge = document.createElement("span");
badge.className = "session-row-badge";
title.append(content);
row.append(title, badge);
document.body.append(row);
Object.defineProperty(title, "clientWidth", { value: params.titleWidth });
Object.defineProperty(content, "scrollWidth", { value: params.textWidth });
return { row, title, content, badge };
}
describe("overflow fade", () => {
afterEach(() => {
document.body.replaceChildren();
});
it("marks only genuinely clipped titles for a resting fade", () => {
const clipped = buildRow({ textWidth: 320, titleWidth: 180 });
createOverflowFadeRef()(clipped.title);
expect(clipped.title.hasAttribute("data-overflow-fade")).toBe(true);
const fitting = buildRow({ textWidth: 120, titleWidth: 180 });
createOverflowFadeRef()(fitting.title);
expect(fitting.title.hasAttribute("data-overflow-fade")).toBe(false);
});
it("reveals only the hidden tail without changing badges", () => {
const { title, badge } = buildRow({ textWidth: 320, titleWidth: 180 });
const badgeBefore = badge.outerHTML;
createOverflowFadeRef()(title);
expect(title.hasAttribute("data-overflow-reveal")).toBe(true);
expect(title.style.getPropertyValue("--overflow-reveal-translate")).toBe("-140px");
expect(title.style.getPropertyValue("--overflow-reveal-duration")).toBe("2240ms");
expect(badge.outerHTML).toBe(badgeBefore);
});
it("leaves fitting titles untouched", () => {
const { title } = buildRow({ textWidth: 120, titleWidth: 180 });
createOverflowFadeRef()(title);
expect(title.hasAttribute("data-overflow-reveal")).toBe(false);
expect(title.style.getPropertyValue("--overflow-reveal-translate")).toBe("");
});
it("bounds reveal duration and reverses travel for RTL", () => {
const short = buildRow({ textWidth: 190, titleWidth: 180 });
createOverflowFadeRef()(short.title);
expect(short.title.style.getPropertyValue("--overflow-reveal-duration")).toBe("1200ms");
const long = buildRow({ textWidth: 900, titleWidth: 180, direction: "rtl" });
createOverflowFadeRef()(long.title);
expect(long.title.style.getPropertyValue("--overflow-reveal-translate")).toBe("720px");
expect(long.title.style.getPropertyValue("--overflow-reveal-duration")).toBe("8000ms");
});
it("ignores detached refs", () => {
expect(() => createOverflowFadeRef()(undefined)).not.toThrow();
});
});
+45
View File
@@ -0,0 +1,45 @@
/** Ref callback that marks single-line text only while it is genuinely clipped. */
export function createOverflowFadeRef() {
let target: HTMLElement | null = null;
let observer: ResizeObserver | null = null;
const sync = () => {
if (!target) {
return;
}
const content = target.querySelector<HTMLElement>(".sidebar-recent-session__name-content");
const contentWidth = content?.scrollWidth ?? target.scrollWidth;
const revealDistance = Math.max(0, contentWidth - target.clientWidth);
target.toggleAttribute("data-overflow-fade", revealDistance > 1);
target.toggleAttribute("data-overflow-reveal", revealDistance > 1);
if (revealDistance <= 1) {
target.style.removeProperty("--overflow-reveal-translate");
target.style.removeProperty("--overflow-reveal-duration");
return;
}
const direction = getComputedStyle(target).direction === "rtl" ? 1 : -1;
target.style.setProperty("--overflow-reveal-translate", `${direction * revealDistance}px`);
const revealDuration = Math.min(8_000, Math.max(1_200, revealDistance * 16));
target.style.setProperty("--overflow-reveal-duration", `${revealDuration}ms`);
};
return (element?: Element) => {
const next = element instanceof HTMLElement ? element : null;
if (next === target) {
sync();
return;
}
observer?.disconnect();
target = next;
observer = null;
if (!target) {
return;
}
sync();
if (typeof ResizeObserver !== "undefined") {
observer = new ResizeObserver(sync);
observer.observe(target);
}
queueMicrotask(sync);
};
}
-34
View File
@@ -5963,40 +5963,6 @@ td.data-table-key-col {
}
}
/* ===========================================
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;
}
}
/* ---- Plugin tab embeds (plugin-served panels) ---- */
.plugin-tab-embed {
+50 -10
View File
@@ -2291,6 +2291,53 @@ body.update-dialog-open .sidebar-update-card__status {
color: var(--text);
}
.sidebar-recent-session__name-content {
display: inline-block;
min-width: max-content;
transform: translateX(0);
}
/* The mask exists only when the measured title is clipped; short titles keep
their full opacity. Revealing transforms only the title content, so row
controls and badges retain their positions. */
.sidebar-recent-session__name[data-overflow-fade] {
-webkit-mask-image: linear-gradient(to right, black 0, black calc(100% - 10px), transparent 100%);
mask-image: linear-gradient(to right, black 0, black calc(100% - 10px), transparent 100%);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
text-overflow: clip;
}
.sidebar-recent-session__name[data-overflow-fade]:dir(rtl) {
-webkit-mask-image: linear-gradient(to left, black 0, black calc(100% - 10px), transparent 100%);
mask-image: linear-gradient(to left, black 0, black calc(100% - 10px), transparent 100%);
}
.session-row-host:is(:hover, :focus-within) .sidebar-recent-session__name[data-overflow-reveal] {
-webkit-mask-image: linear-gradient(to right, transparent 0, black 10px, black 100%);
mask-image: linear-gradient(to right, transparent 0, black 10px, black 100%);
}
.session-row-host:is(:hover, :focus-within)
.sidebar-recent-session__name[data-overflow-reveal]:dir(rtl) {
-webkit-mask-image: linear-gradient(to left, transparent 0, black 10px, black 100%);
mask-image: linear-gradient(to left, transparent 0, black 10px, black 100%);
}
.session-row-host:is(:hover, :focus-within)
.sidebar-recent-session__name[data-overflow-reveal]
.sidebar-recent-session__name-content {
transform: translateX(var(--overflow-reveal-translate, 0));
transition: transform var(--overflow-reveal-duration, 900ms) linear 180ms;
}
@media (prefers-reduced-motion: reduce) {
.sidebar-recent-session__name-content {
transform: none !important;
transition: none !important;
}
}
.sidebar-recent-session--child {
min-height: 30px;
}
@@ -2375,9 +2422,9 @@ body.update-dialog-open .sidebar-update-card__status {
right: calc(var(--sidebar-child-session-toggle-width) + 4px);
}
/* Centered actions overlap both lines, so the full text column yields their width. */
.sidebar-recent-session:not(.sidebar-recent-session--child):is(:hover, :focus-within)
.sidebar-recent-session__text {
/* Centered actions overlap both lines, so reserve their width in every state.
Hover and keyboard focus then reveal chrome without moving titles or badges. */
.sidebar-recent-session:not(.sidebar-recent-session--child) .sidebar-recent-session__text {
padding-right: 52px;
}
@@ -2387,13 +2434,6 @@ body.update-dialog-open .sidebar-update-card__status {
pointer-events: none;
}
/* Touch keeps the actions permanently visible beside the persistent endcap. */
@media (hover: none), (pointer: coarse) {
.sidebar-recent-session:not(.sidebar-recent-session--child) .sidebar-recent-session__text {
padding-right: 52px;
}
}
.nav-collapse-toggle {
width: 36px;
height: 36px;