Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
49df993
conductor-checkpoint-start
zvadaadam Oct 22, 2025
4ec8df4
conductor-checkpoint-msg_01LsFFaFLHSaS3kFZC6NhQUg
zvadaadam Oct 22, 2025
ca69537
conductor-checkpoint-msg_01RaSvwuq2b4DSfS6uW7Yfs8
zvadaadam Oct 22, 2025
4925dd7
conductor-checkpoint-msg_01AjxcMcQ3cat2JxS5Zyaz6u
zvadaadam Oct 22, 2025
697319c
conductor-checkpoint-msg_011KMPUd3Nk91fbLZw6pa9Hj
zvadaadam Oct 22, 2025
eea5a29
conductor-checkpoint-msg_017wFpyFaQQwoePGwgBp1btm
zvadaadam Oct 22, 2025
22c8b4f
conductor-checkpoint-msg_0172L6ZcQHHGi73bba4RLNtu
zvadaadam Oct 22, 2025
6fdf742
conductor-checkpoint-msg_01Kyq685uxYtQ8eYf2jc1oGx
zvadaadam Oct 22, 2025
d0c762b
conductor-checkpoint-msg_01GrXTUi9STrNCUrYrU51YLX
zvadaadam Oct 22, 2025
4be9b23
conductor-checkpoint-msg_018R21UGY63sLeAnA6iWdFGy
zvadaadam Oct 22, 2025
cf19fa8
conductor-checkpoint-msg_0166UevwKoqLvWnB8Bta9g91
zvadaadam Oct 22, 2025
297e338
conductor-checkpoint-msg_016VpEBRP4BpaFxuaehFcACu
zvadaadam Oct 22, 2025
94d646a
conductor-checkpoint-msg_01KwrFsVPiDqwh5uJ9k1qDRg
zvadaadam Oct 22, 2025
bee10d6
conductor-checkpoint-msg_01DRfqcHstQtxgXHLmLexgCv
zvadaadam Oct 22, 2025
ab40ee7
conductor-checkpoint-msg_01KwEyACFmCpdb34U7iPZcsX
zvadaadam Oct 22, 2025
ce4d094
conductor-checkpoint-msg_019Qcq9d9uFwPAcMxUUvDWK9
zvadaadam Oct 22, 2025
6de859c
conductor-checkpoint-msg_01DvBTmKJP7tkjusVAGte15G
zvadaadam Oct 22, 2025
b8cf5de
conductor-checkpoint-msg_01VDXabb7JcrLDmeuHdfiJ3T
zvadaadam Oct 22, 2025
fedaaca
fix: Address code review feedback for auto-scroll implementation
zvadaadam Oct 22, 2025
15d5b14
conductor-checkpoint-msg_016uuo7LPRTUr9qux5xBXwd7
zvadaadam Oct 22, 2025
3108302
conductor-checkpoint-msg_01RGMGrchWcmqM1gt4Pa6rTa
zvadaadam Oct 22, 2025
5ab0521
fix: Resolve scroll button flicker and inconsistent timeouts
zvadaadam Oct 22, 2025
54b0049
conductor-checkpoint-msg_016rJCRWgcDwKP1xRwLLiMZY
zvadaadam Oct 22, 2025
16ad26c
fix: Move scroll button outside scrollable container
zvadaadam Oct 22, 2025
da147c8
conductor-checkpoint-msg_013LrByDTmdgZGGpzgNGUqWt
zvadaadam Oct 22, 2025
7b67619
fix: Adjust scroll button position to prevent cropping
zvadaadam Oct 22, 2025
db676c2
conductor-checkpoint-msg_018GNsCPhgR4ix6mCWgnLowA
zvadaadam Oct 22, 2025
4a89eaa
conductor-checkpoint-msg_012quwArKRjyByKShc11WRdx
zvadaadam Oct 22, 2025
1e16cd2
feat: Add scroll fade effect above message input
zvadaadam Oct 22, 2025
3a093c8
conductor-checkpoint-msg_01CxCWnKE9zXSBzGGjofNzL1
zvadaadam Oct 22, 2025
41b9e3f
fix: Reposition scroll fade to overlay chat content
zvadaadam Oct 22, 2025
f906cc4
conductor-checkpoint-msg_01X5bsH6G3ATZbCigtEqZgvq
zvadaadam Oct 22, 2025
e07baa2
fix: Revert scroll fade - was breaking layout
zvadaadam Oct 22, 2025
3d69ef1
conductor-checkpoint-msg_01Q8CzjrEnqUs8o9syiRX97E
zvadaadam Oct 22, 2025
eae79be
feat: Add scroll fade overlay above message input
zvadaadam Oct 22, 2025
abd512f
refactor: Improve useAutoScroll hook following code review feedback
zvadaadam Oct 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 129 additions & 20 deletions src/features/session/hooks/useAutoScroll.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,168 @@
import { useState, useEffect, RefObject } from "react";
import { useState, useEffect, useRef, RefObject } from "react";
import type { Message, SessionStatus } from "@/shared/types";

interface UseAutoScrollOptions {
messages: Message[];
sessionStatus: SessionStatus;
messagesContainerRef: RefObject<HTMLDivElement>;
messagesEndRef: RefObject<HTMLDivElement>;
messagesEndRef: RefObject<HTMLDivElement>; // Empty div at end of messages
lastMessageRef: RefObject<HTMLDivElement>; // Last message element
// Configuration options
scrollThreshold?: number; // Distance from bottom to consider "at bottom" (default: 100)
inputHeightBuffer?: number; // Buffer for message input height (default: 80)
smoothScrollUser?: boolean; // Use smooth scroll for user messages (default: false)
}

/**
* Hook to manage auto-scroll behavior and scroll-to-bottom button
*
* Features:
* - USER messages: Scroll to top of viewport (push old messages up)
* - ASSISTANT messages: Smart overflow detection
* - If there's visible space below → NO scroll (messages appear naturally)
* - If content would be hidden → AUTO scroll (reveal new content)
* - Shows "scroll to bottom" button when user scrolls up
* - Respects user intent (doesn't auto-scroll if user manually scrolled up)
*
* UX Benefits:
* - Reduces unnecessary scrolling when viewport has space
* - User sees their question + answer simultaneously
* - Less jarring, more natural content flow
* - Only scrolls when content would actually be cut off
*/
export function useAutoScroll({
messages,
sessionStatus,
messagesContainerRef,
messagesEndRef,
lastMessageRef,
scrollThreshold = 100,
inputHeightBuffer = 80,
smoothScrollUser = false,
}: UseAutoScrollOptions) {
const [showScrollButton, setShowScrollButton] = useState(false);
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
const lastMessageCountRef = useRef(0);
const isAutoScrollingRef = useRef(false); // Track if we're auto-scrolling

// Auto-scroll when messages change or session status changes
// Check if user is near bottom (within threshold)
const isNearBottom = (threshold = scrollThreshold) => {
const container = messagesContainerRef.current;
if (!container) return false;

const { scrollTop, scrollHeight, clientHeight } = container;
return scrollHeight - scrollTop - clientHeight < threshold;
};

// Scroll to bottom function
const scrollToBottom = (smooth = false) => {
messagesEndRef.current?.scrollIntoView({
behavior: smooth ? 'smooth' : 'auto',
block: 'end'
});
};

// Auto-scroll when new messages arrive
useEffect(() => {
if (shouldAutoScroll) {
scrollToBottom();
setShouldAutoScroll(false);
// Only auto-scroll if a NEW message was added (not on initial mount)
if (messages.length === 0 || messages.length === lastMessageCountRef.current) {
lastMessageCountRef.current = messages.length;
return;
}

const lastMessage = messages[messages.length - 1];
const container = messagesContainerRef.current;

if (!isUserScrolledUp && container) {
isAutoScrollingRef.current = true;

if (lastMessage.role === 'user') {
// USER message: Scroll marker to TOP of viewport
requestAnimationFrame(() => {
const marker = lastMessageRef.current;
if (marker) {
// Get marker position relative to container
const markerTop = marker.offsetTop;
// Scroll container so marker is at the top
container.scrollTop = markerTop;

if (import.meta.env.DEV) {
console.log('[useAutoScroll] User message scrolled to top:', markerTop);
}
} else {
console.error('[useAutoScroll] lastMessageRef is null!');
}

setTimeout(() => {
isAutoScrollingRef.current = false;
}, 100);
});
} else {
// ASSISTANT message: Scroll to BOTTOM
requestAnimationFrame(() => {
scrollToBottom(false);

setTimeout(() => {
isAutoScrollingRef.current = false;
}, 100);
});
}
}
}, [messages, sessionStatus, shouldAutoScroll]);

// Handle scroll detection for "scroll to bottom" button
lastMessageCountRef.current = messages.length;
}, [messages.length, isUserScrolledUp, smoothScrollUser]);

// Track user scroll behavior
useEffect(() => {
const container = messagesContainerRef.current;
if (!container) return;

const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
setShowScrollButton(!isNearBottom);
// Don't update state if we're auto-scrolling
if (isAutoScrollingRef.current) return;

const nearBottom = isNearBottom();
setShowScrollButton(!nearBottom);
setIsUserScrolledUp(!nearBottom);
};

container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [messagesContainerRef]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Missing isNearBottom dependency in the cleanup

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/features/session/hooks/useAutoScroll.ts
Line: 131:131

Comment:
**logic:** Missing `isNearBottom` dependency in the cleanup

How can I resolve this? If you propose a fix, please make it concise.


function scrollToBottom(smooth = false) {
messagesEndRef.current?.scrollIntoView({
behavior: smooth ? 'smooth' : 'auto',
block: 'end'
// ResizeObserver: Auto-scroll during streaming (when content grows)
useEffect(() => {
const container = messagesContainerRef.current;
if (!container) return;

const resizeObserver = new ResizeObserver(() => {
const { scrollTop, scrollHeight, clientHeight } = container;
const viewportBottom = scrollTop + clientHeight;
const contentBottom = scrollHeight;
const isContentHidden = contentBottom > viewportBottom + inputHeightBuffer;

// Only auto-scroll if:
// 1. In working session (streaming)
// 2. User hasn't scrolled up
// 3. Content would be hidden below viewport
if (sessionStatus === 'working' && !isUserScrolledUp && isContentHidden) {
isAutoScrollingRef.current = true;
scrollToBottom(false);
setTimeout(() => {
isAutoScrollingRef.current = false;
}, 50);
}
});
Comment on lines +152 to 173

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: ResizeObserver may fire many times during streaming, potentially causing performance issues. Consider throttling or debouncing the scroll checks.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/features/session/hooks/useAutoScroll.ts
Line: 138:155

Comment:
**style:** ResizeObserver may fire many times during streaming, potentially causing performance issues. Consider throttling or debouncing the scroll checks.

How can I resolve this? If you propose a fix, please make it concise.

}

function handleScrollToBottomClick() {
setShouldAutoScroll(true);
resizeObserver.observe(container);
return () => resizeObserver.disconnect();
}, [messagesContainerRef, isUserScrolledUp, sessionStatus, inputHeightBuffer]);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Manual scroll to bottom (resets user scroll state)
const handleScrollToBottomClick = () => {
setIsUserScrolledUp(false);
scrollToBottom(true);
}
};

return {
showScrollButton,
Expand Down
21 changes: 14 additions & 7 deletions src/features/session/ui/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface ChatProps {
sessionStatus: SessionStatus;
parseContent: (content: string) => ReactNode;
messagesEndRef: RefObject<HTMLDivElement>;
lastMessageRef: RefObject<HTMLDivElement>;
messagesContainerRef: RefObject<HTMLDivElement>;
showScrollButton?: boolean;
onScrollToBottom?: () => void;
Expand All @@ -25,6 +26,7 @@ export function Chat({
sessionStatus,
parseContent,
messagesEndRef,
lastMessageRef,
messagesContainerRef,
showScrollButton = false,
onScrollToBottom,
Expand Down Expand Up @@ -55,13 +57,18 @@ export function Chat({
) : (
<>
<div className="flex flex-col gap-6 pb-8 min-h-0">
{messages.map(message => (
<MessageItem
key={message.id}
message={message}
parseContent={parseContent}
toolResultMap={toolResultMap}
/>
{messages.map((message, index) => (
<div key={message.id}>
<MessageItem
message={message}
parseContent={parseContent}
toolResultMap={toolResultMap}
/>
{/* Invisible ref marker - always renders even if MessageItem returns null */}
{index === messages.length - 1 && (
<div ref={lastMessageRef} style={{ height: 0, width: 0 }} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Inline styles bypass Tailwind and break consistency. Use Tailwind classes instead:

Suggested change
<div ref={lastMessageRef} style={{ height: 0, width: 0 }} />
<div ref={lastMessageRef} className="h-0 w-0" />

Context Used: Context from dashboard - CLAUDE.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/features/session/ui/Chat.tsx
Line: 69:69

Comment:
**style:** Inline styles bypass Tailwind and break consistency. Use Tailwind classes instead:

```suggestion
                  <div ref={lastMessageRef} className="h-0 w-0" />
```

**Context Used:** Context from `dashboard` - CLAUDE.md ([source](https://app.greptile.com/review/custom-context?memory=5f29a80f-5c70-41a4-8bfe-6ea6c6a3911c))

How can I resolve this? If you propose a fix, please make it concise.

)}
</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Wrapping each MessageItem in a div breaks the original gap-6 spacing (line 59). The gap applies between direct children, so the wrapper divs will have 6-spacing, but MessageItem's internal content won't benefit from the original layout logic. Is the gap-6 spacing still correct with the wrapper divs, or does this change the visual spacing between messages?

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/features/session/ui/Chat.tsx
Line: 61:71

Comment:
**logic:** Wrapping each MessageItem in a div breaks the original gap-6 spacing (line 59). The gap applies between direct children, so the wrapper divs will have 6-spacing, but MessageItem's internal content won't benefit from the original layout logic. Is the gap-6 spacing still correct with the wrapper divs, or does this change the visual spacing between messages?

How can I resolve this? If you propose a fix, please make it concise.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
))}
Comment on lines +54 to 65

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Wrapper divs may cause extra spacing when MessageItem returns null.

Each message is now wrapped in a div, but MessageItem can return null for messages containing only tool_result blocks (see MessageItem.tsx:25-116). When MessageItem returns null, the wrapper div still exists and participates in the gap-6 spacing, potentially creating unexpected gaps.

Consider filtering messages before rendering or conditionally rendering the wrapper:

           <div className="flex flex-col gap-6 pb-8 min-h-0">
-            {messages.map((message, index) => (
+            {messages.map((message, index) => {
+              const isLast = index === messages.length - 1;
+              return (
               <div
                 key={message.id}
-                ref={index === messages.length - 1 ? lastMessageRef : undefined}
+                ref={isLast ? lastMessageRef : undefined}
               >
                 <MessageItem
                   message={message}
                   parseContent={parseContent}
                   toolResultMap={toolResultMap}
                 />
               </div>
-            ))}
+              );
+            })}
           </div>

Or better, conditionally render the wrapper only when MessageItem would render:

{messages.map((message, index) => {
  const isLast = index === messages.length - 1;
  const messageItem = (
    <MessageItem
      message={message}
      parseContent={parseContent}
      toolResultMap={toolResultMap}
    />
  );
  
  return isLast ? (
    <div key={message.id} ref={lastMessageRef}>
      {messageItem}
    </div>
  ) : (
    messageItem
  );
})}

However, this second approach defeats the purpose of always having a ref target for scrolling.

🤖 Prompt for AI Agents
In src/features/session/ui/Chat.tsx around lines 60-71, the current wrapper div
around each message creates unwanted spacing when MessageItem returns null;
update rendering to avoid rendering empty wrapper elements: compute/render the
MessageItem first, skip rendering any wrapper/div when the MessageItem is null,
and only render a container when the MessageItem exists; ensure the
lastMessageRef still attaches to the last rendered message by either forwarding
a ref into MessageItem (implement React.forwardRef in MessageItem and pass
lastMessageRef when that message is last) or by using a callback ref on the
rendered element so scrolling still targets the final visible message.

</div>
{sessionStatus === 'working' && (
Expand Down
17 changes: 12 additions & 5 deletions src/features/session/ui/MessageInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,24 @@ export function MessageInput({
const resizeTextarea = () => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
// Reset to minimum first
el.style.height = '40px';
// Then expand to fit content, max 200px
const newHeight = Math.min(Math.max(el.scrollHeight, 40), 200);
el.style.height = newHeight + 'px';
Comment on lines +46 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Potential flashing issue: resetting to 40px then immediately expanding may cause visible height jump during typing. Consider setting height only when scrollHeight differs from current height. Have you tested this with rapid typing or paste operations to ensure smooth height transitions?

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/features/session/ui/MessageInput.tsx
Line: 46:50

Comment:
**style:** Potential flashing issue: resetting to 40px then immediately expanding may cause visible height jump during typing. Consider setting height only when scrollHeight differs from current height. Have you tested this with rapid typing or paste operations to ensure smooth height transitions?

How can I resolve this? If you propose a fix, please make it concise.

};

// Auto-resize textarea
useEffect(() => {
resizeTextarea();
}, [messageInput]);

// Set initial height on mount
useEffect(() => {
resizeTextarea();
const el = textareaRef.current;
if (el) {
el.style.height = '40px';
}
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Duplicate initialization: inline style on line 96 already sets height to 40px, making this useEffect redundant.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/features/session/ui/MessageInput.tsx
Line: 59:64

Comment:
**style:** Duplicate initialization: inline style on line 96 already sets height to 40px, making this useEffect redundant.

How can I resolve this? If you propose a fix, please make it concise.


return (
Expand Down Expand Up @@ -86,15 +93,15 @@ export function MessageInput({
resizeTextarea();
}}
onBlur={() => setIsFocused(false)}
style={{ height: '40px' }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Inline style conflicts with CSS class: setting height='40px' inline means the resizeTextarea() function must override it every time. The inline style takes precedence, so resizeTextarea() correctly overwrites it, but this creates redundant DOM operations.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/features/session/ui/MessageInput.tsx
Line: 96:96

Comment:
**style:** Inline style conflicts with CSS class: setting height='40px' inline means the resizeTextarea() function must override it every time. The inline style takes precedence, so resizeTextarea() correctly overwrites it, but this creates redundant DOM operations.

How can I resolve this? If you propose a fix, please make it concise.

className="
flex-1 bg-transparent border-none outline-none resize-none
text-body-lg text-foreground placeholder:text-muted-foreground
min-h-[24px] max-h-[200px]
max-h-[200px]
font-sans
overflow-y-auto
scrollbar-vibrancy
"
rows={1}
/>

{/* Action Buttons */}
Expand Down
6 changes: 5 additions & 1 deletion src/features/session/ui/SessionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export interface SessionPanelRef {
export const SessionPanel = forwardRef<SessionPanelRef, SessionPanelProps>(
({ sessionId, onClose, embedded = false, onCompact, onCreatePR, onStop }, ref) => {
const [selectedFile, setSelectedFile] = useState<FileChangeGroup | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null); // Empty div at end for scrolling to bottom
const lastMessageRef = useRef<HTMLDivElement>(null); // Last message element for scrolling to top
const messagesContainerRef = useRef<HTMLDivElement>(null);

// Custom hooks (useSocket manages socket connection lifecycle)
Expand Down Expand Up @@ -171,6 +172,7 @@ export const SessionPanel = forwardRef<SessionPanelRef, SessionPanelProps>(
sessionStatus,
messagesContainerRef,
messagesEndRef,
lastMessageRef,
});

// Expose action handlers to parent
Expand Down Expand Up @@ -255,6 +257,7 @@ export const SessionPanel = forwardRef<SessionPanelRef, SessionPanelProps>(
sessionStatus={sessionStatus}
parseContent={parseContent}
messagesEndRef={messagesEndRef}
lastMessageRef={lastMessageRef}
messagesContainerRef={messagesContainerRef}
showScrollButton={showScrollButton}
onScrollToBottom={handleScrollToBottomClick}
Expand Down Expand Up @@ -327,6 +330,7 @@ export const SessionPanel = forwardRef<SessionPanelRef, SessionPanelProps>(
sessionStatus={sessionStatus}
parseContent={parseContent}
messagesEndRef={messagesEndRef}
lastMessageRef={lastMessageRef}
messagesContainerRef={messagesContainerRef}
showScrollButton={showScrollButton}
onScrollToBottom={handleScrollToBottomClick}
Expand Down