fix(chat): open a session already at its end, and keep it there
A session opened from the sidebar could land above the bottom, or show a frame sitting lower and then snap up. The viewport was pinned before the content was final: the recap note renders once the session record arrives and grew the footer under the pinned viewport, and on large sessions subagent task cards grow when their child sessions load, moving everything above the viewport. - The recap note holds the timeline reveal until the session record is in memory, so it is part of the first finished picture. - The scroll hook holds the reveal until the viewport is pinned; the reveal itself runs once the content height has held still for two frames, with one exact pin against the final height (bounded at 300ms). - Sitting on the end of a session that is not producing output is an invariant: content growth re-pins from a MutationObserver in the same frame the list writes its layout, so no frame paints with the end out of view. Output growth keeps gliding through followEnd, which now glides only while the session is working.
This commit is contained in:
@@ -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<HTMLDivElement | null>(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<ChatContainerProps> = ({
|
||||
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<ChatContainerProps> = ({
|
||||
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<ChatContainerProps> = ({
|
||||
// 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<ResizeObserver | null>(null);
|
||||
const onStatusOverlayNode = React.useCallback((node: HTMLDivElement | null) => {
|
||||
statusOverlayObserverRef.current?.disconnect();
|
||||
@@ -1071,6 +1121,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
sessionMessageCount,
|
||||
composerOverlayHeight,
|
||||
lastUserMessageId,
|
||||
sessionIsWorking,
|
||||
revealGate,
|
||||
onActiveTurnChange: handleActiveTurnChange,
|
||||
});
|
||||
|
||||
@@ -1494,6 +1546,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
scrollToBottom={resumeToLatestInstant}
|
||||
endPinningReleased={userOwnsScroll}
|
||||
revealWaited={revealWaited}
|
||||
revealGate={revealGate}
|
||||
sessionQuestions={sessionQuestions}
|
||||
sessionPermissions={sessionPermissions}
|
||||
isProgrammaticFollowActive={isFollowingProgrammatically}
|
||||
|
||||
@@ -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<SessionRecapNoteProps> = 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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<HTMLDivElement | null>;
|
||||
// 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<HTMLDivElement | null>(null);
|
||||
const listRef = React.useRef<TimelineListHandle | null>(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<string | null>(null);
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user