perf(chat): hold the previous timeline while a session loads; fade only when waited

- ChatContainer swaps the timeline on a deferred copy of the selection so the
  active row, URL, and tab commit first. (This part had been overwritten by a
  concurrent edit before the previous commit and is restored here.)
- A session whose messages are not in memory keeps the previous conversation
  on screen for up to 400ms while they load, instead of flashing a skeleton
  between two conversations.
- The reveal fade runs only for a session the user waited for and is 100ms;
  a session that was ready at the click appears in the same frame.
- The sidebar prefetches the two rows on either side of the open session
  150ms after it settles, two at a time, so neighbouring switches are warm.
This commit is contained in:
Bohdan Triapitsyn
2026-08-29 17:22:31 +03:00
parent 115138f5a3
commit c2136ce838
4 changed files with 82 additions and 17 deletions
@@ -12,6 +12,10 @@ import ChatEmptyState from './ChatEmptyState';
import { useGlobalSyncStore } from '@/sync/global-sync-store';
import MessageList, { type MessageListHandle } from './MessageList';
import { createTimelineRevealGate, TIMELINE_REVEAL_CAP_MS, TimelineRevealGateContext } from './timelineRevealGate';
// How long the previous timeline stays on screen while a session that is not
// in memory loads, before the skeleton takes over.
const SESSION_SWITCH_HOLD_MS = 400;
import { PermissionCard } from './PermissionCard';
import { QuestionCard } from './QuestionCard';
import { hasActiveQuestionToolInCurrentTurn, recoverPendingQuestionWithRetry } from '@/sync/question-recovery';
@@ -176,6 +180,8 @@ type ChatViewportProps = {
} | null;
scrollToBottom: () => void;
endPinningReleased: boolean;
/** The user waited for this session (held or fetched); reveal it with a fade. */
revealWaited: boolean;
sessionQuestions: QuestionRequest[];
sessionPermissions: PermissionRequest[];
isProgrammaticFollowActive: boolean;
@@ -212,6 +218,7 @@ const ChatViewport = React.memo(({
retryOverlay,
scrollToBottom,
endPinningReleased,
revealWaited,
sessionQuestions,
sessionPermissions,
isProgrammaticFollowActive,
@@ -367,8 +374,9 @@ const ChatViewport = React.memo(({
// Opening a session paints the timeline as one finished picture: the root
// stays invisible while any renderer holds a provisional first paint, then
// everything appears together. A warm switch, where nothing is held,
// reveals in the same frame; a cold open fades in once as a whole.
// everything appears together. A session the user waited for fades in
// once as a whole; one that was ready at the click shows in the same
// frame.
const timelineRootRef = React.useRef<HTMLDivElement | null>(null);
const revealGate = React.useMemo(() => createTimelineRevealGate(), [currentSessionKey]);
React.useLayoutEffect(() => {
@@ -391,7 +399,7 @@ const ChatViewport = React.memo(({
if (finished) return;
revealGate.close();
if (revealGate.holds === 0) {
reveal(false);
reveal(revealWaited);
return;
}
revealGate.onEmpty = () => reveal(true);
@@ -402,7 +410,7 @@ const ChatViewport = React.memo(({
if (timer !== null) window.clearTimeout(timer);
revealGate.onEmpty = null;
};
}, [revealGate]);
}, [revealGate, revealWaited]);
const scrollContainerProps = React.useMemo(() => ({
className: 'absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target',
@@ -486,6 +494,7 @@ const ChatViewport = React.memo(({
&& prev.retryOverlay === next.retryOverlay
&& prev.scrollToBottom === next.scrollToBottom
&& prev.endPinningReleased === next.endPinningReleased
&& prev.revealWaited === next.revealWaited
&& prev.sessionQuestions === next.sessionQuestions
&& prev.sessionPermissions === next.sessionPermissions
&& prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive
@@ -625,10 +634,53 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
}) => {
const messagesEnabled = messagesEnabledProp ?? active;
const { t } = useI18n();
// Session UI state
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const currentSessionDirectory = useSessionUIStore((s) => s.currentSessionDirectory);
// Session UI state. The selection is published synchronously by the
// sidebar click, but the chat swaps its content on a deferred copy: the
// first commit paints the cheap reactions (active row, URL, tab) while the
// timeline for the new session renders in an interruptible transition
// behind it. Both fields travel as one value so the key, the message
// subscription, and the loader target never mix an old directory with a
// new session id.
const liveSessionId = useSessionUIStore((s) => s.currentSessionId);
const liveSessionDirectory = useSessionUIStore((s) => s.currentSessionDirectory);
const materializedDraftSessionId = useSessionUIStore((s) => s.materializedDraftSessionId);
const liveSelection = React.useMemo(
() => ({ sessionId: liveSessionId, directory: liveSessionDirectory }),
[liveSessionId, liveSessionDirectory],
);
// A session whose messages are not in memory yet keeps the previous
// timeline on screen while they load, instead of flashing a skeleton
// between two conversations. The hold ends when the session becomes
// renderable or after SESSION_SWITCH_HOLD_MS, whichever comes first, and
// never applies when nothing was shown before or when the session was just
// created from a draft.
const liveSessionRenderable = useSessionRenderable(liveSessionId ?? '', liveSessionDirectory ?? undefined);
const shownSelectionRef = React.useRef(liveSelection);
const [expiredHoldSessionId, setExpiredHoldSessionId] = React.useState<string | null>(null);
const holdPreviousTimeline = Boolean(liveSessionId)
&& !liveSessionRenderable
&& liveSessionId !== materializedDraftSessionId
&& shownSelectionRef.current.sessionId !== null
&& shownSelectionRef.current.sessionId !== liveSessionId
&& expiredHoldSessionId !== liveSessionId;
React.useEffect(() => {
if (!holdPreviousTimeline || !liveSessionId) return;
const timer = window.setTimeout(() => setExpiredHoldSessionId(liveSessionId), SESSION_SWITCH_HOLD_MS);
return () => window.clearTimeout(timer);
}, [holdPreviousTimeline, liveSessionId]);
// A session the user waited for (not in memory at the click) fades in; one
// that was ready appears in the same frame. Decided once per selection so
// a later, warm visit to the same session is instant again.
const lastLiveSessionIdRef = React.useRef<string | null | undefined>(undefined);
const waitedSessionIdRef = React.useRef<string | null>(null);
if (liveSessionId !== lastLiveSessionIdRef.current) {
lastLiveSessionIdRef.current = liveSessionId;
waitedSessionIdRef.current = liveSessionId && !liveSessionRenderable ? liveSessionId : null;
}
const targetSelection = holdPreviousTimeline ? shownSelectionRef.current : liveSelection;
const { sessionId: currentSessionId, directory: currentSessionDirectory } = React.useDeferredValue(targetSelection);
shownSelectionRef.current = { sessionId: currentSessionId, directory: currentSessionDirectory };
const revealWaited = Boolean(currentSessionId) && currentSessionId === waitedSessionIdRef.current;
const clearMaterializedDraftSession = useSessionUIStore((s) => s.clearMaterializedDraftSession);
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
@@ -941,13 +993,17 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
};
}, []);
// Selection policy reads the live selection, not the deferred one: right
// after a click the deferred id still names the previous session (or
// nothing) for one commit, and acting on that would open a draft over the
// session the user just chose.
React.useEffect(() => {
if (autoOpenDraft && !currentSessionId && !draftOpen) {
if (autoOpenDraft && !liveSessionId && !draftOpen) {
// Programmatic fallback, not user navigation — must not clear the
// persisted last-session pointer the cold-launch restore reads.
openNewSessionDraft({ automatic: true });
}
}, [autoOpenDraft, currentSessionId, draftOpen, openNewSessionDraft]);
}, [autoOpenDraft, liveSessionId, draftOpen, openNewSessionDraft]);
const activeTurnChangeRef = React.useRef<(turnId: string | null) => void>(() => {});
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
@@ -1437,6 +1493,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
retryOverlay={retryOverlay}
scrollToBottom={resumeToLatestInstant}
endPinningReleased={userOwnsScroll}
revealWaited={revealWaited}
sessionQuestions={sessionQuestions}
sessionPermissions={sessionPermissions}
isProgrammaticFollowActive={isFollowingProgrammatically}