diff --git a/apps/desktop/src/components/assistant-ui/thread/changed-files-card.tsx b/apps/desktop/src/components/assistant-ui/thread/changed-files-card.tsx
index 8a29c55de9..b3d0fe1ad5 100644
--- a/apps/desktop/src/components/assistant-ui/thread/changed-files-card.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/changed-files-card.tsx
@@ -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}
-
+
{files.map(file => (
))}
-
+
)
}
diff --git a/apps/desktop/src/components/ui/fade-scroll.test.ts b/apps/desktop/src/components/ui/fade-scroll.test.ts
new file mode 100644
index 0000000000..0ee82affe7
--- /dev/null
+++ b/apps/desktop/src/components/ui/fade-scroll.test.ts
@@ -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)'
+ )
+ })
+})
diff --git a/apps/desktop/src/components/ui/fade-scroll.tsx b/apps/desktop/src/components/ui/fade-scroll.tsx
new file mode 100644
index 0000000000..38bafe0c84
--- /dev/null
+++ b/apps/desktop/src/components/ui/fade-scroll.tsx
@@ -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): 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(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 (
+
+ {children}
+
+ )
+}
diff --git a/apps/desktop/src/plugins/kanban/ui.tsx b/apps/desktop/src/plugins/kanban/ui.tsx
index bc59bb7046..626ee9f17f 100644
--- a/apps/desktop/src/plugins/kanban/ui.tsx
+++ b/apps/desktop/src/plugins/kanban/ui.tsx
@@ -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(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 (
-
+
{children}
-
+
)
}
diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts
index fbaf0260e5..ffbc3a3067 100644
--- a/apps/desktop/src/sdk/index.ts
+++ b/apps/desktop/src/sdk/index.ts
@@ -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'