fix(desktop): give every row's trailing metadata one right-aligned slot

The PR and profile chips rendered in the row body, left of the kebab's own
column: they never sat flush right and never handed their space to the kebab
on hover, so a row showing only a PR left a hole where the age would have been.
Both now join the tokens/cost/age figures in the actions slot, and the kebab
covers the end of it — losing whichever item reads last, not the whole slot.
This commit is contained in:
Brooklyn Nicholson 2026-08-10 03:35:57 -05:00
parent 6c371e944c
commit 8de786c7a7
1 changed files with 59 additions and 51 deletions

View File

@ -112,10 +112,9 @@ function SidebarSessionRowImpl({
const totalTokens = session.input_tokens + session.output_tokens
const cost = sessionCostUsd(session)
// Tokens, cost and age share the one trailing slot rather than each claiming
// their own column: several switched on read as one figure, not as a
// widening gutter.
const pinnedFacts = [
// Tokens, cost and age share one figure rather than each claiming a column:
// several switched on read as one number, not as a widening gutter.
const figures = [
rowMeta.includes('tokens') && totalTokens > 0 ? compactNumber(totalTokens) : null,
// Sub-cent spend rounds to "$0.00", which reads as a bug rather than as a
// cheap session — below a cent the row says nothing at all.
@ -123,18 +122,43 @@ function SidebarSessionRowImpl({
pinnedAge ? age : null
].filter(Boolean) as string[]
// The kebab covers the END of the slot, so only the last fact steps aside for
// it. With tokens and age both on, hovering costs you the age and keeps the
// number you switched on to read.
const pinnedTail = pinnedFacts.at(-1) ?? ''
const pinnedHead = pinnedFacts.slice(0, -1).join(' · ')
const pinnedLabel = pinnedFacts.join(' · ')
// Chips that ride in the BODY, beside the title. The kebab lifts out of the
// actions slot, so it only ever covers what's in there — a body chip has no
// reason to step aside, and the hover-age (which does overlay the body) is
// dropped rather than made to fight them.
const bodyChip = Boolean(pr) || pinnedProfile || (showProfile && hasProfileTag)
const pinnedMeta = Boolean(pinnedLabel) || bodyChip
// Everything the Show menu puts after the title shares ONE right-aligned
// slot, in reading order: identity chips, then the figures. The kebab covers
// the END of that slot on hover, so only the last thing in it steps aside —
// with tokens and age both on you lose the age and keep the number you
// switched on, and a PR keeps its place (and its click) unless it IS the last
// thing. Chips used to render in the body instead, which left them stranded
// to the left of the kebab's own column: never flush right, never swapping.
const trailing: { key: string; node: React.ReactNode }[] = []
if ((showProfile || pinnedProfile) && hasProfileTag) {
trailing.push({ key: 'profile', node: <ProfileTag profile={session.profile} /> })
}
if (pr) {
trailing.push({ key: 'pr', node: <PrTag pr={pr} /> })
}
if (figures.length) {
const head = figures.slice(0, -1).join(' · ')
trailing.push({
key: 'figures',
node: (
<span className="pointer-events-none whitespace-nowrap text-[0.625rem] leading-none text-(--ui-text-tertiary)">
{head}
{/* The figures own their tail: the separator goes with it. */}
<span className="inline-block min-w-5 text-right transition-opacity group-hover:opacity-0">
{head && ' · '}
{figures.at(-1)}
</span>
</span>
)
})
}
// A chip that ends the slot hides whole; the figures handle their own tail.
const chipEndsSlot = trailing.length > 0 && !figures.length
// A handed-off session's live source is local, but it originated on a
// messaging platform — surface that origin as a small badge so e.g. a
// Telegram thread continued here still reads as Telegram.
@ -167,38 +191,26 @@ function SidebarSessionRowImpl({
>
<SidebarRowShell
actions={
// Pinned metadata sits in normal flow and the kebab lifts out of it,
// so this slot's intrinsic width IS the metadata's — the row's
// The trailing metadata sits in normal flow and the kebab lifts out
// of it, so this slot's intrinsic width IS the metadata's — the row's
// `auto` actions column measures it and the title truncates against
// whatever is switched on, with no width to hand-maintain.
<div className="relative z-2 flex items-center justify-end" data-row-actions>
{/* Pinned metadata stays put through a turn it was switched on to
be read. Only the tail hands its slot to the kebab; anything
ahead of it stays legible while you hover. The hover-only age is
an overlay instead, so it needs the row to open up 48px of right
padding which beside a body chip reads as a hole. A row that
already shows a chip skips it. */}
{(pinnedLabel || (!liveTurn && !bodyChip)) && (
// whatever is switched on, with no width to hand-maintain. Nothing
// switched on leaves the slot to the kebab alone; hover changes what
// you can see in it, never how wide it is.
<div className="relative z-2 flex items-center justify-end gap-1" data-row-actions>
{trailing.map(({ key, node }, index) => (
<span
className={cn(
'pointer-events-none whitespace-nowrap text-right text-[0.625rem] leading-none text-(--ui-text-tertiary)',
!pinnedLabel && 'absolute right-6 opacity-0 transition-opacity group-hover:opacity-100'
// Never narrower than the kebab that has to cover it.
chipEndsSlot &&
index === trailing.length - 1 &&
'inline-flex min-w-5 justify-end transition-opacity group-hover:opacity-0'
)}
key={key}
>
{pinnedLabel ? (
<>
{pinnedHead}
{/* Never narrower than the kebab that has to cover it. */}
<span className="inline-block min-w-5 transition-opacity group-hover:opacity-0">
{pinnedHead && ' · '}
{pinnedTail}
</span>
</>
) : (
age
)}
{node}
</span>
)}
))}
<SessionActionsMenu
onArchive={onArchive}
onBranch={onBranch}
@ -213,7 +225,7 @@ function SidebarSessionRowImpl({
aria-label={r.sessionActions}
className={cn(
'size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!',
pinnedLabel && 'absolute right-0'
trailing.length > 0 && 'absolute right-0'
)}
size="icon"
variant="ghost"
@ -266,10 +278,10 @@ function SidebarSessionRowImpl({
>
{showsRunningArc(dotState) && <span aria-hidden="true" className="arc-border arc-row" />}
<SidebarRowBody
// Pinned metadata already sits in the actions slot, so the title only
// needs a gap from it — and the same gap on hover, or the row would
// jump every time the kebab took over.
className={cn('z-0', pinnedMeta ? 'pr-2' : 'group-hover:pr-12', branchStem && 'pl-3.5')}
// Every trailing figure lives in the actions slot, which the row
// measures — so the title needs a gap from it and nothing else. Hover
// changes what you can see in that slot, never how wide it is.
className={cn('z-0 pr-2', branchStem && 'pl-3.5')}
// Middle-click = open in a new tab (browser muscle memory).
{...middleClickHandlers(() => {
triggerHaptic('selection')
@ -339,10 +351,6 @@ function SidebarSessionRowImpl({
<SidebarRowLabel className="flex-1 font-normal group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
</SidebarRowLabel>
{/* Stays put on hover, unlike the other chips: it's a link, and the
kebab lives in its own column rather than over this one. */}
{pr && <PrTag pr={pr} />}
{(showProfile || pinnedProfile) && hasProfileTag && <ProfileTag profile={session.profile} />}
</SidebarRowBody>
</SidebarRowShell>
</SessionContextMenu>