diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index ed071edc..9662889a 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -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(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 = ({ }) => { 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(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(undefined); + const waitedSessionIdRef = React.useRef(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 = ({ }; }, []); + // 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 = ({ retryOverlay={retryOverlay} scrollToBottom={resumeToLatestInstant} endPinningReleased={userOwnsScroll} + revealWaited={revealWaited} sessionQuestions={sessionQuestions} sessionPermissions={sessionPermissions} isProgrammaticFollowActive={isFollowingProgrammatically} diff --git a/packages/ui/src/components/session/sidebar/list/useSessionPrefetch.ts b/packages/ui/src/components/session/sidebar/list/useSessionPrefetch.ts index 13a1eeae..5c89d183 100644 --- a/packages/ui/src/components/session/sidebar/list/useSessionPrefetch.ts +++ b/packages/ui/src/components/session/sidebar/list/useSessionPrefetch.ts @@ -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]); diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 6df624cf..30d43cab 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -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 diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index b9fa70ae..af606fa5 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -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