From e56f2f99cfc7ad7a3eb28d09a83b8ef92a779fc9 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 5 Apr 2026 15:58:06 +0300 Subject: [PATCH] perf: freeze detached chat history updates Stop historical chat view from waking on live tail deltas by suspending detached part updates, narrowing scroll manager dependencies, and hardening sync-context HMR behavior during dev. --- .../ui/src/components/chat/ChatContainer.tsx | 14 ++++++-- packages/ui/src/hooks/useChatScrollManager.ts | 21 ++++-------- packages/ui/src/sync/index.ts | 1 + packages/ui/src/sync/sync-context.tsx | 34 ++++++++++++++++--- 4 files changed, 50 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index e05c6989..333c0504 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -32,6 +32,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; import { useViewportStore } from '@/sync/viewport-store'; import { useStreamingStore } from '@/sync/streaming'; import { + useSessionMessageCount, useSessionMessageRecords, useSessions, useDirectorySync, @@ -295,8 +296,12 @@ export const ChatContainer: React.FC = () => { [currentSessionId], ), ); + const sessionMessageCount = useSessionMessageCount(currentSessionId ?? ''); + const [suspendDetachedTailUpdates, setSuspendDetachedTailUpdates] = React.useState(false); // Messages from sync system - const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? ''); + const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', undefined, { + suspendPartUpdates: suspendDetachedTailUpdates, + }); const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES; // Sessions from sync system @@ -475,7 +480,7 @@ export const ChatContainer: React.FC = () => { isProgrammaticFollowActive, } = useChatScrollManager({ currentSessionId, - sessionMessages, + sessionMessageCount, streamingMessageId, sessionMemoryState: sessionMemoryStateMap, updateViewportAnchor, @@ -486,6 +491,11 @@ export const ChatContainer: React.FC = () => { onActiveTurnChange: handleActiveTurnChange, }); + React.useEffect(() => { + const next = Boolean(currentSessionId && streamingMessageId && !isPinned); + setSuspendDetachedTailUpdates((previous) => (previous === next ? previous : next)); + }, [currentSessionId, isPinned, streamingMessageId]); + const viewportMessagesRef = React.useRef(EMPTY_MESSAGES); const viewportSessionIdRef = React.useRef(null); const viewportMessages = React.useMemo(() => { diff --git a/packages/ui/src/hooks/useChatScrollManager.ts b/packages/ui/src/hooks/useChatScrollManager.ts index 64b2131b..ef7ec9ec 100644 --- a/packages/ui/src/hooks/useChatScrollManager.ts +++ b/packages/ui/src/hooks/useChatScrollManager.ts @@ -1,6 +1,4 @@ import React from 'react'; -import type { Part } from '@opencode-ai/sdk/v2'; - import { MessageFreshnessDetector } from '@/lib/messageFreshness'; import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy'; import { @@ -13,11 +11,6 @@ import { useScrollEngine } from './useScrollEngine'; export type ContentChangeReason = 'text' | 'structural' | 'permission'; -interface ChatMessageRecord { - info: Record; - parts: Part[]; -} - interface SessionMemoryState { viewportAnchor: number; isStreaming: boolean; @@ -31,7 +24,7 @@ interface SessionMemoryState { interface UseChatScrollManagerOptions { currentSessionId: string | null; - sessionMessages: ChatMessageRecord[]; + sessionMessageCount: number; sessionPermissions: unknown[]; streamingMessageId: string | null; sessionMemoryState: Map; @@ -73,7 +66,7 @@ const VIEWPORT_ANCHOR_MIN_UPDATE_MS = 150; export const useChatScrollManager = ({ currentSessionId, - sessionMessages, + sessionMessageCount, streamingMessageId, updateViewportAnchor, isSyncing, @@ -330,7 +323,7 @@ export const useChatScrollManager = ({ const { scrollTop, scrollHeight, clientHeight } = container; const position = (scrollTop + clientHeight / 2) / Math.max(scrollHeight, 1); - const estimatedIndex = Math.floor(position * sessionMessages.length); + const estimatedIndex = Math.floor(position * sessionMessageCount); queueViewportAnchor(currentSessionId, estimatedIndex); }, [ currentSessionId, @@ -339,7 +332,7 @@ export const useChatScrollManager = ({ queueViewportAnchor, schedulePinnedStateAndIndicators, scrollEngine, - sessionMessages.length, + sessionMessageCount, updatePinnedState, ]); @@ -469,7 +462,7 @@ export const useChatScrollManager = ({ return; } schedulePinnedStateAndIndicators(); - }, [isSyncing, schedulePinnedStateAndIndicators, sessionMessages.length, shouldSkipLiveContentSync]); + }, [isSyncing, schedulePinnedStateAndIndicators, sessionMessageCount, shouldSkipLiveContentSync]); // Use ResizeObserver to detect content changes and maintain pin React.useEffect(() => { @@ -551,7 +544,7 @@ export const useChatScrollManager = ({ return () => { window.cancelAnimationFrame(rafId); }; - }, [currentSessionId, schedulePinnedStateAndIndicators, sessionMessages.length, shouldSkipLiveContentSync]); + }, [currentSessionId, schedulePinnedStateAndIndicators, sessionMessageCount, shouldSkipLiveContentSync]); const animationHandlersRef = React.useRef>(new Map()); @@ -714,7 +707,7 @@ export const useChatScrollManager = ({ mutationObserver.disconnect(); spy.destroy(); }; - }, [currentSessionId, onActiveTurnChange, scrollRef, sessionMessages.length]); + }, [currentSessionId, onActiveTurnChange, scrollRef, sessionMessageCount]); return { scrollRef, diff --git a/packages/ui/src/sync/index.ts b/packages/ui/src/sync/index.ts index 74205c35..a1061a18 100644 --- a/packages/ui/src/sync/index.ts +++ b/packages/ui/src/sync/index.ts @@ -59,6 +59,7 @@ export { useDirectoryStore, useDirectorySync, useSessionMessages, + useSessionMessageCount, useSessionMessagesResolved, useSessionParts, useSessionStatus, diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 4ed9092b..035531b8 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -877,7 +877,7 @@ const getFirstTextFromParts = (parts: Part[]): string => { return "" } -function usePartsSnapshotForMessageIds(messageIds: string[], directory?: string) { +function usePartsSnapshotForMessageIds(messageIds: string[], directory?: string, suspendUpdates = false) { const store = useDirectoryStore(directory) const prevPartsRef = useRef>({}) const [partsSnapshot, setPartsSnapshot] = React.useState>({}) @@ -906,6 +906,12 @@ function usePartsSnapshotForMessageIds(messageIds: string[], directory?: string) flush() + if (suspendUpdates) { + return () => { + if (timer) clearTimeout(timer) + } + } + const unsub = store.subscribe(() => { if (timer) { pending = true @@ -924,11 +930,21 @@ function usePartsSnapshotForMessageIds(messageIds: string[], directory?: string) unsub() if (timer) clearTimeout(timer) } - }, [messageIds, store]) + }, [messageIds, store, suspendUpdates]) return partsSnapshot } +export function useSessionMessageCount(sessionID: string, directory?: string): number { + return useDirectorySync( + useCallback((state: State) => { + if (!sessionID) return 0 + return state.message[sessionID]?.length ?? 0 + }, [sessionID]), + directory, + ) +} + export function useSessionTextMessages(sessionID: string, directory?: string): SessionTextMessage[] { const messages = useVisibleSessionMessages(sessionID, directory) const messageIds = useMemo(() => messages.map((message) => message.id), [messages]) @@ -973,10 +989,14 @@ export function useUserMessageHistory(sessionID: string, directory?: string): st * Uses a ref-stable parts lookup that only triggers re-renders when * a part array for one of our displayed messages actually changes. */ -export function useSessionMessageRecords(sessionID: string, directory?: string) { +export function useSessionMessageRecords( + sessionID: string, + directory?: string, + options?: { suspendPartUpdates?: boolean }, +) { const messages = useVisibleSessionMessages(sessionID, directory) const messageIds = useMemo(() => messages.map((message) => message.id), [messages]) - const partsSnapshot = usePartsSnapshotForMessageIds(messageIds, directory) + const partsSnapshot = usePartsSnapshotForMessageIds(messageIds, directory, Boolean(options?.suspendPartUpdates)) const previousRecordsRef = useRef<{ list: Array<{ info: (typeof messages)[number]; parts: Part[] }> byId: Map @@ -1052,3 +1072,9 @@ const EMPTY_MESSAGES: Message[] = [] const EMPTY_PARTS: Part[] = [] const EMPTY_PERMISSION_REQUESTS: PermissionRequest[] = [] const EMPTY_QUESTION_REQUESTS: QuestionRequest[] = [] + +if (import.meta.hot) { + import.meta.hot.accept(() => { + import.meta.hot?.invalidate() + }) +}