fix: restore smooth chat auto-scroll and live compaction updates

- Keep assistant auto-scroll smooth during active responses without periodic snaps
- Restore live `/compact` rendering and streamed compaction summaries without refresh
- Preserve global event handling while routing session and message updates efficiently
This commit is contained in:
Bohdan Triapitsyn
2026-04-05 23:01:12 +03:00
parent 8b41a620d9
commit 4a3b7ec5c9
4 changed files with 111 additions and 13 deletions
@@ -494,7 +494,7 @@ export const ChatContainer: React.FC = () => {
} = useChatScrollManager({
currentSessionId,
sessionMessageCount,
streamingMessageId,
sessionIsWorking,
sessionMemoryState: sessionMemoryStateMap,
updateViewportAnchor,
isSyncing,
+65 -10
View File
@@ -26,7 +26,7 @@ interface UseChatScrollManagerOptions {
currentSessionId: string | null;
sessionMessageCount: number;
sessionPermissions: unknown[];
streamingMessageId: string | null;
sessionIsWorking: boolean;
sessionMemoryState: Map<string, SessionMemoryState>;
updateViewportAnchor: (sessionId: string, anchor: number) => void;
isSyncing: boolean;
@@ -67,6 +67,7 @@ const VIEWPORT_ANCHOR_MIN_UPDATE_MS = 150;
export const useChatScrollManager = ({
currentSessionId,
sessionMessageCount,
sessionIsWorking,
updateViewportAnchor,
isSyncing,
isMobile,
@@ -88,6 +89,16 @@ export const useChatScrollManager = ({
return getPinThreshold();
}, [getPinThreshold]);
const getAutoFollowSnapThreshold = React.useCallback(() => {
const container = scrollRef.current;
if (!container || container.clientHeight <= 0) {
return 96;
}
const raw = container.clientHeight * 0.2;
return Math.max(72, Math.min(192, raw));
}, []);
const [showScrollButton, setShowScrollButton] = React.useState(false);
const [isPinned, setIsPinned] = React.useState(true);
const [isOverflowing, setIsOverflowing] = React.useState(false);
@@ -102,6 +113,7 @@ export const useChatScrollManager = ({
const touchLastYRef = React.useRef<number | null>(null);
const pinnedSyncRafRef = React.useRef<number | null>(null);
const preferInstantPinRef = React.useRef(false);
const autoFollowDuringWorkRef = React.useRef(false);
const viewportAnchorTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
const lastViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
@@ -147,13 +159,28 @@ export const useChatScrollManager = ({
scrollEngine.scrollToPosition(Math.max(0, bottom), options);
}, [markProgrammaticScroll, scrollEngine]);
const scrollPinnedToBottom = React.useCallback(() => {
// Auto-follow should not animate across an entire new turn.
// Keep explicit user-triggered scrolls animated, but snap pinned
// updates so a new user/assistant message does not visibly travel
// from the previous sticky header down to the tail.
const scrollPinnedToBottom = React.useCallback((distanceFromBottom: number) => {
if (sessionIsWorking) {
if (autoFollowDuringWorkRef.current || scrollEngine.isFollowingBottom) {
autoFollowDuringWorkRef.current = true;
scrollToBottomInternal({ followBottom: true });
return;
}
if (preferInstantPinRef.current || distanceFromBottom > getAutoFollowSnapThreshold()) {
autoFollowDuringWorkRef.current = false;
scrollToBottomInternal({ instant: true });
return;
}
autoFollowDuringWorkRef.current = true;
scrollToBottomInternal({ followBottom: true });
return;
}
autoFollowDuringWorkRef.current = false;
scrollToBottomInternal({ instant: true });
}, [scrollToBottomInternal]);
}, [getAutoFollowSnapThreshold, scrollEngine.isFollowingBottom, scrollToBottomInternal, sessionIsWorking]);
const updateScrollButtonVisibility = React.useCallback(() => {
const container = scrollRef.current;
@@ -183,6 +210,17 @@ export const useChatScrollManager = ({
}
const distanceFromBottom = getDistanceFromBottom();
if (sessionIsWorking) {
if (distanceFromBottom <= 0.5) {
preferInstantPinRef.current = false;
return;
}
scrollPinnedToBottom(distanceFromBottom);
preferInstantPinRef.current = false;
return;
}
if (distanceFromBottom <= getAutoFollowThreshold()) {
preferInstantPinRef.current = false;
return;
@@ -194,9 +232,9 @@ export const useChatScrollManager = ({
}
if (distanceFromBottom > getAutoFollowThreshold()) {
scrollPinnedToBottom();
scrollPinnedToBottom(distanceFromBottom);
}
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, scrollToBottomInternal, updateScrollButtonVisibility]);
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, scrollToBottomInternal, sessionIsWorking, updateScrollButtonVisibility]);
const schedulePinnedStateAndIndicators = React.useCallback(() => {
if (typeof window === 'undefined') {
@@ -279,6 +317,7 @@ export const useChatScrollManager = ({
const releasePinnedScroll = React.useCallback(() => {
scrollEngine.cancelFollow();
autoFollowDuringWorkRef.current = false;
preferInstantPinRef.current = false;
updatePinnedState(false);
schedulePinnedStateAndIndicators();
@@ -352,6 +391,7 @@ export const useChatScrollManager = ({
delta,
})) {
scrollEngine.cancelFollow();
autoFollowDuringWorkRef.current = false;
updatePinnedState(false);
}
}, [scrollEngine, updatePinnedState]);
@@ -401,6 +441,7 @@ export const useChatScrollManager = ({
delta: syntheticWheelDelta,
})) {
scrollEngine.cancelFollow();
autoFollowDuringWorkRef.current = false;
updatePinnedState(false);
}
};
@@ -438,6 +479,7 @@ export const useChatScrollManager = ({
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
flushViewportAnchor();
pendingViewportAnchorRef.current = null;
autoFollowDuringWorkRef.current = false;
// Always start pinned at bottom on session switch
preferInstantPinRef.current = true;
@@ -452,6 +494,12 @@ export const useChatScrollManager = ({
}, [currentSessionId, flushViewportAnchor, markProgrammaticScroll, scrollToBottomInternal, setShowScrollButtonState, updatePinnedState]);
// Maintain pin-to-bottom when content changes
React.useEffect(() => {
if (!sessionIsWorking) {
autoFollowDuringWorkRef.current = false;
}
}, [sessionIsWorking]);
React.useEffect(() => {
if (isSyncing) {
return;
@@ -477,6 +525,13 @@ export const useChatScrollManager = ({
const clientHeightChanged = nextClientHeight !== lastClientHeight;
if (clientHeightChanged) {
if (isPinnedRef.current && sessionIsWorking) {
lastScrollHeight = nextScrollHeight;
lastClientHeight = nextClientHeight;
schedulePinnedStateAndIndicators();
return;
}
const previousDistanceFromBottom = Math.max(
0,
lastScrollHeight - lastScrollTopRef.current - lastClientHeight,
@@ -521,7 +576,7 @@ export const useChatScrollManager = ({
return () => {
observer.disconnect();
};
}, [markProgrammaticScroll, schedulePinnedStateAndIndicators, shouldSkipLiveContentSync, updateScrollButtonVisibility]);
}, [markProgrammaticScroll, schedulePinnedStateAndIndicators, sessionIsWorking, shouldSkipLiveContentSync, updateScrollButtonVisibility]);
React.useEffect(() => {
if (typeof window === 'undefined') {
+40 -1
View File
@@ -892,7 +892,46 @@ html:not(.dark) .chat-scroll {
/* Reasoning markdown renders at meta size, dimmed. */
.markdown-content.markdown-reasoning {
font-size: var(--text-meta);
color: var(--muted-foreground);
color: var(--surface-muted-foreground);
--markdown-heading1: inherit;
--markdown-heading2: inherit;
--markdown-heading3: inherit;
--markdown-heading4: inherit;
--markdown-link: inherit;
--markdown-link-hover: var(--surface-foreground);
--markdown-blockquote: inherit;
--markdown-bold: inherit;
--markdown-italic: inherit;
--markdown-strikethrough: inherit;
}
.markdown-content.markdown-reasoning p,
.markdown-content.markdown-reasoning li,
.markdown-content.markdown-reasoning td,
.markdown-content.markdown-reasoning th,
.markdown-content.markdown-reasoning h1,
.markdown-content.markdown-reasoning h2,
.markdown-content.markdown-reasoning h3,
.markdown-content.markdown-reasoning h4,
.markdown-content.markdown-reasoning h5,
.markdown-content.markdown-reasoning h6,
.markdown-content.markdown-reasoning strong,
.markdown-content.markdown-reasoning b,
.markdown-content.markdown-reasoning em,
.markdown-content.markdown-reasoning del,
.markdown-content.markdown-reasoning [data-markdown="strong"],
.markdown-content.markdown-reasoning [data-markdown="bold"] {
color: var(--surface-muted-foreground) !important;
}
.markdown-content.markdown-reasoning a,
.markdown-content.markdown-reasoning [data-openchamber-file-link="true"] {
color: var(--surface-muted-foreground) !important;
}
.markdown-content.markdown-reasoning a:hover,
.markdown-content.markdown-reasoning [data-openchamber-file-link="true"]:hover {
color: var(--surface-foreground) !important;
}
.markdown-content.markdown-reasoning strong,
+5 -1
View File
@@ -446,7 +446,11 @@ const resolveDirectoryFromRoutingIndex = (
}
}
if ((!normalizedDirectory || normalizedDirectory === "global") && childStores.children.size === 1) {
if (
(sessionID || messageID)
&& (!normalizedDirectory || normalizedDirectory === "global")
&& childStores.children.size === 1
) {
const onlyDirectory = childStores.children.keys().next().value
if (typeof onlyDirectory === "string" && onlyDirectory.length > 0) {
return onlyDirectory