Skip to content

Commit

Permalink
fix: scroll to the bottom on history change
Browse files Browse the repository at this point in the history
  • Loading branch information
tomasz-stefaniak committed Dec 24, 2024
1 parent f95a186 commit b94ccc6
Show file tree
Hide file tree
Showing 3 changed files with 118 additions and 87 deletions.
62 changes: 62 additions & 0 deletions extensions/vscode/e2e/tests/GUI.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,68 @@ describe("GUI Test", () => {
});

describe("Chat Paths", () => {
it("Send many messages → chat auto scrolls → go to history → open previous chat → it is scrolled to the bottom", async () => {
for (let i = 0; i <= 20; i++) {
const { userMessage, llmResponse } =
TestUtils.generateTestMessagePair(i);
await GUIActions.sendMessage({
view,
message: userMessage,
inputFieldIndex: i,
});
const response = await TestUtils.waitForSuccess(() =>
GUISelectors.getThreadMessageByText(view, llmResponse),
);

const viewportHeight = await driver.executeScript(
"return window.innerHeight",
);

const isInViewport = await driver.executeScript(
`
const rect = arguments[0].getBoundingClientRect();
return (
rect.top >= 0 &&
rect.bottom <= ${viewportHeight}
);
`,
response,
);

expect(isInViewport).to.eq(true);
}

await view.switchBack();

await (await GUISelectors.getHistoryNavButton(view)).click();
await GUIActions.switchToReactIframe();
TestUtils.waitForSuccess(async () => {
await (await GUISelectors.getNthHistoryTableRow(view, 0)).click();
});

const { llmResponse } = TestUtils.generateTestMessagePair(20);
const response = await TestUtils.waitForSuccess(() =>
GUISelectors.getThreadMessageByText(view, llmResponse),
);

const viewportHeight = await driver.executeScript(
"return window.innerHeight",
);

const isInViewport = await driver.executeScript(
`
const rect = arguments[0].getBoundingClientRect();
return (
rect.top >= 0 &&
rect.bottom <= ${viewportHeight}
);
`,
response,
);

expect(isInViewport).to.eq(true);
}).timeout(DEFAULT_TIMEOUT.XL * 1000);

it("Open chat and send message → press arrow up and arrow down to cycle through messages → submit another message → press arrow up and arrow down to cycle through messages", async () => {
await GUIActions.sendMessage({
view,
Expand Down
38 changes: 0 additions & 38 deletions gui/src/components/ChatScrollAnchor.tsx

This file was deleted.

105 changes: 56 additions & 49 deletions gui/src/pages/gui/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
lightGray,
vscBackground,
} from "../../components";
import { ChatScrollAnchor } from "../../components/ChatScrollAnchor";
import CodeToEditCard from "../../components/CodeToEditCard";
import FeedbackDialog from "../../components/dialogs/FeedbackDialog";
import { useFindWidget } from "../../components/find/FindWidget";
Expand Down Expand Up @@ -75,6 +74,7 @@ import {
loadLastSession,
saveCurrentSession,
} from "../../redux/thunks/session";
import { C } from "core/autocomplete/constants/AutocompleteLanguageInfo";

const StopButton = styled.div`
background-color: ${vscBackground};
Expand Down Expand Up @@ -132,6 +132,58 @@ function fallbackRender({ error, resetErrorBoundary }: any) {
);
}

const useAutoScroll = (
ref: React.RefObject<HTMLDivElement>,
history: unknown[],
) => {
const [userHasScrolled, setUserHasScrolled] = useState(false);

useEffect(() => {
if (history.length) {
setUserHasScrolled(false);
}
}, [history.length]);

useEffect(() => {
if (!ref.current || history.length === 0) return;

const handleScroll = () => {
const elem = ref.current;
if (!elem) return;

const isAtBottom =
Math.abs(elem.scrollHeight - elem.scrollTop - elem.clientHeight) < 1;

/**
* We stop auto scrolling if a user manually scrolled up.
* We resume auto scrolling if a user manually scrolled to the bottom.
*/
setUserHasScrolled(!isAtBottom);
};

const resizeObserver = new ResizeObserver(() => {
const elem = ref.current;
if (!elem || userHasScrolled) return;
elem.scrollTop = elem.scrollHeight;
});

ref.current.addEventListener("scroll", handleScroll);

// Observe the container
resizeObserver.observe(ref.current);

// Observe all immediate children
Array.from(ref.current.children).forEach((child) => {
resizeObserver.observe(child);
});

return () => {
resizeObserver.disconnect();
ref.current?.removeEventListener("scroll", handleScroll);
};
}, [ref, history.length, userHasScrolled]);
};

export function Chat() {
const posthog = usePostHog();
const dispatch = useAppDispatch();
Expand All @@ -147,7 +199,6 @@ export function Chat() {
const [stepsOpen, setStepsOpen] = useState<(boolean | undefined)[]>([]);
const mainTextInputRef = useRef<HTMLInputElement>(null);
const stepsDivRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState<boolean>(false);
const history = useAppSelector((state) => state.session.history);
const showChatScrollbar = useAppSelector(
(state) => state.config.config.ui?.showChatScrollbar,
Expand All @@ -168,34 +219,6 @@ export function Chat() {
selectIsSingleRangeEditOrInsertion,
);
const lastSessionId = useAppSelector((state) => state.session.lastSessionId);
const snapToBottom = useCallback(() => {
if (!stepsDivRef.current) return;
const elem = stepsDivRef.current;
elem.scrollTop = elem.scrollHeight - elem.clientHeight;

setIsAtBottom(true);
}, [stepsDivRef, setIsAtBottom]);

const smoothScrollToBottom = useCallback(async () => {
if (!stepsDivRef.current) return;
const elem = stepsDivRef.current;
elem.scrollTo({
top: elem.scrollHeight - elem.clientHeight,
behavior: "smooth",
});

setIsAtBottom(true);
}, [stepsDivRef, setIsAtBottom]);

useEffect(() => {
if (isStreaming) snapToBottom();
}, [isStreaming, snapToBottom]);

// useEffect(() => {
// setTimeout(() => {
// smoothScrollToBottom();
// }, 400);
// }, [smoothScrollToBottom, state.sessionId]);

useEffect(() => {
// Cmd + Backspace to delete current step
Expand All @@ -215,18 +238,6 @@ export function Chat() {
};
}, [isStreaming]);

const handleScroll = () => {
// Temporary fix to account for additional height when code blocks are added
const OFFSET_HERUISTIC = 300;
if (!stepsDivRef.current) return;

const { scrollTop, scrollHeight, clientHeight } = stepsDivRef.current;
const atBottom =
scrollHeight - clientHeight <= scrollTop + OFFSET_HERUISTIC;

setIsAtBottom(atBottom);
};

const { widget, highlights } = useFindWidget(stepsDivRef);

const sendInput = useCallback(
Expand Down Expand Up @@ -340,6 +351,8 @@ export function Chat() {

const showScrollbar = showChatScrollbar || window.innerHeight > 5000;

useAutoScroll(stepsDivRef, history);

return (
<>
{isInEditMode && (
Expand All @@ -356,14 +369,13 @@ export function Chat() {
<StepsDiv
ref={stepsDivRef}
className={`overflow-y-scroll pt-[8px] ${showScrollbar ? "thin-scrollbar" : "no-scrollbar"} ${history.length > 0 ? "flex-1" : ""}`}
onScroll={handleScroll}
>
{highlights}
{history.map((item, index: number) => (
<div
key={item.message.id}
style={{
minHeight: index === history.length - 1 ? "50vh" : 0,
minHeight: index === history.length - 1 ? "25vh" : 0,
}}
>
<ErrorBoundary
Expand Down Expand Up @@ -444,11 +456,6 @@ export function Chat() {
</ErrorBoundary>
</div>
))}
<ChatScrollAnchor
scrollAreaRef={stepsDivRef}
isAtBottom={isAtBottom}
trackVisibility={isStreaming}
/>
</StepsDiv>
<div className={`relative`}>
<div className="absolute -top-8 right-2 z-30">
Expand Down

0 comments on commit b94ccc6

Please sign in to comment.