From 15b0b954137b6c08c77eb06246e332e39fb3b476 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 1 Aug 2026 01:23:59 -0500 Subject: [PATCH 1/2] refactor(desktop): one edge-faded scroller for the whole app The kanban drawer had grown the only edge-aware masked scroller in the tree, and the next surface that wants one would have copied it. Lift it to components/ui as FadeScroll, export it on the plugin SDK, and leave kanban's ScrollFade as a name its call sites already pass `max` to. The mask math comes out as two pure functions. jsdom's CSS parser drops any gradient containing calc(), so a rendered mask-image can't be read back off the style attribute -- edgeMask/scrollEdges are testable for real where the component's inline style is not. --- .../src/components/ui/fade-scroll.test.ts | 50 +++++++++ .../desktop/src/components/ui/fade-scroll.tsx | 102 ++++++++++++++++++ apps/desktop/src/plugins/kanban/ui.tsx | 67 ++---------- apps/desktop/src/sdk/index.ts | 1 + 4 files changed, 160 insertions(+), 60 deletions(-) create mode 100644 apps/desktop/src/components/ui/fade-scroll.test.ts create mode 100644 apps/desktop/src/components/ui/fade-scroll.tsx 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' From 0b8a3582c6bc622f77a12bc862942cdca174f118 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 1 Aug 2026 01:24:05 -0500 Subject: [PATCH 2/2] fix(desktop): cap the changed-files card and fade its overflow A turn that rewrote twenty files grew a twenty-row card, so the summary that is supposed to close the turn became the thing you scroll past to reach the composer. Cap the rows at ~5 and let the clipped edge fade, the way every other overflow in the app reads. The horizontal padding moves onto the scroller so a row's hover fill still bleeds to the card's edge instead of stopping at a scroll gutter. --- .../assistant-ui/thread/changed-files-card.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 => ( ))} -
+ ) }