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.
This commit is contained in:
Bohdan Triapitsyn
2026-04-05 15:58:06 +03:00
parent 4663abd6f3
commit e56f2f99cf
4 changed files with 50 additions and 20 deletions
@@ -32,6 +32,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
import { useViewportStore } from '@/sync/viewport-store'; import { useViewportStore } from '@/sync/viewport-store';
import { useStreamingStore } from '@/sync/streaming'; import { useStreamingStore } from '@/sync/streaming';
import { import {
useSessionMessageCount,
useSessionMessageRecords, useSessionMessageRecords,
useSessions, useSessions,
useDirectorySync, useDirectorySync,
@@ -295,8 +296,12 @@ export const ChatContainer: React.FC = () => {
[currentSessionId], [currentSessionId],
), ),
); );
const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '');
const [suspendDetachedTailUpdates, setSuspendDetachedTailUpdates] = React.useState(false);
// Messages from sync system // Messages from sync system
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? ''); const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', undefined, {
suspendPartUpdates: suspendDetachedTailUpdates,
});
const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES; const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES;
// Sessions from sync system // Sessions from sync system
@@ -475,7 +480,7 @@ export const ChatContainer: React.FC = () => {
isProgrammaticFollowActive, isProgrammaticFollowActive,
} = useChatScrollManager({ } = useChatScrollManager({
currentSessionId, currentSessionId,
sessionMessages, sessionMessageCount,
streamingMessageId, streamingMessageId,
sessionMemoryState: sessionMemoryStateMap, sessionMemoryState: sessionMemoryStateMap,
updateViewportAnchor, updateViewportAnchor,
@@ -486,6 +491,11 @@ export const ChatContainer: React.FC = () => {
onActiveTurnChange: handleActiveTurnChange, onActiveTurnChange: handleActiveTurnChange,
}); });
React.useEffect(() => {
const next = Boolean(currentSessionId && streamingMessageId && !isPinned);
setSuspendDetachedTailUpdates((previous) => (previous === next ? previous : next));
}, [currentSessionId, isPinned, streamingMessageId]);
const viewportMessagesRef = React.useRef<SessionMessageRecord[]>(EMPTY_MESSAGES); const viewportMessagesRef = React.useRef<SessionMessageRecord[]>(EMPTY_MESSAGES);
const viewportSessionIdRef = React.useRef<string | null>(null); const viewportSessionIdRef = React.useRef<string | null>(null);
const viewportMessages = React.useMemo(() => { const viewportMessages = React.useMemo(() => {
+7 -14
View File
@@ -1,6 +1,4 @@
import React from 'react'; import React from 'react';
import type { Part } from '@opencode-ai/sdk/v2';
import { MessageFreshnessDetector } from '@/lib/messageFreshness'; import { MessageFreshnessDetector } from '@/lib/messageFreshness';
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy'; import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
import { import {
@@ -13,11 +11,6 @@ import { useScrollEngine } from './useScrollEngine';
export type ContentChangeReason = 'text' | 'structural' | 'permission'; export type ContentChangeReason = 'text' | 'structural' | 'permission';
interface ChatMessageRecord {
info: Record<string, unknown>;
parts: Part[];
}
interface SessionMemoryState { interface SessionMemoryState {
viewportAnchor: number; viewportAnchor: number;
isStreaming: boolean; isStreaming: boolean;
@@ -31,7 +24,7 @@ interface SessionMemoryState {
interface UseChatScrollManagerOptions { interface UseChatScrollManagerOptions {
currentSessionId: string | null; currentSessionId: string | null;
sessionMessages: ChatMessageRecord[]; sessionMessageCount: number;
sessionPermissions: unknown[]; sessionPermissions: unknown[];
streamingMessageId: string | null; streamingMessageId: string | null;
sessionMemoryState: Map<string, SessionMemoryState>; sessionMemoryState: Map<string, SessionMemoryState>;
@@ -73,7 +66,7 @@ const VIEWPORT_ANCHOR_MIN_UPDATE_MS = 150;
export const useChatScrollManager = ({ export const useChatScrollManager = ({
currentSessionId, currentSessionId,
sessionMessages, sessionMessageCount,
streamingMessageId, streamingMessageId,
updateViewportAnchor, updateViewportAnchor,
isSyncing, isSyncing,
@@ -330,7 +323,7 @@ export const useChatScrollManager = ({
const { scrollTop, scrollHeight, clientHeight } = container; const { scrollTop, scrollHeight, clientHeight } = container;
const position = (scrollTop + clientHeight / 2) / Math.max(scrollHeight, 1); 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); queueViewportAnchor(currentSessionId, estimatedIndex);
}, [ }, [
currentSessionId, currentSessionId,
@@ -339,7 +332,7 @@ export const useChatScrollManager = ({
queueViewportAnchor, queueViewportAnchor,
schedulePinnedStateAndIndicators, schedulePinnedStateAndIndicators,
scrollEngine, scrollEngine,
sessionMessages.length, sessionMessageCount,
updatePinnedState, updatePinnedState,
]); ]);
@@ -469,7 +462,7 @@ export const useChatScrollManager = ({
return; return;
} }
schedulePinnedStateAndIndicators(); schedulePinnedStateAndIndicators();
}, [isSyncing, schedulePinnedStateAndIndicators, sessionMessages.length, shouldSkipLiveContentSync]); }, [isSyncing, schedulePinnedStateAndIndicators, sessionMessageCount, shouldSkipLiveContentSync]);
// Use ResizeObserver to detect content changes and maintain pin // Use ResizeObserver to detect content changes and maintain pin
React.useEffect(() => { React.useEffect(() => {
@@ -551,7 +544,7 @@ export const useChatScrollManager = ({
return () => { return () => {
window.cancelAnimationFrame(rafId); window.cancelAnimationFrame(rafId);
}; };
}, [currentSessionId, schedulePinnedStateAndIndicators, sessionMessages.length, shouldSkipLiveContentSync]); }, [currentSessionId, schedulePinnedStateAndIndicators, sessionMessageCount, shouldSkipLiveContentSync]);
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map()); const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
@@ -714,7 +707,7 @@ export const useChatScrollManager = ({
mutationObserver.disconnect(); mutationObserver.disconnect();
spy.destroy(); spy.destroy();
}; };
}, [currentSessionId, onActiveTurnChange, scrollRef, sessionMessages.length]); }, [currentSessionId, onActiveTurnChange, scrollRef, sessionMessageCount]);
return { return {
scrollRef, scrollRef,
+1
View File
@@ -59,6 +59,7 @@ export {
useDirectoryStore, useDirectoryStore,
useDirectorySync, useDirectorySync,
useSessionMessages, useSessionMessages,
useSessionMessageCount,
useSessionMessagesResolved, useSessionMessagesResolved,
useSessionParts, useSessionParts,
useSessionStatus, useSessionStatus,
+30 -4
View File
@@ -877,7 +877,7 @@ const getFirstTextFromParts = (parts: Part[]): string => {
return "" return ""
} }
function usePartsSnapshotForMessageIds(messageIds: string[], directory?: string) { function usePartsSnapshotForMessageIds(messageIds: string[], directory?: string, suspendUpdates = false) {
const store = useDirectoryStore(directory) const store = useDirectoryStore(directory)
const prevPartsRef = useRef<Record<string, Part[]>>({}) const prevPartsRef = useRef<Record<string, Part[]>>({})
const [partsSnapshot, setPartsSnapshot] = React.useState<Record<string, Part[]>>({}) const [partsSnapshot, setPartsSnapshot] = React.useState<Record<string, Part[]>>({})
@@ -906,6 +906,12 @@ function usePartsSnapshotForMessageIds(messageIds: string[], directory?: string)
flush() flush()
if (suspendUpdates) {
return () => {
if (timer) clearTimeout(timer)
}
}
const unsub = store.subscribe(() => { const unsub = store.subscribe(() => {
if (timer) { if (timer) {
pending = true pending = true
@@ -924,11 +930,21 @@ function usePartsSnapshotForMessageIds(messageIds: string[], directory?: string)
unsub() unsub()
if (timer) clearTimeout(timer) if (timer) clearTimeout(timer)
} }
}, [messageIds, store]) }, [messageIds, store, suspendUpdates])
return partsSnapshot 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[] { export function useSessionTextMessages(sessionID: string, directory?: string): SessionTextMessage[] {
const messages = useVisibleSessionMessages(sessionID, directory) const messages = useVisibleSessionMessages(sessionID, directory)
const messageIds = useMemo(() => messages.map((message) => message.id), [messages]) 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 * Uses a ref-stable parts lookup that only triggers re-renders when
* a part array for one of our displayed messages actually changes. * 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 messages = useVisibleSessionMessages(sessionID, directory)
const messageIds = useMemo(() => messages.map((message) => message.id), [messages]) 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<{ const previousRecordsRef = useRef<{
list: Array<{ info: (typeof messages)[number]; parts: Part[] }> list: Array<{ info: (typeof messages)[number]; parts: Part[] }>
byId: Map<string, { info: (typeof messages)[number]; parts: Part[] }> byId: Map<string, { info: (typeof messages)[number]; parts: Part[] }>
@@ -1052,3 +1072,9 @@ const EMPTY_MESSAGES: Message[] = []
const EMPTY_PARTS: Part[] = [] const EMPTY_PARTS: Part[] = []
const EMPTY_PERMISSION_REQUESTS: PermissionRequest[] = [] const EMPTY_PERMISSION_REQUESTS: PermissionRequest[] = []
const EMPTY_QUESTION_REQUESTS: QuestionRequest[] = [] const EMPTY_QUESTION_REQUESTS: QuestionRequest[] = []
if (import.meta.hot) {
import.meta.hot.accept(() => {
import.meta.hot?.invalidate()
})
}