@@ -107,6 +107,11 @@ const CUSTOM_SENTINEL = "__custom__";
107107// every file's full content into the prompt (which would blow out context on
108108// smaller models and waste tokens on larger ones).
109109const RAG_THRESHOLD_CHARS = 20_000 ;
110+ // Mounting every message in a very long chat is what actually causes scroll
111+ // jank (each one runs a Markdown parse) — so past this many messages, only
112+ // the most recent window renders by default, with older ones revealed a
113+ // window at a time on request rather than all at once.
114+ const RENDER_WINDOW_SIZE = 60 ;
110115// Read-only tools are safe to let the model call repeatedly without a fresh
111116// click each time — write_file and run_command always require explicit
112117// per-call approval since they have real, potentially irreversible effects.
@@ -439,6 +444,7 @@ export default function Chat() {
439444 const [ planSteps , setPlanSteps ] = useState < PlanStep [ ] > ( [ ] ) ;
440445 const [ contextSummary , setContextSummary ] = useState < string | null > ( null ) ;
441446 const [ contextSummaryThroughIndex , setContextSummaryThroughIndex ] = useState ( 0 ) ;
447+ const [ renderLimit , setRenderLimit ] = useState ( RENDER_WINDOW_SIZE ) ;
442448 const [ projectScripts , setProjectScripts ] = useState < ProjectScripts > ( { } ) ;
443449 const [ quickActionRunning , setQuickActionRunning ] = useState ( false ) ;
444450 const [ undoMessage , setUndoMessage ] = useState < string | null > ( null ) ;
@@ -522,6 +528,7 @@ export default function Chat() {
522528 setPlanSteps ( session . planSteps ?? [ ] ) ;
523529 setContextSummary ( session . contextSummary ?? null ) ;
524530 setContextSummaryThroughIndex ( session . contextSummaryThroughIndex ?? 0 ) ;
531+ setRenderLimit ( RENDER_WINDOW_SIZE ) ;
525532 setAutoApprovedTools ( new Set ( ) ) ;
526533 setWriteDiffPreviews ( { } ) ;
527534 setUndoMessage ( null ) ;
@@ -1280,6 +1287,20 @@ export default function Chat() {
12801287 }
12811288
12821289 function scrollToMessage ( index : number ) {
1290+ // The target may be outside the current render window (an older
1291+ // message than what's mounted) — widen it enough to include the
1292+ // target before the DOM node can be found at all.
1293+ if ( index < visibleStartIndex ) {
1294+ setRenderLimit ( messages . length - index + RENDER_WINDOW_SIZE ) ;
1295+ requestAnimationFrame ( ( ) => {
1296+ requestAnimationFrame ( ( ) => {
1297+ viewportRef . current
1298+ ?. querySelector ( `[data-message-index="${ index } "]` )
1299+ ?. scrollIntoView ( { behavior : "smooth" , block : "center" } ) ;
1300+ } ) ;
1301+ } ) ;
1302+ return ;
1303+ }
12831304 const el = viewportRef . current ?. querySelector ( `[data-message-index="${ index } "]` ) ;
12841305 el ?. scrollIntoView ( { behavior : "smooth" , block : "center" } ) ;
12851306 }
@@ -1366,6 +1387,8 @@ export default function Chat() {
13661387 } ;
13671388 } , [ attachments ] ) ;
13681389 const lastAssistantIndex = useMemo ( ( ) => messages . findLastIndex ( ( message ) => message . role === "assistant" ) , [ messages ] ) ;
1390+ const visibleStartIndex = messages . length > renderLimit ? messages . length - renderLimit : 0 ;
1391+ const hiddenMessageCount = visibleStartIndex ;
13691392 const sessionCost = useMemo (
13701393 ( ) =>
13711394 parsedModel && parsedModel . provider !== "ollama" && parsedModel . provider !== "llamacpp"
@@ -1938,25 +1961,36 @@ export default function Chat() {
19381961 </ div >
19391962 </ div >
19401963 ) }
1941- { messages . map ( ( m , i ) => (
1942- < MessageBubble
1943- key = { i }
1944- message = { m }
1945- index = { i }
1946- isLastAssistant = { m . role === "assistant" && i === lastAssistantIndex }
1947- isStreaming = { isStreaming }
1948- copied = { copiedIndex === i }
1949- speaking = { speakingIndex === i }
1950- provider = { parsedModel ?. provider }
1951- modelId = { parsedModel ?. modelId }
1952- onCopy = { handleCopyMessage }
1953- onEdit = { handleEditUserMessage }
1954- onRegenerate = { handleRegenerate }
1955- onToggleSpeak = { toggleSpeak }
1956- onTogglePin = { togglePinMessage }
1957- onFork = { forkFromMessage }
1958- />
1959- ) ) }
1964+ { hiddenMessageCount > 0 && (
1965+ < button
1966+ onClick = { ( ) => setRenderLimit ( ( n ) => n + RENDER_WINDOW_SIZE ) }
1967+ className = "mx-auto rounded-full border border-border bg-muted/50 px-4 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted"
1968+ >
1969+ { t . showEarlierMessages ( hiddenMessageCount ) }
1970+ </ button >
1971+ ) }
1972+ { messages . slice ( visibleStartIndex ) . map ( ( m , localIndex ) => {
1973+ const i = visibleStartIndex + localIndex ;
1974+ return (
1975+ < MessageBubble
1976+ key = { i }
1977+ message = { m }
1978+ index = { i }
1979+ isLastAssistant = { m . role === "assistant" && i === lastAssistantIndex }
1980+ isStreaming = { isStreaming }
1981+ copied = { copiedIndex === i }
1982+ speaking = { speakingIndex === i }
1983+ provider = { parsedModel ?. provider }
1984+ modelId = { parsedModel ?. modelId }
1985+ onCopy = { handleCopyMessage }
1986+ onEdit = { handleEditUserMessage }
1987+ onRegenerate = { handleRegenerate }
1988+ onToggleSpeak = { toggleSpeak }
1989+ onTogglePin = { togglePinMessage }
1990+ onFork = { forkFromMessage }
1991+ />
1992+ ) ;
1993+ } ) }
19601994 { pendingToolCalls . map ( ( call ) => {
19611995 if ( call . name === "request_checkpoint" ) {
19621996 const summary = String ( call . arguments . summary ?? "" ) ;
0 commit comments