mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-05 17:08:33 +00:00
fix(webui): feather clipped activity edges
This commit is contained in:
parent
6e9ae5bd05
commit
a54d5d14cb
@ -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}
|
||||
>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user