fix(webui): soften composer scroll boundary

This commit is contained in:
Xubin Ren
2026-08-27 10:45:40 +08:00
parent 701926eba1
commit cb7b640d36
3 changed files with 36 additions and 4 deletions
@@ -744,8 +744,8 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
ref={messageRegionRef} ref={messageRegionRef}
data-testid="thread-message-region" data-testid="thread-message-region"
className={cn( className={cn(
"thread-viewport-scrollbar row-start-1 flex min-h-0 min-w-0 flex-col", "thread-message-viewport thread-viewport-scrollbar row-start-1 flex min-h-0 min-w-0 flex-col",
"scroll-auto justify-start overflow-x-hidden px-3 pb-4 pt-4 sm:px-4", "scroll-auto justify-start overflow-x-hidden px-3 pb-0 pt-4 sm:px-4",
"[overflow-anchor:none] [scrollbar-width:none]", "[overflow-anchor:none] [scrollbar-width:none]",
"[&::-webkit-scrollbar]:hidden", "[&::-webkit-scrollbar]:hidden",
hasVerticalOverflow ? "overflow-y-auto" : "overflow-hidden", hasVerticalOverflow ? "overflow-y-auto" : "overflow-hidden",
@@ -768,6 +768,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
onQuoteSelection={onQuoteSelection} onQuoteSelection={onQuoteSelection}
/> />
</div> </div>
<div aria-hidden className="thread-message-end-gap shrink-0" />
<div ref={bottomRef} aria-hidden className="h-px shrink-0" /> <div ref={bottomRef} aria-hidden className="h-px shrink-0" />
</div> </div>
) : ( ) : (
@@ -808,7 +809,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
}} }}
className={cn( className={cn(
"row-start-2 z-10 w-full", "row-start-2 z-10 w-full",
hasMessages ? "relative bg-background" : "relative self-center", hasMessages ? "thread-composer-dock relative" : "relative self-center",
)} )}
> >
<div <div
+28
View File
@@ -377,6 +377,7 @@
* bottom without a remount or a transform clone. * bottom without a remount or a transform clone.
*/ */
.thread-layout { .thread-layout {
--thread-composer-fade-height: 2.25rem;
grid-template-rows: minmax(min-content, 1fr) auto 0fr; grid-template-rows: minmax(min-content, 1fr) auto 0fr;
transition: grid-template-rows 220ms ease-out; transition: grid-template-rows 220ms ease-out;
} }
@@ -392,6 +393,33 @@
} }
} }
/**
* Keep the transcript and composer visually continuous. The scrollport owns
* the fade because overflow clips its contents before an adjacent overlay
* can soften the edge. The matching in-flow gap keeps the last line fully
* visible once the user reaches the bottom.
*/
.thread-message-viewport {
-webkit-mask-image: linear-gradient(
to bottom,
#000 0,
#000 calc(100% - var(--thread-composer-fade-height)),
transparent 100%
);
mask-image: linear-gradient(
to bottom,
#000 0,
#000 calc(100% - var(--thread-composer-fade-height)),
transparent 100%
);
}
.thread-message-end-gap {
height: var(--thread-composer-fade-height);
}
.thread-composer-dock {
background: hsl(var(--background));
}
@keyframes composer-status-strip-enter { @keyframes composer-status-strip-enter {
0% { 0% {
max-height: 0; max-height: 0;
+4 -1
View File
@@ -259,7 +259,8 @@ describe("ThreadViewport", () => {
const messageRegion = screen.getByTestId("thread-message-region"); const messageRegion = screen.getByTestId("thread-message-region");
expect(messageRegion).toHaveClass("justify-start"); expect(messageRegion).toHaveClass("justify-start");
expect(messageRegion).not.toHaveClass("justify-end"); expect(messageRegion).not.toHaveClass("justify-end");
expect(messageRegion).toHaveClass("pb-4"); expect(messageRegion).toHaveClass("thread-message-viewport");
expect(messageRegion).toHaveClass("pb-0");
expect(messageRegion.className).not.toContain("5rem"); expect(messageRegion.className).not.toContain("5rem");
}); });
@@ -316,7 +317,9 @@ describe("ThreadViewport", () => {
expect(scroller).not.toContainElement(composerDock); expect(scroller).not.toContainElement(composerDock);
expect(scroller.parentElement).toContainElement(composerDock); expect(scroller.parentElement).toContainElement(composerDock);
expect(composerDock).toHaveClass("relative"); expect(composerDock).toHaveClass("relative");
expect(composerDock).toHaveClass("thread-composer-dock");
expect(composerDock).not.toHaveClass("sticky"); expect(composerDock).not.toHaveClass("sticky");
expect(scroller.querySelector(".thread-message-end-gap")).toBeInTheDocument();
expect(scroller.lastElementChild).toHaveClass("h-px", "shrink-0"); expect(scroller.lastElementChild).toHaveClass("h-px", "shrink-0");
}); });