Feature/improve UI reactiveness (#10)

* lazy load block code

* queue monaco mount

* cache markdown rendering

* improve opened tab navigation delay
This commit is contained in:
davi0015 2026-04-22 20:26:48 +08:00 committed by GitHub
parent 8d3b7400ff
commit 5c0ca803ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 367 additions and 102 deletions

View file

@ -3,16 +3,40 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import React, { JSX, useMemo, useState } from 'react'
import React, { JSX, useState } from 'react'
import { marked, MarkedToken, Token } from 'marked'
// Module-level content-keyed cache for marked.lexer output. Every tab switch / every
// re-render of a bubble otherwise re-lexes the entire message from scratch, even
// though the content is identical to the previous lex. Cache survives component
// unmount/remount. Bounded with a rough LRU on insertion-order (JS Map preserves it)
// so a long chat session doesn't leak unbounded memory.
const LEXER_CACHE_MAX = 500
const lexerCache = new Map<string, Token[]>()
const cachedLex = (raw: string): Token[] => {
const hit = lexerCache.get(raw)
if (hit !== undefined) {
// refresh recency
lexerCache.delete(raw)
lexerCache.set(raw, hit)
return hit
}
const tokens = marked.lexer(raw)
if (lexerCache.size >= LEXER_CACHE_MAX) {
const oldest = lexerCache.keys().next().value
if (oldest !== undefined) lexerCache.delete(oldest)
}
lexerCache.set(raw, tokens)
return tokens
}
import { convertToVscodeLang, detectLanguage } from '../../../../common/helpers/languageHelpers.js'
import { BlockCodeApplyWrapper } from './ApplyBlockHoverButtons.js'
import { useAccessor } from '../util/services.js'
import { URI } from '../../../../../../../base/common/uri.js'
import { isAbsolute } from '../../../../../../../base/common/path.js'
import { separateOutFirstLine } from '../../../../common/helpers/util.js'
import { BlockCode } from '../util/inputs.js'
import { LazyBlockCode } from '../util/inputs.js'
import { CodespanLocationLink } from '../../../../common/chatThreadServiceTypes.js'
import { getBasename, getRelative, voidOpenFileFn } from '../sidebar-tsx/SidebarChat.js'
@ -323,14 +347,14 @@ const RenderToken = ({ token, inPTag, codeURI, chatMessageLocation, tokenIdx, ..
language={language}
uri={uri || 'current'}
>
<BlockCode
<LazyBlockCode
initValue={contents.trimEnd()} // \n\n adds a permanent newline which creates a flash
language={language}
/>
</BlockCodeApplyWrapper>
}
return <BlockCode
return <LazyBlockCode
initValue={contents}
language={language}
/>
@ -545,7 +569,7 @@ const RenderToken = ({ token, inPTag, codeURI, chatMessageLocation, tokenIdx, ..
export const ChatMarkdownRender = ({ string, inPTag = false, chatMessageLocation, ...options }: { string: string, inPTag?: boolean, codeURI?: URI, chatMessageLocation: ChatMessageLocation | undefined } & RenderTokenOptions) => {
string = string.replaceAll('\n•', '\n\n•')
const tokens = marked.lexer(string); // https://marked.js.org/using_pro#renderer
const tokens = cachedLex(string); // https://marked.js.org/using_pro#renderer
return (
<>
{tokens.map((token, index) => (

View file

@ -13,7 +13,7 @@ import { ChatMarkdownRender, ChatMessageLocation, getApplyBoxId } from '../markd
import { URI } from '../../../../../../../base/common/uri.js';
import { IDisposable } from '../../../../../../../base/common/lifecycle.js';
import { ErrorDisplay } from './ErrorDisplay.js';
import { BlockCode, TextAreaFns, VoidCustomDropdownBox, VoidInputBox2, VoidSlider, VoidSwitch, VoidDiffEditor } from '../util/inputs.js';
import { LazyBlockCode, TextAreaFns, VoidCustomDropdownBox, VoidInputBox2, VoidSlider, VoidSwitch, VoidDiffEditor } from '../util/inputs.js';
import { ModelDropdown, } from '../void-settings-tsx/ModelDropdown.js';
import { PastThreadsList, SidebarThreadTabs } from './SidebarThreadSelector.js';
import { VOID_CTRL_L_ACTION_ID } from '../../../actionIDs.js';
@ -2011,7 +2011,7 @@ const CommandTool = ({ toolMessage, type, threadId }: { threadId: string } & ({
componentParams.children = <ToolChildrenWrapper className='whitespace-pre text-nowrap overflow-auto text-sm'>
<div className='!select-text cursor-auto'>
<BlockCode initValue={`${msg.trim()}`} language='shellscript' />
<LazyBlockCode initValue={`${msg.trim()}`} language='shellscript' />
</div>
</ToolChildrenWrapper>
}
@ -3053,6 +3053,136 @@ const EditToolSoFar = ({ toolCallSoFar, }: { toolCallSoFar: RawToolCallObj }) =>
}
// Renders the message list + streaming state + error display for a single thread.
// Extracted so that SidebarChat can render multiple of these in parallel (hidden
// via the `hidden` attribute when not active), which preserves each thread's
// React state — ChatBubble collapse toggles, tool-row open state, scroll position,
// mounted Monaco editors — across tab switches. Returning to a recently-seen
// thread then costs ~0ms because no remounting happens; only the `hidden` flag flips.
//
// Each instance owns its own scroll container ref and subscribes to its own stream
// state, so a background thread can keep streaming while the user is looking at a
// different one.
const ThreadMessagesView = ({ threadId, isActive, scrollContainerRef }: {
threadId: string
isActive: boolean
scrollContainerRef: React.MutableRefObject<HTMLDivElement | null>
}) => {
const accessor = useAccessor()
const commandService = accessor.get('ICommandService')
const chatThreadsService = accessor.get('IChatThreadService')
const chatThreadsState = useChatThreadsState()
const thread = chatThreadsState.allThreads[threadId]
const previousMessages = thread?.messages ?? []
const streamState = useChatThreadsStreamState(threadId)
const isRunning = streamState?.isRunning
const latestError = streamState?.error
const { displayContentSoFar, toolCallSoFar, reasoningSoFar } = streamState?.llmInfo ?? {}
const toolIsGenerating = toolCallSoFar && !toolCallSoFar.isDone
const currCheckpointIdx = thread?.state?.currCheckpointIdx ?? undefined
// Scroll to bottom when this view becomes active (on initial mount AND on
// re-activation after being hidden). Native scrollTop is preserved while
// hidden, but new messages may have streamed in while the user was on
// another thread, so landing-at-bottom on return keeps UX consistent with
// the previous single-thread behavior.
useEffect(() => {
if (isActive) {
scrollToBottom(scrollContainerRef)
}
}, [isActive, scrollContainerRef])
const previousMessagesHTML = useMemo(() => {
return previousMessages.map((message, i) => {
return <ChatBubble
key={i}
currCheckpointIdx={currCheckpointIdx}
chatMessage={message}
messageIdx={i}
isCommitted={true}
chatIsRunning={isRunning}
threadId={threadId}
_scrollToBottom={() => scrollToBottom(scrollContainerRef)}
/>
})
}, [previousMessages, threadId, currCheckpointIdx, isRunning, scrollContainerRef])
const streamingChatIdx = previousMessagesHTML.length
const currStreamingMessageHTML = reasoningSoFar || displayContentSoFar || isRunning ?
<ChatBubble
key={'curr-streaming-msg'}
currCheckpointIdx={currCheckpointIdx}
chatMessage={{
role: 'assistant',
displayContent: displayContentSoFar ?? '',
reasoning: reasoningSoFar ?? '',
anthropicReasoning: null,
}}
messageIdx={streamingChatIdx}
isCommitted={false}
chatIsRunning={isRunning}
threadId={threadId}
_scrollToBottom={null}
/> : null
const generatingTool = toolIsGenerating ?
toolCallSoFar.name === 'edit_file' || toolCallSoFar.name === 'rewrite_file' ? <EditToolSoFar
key={'curr-streaming-tool'}
toolCallSoFar={toolCallSoFar}
/>
: null
: null
return (
<div
// `hidden` maps to `display: none`, which collapses the element out of
// layout AND causes IntersectionObserver to report intersections as
// false for descendants — so LazyBlockCode in hidden threads won't
// mount Monaco editors, keeping memory cost bounded while the thread
// is off-screen.
hidden={!isActive}
className='flex flex-col w-full h-full min-h-0'
>
<ScrollToBottomContainer
scrollContainerRef={scrollContainerRef}
className={`
flex flex-col
px-4 py-4 space-y-4
w-full h-full
overflow-x-hidden
overflow-y-auto
${previousMessagesHTML.length === 0 && !displayContentSoFar ? 'hidden' : ''}
`}
>
{previousMessagesHTML}
{currStreamingMessageHTML}
{generatingTool}
{isRunning === 'LLM' || isRunning === 'idle' && !toolIsGenerating ? <ProseWrapper>
{<IconLoading className='opacity-50 text-sm' />}
</ProseWrapper> : null}
{latestError === undefined ? null :
<div className='px-2 my-1'>
<ErrorDisplay
message={latestError.message}
fullError={latestError.fullError}
onDismiss={() => { chatThreadsService.dismissStreamError(threadId) }}
showDismiss={true}
/>
<WarningBox className='text-sm my-2 mx-4' onClick={() => { commandService.executeCommand(VOID_OPEN_SETTINGS_ACTION_ID) }} text='Open settings' />
</div>
}
</ScrollToBottomContainer>
</div>
)
}
export const SidebarChat = () => {
const textAreaRef = useRef<HTMLTextAreaElement | null>(null)
const textAreaFnsRef = useRef<TextAreaFns | null>(null)
@ -3091,7 +3221,56 @@ export const SidebarChat = () => {
const isDisabled = instructionsAreEmpty || !!isFeatureNameDisabled('Chat', settingsState)
const sidebarRef = useRef<HTMLDivElement>(null)
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
// LRU cache of thread ids that stay mounted in the DOM. Switching between
// cached threads is just a `hidden` flip (near-zero cost); only the first
// visit to a thread pays the full render. Active thread always sits at
// index 0 so it's obvious which one is current. Size cap keeps memory
// bounded — each cached thread holds its DOM tree but no Monaco editors
// (LazyBlockCode doesn't mount while `hidden`).
const CACHED_THREADS_MAX = 5
const [cachedThreadIds, setCachedThreadIds] = useState<string[]>(() => [currentThread.id])
useEffect(() => {
const newId = currentThread.id
setCachedThreadIds(prev => {
const withoutCurrent = prev.filter(id => id !== newId)
const next = [newId, ...withoutCurrent].slice(0, CACHED_THREADS_MAX)
// Skip the state update if order+contents are unchanged, otherwise
// every re-render (stream chunks fire many) would create a new array
// and cascade down to the parallel-thread render list.
if (next.length === prev.length && next.every((id, i) => id === prev[i])) return prev
return next
})
}, [currentThread.id])
// Drop any cached ids for threads that have since been deleted so we don't
// render stale empty placeholders (allThreads[id] would be undefined).
const visibleCachedIds = useMemo(() =>
cachedThreadIds.filter(id => !!chatThreadsState.allThreads[id]),
[cachedThreadIds, chatThreadsState.allThreads]
)
// One scroll container ref per cached thread. Refs are stable objects
// (MutableRefObject) so React's ref assignment picks up the actual DOM
// element after mount. We keep the map in a ref so it survives re-renders
// without causing cascades; entries for evicted threads become harmless
// `{ current: null }` that the next thread with the same id (rare) would
// re-use cleanly.
const scrollContainerRefsMapRef = useRef(new Map<string, React.MutableRefObject<HTMLDivElement | null>>())
const getScrollContainerRef = useCallback((id: string) => {
const map = scrollContainerRefsMapRef.current
let ref = map.get(id)
if (!ref) {
ref = { current: null }
map.set(id, ref)
}
return ref
}, [])
// Points at the currently active thread's scroll container — used by
// `onSubmit` / `onAbort` / mountInfo resolver that need to scroll the
// active view without knowing about the LRU cache.
const scrollContainerRef = getScrollContainerRef(currentThread.id)
const onSubmit = useCallback(async (_forceSubmit?: string) => {
if (isDisabled && !_forceSubmit) return
@ -3137,94 +3316,44 @@ export const SidebarChat = () => {
}, [chatThreadsState, threadId, textAreaRef, scrollContainerRef, isResolved])
// Reset the "input is empty" flag on thread switch. Previously the outer
// Fragment key={threadId} nuked everything including this useState, which
// side-effectively reset the submit button's disabled state. Now that the
// parallel-thread cache keeps SidebarChat mounted across switches, we have
// to reset this explicitly. The textarea itself is still cleared via the
// keyed `threadPageInput` below.
useEffect(() => {
setInstructionsAreEmpty(true)
}, [currentThread.id])
const previousMessagesHTML = useMemo(() => {
// const lastMessageIdx = previousMessages.findLastIndex(v => v.role !== 'checkpoint')
// tool request shows up as Editing... if in progress
return previousMessages.map((message, i) => {
return <ChatBubble
key={i}
currCheckpointIdx={currCheckpointIdx}
chatMessage={message}
messageIdx={i}
isCommitted={true}
chatIsRunning={isRunning}
threadId={threadId}
_scrollToBottom={() => scrollToBottom(scrollContainerRef)}
/>
})
}, [previousMessages, threadId, currCheckpointIdx, isRunning])
const streamingChatIdx = previousMessagesHTML.length
const currStreamingMessageHTML = reasoningSoFar || displayContentSoFar || isRunning ?
<ChatBubble
key={'curr-streaming-msg'}
currCheckpointIdx={currCheckpointIdx}
chatMessage={{
role: 'assistant',
displayContent: displayContentSoFar ?? '',
reasoning: reasoningSoFar ?? '',
anthropicReasoning: null,
}}
messageIdx={streamingChatIdx}
isCommitted={false}
chatIsRunning={isRunning}
threadId={threadId}
_scrollToBottom={null}
/> : null
// the tool currently being generated
const generatingTool = toolIsGenerating ?
toolCallSoFar.name === 'edit_file' || toolCallSoFar.name === 'rewrite_file' ? <EditToolSoFar
key={'curr-streaming-tool'}
toolCallSoFar={toolCallSoFar}
/>
: null
: null
const messagesHTML = <ScrollToBottomContainer
key={'messages' + chatThreadsState.currentThreadId} // force rerender on all children if id changes
scrollContainerRef={scrollContainerRef}
className={`
flex flex-col
px-4 py-4 space-y-4
w-full h-full
overflow-x-hidden
overflow-y-auto
${previousMessagesHTML.length === 0 && !displayContentSoFar ? 'hidden' : ''}
`}
>
{/* previous messages */}
{previousMessagesHTML}
{currStreamingMessageHTML}
{/* Generating tool */}
{generatingTool}
{/* loading indicator */}
{isRunning === 'LLM' || isRunning === 'idle' && !toolIsGenerating ? <ProseWrapper>
{<IconLoading className='opacity-50 text-sm' />}
</ProseWrapper> : null}
{/* error message */}
{latestError === undefined ? null :
<div className='px-2 my-1'>
<ErrorDisplay
message={latestError.message}
fullError={latestError.fullError}
onDismiss={() => { chatThreadsService.dismissStreamError(currentThread.id) }}
showDismiss={true}
/>
<WarningBox className='text-sm my-2 mx-4' onClick={() => { commandService.executeCommand(VOID_OPEN_SETTINGS_ACTION_ID) }} text='Open settings' />
</div>
}
</ScrollToBottomContainer>
// Render one ThreadMessagesView per cached thread id, with only the active
// one visible. The hidden views preserve their full React state (bubble
// collapse toggles, scroll position, streaming progress) and their DOM —
// returning to a recently-seen thread is near-instant because no mount/
// unmount cycle happens; only the `hidden` attribute flips.
const messagesHTML = (
<div className='relative flex-1 min-h-0 w-full'>
{visibleCachedIds.map(id => (
<div
key={id}
// Stack all cached thread views in the same box; only the
// active one's `hidden=false` makes it visible. Absolute
// positioning lets hidden views take zero layout space
// while still being part of the DOM / React tree.
className='absolute inset-0'
hidden={id !== currentThread.id}
>
<ErrorBoundary>
<ThreadMessagesView
threadId={id}
isActive={id === currentThread.id}
scrollContainerRef={getScrollContainerRef(id)}
/>
</ErrorBoundary>
</div>
))}
</div>
)
const onChangeText = useCallback((newStr: string) => {
@ -3359,9 +3488,15 @@ export const SidebarChat = () => {
</div>
// No `key={threadId}` here. Per-thread state that used to be reset by the
// full-subtree-remount this key caused (ChatBubble collapse toggles, tool-
// row expand state, scroll position) is now isolated by construction:
// every cached thread has its own `ThreadMessagesView` subtree with its
// own `useState` instances. SidebarChat-level state (input emptiness, the
// textarea DOM) is either reset explicitly (see `instructionsAreEmpty`
// effect above) or re-mounted via the narrower `threadPageInput` key.
return (
<Fragment key={threadId} // force rerender when change thread
>
<Fragment>
{isLandingPage ?
landingPageContent
: threadPageContent}

View file

@ -3,7 +3,7 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { useEffect, useMemo, useRef, useState } from 'react';
import { startTransition, useEffect, useMemo, useRef, useState } from 'react';
import { CopyButton, IconShell1 } from '../markdown/ApplyBlockHoverButtons.js';
import { useAccessor, useChatThreadsState, useChatThreadsStreamState, useFullChatThreadsStreamState, useSettingsState } from '../util/services.js';
import { IconX } from './SidebarChat.js';
@ -236,7 +236,14 @@ const PastThreadElement = ({ pastThread, idx, hoveredIdx, setHoveredIdx, isRunni
py-1 px-2 rounded text-sm bg-zinc-700/5 hover:bg-zinc-700/10 dark:bg-zinc-300/5 dark:hover:bg-zinc-300/10 cursor-pointer opacity-80 hover:opacity-100
`}
onClick={() => {
chatThreadsService.switchToThread(pastThread.id);
// startTransition marks the state update (swapping the whole message list
// for a new thread) as low-priority / interruptible. The click responds
// instantly; React yields while building the new thread's tree instead of
// blocking the main thread for the full render + commit. Total CPU cost
// is unchanged — this only improves perceived latency.
startTransition(() => {
chatThreadsService.switchToThread(pastThread.id);
});
}}
onMouseEnter={() => setHoveredIdx(idx)}
onMouseLeave={() => setHoveredIdx(null)}
@ -341,7 +348,13 @@ export const SidebarThreadTabs = () => {
<div
key={id}
ref={isActive ? activeTabRef : undefined}
onClick={() => chatThreadsService.switchToThread(id)}
onClick={() => {
// See note on PastThreadsList above — tab clicks benefit the most
// from the transition wrap because the swap is between two heavy threads.
startTransition(() => {
chatThreadsService.switchToThread(id)
})
}}
// Middle-click closes, matching conventional tab UX
// (VS Code editor tabs, browsers, etc).
onMouseDown={(e) => {
@ -379,7 +392,15 @@ export const SidebarThreadTabs = () => {
)
})}
<button
onClick={() => chatThreadsService.openNewThread()}
onClick={() => {
// Opening a new (empty) thread is cheap to render, but the unmount of
// the OUTGOING heavy thread's bubbles + Monaco editors is the expensive
// part. Wrapping in startTransition lets that teardown happen without
// blocking the "+" click response.
startTransition(() => {
chatThreadsService.openNewThread()
})
}}
className='shrink-0 ml-0.5 p-1 rounded text-void-fg-3 opacity-70 hover:opacity-100 hover:bg-zinc-700/10 dark:hover:bg-zinc-300/10'
data-tooltip-id='void-tooltip'
data-tooltip-content='New chat'

View file

@ -1582,7 +1582,9 @@ const normalizeIndentation = (code: string): string => {
const modelOfEditorId: { [id: string]: ITextModel | undefined } = {}
export type BlockCodeProps = { initValue: string, language?: string, maxHeight?: number, showScrollbars?: boolean }
export const BlockCode = ({ initValue, language, maxHeight, showScrollbars }: BlockCodeProps) => {
// Not exported: the only call-site is LazyBlockCode below. Using this directly re-introduces
// the tab-switch/long-chat perf regression we fixed in Perf 1 Fix C — always go through LazyBlockCode.
const BlockCode = ({ initValue, language, maxHeight, showScrollbars }: BlockCodeProps) => {
initValue = normalizeIndentation(initValue)
@ -1716,6 +1718,89 @@ export const BlockCode = ({ initValue, language, maxHeight, showScrollbars }: Bl
}
// Module-level scheduler that mounts at most one Monaco editor per animation frame.
// When a user scrolls fast through a long chat, many LazyBlockCode placeholders cross
// the IntersectionObserver threshold in quick succession. Without a queue they would
// all call setIsVisible(true) in the same React batch, mounting N Monaco editors
// synchronously on the main thread — visible as scroll jank. The queue rate-limits
// those mounts so scroll stays smooth, at the cost of a small staggered reveal.
const lazyMountQueue: Array<() => void> = []
let lazyMountFlushScheduled = false
const flushLazyMountQueue = () => {
lazyMountFlushScheduled = false
const next = lazyMountQueue.shift()
if (next) {
try { next() } catch (err) { console.error('[LazyBlockCode] mount callback failed', err) }
}
if (lazyMountQueue.length > 0) {
lazyMountFlushScheduled = true
requestAnimationFrame(flushLazyMountQueue)
}
}
const scheduleLazyMount = (cb: () => void) => {
lazyMountQueue.push(cb)
if (!lazyMountFlushScheduled) {
lazyMountFlushScheduled = true
requestAnimationFrame(flushLazyMountQueue)
}
}
// Lazy wrapper around BlockCode that defers mounting the heavy Monaco CodeEditorWidget
// until the block is close to the viewport. Rendering dozens of Monaco editors per chat
// thread is the dominant cost on tab switches / long chats (disposable churn, TextMate
// worker attach/detach, scrollbar+sash+hover provider allocation). We render a plain
// <pre><code> placeholder with the same container styling while off-screen, then upgrade
// in-place to the real BlockCode when the IntersectionObserver fires. Upgrade is
// monotonic: once mounted, Monaco stays mounted — the expensive work is the mount itself,
// not the continued existence. Mounts are also funneled through a per-frame queue
// (`scheduleLazyMount`) so a burst of IO fires during fast scrolling staggers instead
// of piling onto a single frame.
export const LazyBlockCode = ({ initValue, language, maxHeight, showScrollbars }: BlockCodeProps) => {
const wrapperRef = useRef<HTMLDivElement | null>(null)
const [isVisible, setIsVisible] = useState(false)
useEffect(() => {
if (isVisible) return
const el = wrapperRef.current
if (!el) return
if (typeof IntersectionObserver === 'undefined') {
setIsVisible(true)
return
}
let cancelled = false
const io = new IntersectionObserver((entries) => {
if (cancelled) return
if (entries.some(e => e.isIntersecting)) {
io.disconnect()
scheduleLazyMount(() => {
if (cancelled) return
setIsVisible(true)
})
}
}, { rootMargin: '500px 0px' })
io.observe(el)
return () => {
cancelled = true
io.disconnect()
}
}, [isVisible])
if (isVisible) {
return <BlockCode initValue={initValue} language={language} maxHeight={maxHeight} showScrollbars={showScrollbars} />
}
const normalized = normalizeIndentation(initValue)
return (
<div ref={wrapperRef} className='relative z-0 px-2 py-1 bg-void-bg-3'>
<pre className='m-0 font-mono text-[13px] leading-[19px] whitespace-pre overflow-x-auto text-void-fg-2'>
<code>{normalized}</code>
</pre>
</div>
)
}
export const VoidButtonBgDarken = ({ children, disabled, onClick, className }: { children: React.ReactNode; disabled?: boolean; onClick: () => void; className?: string }) => {
return <button disabled={disabled}
className={`px-3 py-1 bg-black/10 dark:bg-white/10 rounded-sm overflow-hidden whitespace-nowrap flex items-center justify-center ${className || ''}`}