fix: add sticky headers to the git diff (#22425)

<!--

If you have used AI to produce some or all of this PR, please ensure you
have read our [AI Contribution
guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING)
before submitting.

-->
This commit is contained in:
Kyle Carberry
2026-02-27 19:03:11 -05:00
committed by GitHub
parent 2bdacae5f5
commit 256284b7fe
3 changed files with 51 additions and 7 deletions
@@ -268,7 +268,7 @@ const ChatMessageItem = memo<{
</div>
{fadeFromBottom && (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-12"
className="pointer-events-none absolute inset-x-0 bottom-0 h-1/2 max-h-12"
style={{
background:
"linear-gradient(to top, hsl(var(--surface-secondary)), transparent)",
@@ -400,8 +400,10 @@ const StickyUserMessage: FC<{
}) => {
const [isStuck, setIsStuck] = useState(false);
const [isReady, setIsReady] = useState(false);
const [isTooTall, setIsTooTall] = useState(false);
const sentinelRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const updateFnRef = useRef<(() => void) | null>(null);
// useLayoutEffect so isStuck and --clip-h are both resolved
// before the browser paints, avoiding a flash on load.
@@ -440,9 +442,22 @@ const StickyUserMessage: FC<{
const MIN_HEIGHT = 72;
let scrollerTop = scroller.getBoundingClientRect().top;
let scrollerHeight = scroller.clientHeight;
const update = () => {
const fullHeight = container.offsetHeight;
// Skip sticky behavior for messages that take up
// most of the visible area — accounting for the
// chat input and some breathing room.
const tooTall = fullHeight > scrollerHeight * 0.75;
setIsTooTall(tooTall);
if (tooTall) {
container.style.setProperty("--clip-h", `${fullHeight}px`);
container.style.setProperty("--fade-opacity", "0");
return;
}
const sentinelTop = sentinel.getBoundingClientRect().top;
const scrolledPast = scrollerTop - sentinelTop;
@@ -463,9 +478,11 @@ const StickyUserMessage: FC<{
visible < fullHeight - 8 ? "1" : "0",
);
};
updateFnRef.current = update;
const onResize = () => {
scrollerTop = scroller.getBoundingClientRect().top;
scrollerHeight = scroller.clientHeight;
update();
};
@@ -495,6 +512,16 @@ const StickyUserMessage: FC<{
};
}, []);
// Re-run the height calculation synchronously whenever
// isStuck changes so --clip-h is correct on the same frame
// the overlay appears. Without this, the async
// IntersectionObserver + RAF-throttled scroll handler can
// leave a stale --clip-h for one paint.
// biome-ignore lint/correctness/useExhaustiveDependencies: isStuck is an intentional trigger
useLayoutEffect(() => {
updateFnRef.current?.();
}, [isStuck]);
const handleEditUserMessage = onEditUserMessage
? (messageId: number, text: string) => {
onEditUserMessage(messageId, text);
@@ -519,18 +546,19 @@ const StickyUserMessage: FC<{
<div
ref={containerRef}
className={cn(
"relative sticky top-0 z-10 px-3 -mx-3 pt-2 pb-2",
"relative px-3 -mx-3 pt-2 pb-2",
!isTooTall && "sticky top-0 z-10",
!isReady && "invisible",
isStuck && "pointer-events-none",
isStuck && !isTooTall && "pointer-events-none",
)}
>
{/* Flow element: always in the DOM to preserve
scroll layout. Hidden when stuck so the
clipped overlay takes over visually. */}
<div
className={isStuck ? undefined : "pointer-events-auto"}
className={isStuck && !isTooTall ? undefined : "pointer-events-auto"}
style={
isStuck
isStuck && !isTooTall
? { opacity: "calc(1 - var(--overlay-ready, 0))" }
: undefined
}
@@ -548,7 +576,7 @@ const StickyUserMessage: FC<{
sticky container. max-height + mask are driven
entirely by the --clip-h CSS variable which the
scroll handler sets on the container. */}
{isStuck && (
{isStuck && !isTooTall && (
<div
className="absolute inset-0"
style={{
@@ -40,7 +40,15 @@ function formatPullRequestLabel(url: string): string {
export const FilesChangedPanel: FC<FilesChangedPanelProps> = ({ chatId }) => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const diffOptions = useMemo(() => getDiffViewerOptions(isDark), [isDark]);
const diffOptions = useMemo(() => {
const base = getDiffViewerOptions(isDark);
return {
...base,
// Extend the base CSS to make file headers sticky so they
// remain visible while scrolling through long diffs.
unsafeCSS: `${base.unsafeCSS ?? ""} [data-diffs-header] { position: sticky; top: 0; z-index: 10; background-color: hsl(var(--surface-primary)) !important; }`,
};
}, [isDark]);
const diffStatusQuery = useQuery(chatDiffStatus(chatId));
const diffContentsQuery = useQuery({
+8
View File
@@ -90,6 +90,14 @@ export default defineConfig({
target: process.env.CODER_HOST || "http://localhost:3000",
secure: process.env.NODE_ENV === "production",
configure: (proxy) => {
if (process.env.CODER_SESSION_TOKEN) {
proxy.on("proxyReq", (proxyReq) => {
proxyReq.setHeader(
"Coder-Session-Token",
process.env.CODER_SESSION_TOKEN!,
);
});
}
// Vite does not catch socket errors, and stops the webserver.
// As /logs endpoint can return HTTP 4xx status, we need to embrace
// Vite with a custom error handler to prevent from quitting.