🐛 fix: improve sidebar title clarity and hover scrolling (#15644)

* fix: improve sidebar title clarity and hover scrolling

* fix: address PR review bot findings

Copilot:
- Drive the title fade from CSS so autoprefixer emits -webkit-mask-size for
  WebKit; Web Animations silently drops prefixed keyframe properties.
- Guard MediaQueryList subscription so legacy addListener/removeListener
  engines neither miss the change handler nor throw during cleanup.
This commit is contained in:
Marco Beretta 2026-09-05 19:27:54 +02:00 • committed by GitHub
parent a9da01fbe7
commit 1c115cdebf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 120 additions and 19 deletions

View file

@ -48,6 +48,7 @@ function Conversation({
const [titleInput, setTitleInput] = useState(title || '');
const [renaming, setRenaming] = useState(false);
const [isPopoverActive, setIsPopoverActive] = useState(false);
const [isHovered, setIsHovered] = useState(false);
// Lazy-load ConvoOptions to avoid running heavy hooks for all conversations
const [hasInteracted, setHasInteracted] = useState(false);
@ -222,6 +223,13 @@ function Conversation({
? 'bg-surface-active-alt before:absolute before:bottom-1 before:left-0 before:top-1 before:w-0.5 before:rounded-full before:bg-text-primary'
: 'hover:bg-surface-active-alt',
)}
onPointerEnter={(event) => {
if (event.pointerType === 'mouse') {
setIsHovered(true);
}
}}
onPointerLeave={() => setIsHovered(false)}
onPointerCancel={() => setIsHovered(false)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onFocus={handleMouseEnter}
@ -249,6 +257,7 @@ function Conversation({
<ConvoLink
isActiveConvo={isActiveConvo}
isPopoverActive={isPopoverActive}
isHovered={isHovered}
isSharedBadgeVisible={isSharedBadgeVisible}
title={title}
onRename={handleRename}

View file

@ -1,20 +1,39 @@
import React from 'react';
import React, { useEffect, useRef, useState } from 'react';
import type { TOptions } from 'i18next';
import type { TranslationKeys } from '~/hooks/useLocalize';
import { cn } from '~/utils';
interface ConvoLinkProps {
isActiveConvo: boolean;
isPopoverActive: boolean;
isHovered: boolean;
isSharedBadgeVisible: boolean;
title: string | null;
onRename: () => void;
isSmallScreen: boolean;
localize: (key: any, options?: any) => string;
localize: (key: TranslationKeys, options?: TOptions) => string;
children: React.ReactNode;
}
const TITLE_SPEED_PX_PER_SECOND = 30;
const TITLE_START_DELAY_MS = 600;
const TITLE_HOLD_MS = 2000;
/** Legacy engines expose only `addListener` on MediaQueryList. */
const observeMediaQuery = (query: MediaQueryList, onChange: () => void) => {
if (typeof query.addEventListener === 'function') {
query.addEventListener('change', onChange);
return () => query.removeEventListener('change', onChange);
}
query.addListener(onChange);
return () => query.removeListener(onChange);
};
const ConvoLink: React.FC<ConvoLinkProps> = ({
isActiveConvo,
isPopoverActive,
isHovered,
isSharedBadgeVisible,
title,
onRename,
@ -22,14 +41,90 @@ const ConvoLink: React.FC<ConvoLinkProps> = ({
localize,
children,
}) => {
const [isOverflowing, setIsOverflowing] = useState(false);
const titleRef = useRef<HTMLSpanElement>(null);
const textRef = useRef<HTMLSpanElement>(null);
const displayTitle = title || localize('com_ui_untitled');
useEffect(() => {
const viewport = titleRef.current;
const text = textRef.current;
if (!viewport || !text) {
return;
}
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
let animation: Animation | undefined;
let frame = 0;
const start = () => {
animation?.cancel();
cancelAnimationFrame(frame);
delete viewport.dataset.titleRevealed;
const overflow = text.getBoundingClientRect().width - viewport.getBoundingClientRect().width;
setIsOverflowing(overflow > 0);
if (!isHovered || isSmallScreen || reducedMotion.matches || overflow <= 0) {
return;
}
const style = getComputedStyle(viewport);
const direction = style.direction === 'rtl' ? 1 : -1;
const revealDuration = parseFloat(style.getPropertyValue('--convo-title-reveal-duration'));
const travelDuration = (overflow / TITLE_SPEED_PX_PER_SECOND) * 1000;
const duration = travelDuration + TITLE_HOLD_MS;
const end = `translateX(${direction * overflow}px)`;
animation = text.animate(
[
{ transform: 'translateX(0)' },
{ transform: end, offset: travelDuration / duration },
{ transform: end },
],
{
duration,
delay: TITLE_START_DELAY_MS,
iterations: Infinity,
easing: 'linear',
},
);
/** The fade retreats as the ending arrives, so the last characters read
* clearly without the text overscrolling past the available width. CSS owns
* the mask because Web Animations cannot carry the `-webkit-` fallback that
* older WebKit needs. */
const revealFrom = Math.max(0, travelDuration - revealDuration);
const tick = () => {
const currentTime = typeof animation?.currentTime === 'number' ? animation.currentTime : 0;
const elapsed = currentTime - TITLE_START_DELAY_MS;
viewport.dataset.titleRevealed = String(elapsed > 0 && elapsed % duration >= revealFrom);
frame = requestAnimationFrame(tick);
};
tick();
};
const observer = new ResizeObserver(start);
observer.observe(viewport);
observer.observe(text);
const unobserveMediaQuery = observeMediaQuery(reducedMotion, start);
start();
return () => {
observer.disconnect();
unobserveMediaQuery();
animation?.cancel();
cancelAnimationFrame(frame);
delete viewport.dataset.titleRevealed;
};
}, [isHovered, isSmallScreen, displayTitle]);
return (
<button
type="button"
className={cn(
'flex min-w-0 grow cursor-pointer items-center gap-2 overflow-hidden rounded-lg px-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-text-primary',
'flex w-full min-w-0 grow cursor-pointer items-center gap-2 overflow-hidden rounded-lg px-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-text-primary',
isActiveConvo || isPopoverActive ? 'bg-surface-active-alt' : '',
)}
title={title ?? undefined}
aria-current={isActiveConvo ? 'page' : undefined}
aria-label={
isSharedBadgeVisible
@ -40,12 +135,16 @@ const ConvoLink: React.FC<ConvoLinkProps> = ({
title: title || localize('com_ui_untitled'),
})
}
style={{ width: '100%' }}
>
{children}
<div
className="relative flex-1 grow overflow-hidden whitespace-nowrap"
style={{ textOverflow: 'clip' }}
<span
ref={titleRef}
className={cn(
'min-w-0 flex-1 overflow-hidden whitespace-nowrap [--convo-title-fade-width:24px] [--convo-title-reveal-duration:800ms] [mask-position:left] [mask-repeat:no-repeat] [mask-size:100%_100%] [text-align:start] [transition-duration:0ms] [transition-property:mask-size] [transition-timing-function:linear] [&:dir(rtl)]:[mask-position:right]',
isOverflowing &&
'[mask-image:linear-gradient(to_right,currentColor_calc(100%_-_var(--convo-title-fade-width)),transparent)] [&:dir(rtl)]:[mask-image:linear-gradient(to_left,currentColor_calc(100%_-_var(--convo-title-fade-width)),transparent)]',
'data-[title-revealed=true]:[mask-size:calc(100%_+_var(--convo-title-fade-width))_100%] data-[title-revealed=true]:[transition-duration:var(--convo-title-reveal-duration)]',
)}
onDoubleClick={(e) => {
if (isSmallScreen) {
return;
@ -55,17 +154,10 @@ const ConvoLink: React.FC<ConvoLinkProps> = ({
onRename();
}}
>
{title || localize('com_ui_untitled')}
<div
className={cn(
'pointer-events-none absolute bottom-0 right-0 top-0 w-20 bg-gradient-to-l',
isActiveConvo || isPopoverActive
? 'from-surface-active-alt'
: 'from-surface-primary-alt from-0% to-transparent group-hover:from-surface-active-alt group-hover:from-0%',
)}
aria-hidden="true"
/>
</div>
<span ref={textRef} className="inline-block">
{displayTitle}
</span>
</span>
</button>
);
};