mirror of
https://github.com/voideditor/void
synced 2026-05-23 01:18:25 +00:00
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:
parent
8d3b7400ff
commit
5c0ca803ea
4 changed files with 367 additions and 102 deletions
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 || ''}`}
|
||||
|
|
|
|||
Loading…
Reference in a new issue