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 { 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(() => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export {
|
|||||||
useDirectoryStore,
|
useDirectoryStore,
|
||||||
useDirectorySync,
|
useDirectorySync,
|
||||||
useSessionMessages,
|
useSessionMessages,
|
||||||
|
useSessionMessageCount,
|
||||||
useSessionMessagesResolved,
|
useSessionMessagesResolved,
|
||||||
useSessionParts,
|
useSessionParts,
|
||||||
useSessionStatus,
|
useSessionStatus,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user