Merge pull request #75935 from NousResearch/bb/card-retire
The changed-files card stops at five rows
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user