diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index bfea9122c..30f0d38fe 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 (
@@ -118,37 +142,69 @@ function AppContent(): React.JSX.Element { /> {/* Main area */} -
- {/* Status bar */} +
+ {/* Status bar - fixed height */} - {/* Chat view */} - + {/* Chat and Input container */} +
+ {/* Chat scroll area - only messages */} +
+
+ +
+
- {/* Input */} - + {/* Input area - fixed at bottom, outside scroll */} +
+
+ {/* Scroll to bottom button - above input, left-aligned */} + {!isAtBottom && currentSession && ( +
+ +
+ )} + +
+
+
{/* Right panel - file tree */} diff --git a/src/renderer/src/components/ChatView.tsx b/src/renderer/src/components/ChatView.tsx index ccf352cb1..b5b8678db 100644 --- a/src/renderer/src/components/ChatView.tsx +++ b/src/renderer/src/components/ChatView.tsx @@ -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 }) =>

{children}

, + // Headings: more space above (1.5x) than below (0.5x) for visual grouping + h1: ({ children }: { children?: React.ReactNode }) => ( +

{children}

+ ), + h2: ({ children }: { children?: React.ReactNode }) => ( +

{children}

+ ), + h3: ({ children }: { children?: React.ReactNode }) => ( +

{children}

+ ), + // Lists: consistent spacing with content + ul: ({ children }: { children?: React.ReactNode }) => ( + + ), + ol: ({ children }: { children?: React.ReactNode }) => ( +
    {children}
+ ), + li: ({ children }: { children?: React.ReactNode }) =>
  • {children}
  • , + // 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 {children} + } + // Inline code + return ( + {children} + ) + }, + pre: ({ children }: { children?: React.ReactNode }) => ( +
    +      {children}
    +    
    + ), + // Links + a: ({ href, children }: { href?: string; children?: React.ReactNode }) => ( + + {children} + + ), + // Blockquote: subtle styling + blockquote: ({ children }: { children?: React.ReactNode }) => ( +
    + {children} +
    + ), + hr: () =>
    , + strong: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + em: ({ children }: { children?: React.ReactNode }) => {children}, + // Table components for GFM table support + table: ({ children }: { children?: React.ReactNode }) => ( +
    + {children}
    +
    + ), + thead: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + tbody: ({ children }: { children?: React.ReactNode }) => {children}, + tr: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + th: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + td: ({ children }: { children?: React.ReactNode }) => ( + {children} + ) +} + +// Simpler markdown components for thought blocks +const THOUGHT_MARKDOWN_COMPONENTS = { + p: ({ children }: { children?: React.ReactNode }) =>

    {children}

    , + code: ({ children }: { children?: React.ReactNode }) => ( + {children} + ) +} + 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 } export function ChatView({ @@ -27,36 +120,17 @@ export function ChatView({ hasSession, isInitializing, currentSessionId, - onSelectFolder + onSelectFolder, + bottomRef }: ChatViewProps) { - const bottomRef = useRef(null) - const scrollContainerRef = useRef(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 ( -
    +

    Multica

    @@ -88,30 +162,28 @@ export function ChatView({ } return ( -

    -
    - {messages.map((msg, idx) => ( - - ))} +
    + {messages.map((msg, idx) => ( + + ))} - {/* Permission request - show in feed (only for current session) */} - {/* Completed state is shown inline with the tool call, like other tools */} - {currentPermission && } + {/* Permission request - show in feed (only for current session) */} + {currentPermission && } - {isProcessing && !currentPermission && ( -
    - - Agent is thinking... -
    - )} + {isProcessing && !currentPermission && ( +
    + + Agent is thinking... +
    + )} -
    -
    + {/* Bottom anchor for scroll */} +
    ) } @@ -666,85 +738,7 @@ function TextContentBlock({ content }: { content: string }) { return (
    -

    {children}

    , - // Headings: more space above (1.5x) than below (0.5x) for visual grouping - h1: ({ children }) => ( -

    {children}

    - ), - h2: ({ children }) => ( -

    {children}

    - ), - h3: ({ children }) => ( -

    {children}

    - ), - // Lists: consistent spacing with content - ul: ({ children }) => ( -
      {children}
    - ), - ol: ({ children }) => ( -
      {children}
    - ), - li: ({ children }) =>
  • {children}
  • , - // 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 {children} - } - // Inline code - return ( - - {children} - - ) - }, - pre: ({ children }) => ( -
    -              {children}
    -            
    - ), - // Links - a: ({ href, children }) => ( - - {children} - - ), - // Blockquote: subtle styling - blockquote: ({ children }) => ( -
    - {children} -
    - ), - hr: () =>
    , - strong: ({ children }) => {children}, - em: ({ children }) => {children}, - // Table components for GFM table support - table: ({ children }) => ( -
    - - {children} -
    -
    - ), - thead: ({ children }) => {children}, - tbody: ({ children }) => {children}, - tr: ({ children }) => {children}, - th: ({ children }) => ( - {children} - ), - td: ({ children }) => {children} - }} - > + {content}
    @@ -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 (
    @@ -774,54 +769,21 @@ function ThoughtBlockView({ text }: { text: string }) {
    -

    {children}

    , - code: ({ children }) => ( - - {children} - - ) - }} - > - {text} -
    + {text}
    {/* Preview when collapsed */} {!isExpanded && isLong && (
    -

    {children}

    , - code: ({ children }) => ( - - {children} - - ) - }} - > - {text} -
    + {text}
    )} {/* Show full content when not long */} {!isLong && (
    -

    {children}

    , - code: ({ children }) => ( - - {children} - - ) - }} - > - {text} -
    + {text}
    )}
    diff --git a/src/renderer/src/components/MessageInput.tsx b/src/renderer/src/components/MessageInput.tsx index 367662ee2..abbd4bdd7 100644 --- a/src/renderer/src/components/MessageInput.tsx +++ b/src/renderer/src/components/MessageInput.tsx @@ -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 ( -
    +
    {/* Left: Icon + Text */} @@ -216,9 +216,9 @@ export function MessageInput({ // Normal chat input mode return ( -
    +
    {directoryExists === false && } -
    +
    {/* Image previews */} {images.length > 0 && ( diff --git a/src/renderer/src/hooks/useApp.ts b/src/renderer/src/hooks/useApp.ts index 3f0a58c99..3b3236e02 100644 --- a/src/renderer/src/hooks/useApp.ts +++ b/src/renderer/src/hooks/useApp.ts @@ -68,6 +68,9 @@ export function useApp(): AppState & AppActions { // This is needed because tool_call_update events don't include kind const toolKindMapRef = useRef>(new Map()) + // Track pending session selection to handle rapid switching + const pendingSessionRef = useRef(null) + // State const [sessions, setSessions] = useState([]) const [currentSession, setCurrentSession] = useState(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)}`) + } } }, []) diff --git a/src/renderer/src/hooks/useChatScroll.ts b/src/renderer/src/hooks/useChatScroll.ts new file mode 100644 index 000000000..f0a80cae4 --- /dev/null +++ b/src/renderer/src/hooks/useChatScroll.ts @@ -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 + /** Ref for the bottom anchor element */ + bottomRef: React.RefObject + /** 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(null) + const bottomRef = useRef(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(null) + const userScrollingRef = useRef(false) + const scrollLockTimeoutRef = useRef | null>(null) + const rafRef = useRef(null) + const observerRef = useRef(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 + } +}