perf(ui): drop virtualized DOM in hidden panes
Add DOM instrumentation tags and harden VirtualItem visibility for hidden/zero-sized roots to prevent inactive instances from keeping heavy tool-call markup mounted; restore message stream virtualization margin.
This commit is contained in:
@@ -496,17 +496,24 @@ const App: Component = () => {
|
|||||||
const isActiveInstance = () => activeInstanceId() === instance.id
|
const isActiveInstance = () => activeInstanceId() === instance.id
|
||||||
const isVisible = () => isActiveInstance() && !showFolderSelection()
|
const isVisible = () => isActiveInstance() && !showFolderSelection()
|
||||||
return (
|
return (
|
||||||
<div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}>
|
<div
|
||||||
<InstanceMetadataProvider instance={instance}>
|
class="flex-1 min-h-0 overflow-hidden"
|
||||||
<InstanceShell
|
style={{ display: isVisible() ? "flex" : "none" }}
|
||||||
instance={instance}
|
data-instance-id={instance.id}
|
||||||
escapeInDebounce={escapeInDebounce()}
|
data-instance-active={isActiveInstance() ? "true" : "false"}
|
||||||
paletteCommands={paletteCommands}
|
data-instance-visible={isVisible() ? "true" : "false"}
|
||||||
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
>
|
||||||
onNewSession={() => handleNewSession(instance.id)}
|
<InstanceMetadataProvider instance={instance}>
|
||||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
<InstanceShell
|
||||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
instance={instance}
|
||||||
onExecuteCommand={executeCommand}
|
isActiveInstance={isActiveInstance()}
|
||||||
|
escapeInDebounce={escapeInDebounce()}
|
||||||
|
paletteCommands={paletteCommands}
|
||||||
|
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
||||||
|
onNewSession={() => handleNewSession(instance.id)}
|
||||||
|
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
||||||
|
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
||||||
|
onExecuteCommand={executeCommand}
|
||||||
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
|
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
|
||||||
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
|
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
|
||||||
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
|
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ const log = getLogger("session")
|
|||||||
|
|
||||||
interface InstanceShellProps {
|
interface InstanceShellProps {
|
||||||
instance: Instance
|
instance: Instance
|
||||||
|
// Provided by App-level instance tabs; lets us pause heavy rendering
|
||||||
|
// work for inactive instances while keeping them mounted for fast switching.
|
||||||
|
isActiveInstance?: boolean
|
||||||
escapeInDebounce: boolean
|
escapeInDebounce: boolean
|
||||||
paletteCommands: Accessor<Command[]>
|
paletteCommands: Accessor<Command[]>
|
||||||
onCloseSession: (sessionId: string) => Promise<void> | void
|
onCloseSession: (sessionId: string) => Promise<void> | void
|
||||||
@@ -800,12 +803,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<For each={cachedSessionIds()}>
|
<For each={cachedSessionIds()}>
|
||||||
{(sessionId) => {
|
{(sessionId) => {
|
||||||
const isActive = () => activeSessionIdForInstance() === sessionId
|
const isActive = () => Boolean(props.isActiveInstance) && activeSessionIdForInstance() === sessionId
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="session-cache-pane flex flex-col flex-1 min-h-0"
|
class="session-cache-pane flex flex-col flex-1 min-h-0"
|
||||||
style={{ display: isActive() ? "flex" : "none" }}
|
style={{ display: isActive() ? "flex" : "none" }}
|
||||||
data-session-id={sessionId}
|
data-session-id={sessionId}
|
||||||
|
data-instance-id={props.instance.id}
|
||||||
|
data-session-active={isActive() ? "true" : "false"}
|
||||||
aria-hidden={!isActive()}
|
aria-hidden={!isActive()}
|
||||||
>
|
>
|
||||||
<SessionView
|
<SessionView
|
||||||
@@ -841,7 +846,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="instance-shell2 flex flex-col flex-1 min-h-0">
|
<div
|
||||||
|
class="instance-shell2 flex flex-col flex-1 min-h-0"
|
||||||
|
data-instance-id={props.instance.id}
|
||||||
|
>
|
||||||
<Show when={hasSessions()} fallback={<InstanceWelcomeView instance={props.instance} />}>
|
<Show when={hasSessions()} fallback={<InstanceWelcomeView instance={props.instance} />}>
|
||||||
{sessionLayout}
|
{sessionLayout}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -202,5 +202,15 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
|
|
||||||
const proseClass = () => "markdown-body"
|
const proseClass = () => "markdown-body"
|
||||||
|
|
||||||
return <div ref={containerRef} class={proseClass()} innerHTML={html()} />
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
class={proseClass()}
|
||||||
|
data-view="markdown"
|
||||||
|
data-part-id={resolved().partId}
|
||||||
|
data-markdown-theme={resolved().themeKey}
|
||||||
|
data-markdown-highlight={resolved().highlightEnabled ? "true" : "false"}
|
||||||
|
innerHTML={html()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function getMessageAnchorId(messageId: string) {
|
|||||||
return `message-anchor-${messageId}`
|
return `message-anchor-${messageId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const VIRTUAL_ITEM_MARGIN_PX = 300
|
const VIRTUAL_ITEM_MARGIN_PX = 800
|
||||||
|
|
||||||
interface MessageBlockListProps {
|
interface MessageBlockListProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
|
|||||||
@@ -381,7 +381,15 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={containerClass()}>
|
<div
|
||||||
|
class={containerClass()}
|
||||||
|
data-view="message-item"
|
||||||
|
data-instance-id={props.instanceId}
|
||||||
|
data-session-id={props.sessionId}
|
||||||
|
data-message-id={props.record.id}
|
||||||
|
data-message-role={isUser() ? "user" : "assistant"}
|
||||||
|
data-message-status={props.record.status}
|
||||||
|
>
|
||||||
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
|
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
|
||||||
<div class="message-item-header-row message-item-header-row--top" ref={(el) => (topRowEl = el)}>
|
<div class="message-item-header-row message-item-header-row--top" ref={(el) => (topRowEl = el)}>
|
||||||
<div class="message-header-left">
|
<div class="message-header-left">
|
||||||
|
|||||||
@@ -131,7 +131,12 @@ export default function MessagePart(props: MessagePartProps) {
|
|||||||
<Switch>
|
<Switch>
|
||||||
<Match when={partType() === "text"}>
|
<Match when={partType() === "text"}>
|
||||||
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
|
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
|
||||||
<div class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()} data-role={textContainerRole()}>
|
<div
|
||||||
|
class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()}
|
||||||
|
data-role={textContainerRole()}
|
||||||
|
data-part-type="text"
|
||||||
|
data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined}
|
||||||
|
>
|
||||||
<Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}>
|
<Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}>
|
||||||
<Markdown
|
<Markdown
|
||||||
part={createTextPartForMarkdown()}
|
part={createTextPartForMarkdown()}
|
||||||
|
|||||||
@@ -913,13 +913,30 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="message-stream-container">
|
<div
|
||||||
|
class="message-stream-container"
|
||||||
|
data-instance-id={props.instanceId}
|
||||||
|
data-session-id={props.sessionId}
|
||||||
|
data-stream-active={isActive() ? "true" : "false"}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}
|
class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}
|
||||||
data-scroll-buttons={scrollButtonsCount()}
|
data-scroll-buttons={scrollButtonsCount()}
|
||||||
>
|
>
|
||||||
<div class="message-stream-shell" ref={setShellElement}>
|
<div
|
||||||
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp}>
|
class="message-stream-shell"
|
||||||
|
ref={setShellElement}
|
||||||
|
data-instance-id={props.instanceId}
|
||||||
|
data-session-id={props.sessionId}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="message-stream"
|
||||||
|
ref={setContainerRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
onMouseUp={handleStreamMouseUp}
|
||||||
|
data-instance-id={props.instanceId}
|
||||||
|
data-session-id={props.sessionId}
|
||||||
|
>
|
||||||
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
||||||
<Show when={!props.loading && messageIds().length === 0}>
|
<Show when={!props.loading && messageIds().length === 0}>
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
|
|||||||
@@ -411,7 +411,14 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="message-timeline" role="navigation" aria-label={t("messageTimeline.ariaLabel")}>
|
<div
|
||||||
|
class="message-timeline"
|
||||||
|
role="navigation"
|
||||||
|
aria-label={t("messageTimeline.ariaLabel")}
|
||||||
|
data-view="timeline"
|
||||||
|
data-instance-id={props.instanceId}
|
||||||
|
data-session-id={props.sessionId}
|
||||||
|
>
|
||||||
<For each={props.segments}>
|
<For each={props.segments}>
|
||||||
{(segment) => {
|
{(segment) => {
|
||||||
onCleanup(() => buttonRefs.delete(segment.id))
|
onCleanup(() => buttonRefs.delete(segment.id))
|
||||||
|
|||||||
@@ -976,6 +976,12 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
setToolCallRootEl(element || undefined)
|
setToolCallRootEl(element || undefined)
|
||||||
}}
|
}}
|
||||||
class={`tool-call ${combinedStatusClass()}`}
|
class={`tool-call ${combinedStatusClass()}`}
|
||||||
|
data-part-type="tool"
|
||||||
|
data-tool-name={toolName()}
|
||||||
|
data-instance-id={props.instanceId}
|
||||||
|
data-session-id={props.sessionId}
|
||||||
|
data-message-id={props.messageId}
|
||||||
|
data-part-id={toolCallIdentifier()}
|
||||||
>
|
>
|
||||||
<div class="tool-call-header">
|
<div class="tool-call-header">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -62,6 +62,42 @@ function shouldRenderEntry(entry: IntersectionObserverEntry) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getViewportRect(): { top: number; bottom: number } {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return { top: 0, bottom: 0 }
|
||||||
|
}
|
||||||
|
return { top: 0, bottom: window.innerHeight }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRenderableRoot(root: ObserverRoot): boolean {
|
||||||
|
if (!root) return true
|
||||||
|
if (root instanceof Document) return true
|
||||||
|
if (typeof window === "undefined") return false
|
||||||
|
|
||||||
|
const element = root as Element
|
||||||
|
const style = window.getComputedStyle(element as Element)
|
||||||
|
if (style.display === "none" || style.visibility === "hidden") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const rect = (element as Element).getBoundingClientRect()
|
||||||
|
return rect.width > 0 && rect.height > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRenderByRects(params: {
|
||||||
|
wrapperRect: DOMRect
|
||||||
|
rootRect: { top: number; bottom: number }
|
||||||
|
margin: number
|
||||||
|
}): boolean {
|
||||||
|
const { wrapperRect, rootRect, margin } = params
|
||||||
|
const distanceAbove = rootRect.top - wrapperRect.bottom
|
||||||
|
const distanceBelow = wrapperRect.top - rootRect.bottom
|
||||||
|
const threshold = margin + VISIBILITY_BUFFER_PX
|
||||||
|
if (distanceAbove > threshold || distanceBelow > threshold) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
function subscribeToSharedObserver(
|
function subscribeToSharedObserver(
|
||||||
target: Element,
|
target: Element,
|
||||||
root: ObserverRoot,
|
root: ObserverRoot,
|
||||||
@@ -120,7 +156,10 @@ interface VirtualItemProps {
|
|||||||
export default function VirtualItem(props: VirtualItemProps) {
|
export default function VirtualItem(props: VirtualItemProps) {
|
||||||
const resolved = resolveChildren(() => props.children)
|
const resolved = resolveChildren(() => props.children)
|
||||||
const cachedHeight = sizeCache.get(props.cacheKey)
|
const cachedHeight = sizeCache.get(props.cacheKey)
|
||||||
const [isIntersecting, setIsIntersecting] = createSignal(true)
|
// Default to hidden until we can determine visibility.
|
||||||
|
// This avoids keeping heavy DOM alive when IntersectionObserver
|
||||||
|
// doesn't fire (common for hidden/zero-sized scroll roots).
|
||||||
|
const [isIntersecting, setIsIntersecting] = createSignal(false)
|
||||||
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? 0)
|
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? 0)
|
||||||
const [hasMeasured, setHasMeasured] = createSignal(cachedHeight !== undefined)
|
const [hasMeasured, setHasMeasured] = createSignal(cachedHeight !== undefined)
|
||||||
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
|
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
|
||||||
@@ -148,12 +187,12 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
|
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
|
||||||
|
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
|
||||||
const shouldHideContent = createMemo(() => {
|
const shouldHideContent = createMemo(() => {
|
||||||
if (props.forceVisible?.()) return false
|
if (props.forceVisible?.()) return false
|
||||||
if (!virtualizationEnabled()) return false
|
if (!virtualizationEnabled()) return false
|
||||||
return !isIntersecting()
|
return !isIntersecting()
|
||||||
})
|
})
|
||||||
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
|
|
||||||
|
|
||||||
let wrapperRef: HTMLDivElement | undefined
|
let wrapperRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
@@ -229,14 +268,44 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
function refreshIntersectionObserver(targetRoot: Element | Document | null) {
|
function refreshIntersectionObserver(targetRoot: Element | Document | null) {
|
||||||
cleanupIntersectionObserver()
|
cleanupIntersectionObserver()
|
||||||
if (!wrapperRef) {
|
if (!wrapperRef) {
|
||||||
setIsIntersecting(true)
|
setIsIntersecting(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (typeof IntersectionObserver === "undefined") {
|
if (typeof IntersectionObserver === "undefined") {
|
||||||
setIsIntersecting(true)
|
setIsIntersecting(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const margin = props.threshold ?? DEFAULT_MARGIN_PX
|
const margin = props.threshold ?? DEFAULT_MARGIN_PX
|
||||||
|
|
||||||
|
// If the scroll root is hidden / 0x0, IntersectionObserver can report
|
||||||
|
// `isIntersecting` in unexpected ways (often "true" with null rootBounds),
|
||||||
|
// which keeps heavy DOM alive in background tabs.
|
||||||
|
//
|
||||||
|
// In that state, force-hide and skip attaching the observer. When the
|
||||||
|
// pane becomes visible again, VirtualItem will re-run this setup and
|
||||||
|
// re-attach the observer.
|
||||||
|
const renderable = isRenderableRoot(targetRoot)
|
||||||
|
if (!renderable) {
|
||||||
|
setIsIntersecting(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute an immediate best-effort visibility so switching tabs doesn't
|
||||||
|
// depend on the first IntersectionObserver callback.
|
||||||
|
try {
|
||||||
|
const rootRect =
|
||||||
|
targetRoot && !(targetRoot instanceof Document)
|
||||||
|
? (targetRoot as Element).getBoundingClientRect()
|
||||||
|
: null
|
||||||
|
const bounds = rootRect ? { top: rootRect.top, bottom: rootRect.bottom } : getViewportRect()
|
||||||
|
setIsIntersecting(
|
||||||
|
shouldRenderByRects({ wrapperRect: wrapperRef.getBoundingClientRect(), rootRect: bounds, margin }),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Ignore measurement failures; IntersectionObserver will correct us.
|
||||||
|
}
|
||||||
|
|
||||||
intersectionCleanup = subscribeToSharedObserver(wrapperRef, targetRoot, margin, (entry) => {
|
intersectionCleanup = subscribeToSharedObserver(wrapperRef, targetRoot, margin, (entry) => {
|
||||||
const nextVisible = shouldRenderEntry(entry)
|
const nextVisible = shouldRenderEntry(entry)
|
||||||
queueVisibility(nextVisible)
|
queueVisibility(nextVisible)
|
||||||
@@ -340,4 +409,3 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user