fix(webui): feather clipped activity edges

This commit is contained in:
chengyongru 2026-08-05 11:09:17 +08:00 committed by chengyongru
parent 6e9ae5bd05
commit a54d5d14cb
4 changed files with 106 additions and 4 deletions

View File

@ -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<HTMLDivElement>(null);
const activityContentRef = useRef<HTMLDivElement>(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}
>

View File

@ -10,6 +10,8 @@ interface ThinkingReasoningShellProps {
children: ReactNode;
viewportRef: Ref<HTMLDivElement>;
contentRef: Ref<HTMLDivElement>;
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({
<div
ref={viewportRef}
data-testid={expanded ? "agent-activity-scroll" : undefined}
data-fade-top={fadeTop}
data-fade-bottom={fadeBottom}
onScroll={onScroll}
className="mt-1.5 max-h-[180px] overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
className="activity-scroll-fade mt-1.5 max-h-[180px] overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
aria-hidden={!expanded}
>
<div ref={contentRef} className="flex flex-col gap-0.5">

View File

@ -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;

View File

@ -265,6 +265,53 @@ describe("AgentActivityCluster", () => {
}
});
it("feathers only the activity edges with clipped content", () => {
const raf = installAnimationFrameQueue();
try {
render(
<AgentActivityCluster
messages={activityMessages()}
isTurnStreaming
hasBodyBelow={false}
/>,
);
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",