diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index 91efb5c8ca218..672f92653dd94 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -70,6 +70,31 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> { const AGE_KEY = { day: 'ageDay', hour: 'ageHour', minute: 'ageMin' } as const +// Hover marquee (card title): measure the actual overflow on pointerenter and +// arm the CSS animation only when there is some — CSS can't detect overflow on +// its own, and animating a non-overflowing title would wiggle for nothing. +// Distance-proportional duration keeps the scroll speed constant across short +// and long overflows. State lives in DOM attributes, not React state: hover +// must not re-render a memoized row. +const MARQUEE_PX_PER_SECOND = 80 + +function armMarquee(event: React.PointerEvent) { + const el = event.currentTarget + const distance = el.scrollWidth - el.clientWidth + + if (distance > 2) { + // The keyframes spend 65% of the cycle travelling (10%→75%); scale the + // duration so the travel segment itself moves at the target speed. + el.style.setProperty('--marquee-d', `${distance}px`) + el.style.setProperty('--marquee-t', `${Math.max(1, distance / MARQUEE_PX_PER_SECOND / 0.65)}s`) + el.dataset.marquee = 'true' + } +} + +function disarmMarquee(event: React.PointerEvent) { + delete event.currentTarget.dataset.marquee +} + // The last thing in the trailing slot hands its place to the ⋯ button on hover, // and is never narrower than the button that has to cover it. A PR chip is the // exception while the pointer is on it: it's a link, and the kebab sits @@ -374,34 +399,87 @@ function SidebarSessionRowImpl({ onResume() }} > - {reorderable ? ( - - {lead ?? ( - - )} - - ) : ( - - {lead ?? } - - )} - {handoffSource && handoffLabel ? ( - - - - ) : null} - - {title} - + {(() => { + const leadNode = reorderable ? ( + + {lead ?? ( + + )} + + ) : ( + + {lead ?? } + + ) + + const handoffBadge = + handoffSource && handoffLabel ? ( + + + + ) : null + + if (!card) { + return ( + <> + {leadNode} + {handoffBadge} + + {title} + + + ) + } + + return ( + <> + {/* Header row — ONE div: dot, context, then the age/kebab + cluster in flow at its right edge. Keeping the cluster + inside this line (instead of the shell's full-height side + column) means title/preview/meta below span the card's + entire width — nothing truncates against the kebab. */} +
+ {leadNode} + + {context} + + {handoffBadge} + {actionsNode} +
+ {/* Title + preview: ONE grouped cell with its own tight + internal gap — it does not inherit the card's rhythm. */} +
+ + {title} + + {session.preview && rowMeta.includes('preview') ? ( + + {session.preview} + + ) : null} +
+ {model || size ? ( + + {model ? {model} : null} + {size ? {size} : null} + + ) : null} + + ) + })()} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 66b92f531430d..3fc1f06996d15 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1179,6 +1179,35 @@ body.guest-pointer-lock :is(webview, iframe) { background-color: color-mix(in srgb, var(--dt-midground) 40%, transparent); } + /* Hover marquee: a truncated line that scrolls its overflow into view while + hovered. Opt-in via .hover-marquee + an inner .hover-marquee-inner; the + JS side measures the overflow on pointerenter and sets --marquee-d (and + data-marquee) only when the text actually overflows, so short titles + keep their plain ellipsis and never wiggle. Alternate direction reads + back to the start; the 300ms delay keeps casual pointer passes still. */ + .hover-marquee[data-marquee='true'] { + text-overflow: clip; + } + + .hover-marquee[data-marquee='true'] > .hover-marquee-inner { + display: inline-block; + animation: hover-marquee var(--marquee-t, 3s) linear infinite; + } + + /* One direction, Spotify-style: scroll the tail in, hold, snap back, hold. + The percentages pin the two holds; the travel occupies the middle. */ + @keyframes hover-marquee { + 0%, + 10% { + transform: translateX(0); + } + + 75%, + 100% { + transform: translateX(calc(-1 * var(--marquee-d, 0px))); + } + } + /* Variant for portaled overlays (Radix DropdownMenu, Popover, etc.) that render under document.body, outside the `.scrollbar-dt` scope on #root. Same visual treatment, applied directly to the overlay