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
This commit is contained in:
Bohdan Triapitsyn
2026-05-01 13:48:07 +03:00
parent 1991736ebf
commit d820e25ea1
2 changed files with 28 additions and 4 deletions
@@ -47,6 +47,7 @@ const EMPTY_PERMISSIONS: PermissionRequest[] = [];
const EMPTY_QUESTIONS: QuestionRequest[] = []; const EMPTY_QUESTIONS: QuestionRequest[] = [];
const IDLE_SESSION_STATUS = { type: 'idle' as const }; const IDLE_SESSION_STATUS = { type: 'idle' as const };
const SESSION_RESELECTED_EVENT = 'openchamber:session-reselected'; 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 DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.';
const CHAT_SCROLL_STYLE = { const CHAT_SCROLL_STYLE = {
overflowAnchor: 'none', overflowAnchor: 'none',
@@ -581,12 +582,18 @@ export const ChatContainer: React.FC = () => {
}); });
const { loadEarlier, resumeToBottomInstant, restoreSavedScrollPosition } = timelineController; const { loadEarlier, resumeToBottomInstant, restoreSavedScrollPosition } = timelineController;
const runLatestInstantResume = React.useCallback(async () => { const runLatestInstantResume = React.useCallback(async (options?: { force?: boolean }) => {
if (!currentSessionId) { if (!currentSessionId) {
scrollToBottom({ instant: true, force: true }); scrollToBottom({ instant: true, force: true });
return; return;
} }
if (options?.force) {
await resumeToBottomInstant();
clearRestoreInProgress(currentSessionId);
return;
}
// Check if this session has a saved non-bottom scroll position. // Check if this session has a saved non-bottom scroll position.
const savedMemState = sessionMemoryStateMap.get(currentSessionId); const savedMemState = sessionMemoryStateMap.get(currentSessionId);
const savedPos = savedMemState?.scrollPosition; const savedPos = savedMemState?.scrollPosition;
@@ -606,8 +613,8 @@ export const ChatContainer: React.FC = () => {
clearRestoreInProgress(currentSessionId); clearRestoreInProgress(currentSessionId);
}, [clearRestoreInProgress, currentSessionId, restoreSavedScrollPosition, resumeToBottomInstant, scrollToBottom, sessionIsWorking, sessionMemoryStateMap]); }, [clearRestoreInProgress, currentSessionId, restoreSavedScrollPosition, resumeToBottomInstant, scrollToBottom, sessionIsWorking, sessionMemoryStateMap]);
const resumeToLatestInstant = React.useCallback(() => { const resumeToLatestInstant = React.useCallback((options?: { force?: boolean }) => {
void runLatestInstantResume(); void runLatestInstantResume(options);
}, [runLatestInstantResume]); }, [runLatestInstantResume]);
React.useEffect(() => { React.useEffect(() => {
@@ -680,6 +687,12 @@ export const ChatContainer: React.FC = () => {
React.useEffect(() => { React.useEffect(() => {
if (typeof window === 'undefined' || !currentSessionId) return; 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 handleSessionReselected = (event: Event) => {
const customEvent = event as CustomEvent<string>; const customEvent = event as CustomEvent<string>;
if (customEvent.detail !== currentSessionId) return; if (customEvent.detail !== currentSessionId) return;
@@ -687,11 +700,13 @@ export const ChatContainer: React.FC = () => {
void resumeToBottomInstant(); void resumeToBottomInstant();
}; };
window.addEventListener(CHAT_FORCE_SCROLL_BOTTOM_EVENT, handleForceScrollBottom as EventListener);
window.addEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener); window.addEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener);
return () => { return () => {
window.removeEventListener(CHAT_FORCE_SCROLL_BOTTOM_EVENT, handleForceScrollBottom as EventListener);
window.removeEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener); window.removeEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener);
}; };
}, [currentSessionId, isOverflowing, isPinned, isProgrammaticFollowActive, resumeToBottomInstant]); }, [currentSessionId, isOverflowing, isPinned, isProgrammaticFollowActive, resumeToBottomInstant, resumeToLatestInstant]);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const container = scrollRef.current; const container = scrollRef.current;
+9
View File
@@ -51,6 +51,13 @@ const getRuntimeGit = () => {
return null; 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<boolean> { export async function checkIsGitRepository(directory: string): Promise<boolean> {
const runtime = getRuntimeGit(); const runtime = getRuntimeGit();
if (runtime) return runtime.checkIsGitRepository(directory); if (runtime) return runtime.checkIsGitRepository(directory);
@@ -373,6 +380,8 @@ const runStructuredGenerationInActiveSession = async ({
throw new Error('Generation prompts are empty'); throw new Error('Generation prompts are empty');
} }
requestChatForceScrollBottom(generationSession.sessionId);
const response = await opencodeClient.withDirectory(directory, async () => { const response = await opencodeClient.withDirectory(directory, async () => {
return opencodeClient.getApiClient().session.prompt({ return opencodeClient.getApiClient().session.prompt({
sessionID: generationSession.sessionId, sessionID: generationSession.sessionId,