From d820e25ea1a1553294e7d22b0be153d66c1feb19 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 1 May 2026 13:48:07 +0300 Subject: [PATCH] fix: keep generated prompts scrolled into view Force chat to bottom on normal sends Scroll generated commit prompts into view Preserve saved scroll restore for passive navigation --- .../ui/src/components/chat/ChatContainer.tsx | 23 +++++++++++++++---- packages/ui/src/lib/gitApi.ts | 9 ++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index d6e8cc7c..4a5cf80b 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -47,6 +47,7 @@ const EMPTY_PERMISSIONS: PermissionRequest[] = []; const EMPTY_QUESTIONS: QuestionRequest[] = []; const IDLE_SESSION_STATUS = { type: 'idle' as const }; const SESSION_RESELECTED_EVENT = 'openchamber:session-reselected'; +const CHAT_FORCE_SCROLL_BOTTOM_EVENT = 'openchamber:chat-force-scroll-bottom'; const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.'; const CHAT_SCROLL_STYLE = { overflowAnchor: 'none', @@ -581,12 +582,18 @@ export const ChatContainer: React.FC = () => { }); const { loadEarlier, resumeToBottomInstant, restoreSavedScrollPosition } = timelineController; - const runLatestInstantResume = React.useCallback(async () => { + const runLatestInstantResume = React.useCallback(async (options?: { force?: boolean }) => { if (!currentSessionId) { scrollToBottom({ instant: true, force: true }); return; } + if (options?.force) { + await resumeToBottomInstant(); + clearRestoreInProgress(currentSessionId); + return; + } + // Check if this session has a saved non-bottom scroll position. const savedMemState = sessionMemoryStateMap.get(currentSessionId); const savedPos = savedMemState?.scrollPosition; @@ -606,8 +613,8 @@ export const ChatContainer: React.FC = () => { clearRestoreInProgress(currentSessionId); }, [clearRestoreInProgress, currentSessionId, restoreSavedScrollPosition, resumeToBottomInstant, scrollToBottom, sessionIsWorking, sessionMemoryStateMap]); - const resumeToLatestInstant = React.useCallback(() => { - void runLatestInstantResume(); + const resumeToLatestInstant = React.useCallback((options?: { force?: boolean }) => { + void runLatestInstantResume(options); }, [runLatestInstantResume]); React.useEffect(() => { @@ -680,6 +687,12 @@ export const ChatContainer: React.FC = () => { React.useEffect(() => { if (typeof window === 'undefined' || !currentSessionId) return; + const handleForceScrollBottom = (event: Event) => { + const customEvent = event as CustomEvent<{ sessionId?: string }>; + if (customEvent.detail?.sessionId && customEvent.detail.sessionId !== currentSessionId) return; + resumeToLatestInstant({ force: true }); + }; + const handleSessionReselected = (event: Event) => { const customEvent = event as CustomEvent; if (customEvent.detail !== currentSessionId) return; @@ -687,11 +700,13 @@ export const ChatContainer: React.FC = () => { void resumeToBottomInstant(); }; + window.addEventListener(CHAT_FORCE_SCROLL_BOTTOM_EVENT, handleForceScrollBottom as EventListener); window.addEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener); return () => { + window.removeEventListener(CHAT_FORCE_SCROLL_BOTTOM_EVENT, handleForceScrollBottom as EventListener); window.removeEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener); }; - }, [currentSessionId, isOverflowing, isPinned, isProgrammaticFollowActive, resumeToBottomInstant]); + }, [currentSessionId, isOverflowing, isPinned, isProgrammaticFollowActive, resumeToBottomInstant, resumeToLatestInstant]); React.useLayoutEffect(() => { const container = scrollRef.current; diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index 9ba46841..57d05c5c 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -51,6 +51,13 @@ const getRuntimeGit = () => { return null; }; +const requestChatForceScrollBottom = (sessionId: string) => { + if (typeof window === 'undefined') return; + window.dispatchEvent(new CustomEvent('openchamber:chat-force-scroll-bottom', { + detail: { sessionId }, + })); +}; + export async function checkIsGitRepository(directory: string): Promise { const runtime = getRuntimeGit(); if (runtime) return runtime.checkIsGitRepository(directory); @@ -373,6 +380,8 @@ const runStructuredGenerationInActiveSession = async ({ throw new Error('Generation prompts are empty'); } + requestChatForceScrollBottom(generationSession.sessionId); + const response = await opencodeClient.withDirectory(directory, async () => { return opencodeClient.getApiClient().session.prompt({ sessionID: generationSession.sessionId,