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:
@@ -12,6 +12,10 @@ import ChatEmptyState from './ChatEmptyState';
|
|||||||
import { useGlobalSyncStore } from '@/sync/global-sync-store';
|
import { useGlobalSyncStore } from '@/sync/global-sync-store';
|
||||||
import MessageList, { type MessageListHandle } from './MessageList';
|
import MessageList, { type MessageListHandle } from './MessageList';
|
||||||
import { createTimelineRevealGate, TIMELINE_REVEAL_CAP_MS, TimelineRevealGateContext } from './timelineRevealGate';
|
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 { PermissionCard } from './PermissionCard';
|
||||||
import { QuestionCard } from './QuestionCard';
|
import { QuestionCard } from './QuestionCard';
|
||||||
import { hasActiveQuestionToolInCurrentTurn, recoverPendingQuestionWithRetry } from '@/sync/question-recovery';
|
import { hasActiveQuestionToolInCurrentTurn, recoverPendingQuestionWithRetry } from '@/sync/question-recovery';
|
||||||
@@ -176,6 +180,8 @@ type ChatViewportProps = {
|
|||||||
} | null;
|
} | null;
|
||||||
scrollToBottom: () => void;
|
scrollToBottom: () => void;
|
||||||
endPinningReleased: boolean;
|
endPinningReleased: boolean;
|
||||||
|
/** The user waited for this session (held or fetched); reveal it with a fade. */
|
||||||
|
revealWaited: boolean;
|
||||||
sessionQuestions: QuestionRequest[];
|
sessionQuestions: QuestionRequest[];
|
||||||
sessionPermissions: PermissionRequest[];
|
sessionPermissions: PermissionRequest[];
|
||||||
isProgrammaticFollowActive: boolean;
|
isProgrammaticFollowActive: boolean;
|
||||||
@@ -212,6 +218,7 @@ const ChatViewport = React.memo(({
|
|||||||
retryOverlay,
|
retryOverlay,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
endPinningReleased,
|
endPinningReleased,
|
||||||
|
revealWaited,
|
||||||
sessionQuestions,
|
sessionQuestions,
|
||||||
sessionPermissions,
|
sessionPermissions,
|
||||||
isProgrammaticFollowActive,
|
isProgrammaticFollowActive,
|
||||||
@@ -367,8 +374,9 @@ const ChatViewport = React.memo(({
|
|||||||
|
|
||||||
// Opening a session paints the timeline as one finished picture: the root
|
// Opening a session paints the timeline as one finished picture: the root
|
||||||
// stays invisible while any renderer holds a provisional first paint, then
|
// stays invisible while any renderer holds a provisional first paint, then
|
||||||
// everything appears together. A warm switch, where nothing is held,
|
// everything appears together. A session the user waited for fades in
|
||||||
// reveals in the same frame; a cold open fades in once as a whole.
|
// once as a whole; one that was ready at the click shows in the same
|
||||||
|
// frame.
|
||||||
const timelineRootRef = React.useRef<HTMLDivElement | null>(null);
|
const timelineRootRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const revealGate = React.useMemo(() => createTimelineRevealGate(), [currentSessionKey]);
|
const revealGate = React.useMemo(() => createTimelineRevealGate(), [currentSessionKey]);
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
@@ -391,7 +399,7 @@ const ChatViewport = React.memo(({
|
|||||||
if (finished) return;
|
if (finished) return;
|
||||||
revealGate.close();
|
revealGate.close();
|
||||||
if (revealGate.holds === 0) {
|
if (revealGate.holds === 0) {
|
||||||
reveal(false);
|
reveal(revealWaited);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
revealGate.onEmpty = () => reveal(true);
|
revealGate.onEmpty = () => reveal(true);
|
||||||
@@ -402,7 +410,7 @@ const ChatViewport = React.memo(({
|
|||||||
if (timer !== null) window.clearTimeout(timer);
|
if (timer !== null) window.clearTimeout(timer);
|
||||||
revealGate.onEmpty = null;
|
revealGate.onEmpty = null;
|
||||||
};
|
};
|
||||||
}, [revealGate]);
|
}, [revealGate, revealWaited]);
|
||||||
|
|
||||||
const scrollContainerProps = React.useMemo(() => ({
|
const scrollContainerProps = React.useMemo(() => ({
|
||||||
className: 'absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target',
|
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.retryOverlay === next.retryOverlay
|
||||||
&& prev.scrollToBottom === next.scrollToBottom
|
&& prev.scrollToBottom === next.scrollToBottom
|
||||||
&& prev.endPinningReleased === next.endPinningReleased
|
&& prev.endPinningReleased === next.endPinningReleased
|
||||||
|
&& prev.revealWaited === next.revealWaited
|
||||||
&& prev.sessionQuestions === next.sessionQuestions
|
&& prev.sessionQuestions === next.sessionQuestions
|
||||||
&& prev.sessionPermissions === next.sessionPermissions
|
&& prev.sessionPermissions === next.sessionPermissions
|
||||||
&& prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive
|
&& prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive
|
||||||
@@ -625,10 +634,53 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const messagesEnabled = messagesEnabledProp ?? active;
|
const messagesEnabled = messagesEnabledProp ?? active;
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
// Session UI state
|
// Session UI state. The selection is published synchronously by the
|
||||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
// sidebar click, but the chat swaps its content on a deferred copy: the
|
||||||
const currentSessionDirectory = useSessionUIStore((s) => s.currentSessionDirectory);
|
// 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 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 clearMaterializedDraftSession = useSessionUIStore((s) => s.clearMaterializedDraftSession);
|
||||||
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
|
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
|
||||||
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (autoOpenDraft && !currentSessionId && !draftOpen) {
|
if (autoOpenDraft && !liveSessionId && !draftOpen) {
|
||||||
// Programmatic fallback, not user navigation — must not clear the
|
// Programmatic fallback, not user navigation — must not clear the
|
||||||
// persisted last-session pointer the cold-launch restore reads.
|
// persisted last-session pointer the cold-launch restore reads.
|
||||||
openNewSessionDraft({ automatic: true });
|
openNewSessionDraft({ automatic: true });
|
||||||
}
|
}
|
||||||
}, [autoOpenDraft, currentSessionId, draftOpen, openNewSessionDraft]);
|
}, [autoOpenDraft, liveSessionId, draftOpen, openNewSessionDraft]);
|
||||||
|
|
||||||
const activeTurnChangeRef = React.useRef<(turnId: string | null) => void>(() => {});
|
const activeTurnChangeRef = React.useRef<(turnId: string | null) => void>(() => {});
|
||||||
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
|
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
|
||||||
@@ -1437,6 +1493,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
|||||||
retryOverlay={retryOverlay}
|
retryOverlay={retryOverlay}
|
||||||
scrollToBottom={resumeToLatestInstant}
|
scrollToBottom={resumeToLatestInstant}
|
||||||
endPinningReleased={userOwnsScroll}
|
endPinningReleased={userOwnsScroll}
|
||||||
|
revealWaited={revealWaited}
|
||||||
sessionQuestions={sessionQuestions}
|
sessionQuestions={sessionQuestions}
|
||||||
sessionPermissions={sessionPermissions}
|
sessionPermissions={sessionPermissions}
|
||||||
isProgrammaticFollowActive={isFollowingProgrammatically}
|
isProgrammaticFollowActive={isFollowingProgrammatically}
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import { getSyncSessionMaterializationStatus } from '@/sync/sync-refs';
|
|||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
|
|
||||||
const SESSION_PREFETCH_HOVER_DELAY_MS = 180;
|
const SESSION_PREFETCH_HOVER_DELAY_MS = 180;
|
||||||
const SESSION_PREFETCH_SETTLE_MS = 600;
|
const SESSION_PREFETCH_SETTLE_MS = 150;
|
||||||
const SESSION_PREFETCH_CONCURRENCY = 1;
|
const SESSION_PREFETCH_CONCURRENCY = 2;
|
||||||
const SESSION_PREFETCH_PENDING_LIMIT = 6;
|
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 = {
|
type Args = {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -132,8 +135,7 @@ export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSes
|
|||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId);
|
const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId);
|
||||||
if (currentIndex < 0) return;
|
if (currentIndex < 0) return;
|
||||||
scheduleSessionPrefetch(sortedSessions[currentIndex - 1]);
|
for (const offset of NEIGHBOR_PREFETCH_OFFSETS) scheduleSessionPrefetch(sortedSessions[currentIndex + offset]);
|
||||||
scheduleSessionPrefetch(sortedSessions[currentIndex + 1]);
|
|
||||||
}, SESSION_PREFETCH_SETTLE_MS);
|
}, SESSION_PREFETCH_SETTLE_MS);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [currentSessionId, enabled, prefetchDisabled, scheduleSessionPrefetch, sortedSessions]);
|
}, [currentSessionId, enabled, prefetchDisabled, scheduleSessionPrefetch, sortedSessions]);
|
||||||
@@ -145,8 +147,7 @@ export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSes
|
|||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
const currentIndex = recentSessions.findIndex((session) => session.id === currentSessionId);
|
const currentIndex = recentSessions.findIndex((session) => session.id === currentSessionId);
|
||||||
if (currentIndex < 0) return;
|
if (currentIndex < 0) return;
|
||||||
scheduleSessionPrefetch(recentSessions[currentIndex - 1]);
|
for (const offset of NEIGHBOR_PREFETCH_OFFSETS) scheduleSessionPrefetch(recentSessions[currentIndex + offset]);
|
||||||
scheduleSessionPrefetch(recentSessions[currentIndex + 1]);
|
|
||||||
}, SESSION_PREFETCH_SETTLE_MS);
|
}, SESSION_PREFETCH_SETTLE_MS);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [currentSessionId, enabled, prefetchDisabled, recentSessions, scheduleSessionPrefetch]);
|
}, [currentSessionId, enabled, prefetchDisabled, recentSessions, scheduleSessionPrefetch]);
|
||||||
|
|||||||
@@ -1391,7 +1391,7 @@ html:not(.dark) .chat-scroll {
|
|||||||
|
|
||||||
.oc-chat-hydration-reveal,
|
.oc-chat-hydration-reveal,
|
||||||
[data-timeline-reveal='fading'] {
|
[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
|
/* Timeline root while a freshly opened session still has provisional first
|
||||||
|
|||||||
@@ -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
|
opening a draft when nothing is selected) reads the live store value, because
|
||||||
the deferred one still names the previous session for one commit.
|
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
|
The timeline's first paint for a session is atomic. `ChatContainer` owns a
|
||||||
`TimelineRevealGate` per session key (`components/chat/timelineRevealGate.ts`):
|
`TimelineRevealGate` per session key (`components/chat/timelineRevealGate.ts`):
|
||||||
a markdown renderer whose first paint is provisional (blocks not yet in the
|
a markdown renderer whose first paint is provisional (blocks not yet in the
|
||||||
|
|||||||
Reference in New Issue
Block a user