diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 9662889a..ddcb0fc3 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -11,11 +11,18 @@ import { Skeleton } from '@/components/ui/skeleton'; 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'; +import { createTimelineRevealGate, TIMELINE_REVEAL_CAP_MS, TimelineRevealGateContext, type TimelineRevealGate } 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; +// End inset reserved for the status row that floats over the timeline's +// bottom edge (its tallest resting height plus the mb-2 gap). +const STATUS_OVERLAY_RESERVED_HEIGHT = 40; +// A freshly opened timeline is shown once its content height has held still +// for this many consecutive frames, or after the cap. +const TIMELINE_SETTLE_STABLE_FRAMES = 2; +const TIMELINE_SETTLE_CAP_MS = 300; import { PermissionCard } from './PermissionCard'; import { QuestionCard } from './QuestionCard'; import { hasActiveQuestionToolInCurrentTurn, recoverPendingQuestionWithRetry } from '@/sync/question-recovery'; @@ -182,6 +189,7 @@ type ChatViewportProps = { endPinningReleased: boolean; /** The user waited for this session (held or fetched); reveal it with a fade. */ revealWaited: boolean; + revealGate: TimelineRevealGate; sessionQuestions: QuestionRequest[]; sessionPermissions: PermissionRequest[]; isProgrammaticFollowActive: boolean; @@ -219,6 +227,7 @@ const ChatViewport = React.memo(({ scrollToBottom, endPinningReleased, revealWaited, + revealGate, sessionQuestions, sessionPermissions, isProgrammaticFollowActive, @@ -378,19 +387,49 @@ const ChatViewport = React.memo(({ // 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]); + const endPinningReleasedRef = React.useRef(endPinningReleased); + endPinningReleasedRef.current = endPinningReleased; React.useLayoutEffect(() => { const root = timelineRootRef.current; if (!root) return; root.setAttribute('data-timeline-reveal', 'pending'); let finished = false; let timer: number | null = null; + let frame: number | null = null; + // Revealed once the geometry has settled: after the last hold the + // list still lays rows out from its own measurements over a few + // frames, so the timeline stays hidden — pinned to the end on every + // frame — until the content height has held still for two frames, + // then shows already sitting on the end. The settle is bounded so a + // list that keeps growing (images, late tool output) still appears. const reveal = (fade: boolean) => { if (finished) return; finished = true; if (timer !== null) window.clearTimeout(timer); - if (fade) root.setAttribute('data-timeline-reveal', 'fading'); - else root.removeAttribute('data-timeline-reveal'); + const startedAt = performance.now(); + let lastHeight = -1; + let stableFrames = 0; + const settle = () => { + frame = null; + const node = scrollRef.current; + let height = -1; + if (node) { + height = node.scrollHeight; + if (!endPinningReleasedRef.current) { + const end = height - node.clientHeight; + if (end - node.scrollTop > 1) node.scrollTop = end; + } + } + stableFrames = height === lastHeight ? stableFrames + 1 : 0; + lastHeight = height; + if (stableFrames < TIMELINE_SETTLE_STABLE_FRAMES && performance.now() - startedAt < TIMELINE_SETTLE_CAP_MS) { + frame = window.requestAnimationFrame(settle); + return; + } + if (fade) root.setAttribute('data-timeline-reveal', 'fading'); + else root.removeAttribute('data-timeline-reveal'); + }; + frame = window.requestAnimationFrame(settle); }; // Holds are taken in layout effects, including those of rows the list // mounts in a nested synchronous pass; a microtask runs after all of @@ -408,9 +447,10 @@ const ChatViewport = React.memo(({ return () => { finished = true; if (timer !== null) window.clearTimeout(timer); + if (frame !== null) window.cancelAnimationFrame(frame); revealGate.onEmpty = null; }; - }, [revealGate, revealWaited]); + }, [revealGate, revealWaited, scrollRef]); const scrollContainerProps = React.useMemo(() => ({ className: 'absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target', @@ -495,6 +535,7 @@ const ChatViewport = React.memo(({ && prev.scrollToBottom === next.scrollToBottom && prev.endPinningReleased === next.endPinningReleased && prev.revealWaited === next.revealWaited + && prev.revealGate === next.revealGate && prev.sessionQuestions === next.sessionQuestions && prev.sessionPermissions === next.sessionPermissions && prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive @@ -681,6 +722,7 @@ export const ChatContainer: React.FC = ({ 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); @@ -693,6 +735,10 @@ export const ChatContainer: React.FC = ({ const currentSessionKey = currentSessionId ? JSON.stringify([getRuntimeKey(), effectiveSessionDirectory, currentSessionId]) : null; + // One gate per opened session; the scroll hook holds it until the + // viewport is pinned to the end so the first visible frame is already + // at the bottom. + const revealGate = React.useMemo(() => createTimelineRevealGate(), [currentSessionKey]); const ensureSessionRenderable = React.useCallback( (sessionId: string) => sync.ensureSessionRenderable(sessionId, false, effectiveSessionDirectory), [effectiveSessionDirectory, sync], @@ -1014,7 +1060,11 @@ export const ChatContainer: React.FC = ({ // OVER the timeline's bottom edge; its measured height keeps the live // streaming line above it and reserves matching end inset in the list. const [statusOverlayHeight, setStatusOverlayHeight] = React.useState(0); - const composerOverlayHeight = statusOverlayHeight; + // The reserve is fixed so the timeline's end does not move when the row + // appears a commit after the session opened: a viewport pinned to the end + // would otherwise be left sitting the row's height above it. Measurement + // only extends the reserve for a taller row. + const composerOverlayHeight = Math.max(STATUS_OVERLAY_RESERVED_HEIGHT, statusOverlayHeight); const statusOverlayObserverRef = React.useRef(null); const onStatusOverlayNode = React.useCallback((node: HTMLDivElement | null) => { statusOverlayObserverRef.current?.disconnect(); @@ -1071,6 +1121,8 @@ export const ChatContainer: React.FC = ({ sessionMessageCount, composerOverlayHeight, lastUserMessageId, + sessionIsWorking, + revealGate, onActiveTurnChange: handleActiveTurnChange, }); @@ -1494,6 +1546,7 @@ export const ChatContainer: React.FC = ({ scrollToBottom={resumeToLatestInstant} endPinningReleased={userOwnsScroll} revealWaited={revealWaited} + revealGate={revealGate} sessionQuestions={sessionQuestions} sessionPermissions={sessionPermissions} isProgrammaticFollowActive={isFollowingProgrammatically} diff --git a/packages/ui/src/components/chat/SessionRecapSpacer.tsx b/packages/ui/src/components/chat/SessionRecapSpacer.tsx index dc97ac52..c48ad08b 100644 --- a/packages/ui/src/components/chat/SessionRecapSpacer.tsx +++ b/packages/ui/src/components/chat/SessionRecapSpacer.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useSessionAssistState } from '@/hooks/useSessionAssist'; import { useI18n } from '@/lib/i18n'; +import { TimelineRevealGateContext } from '@/components/chat/timelineRevealGate'; interface SessionRecapNoteProps { sessionId: string; @@ -12,8 +13,17 @@ interface SessionRecapNoteProps { // the last message (above the reserved bottom gap). Appears only after the // 1-minute quiet window, so the layout shift happens off-screen in practice. export const SessionRecapNote: React.FC = React.memo(({ sessionId, directory, isMobile }) => { - const { visibleRecap } = useSessionAssistState(sessionId, directory); + const { visibleRecap, sessionKnown } = useSessionAssistState(sessionId, directory); const { t } = useI18n(); + // The recap is part of the opened session's finished picture: until the + // session record is in memory it cannot be decided, and appearing a commit + // later would grow the footer under a viewport already pinned to the end. + const revealGate = React.useContext(TimelineRevealGateContext); + React.useLayoutEffect(() => { + if (sessionKnown) return undefined; + const release = revealGate?.hold(); + return release ?? undefined; + }, [revealGate, sessionKnown]); if (!visibleRecap) { return null; diff --git a/packages/ui/src/components/chat/timelineRevealGate.ts b/packages/ui/src/components/chat/timelineRevealGate.ts index 8f7b1805..7c03ea03 100644 --- a/packages/ui/src/components/chat/timelineRevealGate.ts +++ b/packages/ui/src/components/chat/timelineRevealGate.ts @@ -13,7 +13,7 @@ import React from 'react'; * A hold that never releases must not hide the chat forever, so the owner * reveals after `TIMELINE_REVEAL_CAP_MS` regardless. */ -type TimelineRevealGate = { +export type TimelineRevealGate = { /** Take a hold; returns the release. Returns null once the gate is closed. */ hold: () => (() => void) | null; /** Stops accepting holds. Existing holds still count. */ diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index 5b8d8606..0fb88e51 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -4,6 +4,7 @@ import { MessageFreshnessDetector } from '@/lib/messageFreshness'; import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy'; import { useViewportStore } from '@/sync/viewport-store'; import { useUIStore } from '@/stores/useUIStore'; +import type { TimelineRevealGate } from '@/components/chat/timelineRevealGate'; import { CHAT_LIST_ANCHOR_OFFSET, getAnchoredTurnMetrics, @@ -76,9 +77,20 @@ interface UseChatTimelineScrollOptions { // Id of the newest user message in the rendered timeline. When a send has // armed the anchor, the next new id here becomes the anchored row. lastUserMessageId: string | null; + // True while the session is producing output. Follow corrections glide + // only then. Outside a live stream — entering a session, a tab becoming + // active, rows re-measuring after a switch — the viewport must land on + // the end instantly: an animated catch-up scrolls visibly through the + // conversation and gets cut short by the next measurement. + sessionIsWorking: boolean; + // Reveal gate of the session being opened. Held until the viewport is + // pinned to the end, so the session is never shown scrolled to the top. + revealGate?: TimelineRevealGate | null; onActiveTurnChange?: (turnId: string | null) => void; } + + export interface UseChatTimelineScrollResult { scrollRef: React.RefObject; // The live scroll element, as state, so effects that must re-bind when the @@ -122,8 +134,12 @@ export const useChatTimelineScroll = ({ sessionMessageCount, composerOverlayHeight, lastUserMessageId, + sessionIsWorking, + revealGate = null, onActiveTurnChange, }: UseChatTimelineScrollOptions): UseChatTimelineScrollResult => { + const sessionIsWorkingRef = React.useRef(sessionIsWorking); + sessionIsWorkingRef.current = sessionIsWorking; const scrollRef = React.useRef(null); const listRef = React.useRef(null); @@ -633,6 +649,10 @@ export const useChatTimelineScroll = ({ const end = node.scrollHeight - node.clientHeight; const distance = end - node.scrollTop; if (distance <= 1) return; + if (!sessionIsWorkingRef.current) { + node.scrollTop = end; + return; + } if (distance > node.clientHeight) { node.scrollTop = end - node.clientHeight; } @@ -864,6 +884,61 @@ export const useChatTimelineScroll = ({ }; }, [queueSave, realContentOverflowsViewport, scrollNode]); + // ── entry pin ─────────────────────────────────────────────────────────── + // An opened session is shown once, already at its end: the reveal gate is + // held until the viewport sits on the end, and the pin is one instant + // write. The list lays its rows out before the first frame, so this + // resolves within a frame; the gate's own cap bounds the wait. + React.useLayoutEffect(() => { + if (!currentSessionKey || !scrollNode) return; + const releaseReveal = revealGate?.hold() ?? null; + let frame: number | null = null; + const settle = () => { + frame = null; + if (!userOwnsScrollRef.current && modeRef.current === 'following-end') { + const end = scrollNode.scrollHeight - scrollNode.clientHeight; + if (end - scrollNode.scrollTop > 1) scrollNode.scrollTop = end; + } + releaseReveal?.(); + }; + frame = requestAnimationFrame(settle); + return () => { + if (frame !== null) cancelAnimationFrame(frame); + releaseReveal?.(); + }; + }, [currentSessionKey, revealGate, scrollNode]); + + // ── pinned end ────────────────────────────────────────────────────────── + // "At the end" is an invariant, not a one-time scroll: while the reader + // sits on the end of a session that is not producing output, any growth + // of the content (a footer that decides to render, a row re-measured) + // keeps the end in view with one instant write. Output growth belongs to + // followEnd, which glides. + React.useEffect(() => { + if (!scrollNode || typeof MutationObserver === 'undefined') return; + const content = scrollNode.firstElementChild; + if (!content) return; + const pin = () => { + if (sessionIsWorkingRef.current) return; + if (userOwnsScrollRef.current || !isAtEndRef.current || modeRef.current !== 'following-end') return; + const end = scrollNode.scrollHeight - scrollNode.clientHeight; + if (end - scrollNode.scrollTop > 1) scrollNode.scrollTop = end; + }; + // A MutationObserver runs as a microtask right after the list writes + // its layout (row positions, container height), before the frame is + // painted, so the pin lands in the same frame as the growth. A + // ResizeObserver would only see the container a rendering step later + // and let one frame paint with the end out of view. + const mutations = new MutationObserver(pin); + mutations.observe(content, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] }); + const resizes = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(pin); + resizes?.observe(content); + return () => { + mutations.disconnect(); + resizes?.disconnect(); + }; + }, [scrollNode]); + // ── session lifecycle ─────────────────────────────────────────────────── const lastSessionKeyRef = React.useRef(null); React.useEffect(() => { diff --git a/packages/ui/src/hooks/useSessionAssist.ts b/packages/ui/src/hooks/useSessionAssist.ts index 09430efc..78c7f114 100644 --- a/packages/ui/src/hooks/useSessionAssist.ts +++ b/packages/ui/src/hooks/useSessionAssist.ts @@ -55,6 +55,8 @@ export interface SessionAssistState { visibleRecap: string | null; /** Suggestion text — fresh payload, session idle; caller still gates on input emptiness. */ suggestion: string | null; + /** False until the session record is in memory; the recap cannot be decided before that. */ + sessionKnown: boolean; } export function useSessionAssistState(sessionId: string, directory?: string): SessionAssistState { @@ -93,5 +95,6 @@ export function useSessionAssistState(sessionId: string, directory?: string): Se assist, visibleRecap: sessionRecapEnabled && assist && assist.recap && quietElapsed ? assist.recap : null, suggestion: sessionSuggestionEnabled && assist && assist.suggestion ? assist.suggestion : null, + sessionKnown: session !== undefined && session !== null, }; } diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index af606fa5..e7c863cb 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -461,6 +461,16 @@ through `Suspense`: a suspended boundary shows its fallback for a tick and React then throttles later-resolving boundaries by ~300ms, which staggered user and assistant text on a cold open. +An opened session is shown already at its end. The scroll hook holds the gate +until the viewport is pinned; the recap note holds it until the session record +is in memory, because it cannot decide whether it renders before that and would +otherwise grow the footer under a pinned viewport. The reveal itself runs on +the next frame after the last hold releases, with one exact pin against the +final content height. Afterwards "at the end" is an invariant, not a scroll: +while the reader sits on the end of a session that is not producing output, +content growth re-pins with one instant write; output growth belongs to the +follow logic, which glides only while the session is working. + `bun run profile:switch` measures both moments; see `scripts/perf/DOCUMENTATION.md`. Select leaf values, not containers: