mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 04:56:20 +02:00
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:
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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)}`)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
208
src/renderer/src/hooks/useChatScroll.ts
Normal file
208
src/renderer/src/hooks/useChatScroll.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user