fix desktop notifications and chat scroll stability

This commit is contained in:
Bohdan Triapitsyn
2026-04-06 17:36:08 +03:00
parent e42471ee7b
commit 9254ec0783
10 changed files with 292 additions and 97 deletions
@@ -631,6 +631,7 @@ export const ChatContainer: React.FC = () => {
}, [currentSessionId, isDesktopExpandedInput, scrollRef]);
const hasHistoryMetadata = Boolean(historyMeta);
const lastHydratedSessionRef = React.useRef<string | null>(null);
const isSessionHydrating =
Boolean(currentSessionId)
@@ -640,12 +641,15 @@ export const ChatContainer: React.FC = () => {
if (!currentSessionId) return;
if (hasSessionMessagesEntry && hasHistoryMetadata) return;
const isSessionSwitch = lastHydratedSessionRef.current !== currentSessionId;
lastHydratedSessionRef.current = currentSessionId;
const load = async () => {
await loadMessages(currentSessionId).finally(() => {
const statusType = sessionStatusForCurrent.type ?? 'idle';
const isActivePhase = statusType === 'busy' || statusType === 'retry';
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
const shouldSkipScroll = (isActivePhase && isPinned) || hasHashTarget;
const shouldSkipScroll = hasHashTarget || (isActivePhase && isPinned && !isSessionSwitch);
if (!shouldSkipScroll) {
if (typeof window === 'undefined') {
@@ -1692,17 +1692,10 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
return false;
}
const container = resolveScrollContainer();
if (!container) {
return false;
}
const virtualizerBehavior = behavior === 'smooth' ? 'smooth' : 'auto';
historyVirtualizer.scrollToIndex(index, { align: 'start', behavior: virtualizerBehavior });
const targetTop = Math.max(0, container.scrollTop - 50);
container.scrollTo({ top: targetTop, behavior });
return true;
}, [historyEntries.length, historyVirtualizer, resolveScrollContainer, shouldVirtualizeHistory]);
}, [historyEntries.length, historyVirtualizer, shouldVirtualizeHistory]);
const scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => {
const container = resolveScrollContainer();
@@ -14,19 +14,17 @@ import {
import type { TurnHistorySignals } from '../lib/turns/historySignals';
import { getMemoryLimits, type SessionHistoryMeta } from '@/stores/types/sessionTypes';
const waitForFrames = async (count = 1): Promise<void> => {
if (typeof window === 'undefined') {
return;
}
for (let index = 0; index < count; index += 1) {
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => resolve());
});
}
};
type ViewportAnchor = { messageId: string; offsetTop: number };
type PendingScrollRequest = {
sessionId: string;
kind: 'turn' | 'message';
id: string;
behavior: ScrollBehavior;
turnId: string | null;
resolve: (value: boolean) => void;
};
interface UseChatTimelineControllerOptions {
sessionId: string | null;
messages: ChatMessageEntry[];
@@ -100,6 +98,8 @@ export const useChatTimelineController = ({
const historyMetaRef = React.useRef<SessionHistoryMeta | null>(historyMeta);
const previousTurnCountRef = React.useRef(turnWindowModel.turnCount);
const initializedSessionRef = React.useRef<string | null>(null);
const pendingRenderResolversRef = React.useRef<Array<() => void>>([]);
const pendingScrollRequestRef = React.useRef<PendingScrollRequest | null>(null);
const historySignals = React.useMemo(() => {
const defaultLimit = getMemoryLimits().HISTORICAL_MESSAGES;
@@ -189,10 +189,78 @@ export const useChatTimelineController = ({
previousTurnCountRef.current = nextTurnCount;
}, [turnWindowModel.turnCount]);
const resolvePendingRenderWaiters = React.useCallback(() => {
const resolvers = pendingRenderResolversRef.current;
if (resolvers.length === 0) {
return;
}
pendingRenderResolversRef.current = [];
resolvers.forEach((resolve) => resolve());
}, []);
const waitForNextRenderCommit = React.useCallback((): Promise<void> => {
return new Promise<void>((resolve) => {
pendingRenderResolversRef.current.push(resolve);
});
}, []);
const resolvePendingScrollRequest = React.useCallback((value: boolean) => {
const pending = pendingScrollRequestRef.current;
if (!pending) {
return;
}
pendingScrollRequestRef.current = null;
pending.resolve(value);
}, []);
const attemptPendingScrollRequest = React.useCallback(() => {
const pending = pendingScrollRequestRef.current;
if (!pending) {
return;
}
if (pending.sessionId !== sessionIdRef.current) {
resolvePendingScrollRequest(false);
return;
}
const didScroll = pending.kind === 'turn'
? (messageListRef.current?.scrollToTurnId(pending.id, { behavior: pending.behavior }) ?? false)
: (messageListRef.current?.scrollToMessageId(pending.id, { behavior: pending.behavior }) ?? false);
if (didScroll) {
if (pending.turnId) {
setActiveTurnId(pending.turnId);
}
resolvePendingScrollRequest(true);
return;
}
const targetIndex = pending.kind === 'turn'
? turnModelRef.current.turnIndexById.get(pending.id)
: turnModelRef.current.messageToTurnIndex.get(pending.id);
if (typeof targetIndex === 'number' && targetIndex >= turnStartRef.current) {
resolvePendingScrollRequest(false);
}
}, [messageListRef, resolvePendingScrollRequest]);
React.useEffect(() => {
return () => {
resolvePendingRenderWaiters();
resolvePendingScrollRequest(false);
};
}, [resolvePendingRenderWaiters, resolvePendingScrollRequest]);
const renderedMessages = React.useMemo(() => {
return windowMessagesByTurn(messages, turnWindowModel, turnStart);
}, [messages, turnStart, turnWindowModel]);
React.useLayoutEffect(() => {
resolvePendingRenderWaiters();
attemptPendingScrollRequest();
}, [attemptPendingScrollRequest, renderedMessages, resolvePendingRenderWaiters, turnStart]);
// --- Synchronous scroll compensation for load-more / reveal ---
// fetchOlderHistory and revealBufferedTurns store a snapshot here
// before triggering the state change. useLayoutEffect consumes it
@@ -257,10 +325,10 @@ export const useChatTimelineController = ({
return next > 0 ? next : 0;
});
await waitForFrames(1);
await waitForNextRenderCommit();
setPendingRevealWork(false);
return true;
}, [captureViewportAnchor, scrollRef]);
}, [captureViewportAnchor, scrollRef, waitForNextRenderCommit]);
const fetchOlderHistory = React.useCallback(async (input: {
preserveViewport: boolean;
@@ -344,26 +412,29 @@ export const useChatTimelineController = ({
if (turnIndex < turnStartRef.current) {
setTurnStart(turnIndex);
await waitForFrames(2);
}
const didScroll = messageListRef.current?.scrollToTurnId(turnId, {
behavior: options?.behavior,
}) ?? false;
const result = await new Promise<boolean>((resolve) => {
pendingScrollRequestRef.current = {
sessionId: sessionIdRef.current ?? sessionId ?? '',
kind: 'turn',
id: turnId,
behavior: options?.behavior ?? 'auto',
turnId,
resolve,
};
attemptPendingScrollRequest();
});
if (didScroll) {
setActiveTurnId(turnId);
if (result) {
return true;
}
await waitForFrames(2);
return messageListRef.current?.scrollToTurnId(turnId, {
behavior: options?.behavior,
}) ?? false;
return false;
} finally {
setPendingRevealWork(false);
}
}, [messageListRef, sessionId]);
}, [attemptPendingScrollRequest, sessionId]);
const scrollToMessage = React.useCallback(async (
messageId: string,
@@ -389,28 +460,29 @@ export const useChatTimelineController = ({
if (turnIndex < turnStartRef.current) {
setTurnStart(turnIndex);
await waitForFrames(2);
}
const didScroll = messageListRef.current?.scrollToMessageId(messageId, {
behavior: options?.behavior,
}) ?? false;
const result = await new Promise<boolean>((resolve) => {
pendingScrollRequestRef.current = {
sessionId: sessionIdRef.current ?? sessionId ?? '',
kind: 'message',
id: messageId,
behavior: options?.behavior ?? 'auto',
turnId: turnId ?? null,
resolve,
};
attemptPendingScrollRequest();
});
if (didScroll) {
if (turnId) {
setActiveTurnId(turnId);
}
if (result) {
return true;
}
await waitForFrames(2);
return messageListRef.current?.scrollToMessageId(messageId, {
behavior: options?.behavior,
}) ?? false;
return false;
} finally {
setPendingRevealWork(false);
}
}, [messageListRef, sessionId]);
}, [attemptPendingScrollRequest, sessionId]);
const resumeToBottom = React.useCallback(() => {
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);