fix(ui): smart auto-scroll for Insights streaming responses (#1591)
* fix(ui): smart auto-scroll for Insights streaming responses Previously, auto-scroll would always jump to bottom during streaming, making it impossible to read earlier messages. Now tracks user scroll position and only auto-scrolls if user is already at the bottom. - Add isUserAtBottom state to track user scroll position - Use viewportEl state with onViewportRef for reliable element access - Check scroll position within 100px threshold of bottom - Resume auto-scroll when user sends a new message - Remove unused messagesEndRef and RAF-based scrolling logic Closes #1348 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(windows): add Windows file-based credential fallback (PR #1561) This includes all changes from PR #1561: - Add shared file credential helpers (getCredentialsFromFile, getFullCredentialsFromFile) - Add Windows file-based credential storage functions: - getWindowsCredentialsPath() - path to .credentials.json - getCredentialsFromWindowsFile() - read basic credentials from file - getCredentialsFromWindows() - tries Credential Manager first, falls back to file - getFullCredentialsFromWindowsFile() - read full credentials from file - getFullCredentialsFromWindows() - tries Credential Manager first, falls back to file - updateWindowsFileCredentials() - write credentials to file - updateWindowsCredentials() - updates both Credential Manager and file - Fix PtrToStructure failure in Windows Credential Manager PowerShell script by defining proper CREDENTIAL struct with IntPtr fields - Update index.ts to check migrated profiles for valid credentials via file fallback before showing re-auth modal - Update claude-code-handlers.ts to check Windows .credentials.json file - Refactor Linux credential code to use shared helpers - Add/update tests for Windows file fallback scenarios Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(windows): add path normalization and smart credential source comparison Additional fixes on top of PR #1561: 1. Path normalization in claude-profile-manager.ts: - Normalize forward slashes to backslashes on Windows in getActiveProfileEnv() and getProfileEnv() - This ensures CLAUDE_CONFIG_DIR hash matches what Claude CLI computes - Fixes credential lookup failures when paths have mixed separators 2. Smart credential source comparison in credential-utils.ts: - getCredentialsFromWindows() now checks both file and Credential Manager - When both have tokens, prefers file (Claude CLI primary storage) - getFullCredentialsFromWindows() compares expiry times when both have tokens - Returns the token with later expiry (more recently refreshed) - Fixes stale token issue when Credential Manager has old tokens 3. Updated tests: - Fixed test to properly mock file not existing when testing Credential Manager - Added test for preferring file when both sources have tokens Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: remove #1585 credential code from this PR Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
This commit is contained in:
@@ -105,10 +105,36 @@ export function Insights({ projectId }: InsightsProps) {
|
||||
const [creatingTask, setCreatingTask] = useState<string | null>(null);
|
||||
const [taskCreated, setTaskCreated] = useState<Set<string>>(new Set());
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
const [isUserAtBottom, setIsUserAtBottom] = useState(true);
|
||||
const [viewportEl, setViewportEl] = useState<HTMLElement | null>(null);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const scrollAreaViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Scroll threshold in pixels - user is considered "at bottom" if within this distance
|
||||
const SCROLL_BOTTOM_THRESHOLD = 100;
|
||||
|
||||
// Check if user is near the bottom of scroll area
|
||||
const checkIfAtBottom = useCallback((viewport: HTMLElement) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = viewport;
|
||||
return scrollHeight - scrollTop - clientHeight <= SCROLL_BOTTOM_THRESHOLD;
|
||||
}, []);
|
||||
|
||||
// Handle scroll events to track user position
|
||||
const handleScroll = useCallback(() => {
|
||||
if (viewportEl) {
|
||||
setIsUserAtBottom(checkIfAtBottom(viewportEl));
|
||||
}
|
||||
}, [viewportEl, checkIfAtBottom]);
|
||||
|
||||
// Set up scroll listener and check initial position when viewport becomes available
|
||||
useEffect(() => {
|
||||
if (viewportEl) {
|
||||
// Check initial scroll position
|
||||
setIsUserAtBottom(checkIfAtBottom(viewportEl));
|
||||
viewportEl.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => viewportEl.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
}, [viewportEl, handleScroll, checkIfAtBottom]);
|
||||
|
||||
// Load session and set up listeners on mount
|
||||
useEffect(() => {
|
||||
@@ -117,47 +143,14 @@ export function Insights({ projectId }: InsightsProps) {
|
||||
return cleanup;
|
||||
}, [projectId]);
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
// Uses requestAnimationFrame to ensure DOM layout is complete before scrolling
|
||||
// and direct scrollTop manipulation for more predictable behavior than scrollIntoView
|
||||
// Smart auto-scroll: only scroll if user is already at bottom
|
||||
// This allows users to scroll up to read previous messages without being
|
||||
// yanked back down during streaming responses
|
||||
useEffect(() => {
|
||||
const scrollToBottom = () => {
|
||||
const viewport = scrollAreaViewportRef.current;
|
||||
if (viewport) {
|
||||
// Use direct scrollTop manipulation for immediate, predictable scrolling
|
||||
// This avoids race conditions with smooth scrolling animation on macOS
|
||||
viewport.scrollTop = viewport.scrollHeight;
|
||||
} else {
|
||||
// Fallback to scrollIntoView for the sentinel element
|
||||
messagesEndRef.current?.scrollIntoView({ block: 'end' });
|
||||
}
|
||||
};
|
||||
|
||||
// During streaming, use requestAnimationFrame to ensure DOM is updated
|
||||
// After streaming completes, scroll immediately without animation
|
||||
const isStreaming = !!streamingContent;
|
||||
let rafId: number | undefined;
|
||||
if (isStreaming) {
|
||||
rafId = requestAnimationFrame(scrollToBottom);
|
||||
} else {
|
||||
// Small delay for non-streaming updates to allow layout to settle
|
||||
const timeoutId = setTimeout(() => {
|
||||
rafId = requestAnimationFrame(scrollToBottom);
|
||||
}, 10);
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
if (rafId !== undefined) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
};
|
||||
if (isUserAtBottom && viewportEl) {
|
||||
viewportEl.scrollTop = viewportEl.scrollHeight;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (rafId !== undefined) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
};
|
||||
}, [session?.messages, streamingContent]);
|
||||
}, [session?.messages, streamingContent, isUserAtBottom, viewportEl]);
|
||||
|
||||
// Focus textarea on mount
|
||||
useEffect(() => {
|
||||
@@ -169,17 +162,13 @@ export function Insights({ projectId }: InsightsProps) {
|
||||
setTaskCreated(new Set());
|
||||
}, [session?.id]);
|
||||
|
||||
// Stable callback for viewport ref to avoid creating new function on each render
|
||||
const handleViewportRef = useCallback((ref: HTMLDivElement | null) => {
|
||||
scrollAreaViewportRef.current = ref;
|
||||
}, []);
|
||||
|
||||
const handleSend = () => {
|
||||
const message = inputValue.trim();
|
||||
if (!message || status.phase === 'thinking' || status.phase === 'streaming') return;
|
||||
|
||||
setInputValue('');
|
||||
sendMessage(projectId, message);
|
||||
setIsUserAtBottom(true); // Resume auto-scroll when user sends a message
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -304,7 +293,7 @@ export function Insights({ projectId }: InsightsProps) {
|
||||
{/* Messages */}
|
||||
<ScrollArea
|
||||
className="flex-1 px-6 py-4"
|
||||
onViewportRef={handleViewportRef}
|
||||
onViewportRef={setViewportEl}
|
||||
>
|
||||
{messages.length === 0 && !streamingContent ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
@@ -399,7 +388,6 @@ export function Insights({ projectId }: InsightsProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
Reference in New Issue
Block a user