From 0856a19c87bb5c642b21c3ff42820e1af8864846 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Tue, 11 Aug 2026 01:38:14 +0300 Subject: [PATCH] History rail: a truncated title shows its full prompt in a tooltip (fix #1494) Replaces the hover marquee, which forced reading at the animation's pace and moved the text under the cursor. The end-fade stays as the truncation cue; a title that fits gets no tooltip at all. The marquee CSS (keyframes, hover masks, inner span) goes with it. Co-Authored-By: Claude Fable 5 --- .../components/RunHistory.test.tsx | 29 ++++++++++++++++ .../components/RunHistory.tsx | 26 ++++++++++---- .../framework-dashboard/layouts/tailwind.css | 34 +++---------------- 3 files changed, 53 insertions(+), 36 deletions(-) diff --git a/packages/framework-dashboard/components/RunHistory.test.tsx b/packages/framework-dashboard/components/RunHistory.test.tsx index 69aecd5b..5ada0dc9 100644 --- a/packages/framework-dashboard/components/RunHistory.test.tsx +++ b/packages/framework-dashboard/components/RunHistory.test.tsx @@ -3,6 +3,7 @@ import type { RunMeta, ProjectSummary } from '@gemstack/the-framework' import { afterEach, describe, expect, test, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { SidebarProvider } from './ui/sidebar.js' +import { hoverTooltip } from '../test-utils.js' // RunHistory pulls in AddProjectPanel, which imports the projects telefunc shim; stub it so the // import graph does not drag telefunc into jsdom. Import RunHistory after the mock is in place. @@ -339,3 +340,31 @@ describe('cloud sessions on the rail (#1263/#1264)', () => { expect(screen.queryByLabelText('Runs as a Claude Code cloud session')).toBeNull() }) }) + +describe('RunHistory title tooltip (#1494)', () => { + // jsdom gives every element zero widths, so the overflow measure needs stubbed getters to see + // a title wider than its rail slot. + test('a title that overflows the rail shows its full prompt in a tooltip', async () => { + const long = 'refactor the queue promotion sweep so drains claim tickets through lock files' + const scrollSpy = vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(240) + const clientSpy = vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(120) + try { + renderRail( {}} />) + const title = await screen.findByText(long) + const tip = await hoverTooltip(title) + expect(tip.textContent).toContain(long) + } finally { + scrollSpy.mockRestore() + clientSpy.mockRestore() + } + }) + + test('a title that fits is a plain span — no tooltip wiring at all', () => { + renderRail( {}} />) + const title = screen.getByText("replace 'Hello, world!' with 'Welcome!'") + // Not overflowing (zero widths measure as fitting): hovering has no listeners to open anything. + fireEvent.mouseEnter(title) + fireEvent.mouseMove(title) + expect(screen.queryByRole('tooltip')).toBeNull() + }) +}) diff --git a/packages/framework-dashboard/components/RunHistory.tsx b/packages/framework-dashboard/components/RunHistory.tsx index 5303b0f8..16d41b9a 100644 --- a/packages/framework-dashboard/components/RunHistory.tsx +++ b/packages/framework-dashboard/components/RunHistory.tsx @@ -548,15 +548,17 @@ function RunRow({ // "In cloud" outranks "publishing…": a web run's local half is over either way, and the cloud // side owns its own push/PR, so the cloud word is the truer one for that row. const publishingNow = publishing && !inCloud - // The title only fades + marquees when it actually overflows the fixed-width rail; a short one - // shows plainly. Measured here since CSS cannot tell. The rail width is fixed, so intent is the - // only thing that changes the answer. + // The title only fades + carries a tooltip when it actually overflows the fixed-width rail; a + // short one shows plainly. Measured here since CSS cannot tell. The rail width is fixed, so + // intent is the only thing that changes the answer. const titleRef = useRef(null) const [overflowing, setOverflowing] = useState(false) useEffect(() => { const el = titleRef.current if (el) setOverflowing(el.scrollWidth > el.clientWidth + 1) }, [intent]) + const titleText = intent || 'New session' + const titleClass = cn('rail-title w-full px-2 text-sm font-normal', overflowing && 'is-overflowing') return ( ) } diff --git a/packages/framework-dashboard/layouts/tailwind.css b/packages/framework-dashboard/layouts/tailwind.css index 52d984db..77224cf0 100644 --- a/packages/framework-dashboard/layouts/tailwind.css +++ b/packages/framework-dashboard/layouts/tailwind.css @@ -130,44 +130,18 @@ body { color: var(--foreground); } -/* Recents row title: the truncated end fades out (like the reference) instead of a hard ellipsis, - and hovering the row marquees a long title by exactly its overflow so it can be read without a - tooltip. `container-type: inline-size` lets the keyframe measure the overflow itself — 100cqw is - the visible width, 100% the text's own width, so `min(0, 100cqw - 100%)` is 0 for a title that - fits and the negative overflow for one that does not (short titles never move). */ +/* Recents row title: the truncated end fades out (like the reference) instead of a hard ellipsis. + A long title's full text shows in a tooltip on hover (#1494) — the old hover marquee is gone. */ .rail-title { overflow: hidden; white-space: nowrap; - container-type: inline-size; } -/* Only a title that actually overflows fades and marquees (the `is-overflowing` class is set by a - measure in RunRow); a short one shows plainly, with no gradient. */ +/* Only a title that actually overflows fades (the `is-overflowing` class is set by a measure in + RunRow); a short one shows plainly, with no gradient. */ .rail-title.is-overflowing { -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 3rem), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 3rem), transparent); } -/* While marqueeing, fade both borders so the title dissolves at each card edge, not only the end. */ -.rail-row:hover .rail-title.is-overflowing { - -webkit-mask-image: linear-gradient(to right, transparent, #000 1.25rem, #000 calc(100% - 3rem), transparent); - mask-image: linear-gradient(to right, transparent, #000 1.25rem, #000 calc(100% - 3rem), transparent); -} -.rail-title-inner { - display: inline-block; - will-change: transform; -} -.rail-row:hover .rail-title.is-overflowing .rail-title-inner { - animation: rail-marquee 4s ease-in-out 0.35s infinite alternate; -} -@keyframes rail-marquee { - to { - transform: translateX(min(0px, calc(100cqw - 100%))); - } -} -@media (prefers-reduced-motion: reduce) { - .rail-row:hover .rail-title-inner { - animation: none; - } -} /* The Recents fade strip only appears once the list is scrolled, so it never dims the first row at rest. Its opacity is driven by the rail's own scroll position (the same scroll-timeline mechanism