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:
kaigler
2026-01-29 02:40:37 -06:00
committed by GitHub
parent c1f24c07fb
commit eee97e7ea5
@@ -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' });
if (isUserAtBottom && viewportEl) {
viewportEl.scrollTop = viewportEl.scrollHeight;
}
};
// 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);
}
};
}
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>