Merge pull request #75935 from NousResearch/bb/card-retire

The changed-files card stops at five rows
This commit is contained in:
brooklyn!
2026-08-01 01:31:30 -05:00
committed by GitHub
5 changed files with 168 additions and 63 deletions
@@ -5,11 +5,16 @@ import { useSessionView } from '@/app/chat/session-view'
import { deriveChangedFiles } from '@/components/assistant-ui/thread/changed-files'
import { WIDGET_SHELL_CLASS } from '@/components/chat/widget-shell'
import { DiffCount } from '@/components/ui/diff-count'
import { FadeScroll } from '@/components/ui/fade-scroll'
import { FileTypeIcon } from '@/components/ui/file-type-icon'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { openReviewForPath, revealReview } from '@/store/review'
// ~5 rows. A turn that rewrites twenty files should still read as one card in
// the transcript, not a wall the user has to scroll past to reach the composer.
const MAX_ROWS_HEIGHT = '9.375rem'
/**
* Cursor-style "N files changed" summary closing out the newest assistant turn:
* one row per file it edited with that file's +/-, and a Review action opening
@@ -47,10 +52,10 @@ export const ChangedFilesCard: FC<{ parts: readonly unknown[] }> = ({ parts }) =
{copy.reviewChanges}
</button>
</div>
<div className="mt-1.5 flex flex-col">
<FadeScroll className="-mx-1.5 mt-1.5 flex flex-col px-1.5" maxHeight={MAX_ROWS_HEIGHT}>
{files.map(file => (
<button
className="row-hover -mx-1.5 flex items-center gap-2 rounded-md px-1.5 py-1 text-left"
className="row-hover flex shrink-0 items-center gap-2 rounded-md px-1.5 py-1 text-left"
key={file.path}
onClick={() => void openReviewForPath(file.path, scopeCwd)}
title={file.path}
@@ -61,7 +66,7 @@ export const ChangedFilesCard: FC<{ parts: readonly unknown[] }> = ({ parts }) =
<DiffCount added={file.added} removed={file.removed} />
</button>
))}
</div>
</FadeScroll>
</div>
)
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { edgeMask, scrollEdges } from './fade-scroll'
const box = (scrollTop: number, clientHeight: number, scrollHeight: number) => ({
clientHeight,
scrollHeight,
scrollTop
})
describe('scrollEdges', () => {
it('reports no clipped edge when the content fits', () => {
expect(scrollEdges(box(0, 150, 150))).toEqual({ above: false, below: false })
})
it('reports only the bottom at the top of a clipped list', () => {
expect(scrollEdges(box(0, 150, 400))).toEqual({ above: false, below: true })
})
it('reports both edges mid-scroll', () => {
expect(scrollEdges(box(100, 150, 400))).toEqual({ above: true, below: true })
})
it('reports only the top once scrolled to the end', () => {
expect(scrollEdges(box(250, 150, 400))).toEqual({ above: true, below: false })
})
})
describe('edgeMask', () => {
// The whole point of an edge-AWARE fade: content that fits is never dimmed.
it('is absent when nothing is clipped', () => {
expect(edgeMask({ above: false, below: false })).toBeUndefined()
})
it('leaves the first row fully opaque when nothing is hidden above it', () => {
expect(edgeMask({ above: false, below: true })).toBe(
'linear-gradient(to bottom, black, black calc(100% - 1.25rem), transparent)'
)
})
it('leaves the last row fully opaque once the bottom is reached', () => {
expect(edgeMask({ above: true, below: false })).toBe('linear-gradient(to bottom, transparent, black 1.25rem, black)')
})
it('fades both edges mid-scroll', () => {
expect(edgeMask({ above: true, below: true })).toBe(
'linear-gradient(to bottom, transparent, black 1.25rem, black calc(100% - 1.25rem), transparent)'
)
})
})
@@ -0,0 +1,102 @@
import { type ReactNode, useCallback, useLayoutEffect, useRef, useState } from 'react'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { cn } from '@/lib/utils'
/** How much of a clipped edge the gradient eats. */
const FADE = '1.25rem'
export interface FadeEdges {
above: boolean
below: boolean
}
/**
* The mask for a pair of clipped edges, or `undefined` when nothing is clipped
* — a list that fits must not be dimmed at all.
*/
export function edgeMask({ above, below }: FadeEdges): string | undefined {
if (!above && !below) {
return undefined
}
const top = above ? `transparent, black ${FADE}` : 'black'
const bottom = below ? `black calc(100% - ${FADE}), transparent` : 'black'
return `linear-gradient(to bottom, ${top}, ${bottom})`
}
/** Which edges of a scroller currently have content clipped behind them. */
export function scrollEdges(el: Pick<HTMLElement, 'clientHeight' | 'scrollHeight' | 'scrollTop'>): FadeEdges {
return {
above: el.scrollTop > 1,
below: el.scrollTop + el.clientHeight < el.scrollHeight - 1
}
}
/**
* A height-capped scroller whose clipped edges fade out.
*
* The fade is a mask-image, not an overlay: it resolves against whatever
* background the parent happens to have, so one component works on the chat
* backdrop, inside a widget panel, and in a drawer without knowing any of
* their fills. Same technique as FadeText, on the other axis.
*
* The gradient is EDGE-AWARE — a side only fades when it actually has content
* clipped behind it, so a list that fits shows no gradient at all and a list
* scrolled to the bottom stops fading its last row. That state is tracked on
* scroll and on resize (via the app's shared observer, so N of these cost one
* delivery per frame rather than N).
*
* `deps` re-pins the scroller to the bottom when it changes — newest-at-bottom
* feeds want that; a plain list should leave it unset.
*/
export function FadeScroll({
children,
className,
deps,
maxHeight = '9rem'
}: {
children: ReactNode
className?: string
deps?: unknown
maxHeight?: string
}) {
const ref = useRef<HTMLDivElement>(null)
const [edges, setEdges] = useState({ above: false, below: false })
const measure = useCallback(() => {
const el = ref.current
if (!el) {
return
}
const next = scrollEdges(el)
setEdges(prev => (prev.above === next.above && prev.below === next.below ? prev : next))
}, [])
useLayoutEffect(() => {
if (deps !== undefined && ref.current) {
ref.current.scrollTop = ref.current.scrollHeight
}
measure()
}, [deps, measure])
useResizeObserver(measure, ref)
const mask = edgeMask(edges)
return (
<div
className={cn('overflow-y-auto overscroll-contain', className)}
onScroll={measure}
ref={ref}
style={mask ? { maskImage: mask, maxHeight, WebkitMaskImage: mask } : { maxHeight }}
>
{children}
</div>
)
}
+7 -60
View File
@@ -9,12 +9,13 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
FadeScroll,
profileColor,
profileColorSoft,
relativeTime,
useQuery
} from '@hermes/plugin-sdk'
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { type ReactNode, useEffect, useState } from 'react'
import { fetchOrchestration, ORCHESTRATION_KEY } from './api'
import { columnLabel, useKanban } from './i18n'
@@ -270,67 +271,13 @@ export function Callout({
)
}
// A short, edge-masked scroll area. The fades are EDGE-AWARE like the rest of
// the app: a gradient only appears on a side that actually has clipped content
// (nothing to scroll → no mask at all), tracked via scroll + resize. Plus
// `overscroll-contain` so scrolling it never chains into the drawer. When
// `deps` is provided it re-pins to the bottom on change — the activity feed's
// newest-at-bottom behavior.
// A short, edge-masked scroll area. Thin wrapper over the app's FadeScroll so
// the drawer's scrollers behave exactly like the ones in chat; kept as a local
// name because every call site here passes `max`.
export function ScrollFade({ children, deps, max = '9rem' }: { children: ReactNode; deps?: unknown; max?: string }) {
const ref = useRef<HTMLDivElement>(null)
const [edges, setEdges] = useState({ above: false, below: false })
const measure = () => {
const el = ref.current
if (!el) {
return
}
const above = el.scrollTop > 1
const below = el.scrollTop + el.clientHeight < el.scrollHeight - 1
setEdges(prev => (prev.above === above && prev.below === below ? prev : { above, below }))
}
useLayoutEffect(() => {
if (deps !== undefined && ref.current) {
ref.current.scrollTop = ref.current.scrollHeight
}
measure()
}, [deps])
useLayoutEffect(() => {
const el = ref.current
if (!el) {
return
}
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => observer.disconnect()
}, [])
const stops = [
edges.above ? 'transparent, black 1.25rem' : 'black',
edges.below ? 'calc(100% - 1.25rem), transparent' : 'black'
]
const mask = `linear-gradient(to bottom, ${stops[0]}, black ${stops[1]})`
return (
<div
className="overflow-y-auto overscroll-contain"
onScroll={measure}
ref={ref}
style={
edges.above || edges.below ? { maskImage: mask, maxHeight: max, WebkitMaskImage: mask } : { maxHeight: max }
}
>
<FadeScroll deps={deps} maxHeight={max}>
{children}
</div>
</FadeScroll>
)
}
+1
View File
@@ -164,6 +164,7 @@ export {
} from '@/components/ui/dropdown-menu'
export { EmptyState } from '@/components/ui/empty-state'
export { ErrorState } from '@/components/ui/error-state'
export { FadeScroll } from '@/components/ui/fade-scroll'
export { GlyphSpinner } from '@/components/ui/glyph-spinner'
export { Input } from '@/components/ui/input'
export { Kbd, KbdGroup } from '@/components/ui/kbd'