Skip to content

Commit 2a3bd2a

Browse files
feat(web): replace manual auto-scroll with useStickToBottom in chat thread (#1031)
* feat(web): replace manual auto-scroll with useStickToBottom in chat thread Replaces the custom scroll tracking implementation (manual scroll listeners, scrollIntoView calls, isAutoScrollEnabled state) with the useStickToBottom library already used in detailsCard. Fixes layout regressions for sticky answer headers and chat box visibility. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: update CHANGELOG for #1031 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * changelog * feedabck --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 621ce07 commit 2a3bd2a

File tree

2 files changed

+61
-87
lines changed

2 files changed

+61
-87
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Changed the `webUrl` property of the `/api/repos` api to return a URL rather than just a path. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014)
1313
- Changed the ask search scope selector to allow submitting questions with no search scope selected. When no selection is made, the agent will be able to search over all repos the user has access to. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014)
1414
- Renamed the `search_code` tool to `grep` for ask and mcp. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014)
15+
- Improved auto-scroll behavior in the ask chat thread. [#1031](https://github.com/sourcebot-dev/sourcebot/pull/1031)
1516

1617
### Added
1718
- Added `glob`, `find_symbol_definitions`, and `find_symbol_references` tools to the ask agent and MCP server. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014)

packages/web/src/features/chat/components/chatThread/chatThread.tsx

Lines changed: 60 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import { useToast } from '@/components/hooks/use-toast';
44
import { Button } from '@/components/ui/button';
5-
import { ScrollArea } from '@/components/ui/scroll-area';
65
import { Separator } from '@/components/ui/separator';
76
import { CustomSlateEditor } from '@/features/chat/customSlateEditor';
87
import { AdditionalChatRequestParams, CustomEditor, LanguageModelInfo, SBChatMessage, SearchScope, Source } from '@/features/chat/types';
@@ -12,6 +11,7 @@ import { CreateUIMessage, DefaultChatTransport } from 'ai';
1211
import { ArrowDownIcon, CopyIcon } from 'lucide-react';
1312
import { useNavigationGuard } from 'next-navigation-guard';
1413
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
14+
import { useStickToBottom } from 'use-stick-to-bottom';
1515
import { Descendant } from 'slate';
1616
import { useMessagePairs } from '../../useMessagePairs';
1717
import { useSelectedLanguageModel } from '../../useSelectedLanguageModel';
@@ -67,10 +67,8 @@ export const ChatThread = ({
6767
chatName,
6868
}: ChatThreadProps) => {
6969
const [isErrorBannerVisible, setIsErrorBannerVisible] = useState(false);
70-
const scrollAreaRef = useRef<HTMLDivElement>(null);
71-
const latestMessagePairRef = useRef<HTMLDivElement>(null);
7270
const hasSubmittedInputMessage = useRef(false);
73-
const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(false);
71+
const { scrollRef, contentRef, scrollToBottom, isAtBottom } = useStickToBottom({ initial: false });
7472
const { toast } = useToast();
7573
const router = useRouter();
7674
const params = useParams<{ domain: string }>();
@@ -204,9 +202,9 @@ export const ChatThread = ({
204202
}
205203

206204
sendMessage(inputMessage);
207-
setIsAutoScrollEnabled(true);
205+
scrollToBottom();
208206
hasSubmittedInputMessage.current = true;
209-
}, [inputMessage, sendMessage]);
207+
}, [inputMessage, scrollToBottom, sendMessage]);
210208

211209
// Restore pending message after OAuth redirect (askgh login wall)
212210
useEffect(() => {
@@ -234,28 +232,24 @@ export const ChatThread = ({
234232
const mentions = getAllMentionElements(children);
235233
const message = createUIMessage(text, mentions.map(({ data }) => data), selectedSearchScopes);
236234
sendMessage(message);
237-
setIsAutoScrollEnabled(true);
235+
scrollToBottom();
238236
} catch (error) {
239237
console.error('Failed to restore pending message:', error);
240238
}
241-
}, [isAuthenticated, isOwner, chatId, sendMessage, selectedSearchScopes]);
239+
}, [isAuthenticated, isOwner, chatId, sendMessage, selectedSearchScopes, scrollToBottom]);
242240

243-
// Track scroll position changes.
241+
// Track scroll position for history state restoration.
244242
useEffect(() => {
245-
const scrollElement = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement;
246-
if (!scrollElement) return;
243+
const scrollElement = scrollRef.current;
244+
if (!scrollElement) {
245+
return;
246+
}
247247

248248
let timeout: NodeJS.Timeout | null = null;
249249

250250
const handleScroll = () => {
251251
const scrollOffset = scrollElement.scrollTop;
252252

253-
const threshold = 50; // pixels from bottom to consider "at bottom"
254-
const { scrollHeight, clientHeight } = scrollElement;
255-
const isAtBottom = scrollHeight - scrollOffset - clientHeight <= threshold;
256-
setIsAutoScrollEnabled(isAtBottom);
257-
258-
// Debounce the history state update
259253
if (timeout) {
260254
clearTimeout(timeout);
261255
}
@@ -279,10 +273,11 @@ export const ChatThread = ({
279273
clearTimeout(timeout);
280274
}
281275
};
282-
}, []);
276+
}, [scrollRef]);
283277

278+
// Restore scroll position from history state on mount.
284279
useEffect(() => {
285-
const scrollElement = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement;
280+
const scrollElement = scrollRef.current;
286281
if (!scrollElement) {
287282
return;
288283
}
@@ -298,26 +293,7 @@ export const ChatThread = ({
298293
behavior: 'instant',
299294
});
300295
}, 10);
301-
}, []);
302-
303-
// When messages are being streamed, scroll to the latest message
304-
// assuming auto scrolling is enabled.
305-
useEffect(() => {
306-
if (
307-
!latestMessagePairRef.current ||
308-
!isAutoScrollEnabled ||
309-
messages.length === 0
310-
) {
311-
return;
312-
}
313-
314-
latestMessagePairRef.current.scrollIntoView({
315-
behavior: 'smooth',
316-
block: 'end',
317-
inline: 'nearest',
318-
});
319-
320-
}, [isAutoScrollEnabled, messages]);
296+
}, [scrollRef]);
321297

322298

323299
// Keep the error state & banner visibility in sync.
@@ -345,10 +321,10 @@ export const ChatThread = ({
345321
const message = createUIMessage(text, mentions.map(({ data }) => data), selectedSearchScopes);
346322
sendMessage(message);
347323

348-
setIsAutoScrollEnabled(true);
324+
scrollToBottom();
349325

350326
resetEditor(editor);
351-
}, [sendMessage, selectedSearchScopes, isAuthenticated, captureEvent, chatId]);
327+
}, [sendMessage, selectedSearchScopes, isAuthenticated, captureEvent, chatId, scrollToBottom]);
352328

353329
const onDuplicate = useCallback(async (newName: string): Promise<string | null> => {
354330
if (!defaultChatId) {
@@ -379,64 +355,61 @@ export const ChatThread = ({
379355
/>
380356
)}
381357

382-
<ScrollArea
383-
ref={scrollAreaRef}
384-
className="flex flex-col h-full w-full p-4 overflow-hidden"
385-
>
386-
{
387-
messagePairs.length === 0 ? (
388-
<div className="flex items-center justify-center text-center h-full">
389-
<p className="text-muted-foreground">no messages</p>
390-
</div>
391-
) : (
392-
<>
393-
{messagePairs.map(([userMessage, assistantMessage], index) => {
394-
const isLastPair = index === messagePairs.length - 1;
395-
const isStreaming = isLastPair && (status === "streaming" || status === "submitted");
396-
// Use a stable key based on user message ID
397-
const key = userMessage.id;
398-
399-
return (
400-
<Fragment key={key}>
401-
<ChatThreadListItem
402-
index={index}
403-
chatId={chatId}
404-
userMessage={userMessage}
405-
assistantMessage={assistantMessage}
406-
isStreaming={isStreaming}
407-
sources={sources}
408-
ref={isLastPair ? latestMessagePairRef : undefined}
409-
/>
410-
{index !== messagePairs.length - 1 && (
411-
<Separator className="my-12" />
412-
)}
413-
</Fragment>
414-
);
415-
})}
416-
</>
417-
)
418-
}
358+
<div className="relative h-full w-full p-4 overflow-hidden min-h-0">
359+
<div
360+
ref={scrollRef}
361+
className="h-full w-full overflow-y-auto overflow-x-hidden"
362+
>
363+
<div ref={contentRef}>
364+
{
365+
messagePairs.length === 0 ? (
366+
<div className="flex items-center justify-center text-center h-full min-h-full">
367+
<p className="text-muted-foreground">no messages</p>
368+
</div>
369+
) : (
370+
<>
371+
{messagePairs.map(([userMessage, assistantMessage], index) => {
372+
const isLastPair = index === messagePairs.length - 1;
373+
const isStreaming = isLastPair && (status === "streaming" || status === "submitted");
374+
// Use a stable key based on user message ID
375+
const key = userMessage.id;
376+
377+
return (
378+
<Fragment key={key}>
379+
<ChatThreadListItem
380+
index={index}
381+
chatId={chatId}
382+
userMessage={userMessage}
383+
assistantMessage={assistantMessage}
384+
isStreaming={isStreaming}
385+
sources={sources}
386+
/>
387+
{index !== messagePairs.length - 1 && (
388+
<Separator className="my-12" />
389+
)}
390+
</Fragment>
391+
);
392+
})}
393+
</>
394+
)
395+
}
396+
</div>
397+
</div>
419398
{
420-
(!isAutoScrollEnabled && status === "streaming") && (
399+
(!isAtBottom && status === "streaming") && (
421400
<div className="absolute bottom-5 left-0 right-0 h-10 flex flex-row items-center justify-center">
422401
<Button
423402
variant="outline"
424403
size="icon"
425404
className="rounded-full animate-bounce-slow h-8 w-8"
426-
onClick={() => {
427-
latestMessagePairRef.current?.scrollIntoView({
428-
behavior: 'instant',
429-
block: 'end',
430-
inline: 'nearest',
431-
});
432-
}}
405+
onClick={() => scrollToBottom('instant')}
433406
>
434407
<ArrowDownIcon className="w-4 h-4" />
435408
</Button>
436409
</div>
437410
)
438411
}
439-
</ScrollArea>
412+
</div>
440413
<div className="w-full max-w-3xl mx-auto mb-8">
441414
<SignInPromptBanner
442415
chatId={chatId}

0 commit comments

Comments
 (0)