Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions packages/framework-dashboard/components/RunHistory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(<RunHistory projectId="p1" runs={[run({ intent: long })]} selectedRunId={null} onSelect={() => {}} />)
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(<RunHistory projectId="p1" runs={[run()]} selectedRunId={null} onSelect={() => {}} />)
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()
})
})
26 changes: 20 additions & 6 deletions packages/framework-dashboard/components/RunHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLSpanElement>(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 (
<Button
variant="ghost"
Expand Down Expand Up @@ -617,9 +619,21 @@ function RunRow({
</span>
)}
</span>
<span ref={titleRef} className={cn('rail-title w-full px-2 text-sm font-normal', overflowing && 'is-overflowing')}>
<span className="rail-title-inner">{intent || 'New session'}</span>
</span>
{/* A truncated title shows its full prompt in a tooltip (#1494) — the hover marquee it
replaces 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. */}
{overflowing ? (
<Tooltip>
<TooltipTrigger render={<span ref={titleRef} className={titleClass} />}>{titleText}</TooltipTrigger>
<TooltipContent side="right" className="max-w-96 whitespace-pre-wrap break-words">
{titleText}
</TooltipContent>
</Tooltip>
) : (
<span ref={titleRef} className={titleClass}>
{titleText}
</span>
)}
</Button>
)
}
34 changes: 4 additions & 30 deletions packages/framework-dashboard/layouts/tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading