From 5c0ca803ea3b540d94307debaf17518396730fea Mon Sep 17 00:00:00 2001 From: davi0015 Date: Wed, 22 Apr 2026 20:26:48 +0800 Subject: [PATCH] Feature/improve UI reactiveness (#10) * lazy load block code * queue monaco mount * cache markdown rendering * improve opened tab navigation delay --- .../react/src/markdown/ChatMarkdownRender.tsx | 34 +- .../react/src/sidebar-tsx/SidebarChat.tsx | 319 +++++++++++++----- .../src/sidebar-tsx/SidebarThreadSelector.tsx | 29 +- .../void/browser/react/src/util/inputs.tsx | 87 ++++- 4 files changed, 367 insertions(+), 102 deletions(-) diff --git a/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx b/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx index 97214330..bffe669a 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx @@ -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() +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'} > - } - return @@ -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) => ( diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx index 99795344..2b767625 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx @@ -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 =
- +
} @@ -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 +}) => { + 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 scrollToBottom(scrollContainerRef)} + /> + }) + }, [previousMessages, threadId, currCheckpointIdx, isRunning, scrollContainerRef]) + + const streamingChatIdx = previousMessagesHTML.length + const currStreamingMessageHTML = reasoningSoFar || displayContentSoFar || isRunning ? + : null + + const generatingTool = toolIsGenerating ? + toolCallSoFar.name === 'edit_file' || toolCallSoFar.name === 'rewrite_file' ? + : null + : null + + return ( + + ) +} + + export const SidebarChat = () => { const textAreaRef = useRef(null) const textAreaFnsRef = useRef(null) @@ -3091,7 +3221,56 @@ export const SidebarChat = () => { const isDisabled = instructionsAreEmpty || !!isFeatureNameDisabled('Chat', settingsState) const sidebarRef = useRef(null) - const scrollContainerRef = useRef(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(() => [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>()) + 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 scrollToBottom(scrollContainerRef)} - /> - }) - }, [previousMessages, threadId, currCheckpointIdx, isRunning]) - - const streamingChatIdx = previousMessagesHTML.length - const currStreamingMessageHTML = reasoningSoFar || displayContentSoFar || isRunning ? - : null - - - // the tool currently being generated - const generatingTool = toolIsGenerating ? - toolCallSoFar.name === 'edit_file' || toolCallSoFar.name === 'rewrite_file' ? - : null - : null - - const messagesHTML = - {/* previous messages */} - {previousMessagesHTML} - {currStreamingMessageHTML} - - {/* Generating tool */} - {generatingTool} - - {/* loading indicator */} - {isRunning === 'LLM' || isRunning === 'idle' && !toolIsGenerating ? - {} - : null} - - - {/* error message */} - {latestError === undefined ? null : -
- { chatThreadsService.dismissStreamError(currentThread.id) }} - showDismiss={true} - /> - - { commandService.executeCommand(VOID_OPEN_SETTINGS_ACTION_ID) }} text='Open settings' /> -
- } -
+ // 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 = ( +
+ {visibleCachedIds.map(id => ( + + ))} +
+ ) const onChangeText = useCallback((newStr: string) => { @@ -3359,9 +3488,15 @@ export const SidebarChat = () => { + // 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 ( - + {isLandingPage ? landingPageContent : threadPageContent} diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx index 4f3311f9..7cfc1a05 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx @@ -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 = () => {
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 = () => { ) })}