From 4a3b7ec5c907a5a8c80872658eb2bf17b246b203 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 5 Apr 2026 23:01:12 +0300 Subject: [PATCH] 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 --- .../ui/src/components/chat/ChatContainer.tsx | 2 +- packages/ui/src/hooks/useChatScrollManager.ts | 75 ++++++++++++++++--- packages/ui/src/index.css | 41 +++++++++- packages/ui/src/sync/sync-context.tsx | 6 +- 4 files changed, 111 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index e104485f..fd1bbd12 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -494,7 +494,7 @@ export const ChatContainer: React.FC = () => { } = useChatScrollManager({ currentSessionId, sessionMessageCount, - streamingMessageId, + sessionIsWorking, sessionMemoryState: sessionMemoryStateMap, updateViewportAnchor, isSyncing, diff --git a/packages/ui/src/hooks/useChatScrollManager.ts b/packages/ui/src/hooks/useChatScrollManager.ts index a7e92311..a17a5e2d 100644 --- a/packages/ui/src/hooks/useChatScrollManager.ts +++ b/packages/ui/src/hooks/useChatScrollManager.ts @@ -26,7 +26,7 @@ interface UseChatScrollManagerOptions { currentSessionId: string | null; sessionMessageCount: number; sessionPermissions: unknown[]; - streamingMessageId: string | null; + sessionIsWorking: boolean; sessionMemoryState: Map; 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(null); const pinnedSyncRafRef = React.useRef(null); const preferInstantPinRef = React.useRef(false); + const autoFollowDuringWorkRef = React.useRef(false); const viewportAnchorTimerRef = React.useRef | 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') { diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 668b0419..b5da75ae 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -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, diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 0a08d9af..3f66030e 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -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