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:
@@ -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<SessionMessageRecord[]>(EMPTY_MESSAGES);
|
||||
const viewportSessionIdRef = React.useRef<string | null>(null);
|
||||
const viewportMessages = React.useMemo(() => {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, SessionMemoryState>;
|
||||
@@ -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<Map<string, AnimationHandlers>>(new Map());
|
||||
|
||||
@@ -714,7 +707,7 @@ export const useChatScrollManager = ({
|
||||
mutationObserver.disconnect();
|
||||
spy.destroy();
|
||||
};
|
||||
}, [currentSessionId, onActiveTurnChange, scrollRef, sessionMessages.length]);
|
||||
}, [currentSessionId, onActiveTurnChange, scrollRef, sessionMessageCount]);
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
|
||||
@@ -59,6 +59,7 @@ export {
|
||||
useDirectoryStore,
|
||||
useDirectorySync,
|
||||
useSessionMessages,
|
||||
useSessionMessageCount,
|
||||
useSessionMessagesResolved,
|
||||
useSessionParts,
|
||||
useSessionStatus,
|
||||
|
||||
@@ -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<Record<string, Part[]>>({})
|
||||
const [partsSnapshot, setPartsSnapshot] = React.useState<Record<string, Part[]>>({})
|
||||
@@ -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<string, { info: (typeof messages)[number]; parts: Part[] }>
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user