From a54d5d14cb14e02ff9e7dac8728a1da809a6e81b Mon Sep 17 00:00:00 2001 From: chengyongru Date: Wed, 5 Aug 2026 11:09:17 +0800 Subject: [PATCH] fix(webui): feather clipped activity edges --- .../thread/AgentActivityCluster.tsx | 27 +++++++++-- .../activity/ThinkingReasoningShell.tsx | 8 +++- webui/src/globals.css | 28 +++++++++++ .../src/tests/agent-activity-cluster.test.tsx | 47 +++++++++++++++++++ 4 files changed, 106 insertions(+), 4 deletions(-) diff --git a/webui/src/components/thread/AgentActivityCluster.tsx b/webui/src/components/thread/AgentActivityCluster.tsx index fddd92e0d..13e502d5c 100644 --- a/webui/src/components/thread/AgentActivityCluster.tsx +++ b/webui/src/components/thread/AgentActivityCluster.tsx @@ -174,6 +174,7 @@ export function AgentActivityCluster({ const [outerOpenLocal, setOuterOpenLocal] = useState(false); const [completionHoldOpen, setCompletionHoldOpen] = useState(false); const [now, setNow] = useState(() => Date.now()); + const [activityScrollFade, setActivityScrollFade] = useState({ top: false, bottom: false }); const activityScrollRef = useRef(null); const activityContentRef = useRef(null); const autoFollowActivityRef = useRef(true); @@ -227,11 +228,26 @@ export function AgentActivityCluster({ } }, []); + const syncActivityScrollFade = useCallback(() => { + const el = activityScrollRef.current; + if (!el) return; + const maxScrollTop = Math.max(0, el.scrollHeight - el.clientHeight); + const scrollTop = Math.min(maxScrollTop, Math.max(0, el.scrollTop)); + const next = { + top: scrollTop > 1, + bottom: maxScrollTop - scrollTop > 1, + }; + setActivityScrollFade((current) => + current.top === next.top && current.bottom === next.bottom ? current : next, + ); + }, []); + const scrollActivityToBottom = useCallback(() => { const el = activityScrollRef.current; if (!el) return; el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight); - }, []); + syncActivityScrollFade(); + }, [syncActivityScrollFade]); const scheduleActivityScrollToBottom = useCallback(() => { cancelActivityScrollFrame(); @@ -265,11 +281,13 @@ export function AgentActivityCluster({ const observer = new ResizeObserver(() => { if (autoFollowActivityRef.current) { scheduleActivityScrollToBottom(); + } else { + syncActivityScrollFade(); } }); observer.observe(target); return () => observer.disconnect(); - }, [outerExpanded, scheduleActivityScrollToBottom]); + }, [outerExpanded, scheduleActivityScrollToBottom, syncActivityScrollFade]); useEffect(() => cancelActivityScrollFrame, [cancelActivityScrollFrame]); @@ -298,7 +316,8 @@ export function AgentActivityCluster({ if (!el) return; const distance = el.scrollHeight - el.scrollTop - el.clientHeight; autoFollowActivityRef.current = distance < ACTIVITY_SCROLL_NEAR_BOTTOM_PX; - }, []); + syncActivityScrollFade(); + }, [syncActivityScrollFade]); if (!hasVisibleActivity) return null; @@ -322,6 +341,8 @@ export function AgentActivityCluster({ label={thoughtLabel} viewportRef={activityScrollRef} contentRef={activityContentRef} + fadeTop={activityScrollFade.top} + fadeBottom={activityScrollFade.bottom} onToggle={toggleOuter} onScroll={onActivityScroll} > diff --git a/webui/src/components/thread/activity/ThinkingReasoningShell.tsx b/webui/src/components/thread/activity/ThinkingReasoningShell.tsx index 2efcd9a85..26666ee2d 100644 --- a/webui/src/components/thread/activity/ThinkingReasoningShell.tsx +++ b/webui/src/components/thread/activity/ThinkingReasoningShell.tsx @@ -10,6 +10,8 @@ interface ThinkingReasoningShellProps { children: ReactNode; viewportRef: Ref; contentRef: Ref; + fadeTop: boolean; + fadeBottom: boolean; onToggle: () => void; onScroll: () => void; } @@ -21,6 +23,8 @@ export function ThinkingReasoningShell({ children, viewportRef, contentRef, + fadeTop, + fadeBottom, onToggle, onScroll, }: ThinkingReasoningShellProps) { @@ -75,8 +79,10 @@ export function ThinkingReasoningShell({
diff --git a/webui/src/globals.css b/webui/src/globals.css index 441088f1f..720326445 100644 --- a/webui/src/globals.css +++ b/webui/src/globals.css @@ -682,6 +682,34 @@ container-type: inline-size; } + /* Soften only the activity edges that have clipped content beyond them. */ + .activity-scroll-fade[data-fade-top="true"][data-fade-bottom="false"] { + -webkit-mask-image: linear-gradient(to bottom, transparent, #000 14px); + mask-image: linear-gradient(to bottom, transparent, #000 14px); + } + + .activity-scroll-fade[data-fade-top="false"][data-fade-bottom="true"] { + -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 14px), transparent); + mask-image: linear-gradient(to bottom, #000 calc(100% - 14px), transparent); + } + + .activity-scroll-fade[data-fade-top="true"][data-fade-bottom="true"] { + -webkit-mask-image: linear-gradient( + to bottom, + transparent, + #000 14px, + #000 calc(100% - 14px), + transparent + ); + mask-image: linear-gradient( + to bottom, + transparent, + #000 14px, + #000 calc(100% - 14px), + transparent + ); + } + @supports (content-visibility: auto) { .thread-render-unit { content-visibility: auto; diff --git a/webui/src/tests/agent-activity-cluster.test.tsx b/webui/src/tests/agent-activity-cluster.test.tsx index 820677cc8..a37ac3e07 100644 --- a/webui/src/tests/agent-activity-cluster.test.tsx +++ b/webui/src/tests/agent-activity-cluster.test.tsx @@ -265,6 +265,53 @@ describe("AgentActivityCluster", () => { } }); + it("feathers only the activity edges with clipped content", () => { + const raf = installAnimationFrameQueue(); + try { + render( + , + ); + + const scrollport = screen.getByTestId("agent-activity-scroll"); + setScrollGeometry(scrollport, { + scrollHeight: 1000, + clientHeight: 120, + scrollTop: 0, + }); + + act(() => { + raf.flush(); + }); + expect(scrollport).toHaveAttribute("data-fade-top", "true"); + expect(scrollport).toHaveAttribute("data-fade-bottom", "false"); + + scrollport.scrollTop = 440; + fireEvent.scroll(scrollport); + expect(scrollport).toHaveAttribute("data-fade-top", "true"); + expect(scrollport).toHaveAttribute("data-fade-bottom", "true"); + + scrollport.scrollTop = 0; + fireEvent.scroll(scrollport); + expect(scrollport).toHaveAttribute("data-fade-top", "false"); + expect(scrollport).toHaveAttribute("data-fade-bottom", "true"); + + setScrollGeometry(scrollport, { + scrollHeight: 100, + clientHeight: 120, + scrollTop: 0, + }); + fireEvent.scroll(scrollport); + expect(scrollport).toHaveAttribute("data-fade-top", "false"); + expect(scrollport).toHaveAttribute("data-fade-bottom", "false"); + } finally { + raf.restore(); + } + }); + it("turns the live reasoning marker into an animated check when thinking completes", async () => { const liveReasoning: UIMessage = { id: "r-check",