feat: add scroll to bottom button and optimize chat scroll behavior

- Add useChatScroll hook with IntersectionObserver for precise bottom detection
- Add "Scroll to bottom" button that appears when user scrolls up
- Use requestAnimationFrame for smooth scrolling without jitter
- Implement user scroll lock to respect user intent during streaming
- Auto-scroll only when user is at bottom

Also includes React best practices cleanup:
- Fix Rules of Hooks violation in ThoughtBlockView
- Add useMemo for expensive groupUpdatesIntoMessages
- Hoist ReactMarkdown components outside render
- Wrap handlers with useCallback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-01-19 12:49:28 +08:00
parent 3b4ab35b32
commit 9e208562fa
5 changed files with 456 additions and 216 deletions

View File

@@ -1,7 +1,7 @@
/**
* Main App component
*/
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useApp } from './hooks/useApp'
import { ChatView, MessageInput, StatusBar, UpdateNotification } from './components'
import { AppSidebar } from './components/AppSidebar'
@@ -14,6 +14,9 @@ import { useModalStore } from './stores/modalStore'
import { RightPanel, RightPanelHeader, RightPanelContent } from './components/layout'
import { FileTree } from './components/FileTree'
import { Toaster } from '@/components/ui/sonner'
import { useChatScroll } from './hooks/useChatScroll'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
function AppContent(): React.JSX.Element {
const {
@@ -55,21 +58,24 @@ function AppContent(): React.JSX.Element {
})
// Wrapper to also persist to localStorage
const handleSetDefaultAgent = (agentId: string) => {
const handleSetDefaultAgent = useCallback((agentId: string) => {
localStorage.setItem('multica:defaultAgentId', agentId)
setDefaultAgentId(agentId)
}
}, [])
const handleNewSession = () => {
const handleNewSession = useCallback(() => {
clearCurrentSession()
}
}, [clearCurrentSession])
const handleCreateSession = async (cwd: string) => {
// Create session with default agent (agent starts automatically)
await createSession(cwd, defaultAgentId)
}
const handleCreateSession = useCallback(
async (cwd: string) => {
// Create session with default agent (agent starts automatically)
await createSession(cwd, defaultAgentId)
},
[createSession, defaultAgentId]
)
const handleSelectFolder = async () => {
const handleSelectFolder = useCallback(async () => {
const dir = await window.electronAPI.selectDirectory()
if (dir) {
// Check if the default agent is installed before creating session
@@ -81,12 +87,15 @@ function AppContent(): React.JSX.Element {
}
await createSession(dir, defaultAgentId)
}
}
}, [createSession, defaultAgentId, openModal])
const handleSelectSession = async (sessionId: string) => {
// Select session (agent starts automatically via resumeSession)
await selectSession(sessionId)
}
const handleSelectSession = useCallback(
async (sessionId: string) => {
// Select session (agent starts automatically via resumeSession)
await selectSession(sessionId)
},
[selectSession]
)
// Check if current session has a running agent
const isCurrentSessionRunning = currentSession
@@ -94,10 +103,25 @@ function AppContent(): React.JSX.Element {
: false
// Handler for deleting the current session (opens delete modal)
const handleDeleteCurrentSession = () => {
const handleDeleteCurrentSession = useCallback(() => {
if (!currentSession) return
openModal('deleteSession', currentSession)
}
}, [currentSession, openModal])
// Chat scroll - managed at App level for unified scroll context
const { containerRef, bottomRef, isAtBottom, handleScroll, scrollToBottom, onContentUpdate } =
useChatScroll({
sessionId: currentSession?.id ?? null,
isStreaming: isProcessing
})
// Memoized scroll button handler
const handleScrollToBottom = useCallback(() => scrollToBottom(true), [scrollToBottom])
// Trigger scroll update when content changes
useEffect(() => {
onContentUpdate()
}, [sessionUpdates, onContentUpdate])
return (
<div className="flex h-screen flex-col bg-background text-foreground">
@@ -118,37 +142,69 @@ function AppContent(): React.JSX.Element {
/>
{/* Main area */}
<main className="flex min-w-0 flex-1 flex-col">
{/* Status bar */}
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* Status bar - fixed height */}
<StatusBar
runningSessionsCount={runningSessionsStatus.runningSessions}
currentSession={currentSession}
isCurrentSessionRunning={isCurrentSessionRunning}
/>
{/* Chat view */}
<ChatView
updates={sessionUpdates}
isProcessing={isProcessing}
hasSession={!!currentSession}
isInitializing={isInitializing}
currentSessionId={currentSession?.id ?? null}
onSelectFolder={handleSelectFolder}
/>
{/* Chat and Input container */}
<div className="relative flex-1 overflow-hidden flex flex-col">
{/* Chat scroll area - only messages */}
<div ref={containerRef} onScroll={handleScroll} className="flex-1 overflow-y-auto px-4">
<div className="mx-auto max-w-3xl pb-12 px-2">
<ChatView
updates={sessionUpdates}
isProcessing={isProcessing}
hasSession={!!currentSession}
isInitializing={isInitializing}
currentSessionId={currentSession?.id ?? null}
onSelectFolder={handleSelectFolder}
bottomRef={bottomRef}
/>
</div>
</div>
{/* Input */}
<MessageInput
onSend={sendPrompt}
onCancel={cancelRequest}
isProcessing={isProcessing}
disabled={!currentSession || currentSession.directoryExists === false}
workingDirectory={currentSession?.workingDirectory}
currentAgentId={currentSession?.agentId}
onAgentChange={switchSessionAgent}
isSwitchingAgent={isSwitchingAgent}
directoryExists={currentSession?.directoryExists}
onDeleteSession={handleDeleteCurrentSession}
/>
{/* Input area - fixed at bottom, outside scroll */}
<div>
<div className="relative mx-auto max-w-3xl">
{/* Scroll to bottom button - above input, left-aligned */}
{!isAtBottom && currentSession && (
<div className="absolute bottom-full left-0 pb-2 pointer-events-none">
<button
onClick={handleScrollToBottom}
className={cn(
'pointer-events-auto',
'flex items-center gap-1.5 px-2.5 py-1 rounded-md',
'bg-card/80 backdrop-blur-sm border border-border/50',
'text-xs text-muted-foreground hover:text-foreground hover:bg-card',
'shadow-md hover:shadow-lg',
'transition-all duration-200 ease-out cursor-pointer',
'animate-in fade-in slide-in-from-bottom-2 duration-200'
)}
>
<ChevronDown className="h-3 w-3" />
<span>Scroll to bottom</span>
</button>
</div>
)}
<MessageInput
onSend={sendPrompt}
onCancel={cancelRequest}
isProcessing={isProcessing}
disabled={!currentSession || currentSession.directoryExists === false}
workingDirectory={currentSession?.workingDirectory}
currentAgentId={currentSession?.agentId}
onAgentChange={switchSessionAgent}
isSwitchingAgent={isSwitchingAgent}
directoryExists={currentSession?.directoryExists}
onDeleteSession={handleDeleteCurrentSession}
/>
</div>
</div>
</div>
</main>
{/* Right panel - file tree */}

View File

@@ -1,7 +1,8 @@
/**
* Chat view component - displays messages and tool calls
* Note: Scroll behavior is managed by parent (App.tsx) for unified scroll context
*/
import { useEffect, useRef, useState } from 'react'
import { useState, useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import type { StoredSessionUpdate } from '../../../shared/types'
@@ -12,6 +13,96 @@ import { PermissionRequestItem } from './permission'
import { usePermissionStore } from '../stores/permissionStore'
import { cn } from '@/lib/utils'
// Hoisted ReactMarkdown components for better performance (avoids object recreation on each render)
const TEXT_MARKDOWN_COMPONENTS = {
// Paragraphs: consistent spacing, tighter line height for readability
p: ({ children }: { children?: React.ReactNode }) => <p className="mb-4 last:mb-0">{children}</p>,
// Headings: more space above (1.5x) than below (0.5x) for visual grouping
h1: ({ children }: { children?: React.ReactNode }) => (
<h1 className="text-xl font-bold mt-6 mb-3 first:mt-0">{children}</h1>
),
h2: ({ children }: { children?: React.ReactNode }) => (
<h2 className="text-lg font-bold mt-5 mb-2.5 first:mt-0">{children}</h2>
),
h3: ({ children }: { children?: React.ReactNode }) => (
<h3 className="text-base font-semibold mt-4 mb-2 first:mt-0">{children}</h3>
),
// Lists: consistent spacing with content
ul: ({ children }: { children?: React.ReactNode }) => (
<ul className="list-disc pl-5 mb-4 last:mb-0 space-y-1.5">{children}</ul>
),
ol: ({ children }: { children?: React.ReactNode }) => (
<ol className="list-decimal pl-5 mb-4 last:mb-0 space-y-1.5">{children}</ol>
),
li: ({ children }: { children?: React.ReactNode }) => <li>{children}</li>,
// Code: pre handles container, code is transparent for blocks
code: ({ className, children }: { className?: string; children?: React.ReactNode }) => {
const isBlock = className?.includes('language-')
if (isBlock) {
// Block code inside pre - no extra styling, pre handles it
return <code>{children}</code>
}
// Inline code
return (
<code className="bg-muted/70 rounded px-1.5 py-0.5 text-[13px] font-mono">{children}</code>
)
},
pre: ({ children }: { children?: React.ReactNode }) => (
<pre className="bg-muted rounded-lg px-4 py-3 mb-4 last:mb-0 overflow-x-auto text-[13px] font-mono leading-relaxed">
{children}
</pre>
),
// Links
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
<a
href={href}
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
// Blockquote: subtle styling
blockquote: ({ children }: { children?: React.ReactNode }) => (
<blockquote className="border-l-2 border-border pl-4 my-4 text-muted-foreground">
{children}
</blockquote>
),
hr: () => <hr className="border-border my-6" />,
strong: ({ children }: { children?: React.ReactNode }) => (
<strong className="font-semibold">{children}</strong>
),
em: ({ children }: { children?: React.ReactNode }) => <em className="italic">{children}</em>,
// Table components for GFM table support
table: ({ children }: { children?: React.ReactNode }) => (
<div className="overflow-x-auto mb-4 last:mb-0">
<table className="min-w-full border-collapse border border-border text-sm">{children}</table>
</div>
),
thead: ({ children }: { children?: React.ReactNode }) => (
<thead className="bg-muted/50">{children}</thead>
),
tbody: ({ children }: { children?: React.ReactNode }) => <tbody>{children}</tbody>,
tr: ({ children }: { children?: React.ReactNode }) => (
<tr className="border-b border-border">{children}</tr>
),
th: ({ children }: { children?: React.ReactNode }) => (
<th className="border border-border px-3 py-2 text-left font-semibold">{children}</th>
),
td: ({ children }: { children?: React.ReactNode }) => (
<td className="border border-border px-3 py-2">{children}</td>
)
}
// Simpler markdown components for thought blocks
const THOUGHT_MARKDOWN_COMPONENTS = {
p: ({ children }: { children?: React.ReactNode }) => <p className="mb-2 last:mb-0">{children}</p>,
code: ({ children }: { children?: React.ReactNode }) => (
<code className="bg-background rounded px-1 py-0.5 text-xs font-mono">{children}</code>
)
}
interface ChatViewProps {
updates: StoredSessionUpdate[]
isProcessing: boolean
@@ -19,6 +110,8 @@ interface ChatViewProps {
isInitializing: boolean
currentSessionId: string | null
onSelectFolder?: () => void
/** Ref for bottom anchor - passed from parent for scroll management */
bottomRef?: React.RefObject<HTMLDivElement | null>
}
export function ChatView({
@@ -27,36 +120,17 @@ export function ChatView({
hasSession,
isInitializing,
currentSessionId,
onSelectFolder
onSelectFolder,
bottomRef
}: ChatViewProps) {
const bottomRef = useRef<HTMLDivElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const isNearBottomRef = useRef(true)
const pendingPermission = usePermissionStore((s) => s.pendingRequests[0] ?? null)
// Only show permission request if it belongs to the current session
const currentPermission =
pendingPermission?.multicaSessionId === currentSessionId ? pendingPermission : null
// Track scroll position to determine if user is near bottom
const handleScroll = () => {
const container = scrollContainerRef.current
if (!container) return
// Consider "near bottom" if within 100px of the bottom
const threshold = 100
const isNear = container.scrollHeight - container.scrollTop - container.clientHeight < threshold
isNearBottomRef.current = isNear
}
// Auto-scroll to bottom only if user is already near bottom
useEffect(() => {
if (isNearBottomRef.current) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}
}, [updates, currentPermission])
// Group updates into messages
const messages = groupUpdatesIntoMessages(updates)
// Group updates into messages (memoized to avoid expensive recomputation)
const messages = useMemo(() => groupUpdatesIntoMessages(updates), [updates])
// Show initializing state
if (isInitializing) {
@@ -65,7 +139,7 @@ export function ChatView({
if (messages.length === 0) {
return (
<div className="flex flex-1 items-center justify-center">
<div className="flex flex-1 items-center justify-center py-20">
<div className="text-center">
<h1 className="mb-2 text-3xl font-bold">Multica</h1>
<p className="text-muted-foreground mb-6">
@@ -88,30 +162,28 @@ export function ChatView({
}
return (
<div ref={scrollContainerRef} onScroll={handleScroll} className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-3xl space-y-5 px-8 py-6">
{messages.map((msg, idx) => (
<MessageBubble
key={idx}
message={msg}
isLastMessage={idx === messages.length - 1}
isProcessing={isProcessing}
/>
))}
<div className="space-y-5 py-5">
{messages.map((msg, idx) => (
<MessageBubble
key={idx}
message={msg}
isLastMessage={idx === messages.length - 1}
isProcessing={isProcessing}
/>
))}
{/* Permission request - show in feed (only for current session) */}
{/* Completed state is shown inline with the tool call, like other tools */}
{currentPermission && <PermissionRequestItem request={currentPermission} />}
{/* Permission request - show in feed (only for current session) */}
{currentPermission && <PermissionRequestItem request={currentPermission} />}
{isProcessing && !currentPermission && (
<div className="flex items-center gap-2 text-muted-foreground">
<LoadingDots />
<span className="text-sm">Agent is thinking...</span>
</div>
)}
{isProcessing && !currentPermission && (
<div className="flex items-center gap-2 text-muted-foreground">
<LoadingDots />
<span className="text-sm">Agent is thinking...</span>
</div>
)}
<div ref={bottomRef} />
</div>
{/* Bottom anchor for scroll */}
<div ref={bottomRef} />
</div>
)
}
@@ -666,85 +738,7 @@ function TextContentBlock({ content }: { content: string }) {
return (
<div className="prose prose-invert max-w-none text-[15px] leading-[1.7]">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// Paragraphs: consistent spacing, tighter line height for readability
p: ({ children }) => <p className="mb-4 last:mb-0">{children}</p>,
// Headings: more space above (1.5x) than below (0.5x) for visual grouping
h1: ({ children }) => (
<h1 className="text-xl font-bold mt-6 mb-3 first:mt-0">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-lg font-bold mt-5 mb-2.5 first:mt-0">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-base font-semibold mt-4 mb-2 first:mt-0">{children}</h3>
),
// Lists: consistent spacing with content
ul: ({ children }) => (
<ul className="list-disc pl-5 mb-4 last:mb-0 space-y-1.5">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal pl-5 mb-4 last:mb-0 space-y-1.5">{children}</ol>
),
li: ({ children }) => <li>{children}</li>,
// Code: pre handles container, code is transparent for blocks
code: ({ className, children }) => {
const isBlock = className?.includes('language-')
if (isBlock) {
// Block code inside pre - no extra styling, pre handles it
return <code>{children}</code>
}
// Inline code
return (
<code className="bg-muted/70 rounded px-1.5 py-0.5 text-[13px] font-mono">
{children}
</code>
)
},
pre: ({ children }) => (
<pre className="bg-muted rounded-lg px-4 py-3 mb-4 last:mb-0 overflow-x-auto text-[13px] font-mono leading-relaxed">
{children}
</pre>
),
// Links
a: ({ href, children }) => (
<a
href={href}
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
// Blockquote: subtle styling
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-border pl-4 my-4 text-muted-foreground">
{children}
</blockquote>
),
hr: () => <hr className="border-border my-6" />,
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
// Table components for GFM table support
table: ({ children }) => (
<div className="overflow-x-auto mb-4 last:mb-0">
<table className="min-w-full border-collapse border border-border text-sm">
{children}
</table>
</div>
),
thead: ({ children }) => <thead className="bg-muted/50">{children}</thead>,
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => <tr className="border-b border-border">{children}</tr>,
th: ({ children }) => (
<th className="border border-border px-3 py-2 text-left font-semibold">{children}</th>
),
td: ({ children }) => <td className="border border-border px-3 py-2">{children}</td>
}}
>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={TEXT_MARKDOWN_COMPONENTS}>
{content}
</ReactMarkdown>
</div>
@@ -753,12 +747,13 @@ function TextContentBlock({ content }: { content: string }) {
// Thought block view - expanded, collapsible
function ThoughtBlockView({ text }: { text: string }) {
// Skip rendering if content is empty or only whitespace
if (!text || !text.trim()) return null
// Hooks must be called before any conditional returns (Rules of Hooks)
const [isExpanded, setIsExpanded] = useState(false)
const isLong = text.length > 200
// Skip rendering if content is empty or only whitespace
if (!text || !text.trim()) return null
return (
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<div className="rounded-lg border bg-card/50 p-3">
@@ -774,54 +769,21 @@ function ThoughtBlockView({ text }: { text: string }) {
<CollapsibleContent>
<div className="mt-2 text-sm text-muted-foreground">
<ReactMarkdown
components={{
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
code: ({ children }) => (
<code className="bg-background rounded px-1 py-0.5 text-xs font-mono">
{children}
</code>
)
}}
>
{text}
</ReactMarkdown>
<ReactMarkdown components={THOUGHT_MARKDOWN_COMPONENTS}>{text}</ReactMarkdown>
</div>
</CollapsibleContent>
{/* Preview when collapsed */}
{!isExpanded && isLong && (
<div className="mt-2 text-sm text-muted-foreground line-clamp-3">
<ReactMarkdown
components={{
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
code: ({ children }) => (
<code className="bg-background rounded px-1 py-0.5 text-xs font-mono">
{children}
</code>
)
}}
>
{text}
</ReactMarkdown>
<ReactMarkdown components={THOUGHT_MARKDOWN_COMPONENTS}>{text}</ReactMarkdown>
</div>
)}
{/* Show full content when not long */}
{!isLong && (
<div className="mt-2 text-sm text-muted-foreground">
<ReactMarkdown
components={{
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
code: ({ children }) => (
<code className="bg-background rounded px-1 py-0.5 text-xs font-mono">
{children}
</code>
)
}}
>
{text}
</ReactMarkdown>
<ReactMarkdown components={THOUGHT_MARKDOWN_COMPONENTS}>{text}</ReactMarkdown>
</div>
)}
</div>

View File

@@ -28,7 +28,7 @@ const SUPPORTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/we
// Warning banner component for missing directory
function DirectoryWarningBanner({ onDeleteSession }: { onDeleteSession?: () => void }) {
return (
<div className="mx-auto max-w-3xl mb-2">
<div className="mb-2">
<div className="bg-amber-500/10 border border-amber-500/30 rounded-xl px-4 py-3">
<div className="flex items-center justify-between gap-4">
{/* Left: Icon + Text */}
@@ -216,9 +216,9 @@ export function MessageInput({
// Normal chat input mode
return (
<div className="p-4">
<div className="pb-2">
{directoryExists === false && <DirectoryWarningBanner onDeleteSession={onDeleteSession} />}
<div className="mx-auto max-w-3xl">
<div>
<div className="bg-card transition-colors duration-200 rounded-xl p-3 border border-border">
{/* Image previews */}
{images.length > 0 && (

View File

@@ -68,6 +68,9 @@ export function useApp(): AppState & AppActions {
// This is needed because tool_call_update events don't include kind
const toolKindMapRef = useRef<Map<string, string>>(new Map())
// Track pending session selection to handle rapid switching
const pendingSessionRef = useRef<string | null>(null)
// State
const [sessions, setSessions] = useState<MulticaSession[]>([])
const [currentSession, setCurrentSession] = useState<MulticaSession | null>(null)
@@ -278,17 +281,28 @@ export function useApp(): AppState & AppActions {
const selectSession = useCallback(async (sessionId: string) => {
try {
// Load session without starting agent (lazy loading)
const session = await window.electronAPI.loadSession(sessionId)
setCurrentSession(session)
// Mark this as the pending session (for rapid switching protection)
pendingSessionRef.current = sessionId
// Load session data for history
const data = await window.electronAPI.getSession(sessionId)
if (data) {
setSessionUpdates(data.updates)
// Load session and history in parallel
const [session, data] = await Promise.all([
window.electronAPI.loadSession(sessionId),
window.electronAPI.getSession(sessionId)
])
// Verify: user might have switched to another session while loading
if (pendingSessionRef.current !== sessionId) {
return // Discard, user already switched elsewhere
}
// Update both states together - React will batch them into one render
setCurrentSession(session)
setSessionUpdates(data?.updates ?? [])
} catch (err) {
toast.error(`Failed to select session: ${getErrorMessage(err)}`)
// Only show error if this is still the pending session
if (pendingSessionRef.current === sessionId) {
toast.error(`Failed to select session: ${getErrorMessage(err)}`)
}
}
}, [])

View File

@@ -0,0 +1,208 @@
/**
* Chat scroll behavior hook
*
* Handles:
* 1. Session switching - reset to "at bottom" state, scroll handled by onContentUpdate
* 2. Auto-scroll during streaming - only if at bottom, using rAF to prevent jitter
* 3. User scroll detection - respect user intent with a lock period
* 4. IntersectionObserver for precise bottom detection
*
* Design principle: Keep it simple. No scroll position storage per session.
* Switching sessions always scrolls to bottom (better UX, simpler code).
*/
import { useRef, useEffect, useCallback, useState } from 'react'
interface UseChatScrollOptions {
/** Current session ID - scroll resets when this changes */
sessionId: string | null
/** Whether new content is being generated */
isStreaming?: boolean
}
interface UseChatScrollReturn {
/** Ref for the scroll container */
containerRef: React.RefObject<HTMLDivElement | null>
/** Ref for the bottom anchor element */
bottomRef: React.RefObject<HTMLDivElement | null>
/** Whether the user is currently at the bottom */
isAtBottom: boolean
/** Scroll event handler - attach to container's onScroll */
handleScroll: () => void
/** Manually scroll to bottom (e.g., for a "scroll to bottom" button) */
scrollToBottom: (smooth?: boolean) => void
/** Call this when new content arrives to auto-scroll if appropriate */
onContentUpdate: () => void
}
// How long to "lock" after user scrolls (ms)
const USER_SCROLL_LOCK_DURATION = 150
// Threshold for "at bottom" detection (percentage of container height)
// 20% provides good coverage for large content like headers, code blocks, dividers
const AT_BOTTOM_THRESHOLD_PERCENT = 0.2
export function useChatScroll({
sessionId,
isStreaming: _isStreaming = false
}: UseChatScrollOptions): UseChatScrollReturn {
// Note: _isStreaming is available for future use (e.g., different scroll behavior during streaming)
void _isStreaming
const containerRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
// Track if user is at bottom (for UI, e.g., showing "scroll to bottom" button)
const [isAtBottom, setIsAtBottom] = useState(true)
// Internal refs for scroll logic (refs don't trigger re-renders)
const isAtBottomRef = useRef(true)
const prevSessionIdRef = useRef<string | null>(null)
const userScrollingRef = useRef(false)
const scrollLockTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const rafRef = useRef<number | null>(null)
const observerRef = useRef<IntersectionObserver | null>(null)
// Cleanup function for timeouts and rafs
const cleanup = useCallback(() => {
if (scrollLockTimeoutRef.current) {
clearTimeout(scrollLockTimeoutRef.current)
scrollLockTimeoutRef.current = null
}
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}, [])
// Scroll to bottom with rAF debouncing
const scrollToBottom = useCallback((smooth = false) => {
// Cancel any pending scroll
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
}
rafRef.current = requestAnimationFrame(() => {
bottomRef.current?.scrollIntoView({
behavior: smooth ? 'smooth' : 'instant'
})
rafRef.current = null
})
}, [])
// Session change detection - reset state and let onContentUpdate handle scrolling
useEffect(() => {
if (sessionId !== prevSessionIdRef.current) {
prevSessionIdRef.current = sessionId
// Reset scroll state
userScrollingRef.current = false
cleanup()
// Reset to "at bottom" state - onContentUpdate will scroll when content arrives
isAtBottomRef.current = true
setIsAtBottom(true)
}
}, [sessionId, cleanup])
// Set up IntersectionObserver for precise bottom detection
useEffect(() => {
// Clean up previous observer
if (observerRef.current) {
observerRef.current.disconnect()
}
const container = containerRef.current
const bottom = bottomRef.current
if (!container || !bottom) return
// Calculate rootMargin based on container height (20%)
const rootMarginBottom = Math.round(container.clientHeight * AT_BOTTOM_THRESHOLD_PERCENT)
observerRef.current = new IntersectionObserver(
([entry]) => {
// Only update if user is not actively scrolling
if (!userScrollingRef.current) {
const isVisible = entry.isIntersecting
isAtBottomRef.current = isVisible
setIsAtBottom(isVisible)
}
},
{
root: container,
// Use percentage-based margin for better coverage of large content
rootMargin: `0px 0px ${rootMarginBottom}px 0px`,
threshold: 0
}
)
observerRef.current.observe(bottom)
return () => {
if (observerRef.current) {
observerRef.current.disconnect()
observerRef.current = null
}
}
}, [sessionId]) // Re-create observer when session changes
// Handle user scroll - detect scrolling and set lock period
const handleScroll = useCallback(() => {
// Mark user as scrolling
userScrollingRef.current = true
// Clear previous timeout
if (scrollLockTimeoutRef.current) {
clearTimeout(scrollLockTimeoutRef.current)
}
// After lock period (debounced): update isAtBottom state
scrollLockTimeoutRef.current = setTimeout(() => {
userScrollingRef.current = false
const container = containerRef.current
if (container) {
// Update isAtBottom based on current scroll position
const threshold = container.clientHeight * AT_BOTTOM_THRESHOLD_PERCENT
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight
const atBottom = distanceFromBottom < threshold
isAtBottomRef.current = atBottom
setIsAtBottom(atBottom)
}
scrollLockTimeoutRef.current = null
}, USER_SCROLL_LOCK_DURATION)
}, []) // No dependencies - stable callback
// Called when new content arrives (streaming chunks, new messages)
const onContentUpdate = useCallback(() => {
// Don't auto-scroll if:
// 1. User is actively scrolling
// 2. User has scrolled away from bottom
if (userScrollingRef.current || !isAtBottomRef.current) {
return
}
// Use instant scroll during streaming to avoid animation stacking
scrollToBottom(false)
}, [scrollToBottom])
// Cleanup on unmount
useEffect(() => {
return () => {
cleanup()
if (observerRef.current) {
observerRef.current.disconnect()
}
}
}, [cleanup])
return {
containerRef,
bottomRef,
isAtBottom,
handleScroll,
scrollToBottom,
onContentUpdate
}
}