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}
@@ -5,9 +5,12 @@ import { getSyncSessionMaterializationStatus } from '@/sync/sync-refs';
import { isVSCodeRuntime } from '@/lib/desktop';
const SESSION_PREFETCH_HOVER_DELAY_MS = 180;
const SESSION_PREFETCH_SETTLE_MS = 600;
const SESSION_PREFETCH_CONCURRENCY = 1;
const SESSION_PREFETCH_PENDING_LIMIT = 6;
const SESSION_PREFETCH_SETTLE_MS = 150;
const SESSION_PREFETCH_CONCURRENCY = 2;
const SESSION_PREFETCH_PENDING_LIMIT = 8;
// Nearest first: the rows right next to the open session are the likeliest
// next click.
const NEIGHBOR_PREFETCH_OFFSETS = [-1, 1, -2, 2];
type Args = {
enabled?: boolean;
@@ -132,8 +135,7 @@ export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSes
const timer = window.setTimeout(() => {
const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId);
if (currentIndex < 0) return;
scheduleSessionPrefetch(sortedSessions[currentIndex - 1]);
scheduleSessionPrefetch(sortedSessions[currentIndex + 1]);
for (const offset of NEIGHBOR_PREFETCH_OFFSETS) scheduleSessionPrefetch(sortedSessions[currentIndex + offset]);
}, SESSION_PREFETCH_SETTLE_MS);
return () => window.clearTimeout(timer);
}, [currentSessionId, enabled, prefetchDisabled, scheduleSessionPrefetch, sortedSessions]);
@@ -145,8 +147,7 @@ export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSes
const timer = window.setTimeout(() => {
const currentIndex = recentSessions.findIndex((session) => session.id === currentSessionId);
if (currentIndex < 0) return;
scheduleSessionPrefetch(recentSessions[currentIndex - 1]);
scheduleSessionPrefetch(recentSessions[currentIndex + 1]);
for (const offset of NEIGHBOR_PREFETCH_OFFSETS) scheduleSessionPrefetch(recentSessions[currentIndex + offset]);
}, SESSION_PREFETCH_SETTLE_MS);
return () => window.clearTimeout(timer);
}, [currentSessionId, enabled, prefetchDisabled, recentSessions, scheduleSessionPrefetch]);
+1 -1
View File
@@ -1391,7 +1391,7 @@ html:not(.dark) .chat-scroll {
.oc-chat-hydration-reveal,
[data-timeline-reveal='fading'] {
animation: oc-chat-hydration-reveal 180ms ease-out both;
animation: oc-chat-hydration-reveal 100ms ease-out both;
}
/* Timeline root while a freshly opened session still has provisional first
+7
View File
@@ -441,6 +441,13 @@ in a transition behind it. Selection *policy* inside `ChatContainer` (auto-
opening a draft when nothing is selected) reads the live store value, because
the deferred one still names the previous session for one commit.
A session whose messages are not in memory at the click keeps the previous
timeline on screen while they load (up to 400ms), then swaps straight to the
finished view; the skeleton appears only when loading takes longer. A session
the user waited for fades in (100ms); one that was ready appears in the same
frame. The sidebar prefetches the two rows on either side of the open session
shortly after it settles, so most neighbouring switches are warm.
The timeline's first paint for a session is atomic. `ChatContainer` owns a
`TimelineRevealGate` per session key (`components/chat/timelineRevealGate.ts`):
a markdown renderer whose first paint is provisional (blocks not yet in the