Merge pull request #90920 from NousResearch/bb/session-row-trailing

Sidebar rows own their right edge
This commit is contained in:
brooklyn!
2026-08-20 11:45:10 -05:00
committed by GitHub
6 changed files with 96 additions and 68 deletions
+21 -6
View File
@@ -23,9 +23,19 @@ export function SidebarSectionMeta({ children }: { children: React.ReactNode })
// Height lives ONLY on SidebarRowShell (min-h-[1.625rem]). Inset children
// stretch to fill the cell and center content internally — never items-center
// on the shell grid, or short clusters (projects) float 1–2px off sessions.
//
// `rowPadX` is the BODY's padding: the lead's inset, plus the gap the label
// keeps from the actions column, both inside the row's click target.
// `rowPadTrail` is the row's own trailing inset and belongs to the SHELL — the
// only box containing both the actions column AND the card's in-body cluster,
// so one class insets every trailing thing a row can render. Owned anywhere
// else, the age / chips / kebab sit flush on the border box, which is exactly
// where a working row paints its arc (`.arc-row` has zero standoff) — the ring
// ran through the text.
const rowMinH = 'min-h-[1.625rem]'
const rowPadX = 'pl-2 pr-1'
const rowPadX = 'pl-2 pr-2'
const rowPadTrail = 'pr-2'
const rowGap = 'gap-1.5'
const rowLead = 'grid size-3.5 shrink-0 place-items-center'
const rowInset = cn(rowPadX, rowGap, 'flex h-full min-w-0 items-center self-stretch py-0.5')
@@ -71,9 +81,11 @@ export function SidebarDateDivider({
)
}
/** Outer grid — sole owner of row height. The trailing `actions` slot is
* marked `data-row-actions` so a row-wide drag gesture can exclude it with
* one selector: it holds real controls, never grab surface. */
/** Outer grid — sole owner of row height and of the trailing inset. The
* `actions` slot is marked `data-row-actions` so a row-wide drag gesture can
* exclude it with one selector: it holds real controls, never grab surface.
* It stretches so that exclusion covers the column, not just its tallest
* control. */
export function SidebarRowShell({
actions,
actionsClassName,
@@ -82,10 +94,13 @@ export function SidebarRowShell({
...props
}: React.ComponentProps<'div'> & { actions?: React.ReactNode; actionsClassName?: string }) {
return (
<div className={cn(rowMinH, 'grid grid-cols-[minmax(0,1fr)_auto] items-stretch rounded-md', className)} {...props}>
<div
className={cn(rowMinH, rowPadTrail, 'grid grid-cols-[minmax(0,1fr)_auto] items-stretch rounded-md', className)}
{...props}
>
{children}
{actions ? (
<div className={cn('flex shrink-0 items-center self-center', actionsClassName)} data-row-actions>
<div className={cn('flex shrink-0 items-center self-stretch', actionsClassName)} data-row-actions>
{actions}
</div>
) : null}
@@ -23,6 +23,7 @@ import type { CronJob } from '@/types/hermes'
import { jobState, jobTitle, STATE_DOT } from '../../cron/job-state'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import { SidebarRowBody, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
import { SidebarLoadMoreRow } from './load-more-row'
const INACTIVE_STATES = new Set(['completed', 'disabled', 'error', 'paused'])
@@ -304,21 +305,57 @@ function CronJobSidebarRow({
return (
<div>
{/* The shared row chrome, not a copy of it: a cron job and a session sit
in the same list, so they line up only if one place owns the geometry. */}
<ActionsContextMenu ariaLabel={c.actionsTitle} contentClassName="w-44" items={items}>
<div className="group/cron relative grid min-h-[1.625rem] grid-cols-[minmax(0,1fr)_auto] items-center rounded-md hover:bg-(--chrome-action-hover)">
{/* Lead with the dot in the same w-3.5 cell + pl-2 the session rows use
so the cron dots line up with the sessions above; the caret sits next
to the label (matching the other sidebar disclosures) and the whole
label area toggles the run peek. */}
<SidebarRowShell
actions={
/* Trailing cluster: countdown by default, quick actions on hover. */
<div className="flex items-center gap-0.5">
<span className="text-[0.6875rem] text-(--ui-text-tertiary) tabular-nums group-hover/cron:hidden">
{meta}
</span>
<div className="hidden items-center gap-0.5 group-hover/cron:flex">
<Tip label={c.triggerNow}>
<button
aria-label={c.triggerNow}
className="grid size-5 place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground disabled:cursor-wait disabled:opacity-60"
disabled={busy}
onClick={onTrigger}
type="button"
>
{busy ? (
<GlyphSpinner ariaLabel={c.triggerNow} className="text-[0.75rem]" />
) : (
<Codicon name="zap" size="0.75rem" />
)}
</button>
</Tip>
<Tip label={c.manage}>
<button
aria-label={c.manage}
className="grid size-5 place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={onManage}
type="button"
>
<Codicon name="watch" size="0.75rem" />
</button>
</Tip>
</div>
</div>
}
className="group/cron relative hover:bg-(--chrome-action-hover)"
>
{/* The caret sits next to the label (matching the other sidebar
disclosures) and the whole label area toggles the run peek. */}
<Tip label={label}>
<button
<SidebarRowBody
aria-expanded={expanded}
aria-label={expanded ? c.hideRuns : c.showRuns}
className="flex min-w-0 items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
onClick={onTogglePeek}
type="button"
>
<span className="grid w-3.5 shrink-0 place-items-center">
<SidebarRowLead>
<span
aria-hidden="true"
className={cn(
@@ -327,10 +364,8 @@ function CronJobSidebarRow({
state === 'running' && 'size-1.5 animate-pulse'
)}
/>
</span>
<span className="min-w-0 truncate text-[0.8125rem] text-(--ui-text-secondary) group-hover/cron:text-foreground">
{label}
</span>
</SidebarRowLead>
<SidebarRowLabel className="group-hover/cron:text-foreground">{label}</SidebarRowLabel>
<DisclosureCaret
className={cn(
'shrink-0 text-(--ui-text-tertiary) transition',
@@ -338,42 +373,9 @@ function CronJobSidebarRow({
)}
open={expanded}
/>
</button>
</SidebarRowBody>
</Tip>
{/* Trailing cluster: countdown by default, quick actions on hover. */}
<div className="flex items-center gap-0.5 justify-self-end pr-1">
<span className="text-[0.6875rem] text-(--ui-text-tertiary) tabular-nums group-hover/cron:hidden">
{meta}
</span>
<div className="hidden items-center gap-0.5 group-hover/cron:flex">
<Tip label={c.triggerNow}>
<button
aria-label={c.triggerNow}
className="grid size-5 place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground disabled:cursor-wait disabled:opacity-60"
disabled={busy}
onClick={onTrigger}
type="button"
>
{busy ? (
<GlyphSpinner ariaLabel={c.triggerNow} className="text-[0.75rem]" />
) : (
<Codicon name="zap" size="0.75rem" />
)}
</button>
</Tip>
<Tip label={c.manage}>
<button
aria-label={c.manage}
className="grid size-5 place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={onManage}
type="button"
>
<Codicon name="watch" size="0.75rem" />
</button>
</Tip>
</div>
</div>
</div>
</SidebarRowShell>
</ActionsContextMenu>
{expanded && <CronJobSidebarRuns jobId={job.id} onOpenRun={onOpenRun} />}
</div>
@@ -11,7 +11,9 @@ interface SidebarLoadMoreRowProps {
// Compact "load more" affordance shared by recents, messaging, and cron. Kept
// intentionally identical to workspace "show more" controls (ellipsis button)
// so pagination reads as one interaction everywhere.
// so pagination reads as one interaction everywhere. It hangs off the list
// instead of sitting in a row, so it repeats the row's trailing inset
// (SidebarRowShell's `pr-2`) to stay on the edge the rows stop at.
export function SidebarLoadMoreRow({ step, onClick, loading = false }: SidebarLoadMoreRowProps) {
const { t } = useI18n()
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
@@ -20,7 +22,7 @@ export function SidebarLoadMoreRow({ step, onClick, loading = false }: SidebarLo
<Tip label={label}>
<button
aria-label={label}
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground disabled:cursor-default disabled:opacity-60 disabled:hover:bg-transparent disabled:hover:text-(--ui-text-tertiary)"
className="mr-2 ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground disabled:cursor-default disabled:opacity-60 disabled:hover:bg-transparent disabled:hover:text-(--ui-text-tertiary)"
disabled={loading}
onClick={onClick}
type="button"
@@ -48,6 +48,8 @@ export function WorkspaceAddButton({ label, onClick }: { label: string; onClick:
}
// Reveals the next page of already-loaded rows within a workspace/worktree.
// Hangs off the lane instead of sitting in a row, so it repeats the row's
// trailing inset (SidebarRowShell's `pr-2`) to stay on the edge the rows stop at.
export function WorkspaceShowMoreButton({
count,
label,
@@ -64,7 +66,7 @@ export function WorkspaceShowMoreButton({
<Tip label={text}>
<button
aria-label={text}
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
className="mr-2 ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={onClick}
type="button"
>
@@ -4,19 +4,22 @@ import { Skeleton } from '@/components/ui/skeleton'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { SidebarRowCluster, SidebarRowShell, SidebarRowStack } from './chrome'
// Stands in for session rows, so it borrows their chrome instead of copying
// the grid — a placeholder on a different edge than the rows it resolves into
// makes the list step sideways on load.
export function SidebarSessionSkeletons() {
return (
<div aria-hidden="true" className="grid gap-px">
<SidebarRowStack aria-hidden="true">
{['w-32', 'w-40', 'w-28', 'w-36', 'w-24'].map((width, i) => (
<div
className="grid min-h-[1.625rem] grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md pl-2"
key={`${width}-${i}`}
>
<Skeleton className={cn('h-3 rounded-sm', width)} />
<Skeleton className="mx-auto size-3.5 rounded-sm opacity-60" />
</div>
<SidebarRowShell actions={<Skeleton className="size-3.5 rounded-sm opacity-60" />} key={`${width}-${i}`}>
<SidebarRowCluster>
<Skeleton className={cn('h-3 rounded-sm', width)} />
</SidebarRowCluster>
</SidebarRowShell>
))}
</div>
</SidebarRowStack>
)
}
@@ -405,9 +405,13 @@ function SidebarSessionRowImpl({
<SidebarRowBody
// 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.
// changes what you can see in that slot, never how wide it is. The
// card has no such column to clear (its cluster is INSIDE the body,
// ending at the shell's own trailing inset), and keeping the gap
// would pull the header in past every line below it.
className={cn(
'z-0 pr-2',
'z-0',
card && 'pr-0',
branchStem && 'pl-3.5',
// The card is a grid with ONE spacing knob: --card-gap. Every row
// gap is gap-y-(--card-gap); the title/preview group opts out