From 47f0cdf3a2e5c4ad2c78a79f57bed226d9fddc59 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 00:20:40 -0500 Subject: [PATCH] perf(desktop): freeze hidden tab transcripts during streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep-alive keeps every ever-active tab mounted, but each hidden tab's ChatRuntimeBoundary still subscribed to its view's $messages — so every streaming delta flush (~30x/s) re-rendered every busy tab's whole thread, and five concurrent sessions dropped the app to a crawl. Flow the pane layer's visibility down as PaneVisibleContext and gate the $messages subscription on it: a hidden tab freezes its transcript (status dots stay live through the separate status atoms) and catches up in one commit on reveal, since subscribe fires immediately with the current value. --- apps/desktop/src/app/chat/index.tsx | 30 +++++++++++++++++-- .../components/pane-shell/pane-visibility.ts | 10 +++++++ .../pane-shell/tree/renderer/tree-group.tsx | 8 +++-- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 8f7a2670a97c..7274a9be4b88 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -1,14 +1,16 @@ import { type AppendMessage, AssistantRuntimeProvider, type ThreadMessage } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' +import type { ReadableAtom } from 'nanostores' import type * as React from 'react' -import { Suspense, useCallback, useEffect, useMemo } from 'react' +import { Suspense, useCallback, useEffect, useMemo, useState } from 'react' import { useLocation } from 'react-router-dom' import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils' import { Thread } from '@/components/assistant-ui/thread' import { Backdrop } from '@/components/Backdrop' import { COMPOSER_HEART_CONFIG, HeartField } from '@/components/chat/vibe-hearts' +import { usePaneVisible } from '@/components/pane-shell/pane-visibility' import { $sessionTileDragging, $sessionTileEdgeHover } from '@/components/pane-shell/tree/store' import { PromptOverlays } from '@/components/prompt-overlays' import { Button } from '@/components/ui/button' @@ -174,6 +176,30 @@ interface ChatRuntimeBoundaryProps { const NO_MESSAGES: ChatMessage[] = [] +/** + * The view's $messages, live only while this surface is the VISIBLE tab. + * + * Keep-alive keeps every ever-active tab MOUNTED (tree-group.tsx), so without + * this gate a hidden tab re-renders its entire thread on every streaming + * delta flush (~30×/s) — five busy tabs quintuple the per-token render cost + * and the app crawls. Hidden tabs freeze their transcript instead (status + * dots stay live through the separate status atoms) and catch up in one + * commit on reveal — the subscribe fires immediately with the current value. + */ +function useMessagesWhileVisible($messages: ReadableAtom): ChatMessage[] { + const visible = usePaneVisible() + const [messages, setMessages] = useState(() => $messages.get()) + + // nanostores types the listener value ReadonlyIfObject; the store publishes + // a fresh array per flush, so the cast is safe and avoids a per-token clone. + useEffect( + () => (visible ? $messages.subscribe(value => setMessages(value as ChatMessage[])) : undefined), + [$messages, visible] + ) + + return messages +} + /** * Owns the $messages subscription and the assistant-ui external-store runtime. * @@ -193,7 +219,7 @@ function ChatRuntimeBoundary({ onThreadMessagesChange, suppressMessages }: ChatRuntimeBoundaryProps) { - const storeMessages = useStore(useSessionView().$messages) + const storeMessages = useMessagesWhileVisible(useSessionView().$messages) const messages = suppressMessages ? NO_MESSAGES : storeMessages const runtimeMessageRepository = useRuntimeMessageRepository(messages) diff --git a/apps/desktop/src/components/pane-shell/pane-visibility.ts b/apps/desktop/src/components/pane-shell/pane-visibility.ts index e16975b5224e..986b9ee3bde2 100644 --- a/apps/desktop/src/components/pane-shell/pane-visibility.ts +++ b/apps/desktop/src/components/pane-shell/pane-visibility.ts @@ -10,6 +10,8 @@ * has to skip hidden panes, or it silently answers with the wrong tab. */ +import { createContext, useContext } from 'react' + /** Marks a mounted-but-hidden pane layer (an inactive tab in a stack). */ export const PANE_HIDDEN_ATTR = 'data-pane-hidden' @@ -18,6 +20,14 @@ const HIDDEN_PANE = `[${PANE_HIDDEN_ATTR}]` /** Spread onto a kept pane layer so the lookups below can skip it. */ export const hiddenPaneProps = (hidden: boolean): Record => (hidden ? { [PANE_HIDDEN_ATTR]: '' } : {}) +/** React face of the same policy: the pane layer provides its visibility so a + * kept-alive surface can gate hot subscriptions (streaming re-renders) off + * while it's an inactive tab. Default TRUE — surfaces outside a tab stack + * (secondary windows, plain routes) are always visible. */ +export const PaneVisibleContext = createContext(true) + +export const usePaneVisible = (): boolean => useContext(PaneVisibleContext) + /** `querySelectorAll` minus anything inside an inactive tab. */ export const queryAllVisible = (selector: string, root: ParentNode = document): T[] => [...root.querySelectorAll(selector)].filter(el => !el.closest(HIDDEN_PANE)) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index 6df3e2163dfc..1ed752f1dda6 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -30,7 +30,7 @@ import { cn } from '@/lib/utils' import { $layoutEditMode } from '../../edit-mode' import { useWindowControlsOverlap } from '../../geometry' -import { hiddenPaneProps } from '../../pane-visibility' +import { hiddenPaneProps, PaneVisibleContext } from '../../pane-visibility' import type { DropPosition, GroupNode, RootEdge } from '../model' import { adjacentGroup } from '../model' import { @@ -544,7 +544,11 @@ export function TreeGroup({ {...hiddenPaneProps(!isActive)} > {pane?.render ? ( - {pane.render()} + // Visibility flows to the pane so a kept-alive chat surface + // can gate its hot (per-token) subscriptions while hidden. + + {pane.render()} + ) : ( isActive && (