feat(settings): add streaming auto-follow toggle

New Streaming section on the Chat settings page with a checkbox that
controls whether the viewport follows new content while a response
streams. Default stays on. With it off, the anchored user message still
parks at the top on send, but no glide or end-follow correction runs and
the list's maintain-scroll-at-end stays disabled; the scroll-to-bottom
pill and session open keep scrolling explicitly.

Persisted through desktop settings like the other chat toggles (auto-save
diff, authoritative apply, sanitize), registered in settings search, and
localized in every locale.
This commit is contained in:
Bohdan Triapitsyn
2026-08-25 14:53:43 +03:00
parent 87c1ae809d
commit dac31ee026
19 changed files with 109 additions and 1 deletions
@@ -1021,6 +1021,10 @@ const TimelineList = React.memo(({
rowContext,
}: TimelineListProps) => {
const listRef = React.useRef<LegendListRef | null>(null);
// With streaming auto-follow off, content growth must never move the
// viewport; explicit commands (the scroll-to-bottom pill, session open)
// still scroll through the imperative handle.
const streamingAutoFollowEnabled = useUIStore((state) => state.streamingAutoFollowEnabled);
const isAtEndRef = React.useRef(true);
const setListRef = React.useCallback((list: LegendListRef | null) => {
@@ -1067,7 +1071,7 @@ const TimelineList = React.memo(({
contentInsetEndAdjustment={composerOverlayHeight}
// While a turn is anchored, the reserved end space — not the
// live edge — defines where the viewport rests.
maintainScrollAtEnd={anchoredEndSpace
maintainScrollAtEnd={anchoredEndSpace || !streamingAutoFollowEnabled
? false
: { animated: false, on: { dataChange: true, itemLayout: true, layout: true } }}
// Prepending older history must not move what the user is
@@ -302,6 +302,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const sessionGoalDefaultBudget = useUIStore(state => state.sessionGoalDefaultBudget);
const setSessionGoalDefaultBudget = useUIStore(state => state.setSessionGoalDefaultBudget);
const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces);
const streamingAutoFollowEnabled = useUIStore(state => state.streamingAutoFollowEnabled);
const setStreamingAutoFollowEnabled = useUIStore(state => state.setStreamingAutoFollowEnabled);
const collapsibleThinkingBlocks = useUIStore(state => state.collapsibleThinkingBlocks);
const setCollapsibleThinkingBlocks = useUIStore(state => state.setCollapsibleThinkingBlocks);
@@ -1839,6 +1841,20 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
)}
</SettingsSection>
)}
<SettingsSection
title={t('settings.openchamber.visual.section.streaming')}
settingsItem="chat.streaming"
contentClassName={SETTINGS_OPTION_STACK_CLASS}
>
<SettingsCheckboxRow
checked={streamingAutoFollowEnabled}
onChange={setStreamingAutoFollowEnabled}
label={t('settings.openchamber.visual.field.streamingAutoFollow')}
ariaLabel={t('settings.openchamber.visual.field.streamingAutoFollowAria')}
info={t('settings.openchamber.visual.field.streamingAutoFollowInfo')}
settingsItem="chat.streaming-auto-follow"
/>
</SettingsSection>
{(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || (shouldShow('promptNavigatorEnabled') && !isVSCode) || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('codeBlockLineWrap')) && (
<SettingsSection
@@ -3,6 +3,7 @@ import React from 'react';
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 {
CHAT_LIST_ANCHOR_OFFSET,
getAnchoredTurnMetrics,
@@ -520,7 +521,15 @@ export const useChatTimelineScroll = ({
first: null,
second: null,
});
// User preference: with auto-follow off, streaming growth never moves the
// viewport — the anchored user message still parks at the top on send, but
// no glide or end-follow correction runs afterwards.
const streamingAutoFollowEnabled = useUIStore((state) => state.streamingAutoFollowEnabled);
const streamingAutoFollowEnabledRef = React.useRef(streamingAutoFollowEnabled);
streamingAutoFollowEnabledRef.current = streamingAutoFollowEnabled;
const onTimelineDataChange = React.useCallback(() => {
if (!streamingAutoFollowEnabledRef.current) return;
if (!isLiveFollowActive()) return;
// Following the end needs no animation frames: the totalSize listener
@@ -7,6 +7,7 @@ import type { TerminalShell } from '@/lib/api/types';
type AppearanceSlice = {
showReasoningTraces: boolean;
streamingAutoFollowEnabled: boolean;
workStatusPanelEnabled: boolean;
workStatusHiddenSections: string[];
sessionRecapEnabled: boolean;
@@ -62,6 +63,7 @@ export const startAppearanceAutoSave = (): void => {
let previous: AppearanceSlice = {
showReasoningTraces: useUIStore.getState().showReasoningTraces,
streamingAutoFollowEnabled: useUIStore.getState().streamingAutoFollowEnabled,
workStatusPanelEnabled: useUIStore.getState().workStatusPanelEnabled,
workStatusHiddenSections: useUIStore.getState().workStatusHiddenSections,
sessionRecapEnabled: useUIStore.getState().sessionRecapEnabled,
@@ -104,6 +106,7 @@ export const startAppearanceAutoSave = (): void => {
useUIStore.subscribe((state) => {
const current: AppearanceSlice = {
showReasoningTraces: state.showReasoningTraces,
streamingAutoFollowEnabled: state.streamingAutoFollowEnabled,
workStatusPanelEnabled: state.workStatusPanelEnabled,
workStatusHiddenSections: state.workStatusHiddenSections,
sessionRecapEnabled: state.sessionRecapEnabled,
@@ -156,6 +159,9 @@ export const startAppearanceAutoSave = (): void => {
if (current.showReasoningTraces !== previous.showReasoningTraces) {
diff.showReasoningTraces = current.showReasoningTraces;
}
if (current.streamingAutoFollowEnabled !== previous.streamingAutoFollowEnabled) {
diff.streamingAutoFollowEnabled = current.streamingAutoFollowEnabled;
}
if (current.sessionRecapEnabled !== previous.sessionRecapEnabled) {
diff.sessionRecapEnabled = current.sessionRecapEnabled;
}
+1
View File
@@ -131,6 +131,7 @@ export type DesktopSettings = {
defaultVariant?: string;
defaultAgent?: string;
smallModelUseDefault?: boolean;
streamingAutoFollowEnabled?: boolean;
sessionRecapEnabled?: boolean;
sessionSuggestionEnabled?: boolean;
sessionGoalEnabled?: boolean;
@@ -1856,6 +1856,10 @@ export const settingsDict = {
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'Werkzeuge standardmäßig geöffnet anzeigen:',
'settings.openchamber.visual.section.sessionAssistance': 'Sitzungshilfe',
'settings.openchamber.visual.section.reasoning': 'Reasoning',
'settings.openchamber.visual.section.streaming': 'Streaming',
'settings.openchamber.visual.field.streamingAutoFollow': 'Neuen Inhalten beim Streaming folgen',
'settings.openchamber.visual.field.streamingAutoFollowAria': 'Neuen Inhalten automatisch folgen, während eine Antwort gestreamt wird',
'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Während eine Antwort eintrifft, folgt die Ansicht laufend dem neuesten Inhalt. Deaktivieren, um die Ansicht ruhig zu halten und manuell zu scrollen.',
'settings.openchamber.visual.section.messageAppearance': 'Nachrichten-Erscheinungsbild',
'settings.openchamber.visual.section.toolsAndFiles': 'Werkzeuge & Dateien',
'settings.openchamber.visual.section.composer': 'Komponist',
@@ -1929,6 +1929,10 @@ export const settingsDict = {
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'Show tools opened by default',
'settings.openchamber.visual.section.sessionAssistance': 'Session Assistance',
'settings.openchamber.visual.section.reasoning': 'Reasoning',
'settings.openchamber.visual.section.streaming': 'Streaming',
'settings.openchamber.visual.field.streamingAutoFollow': 'Follow new content while streaming',
'settings.openchamber.visual.field.streamingAutoFollowAria': 'Automatically follow new content while a response streams',
'settings.openchamber.visual.field.streamingAutoFollowInfo': 'While a reply streams in, the view keeps gliding to the newest content. Turn this off to keep the view still and scroll manually.',
'settings.openchamber.visual.section.messageAppearance': 'Message Appearance',
'settings.openchamber.visual.section.toolsAndFiles': 'Tools & Files',
'settings.openchamber.visual.section.composer': 'Composer',
@@ -1906,6 +1906,10 @@ export const settingsDict = {
"settings.openchamber.visual.section.showToolsOpenedByDefault": "Mostrar herramientas abiertas por defecto",
"settings.openchamber.visual.section.sessionAssistance": "Asistencia de sesión",
"settings.openchamber.visual.section.reasoning": "Razonamiento",
"settings.openchamber.visual.section.streaming": "Streaming",
"settings.openchamber.visual.field.streamingAutoFollow": "Seguir el contenido nuevo durante el streaming",
"settings.openchamber.visual.field.streamingAutoFollowAria": "Seguir automáticamente el contenido nuevo mientras se transmite una respuesta",
"settings.openchamber.visual.field.streamingAutoFollowInfo": "Mientras llega una respuesta, la vista se desplaza hacia el contenido más reciente. Desactívalo para mantener la vista quieta y desplazarte manualmente.",
"settings.openchamber.visual.section.messageAppearance": "Apariencia de los mensajes",
"settings.openchamber.visual.section.toolsAndFiles": "Herramientas y archivos",
"settings.openchamber.visual.section.composer": "Compositor",
@@ -1820,6 +1820,10 @@ export const settingsDict = {
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'Afficher les outils ouverts par défaut',
'settings.openchamber.visual.section.sessionAssistance': 'Assistance de session',
'settings.openchamber.visual.section.reasoning': 'Raisonnement',
'settings.openchamber.visual.section.streaming': 'Streaming',
'settings.openchamber.visual.field.streamingAutoFollow': 'Suivre le nouveau contenu pendant le streaming',
'settings.openchamber.visual.field.streamingAutoFollowAria': 'Suivre automatiquement le nouveau contenu pendant la diffusion dune réponse',
'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Pendant quune réponse arrive, la vue glisse vers le contenu le plus récent. Désactivez pour garder la vue immobile et défiler manuellement.',
'settings.openchamber.visual.section.messageAppearance': 'Apparence des messages',
'settings.openchamber.visual.section.toolsAndFiles': 'Outils et fichiers',
'settings.openchamber.visual.section.composer': 'Zone de saisie',
@@ -1939,6 +1939,10 @@ export const settingsDict = {
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'デフォルトで開くツールを表示',
'settings.openchamber.visual.section.sessionAssistance': 'セッション支援',
'settings.openchamber.visual.section.reasoning': '推論',
'settings.openchamber.visual.section.streaming': 'ストリーミング',
'settings.openchamber.visual.field.streamingAutoFollow': '応答のストリーミング中に新しい内容を追従',
'settings.openchamber.visual.field.streamingAutoFollowAria': '応答のストリーミング中に新しい内容へ自動スクロールする',
'settings.openchamber.visual.field.streamingAutoFollowInfo': '応答の受信中、ビューは常に最新の内容へスクロールします。オフにするとビューは動かず、手動でスクロールできます。',
'settings.openchamber.visual.section.messageAppearance': 'メッセージの外観',
'settings.openchamber.visual.section.toolsAndFiles': 'ツールとファイル',
'settings.openchamber.visual.section.composer': '入力欄',
@@ -1906,6 +1906,10 @@ export const settingsDict = {
'settings.openchamber.visual.section.showToolsOpenedByDefault': '도구를 기본으로 펼쳐 표시',
'settings.openchamber.visual.section.sessionAssistance': '세션 지원',
'settings.openchamber.visual.section.reasoning': '추론',
'settings.openchamber.visual.section.streaming': '스트리밍',
'settings.openchamber.visual.field.streamingAutoFollow': '스트리밍 중 새 내용 따라가기',
'settings.openchamber.visual.field.streamingAutoFollowAria': '응답 스트리밍 중 새 내용으로 자동 스크롤',
'settings.openchamber.visual.field.streamingAutoFollowInfo': '응답이 스트리밍되는 동안 화면이 최신 내용으로 계속 이동합니다. 끄면 화면이 고정되어 직접 스크롤할 수 있습니다.',
'settings.openchamber.visual.section.messageAppearance': '메시지 모양',
'settings.openchamber.visual.section.toolsAndFiles': '도구 및 파일',
'settings.openchamber.visual.section.composer': '입력창',
@@ -1209,6 +1209,10 @@ export const settingsDict = {
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'Pokaż narzędzia domyślnie otwarte',
'settings.openchamber.visual.section.sessionAssistance': 'Wsparcie sesji',
'settings.openchamber.visual.section.reasoning': 'Rozumowanie',
'settings.openchamber.visual.section.streaming': 'Streaming',
'settings.openchamber.visual.field.streamingAutoFollow': 'Podążaj za nową treścią podczas streamingu',
'settings.openchamber.visual.field.streamingAutoFollowAria': 'Automatycznie podążaj za nową treścią podczas streamowania odpowiedzi',
'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Podczas napływania odpowiedzi widok płynnie podąża za najnowszą treścią. Wyłącz, aby widok pozostał nieruchomy i przewijać ręcznie.',
'settings.openchamber.visual.section.messageAppearance': 'Wygląd wiadomości',
'settings.openchamber.visual.section.toolsAndFiles': 'Narzędzia i pliki',
'settings.openchamber.visual.section.composer': 'Pole wiadomości',
@@ -1906,6 +1906,10 @@ export const settingsDict = {
"settings.openchamber.visual.section.showToolsOpenedByDefault": "Mostrar ferramentas abertas por padrão",
"settings.openchamber.visual.section.sessionAssistance": "Assistência da sessão",
"settings.openchamber.visual.section.reasoning": "Raciocínio",
"settings.openchamber.visual.section.streaming": "Streaming",
"settings.openchamber.visual.field.streamingAutoFollow": "Seguir o novo conteúdo durante o streaming",
"settings.openchamber.visual.field.streamingAutoFollowAria": "Seguir automaticamente o novo conteúdo enquanto uma resposta é transmitida",
"settings.openchamber.visual.field.streamingAutoFollowInfo": "Enquanto uma resposta chega, a visualização acompanha o conteúdo mais recente. Desative para manter a visualização parada e rolar manualmente.",
"settings.openchamber.visual.section.messageAppearance": "Aparência das mensagens",
"settings.openchamber.visual.section.toolsAndFiles": "Ferramentas e arquivos",
"settings.openchamber.visual.section.composer": "Campo de mensagem",
@@ -1906,6 +1906,10 @@ export const settingsDict = {
"settings.openchamber.visual.section.showToolsOpenedByDefault": "Показувати інструменти відкритими за замовчуванням",
"settings.openchamber.visual.section.sessionAssistance": "Допомога із сесією",
"settings.openchamber.visual.section.reasoning": "Міркування",
"settings.openchamber.visual.section.streaming": "Стримінг",
"settings.openchamber.visual.field.streamingAutoFollow": "Слідкувати за новим вмістом під час стримінгу",
"settings.openchamber.visual.field.streamingAutoFollowAria": "Автоматично слідкувати за новим вмістом під час стримінгу відповіді",
"settings.openchamber.visual.field.streamingAutoFollowInfo": "Поки відповідь надходить, вигляд плавно рухається до найновішого вмісту. Вимкніть, щоб вигляд залишався нерухомим і гортати вручну.",
"settings.openchamber.visual.section.messageAppearance": "Вигляд повідомлень",
"settings.openchamber.visual.section.toolsAndFiles": "Інструменти та файли",
"settings.openchamber.visual.section.composer": "Поле вводу",
@@ -1906,6 +1906,10 @@ export const settingsDict = {
'settings.openchamber.visual.section.showToolsOpenedByDefault': '默认展开以下工具',
'settings.openchamber.visual.section.sessionAssistance': '会话辅助',
'settings.openchamber.visual.section.reasoning': '推理',
'settings.openchamber.visual.section.streaming': '流式输出',
'settings.openchamber.visual.field.streamingAutoFollow': '流式输出时跟随新内容',
'settings.openchamber.visual.field.streamingAutoFollowAria': '在回复流式输出时自动跟随新内容',
'settings.openchamber.visual.field.streamingAutoFollowInfo': '回复流式输出时,视图会持续滚动到最新内容。关闭后视图保持不动,可手动滚动。',
'settings.openchamber.visual.section.messageAppearance': '消息外观',
'settings.openchamber.visual.section.toolsAndFiles': '工具和文件',
'settings.openchamber.visual.section.composer': '输入框',
@@ -1813,6 +1813,10 @@ export const settingsDict = {
'settings.openchamber.visual.section.showToolsOpenedByDefault': '預設展開以下工具',
'settings.openchamber.visual.section.sessionAssistance': '工作階段輔助',
'settings.openchamber.visual.section.reasoning': '推理',
'settings.openchamber.visual.section.streaming': '串流',
'settings.openchamber.visual.field.streamingAutoFollow': '串流時跟隨新內容',
'settings.openchamber.visual.field.streamingAutoFollowAria': '回覆串流時自動跟隨新內容',
'settings.openchamber.visual.field.streamingAutoFollowInfo': '回覆串流時,畫面會持續捲動到最新內容。關閉後畫面保持不動,可手動捲動。',
'settings.openchamber.visual.section.messageAppearance': '訊息外觀',
'settings.openchamber.visual.section.toolsAndFiles': '工具與檔案',
'settings.openchamber.visual.section.composer': '輸入框',
+7
View File
@@ -531,6 +531,7 @@ const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopS
darkThemeId: DEFAULT_DARK_THEME_ID,
openInAppId: DEFAULT_OPEN_IN_APP_ID,
showReasoningTraces: defaults.showReasoningTraces,
streamingAutoFollowEnabled: defaults.streamingAutoFollowEnabled,
workStatusPanelEnabled: defaults.workStatusPanelEnabled,
workStatusHiddenSections: defaults.workStatusHiddenSections,
sessionRecapEnabled: defaults.sessionRecapEnabled,
@@ -637,6 +638,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) {
store.setShowReasoningTraces(settings.showReasoningTraces);
}
if (typeof settings.streamingAutoFollowEnabled === 'boolean' && settings.streamingAutoFollowEnabled !== store.streamingAutoFollowEnabled) {
store.setStreamingAutoFollowEnabled(settings.streamingAutoFollowEnabled);
}
if (typeof settings.sessionRecapEnabled === 'boolean' && settings.sessionRecapEnabled !== store.sessionRecapEnabled) {
store.setSessionRecapEnabled(settings.sessionRecapEnabled);
}
@@ -1158,6 +1162,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.showReasoningTraces === 'boolean') {
result.showReasoningTraces = candidate.showReasoningTraces;
}
if (typeof candidate.streamingAutoFollowEnabled === 'boolean') {
result.streamingAutoFollowEnabled = candidate.streamingAutoFollowEnabled;
}
if (typeof candidate.sessionRecapEnabled === 'boolean') {
result.sessionRecapEnabled = candidate.sessionRecapEnabled;
}
+13
View File
@@ -240,6 +240,19 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
titleKey: 'settings.openchamber.visual.section.reasoning',
keywords: ['thinking', 'traces'],
},
{
id: 'chat.streaming',
page: 'chat',
titleKey: 'settings.openchamber.visual.section.streaming',
keywords: ['stream', 'scroll'],
},
{
id: 'chat.streaming-auto-follow',
page: 'chat',
titleKey: 'settings.openchamber.visual.field.streamingAutoFollow',
descriptionKey: 'settings.openchamber.visual.field.streamingAutoFollowInfo',
keywords: ['autoscroll', 'auto-scroll', 'follow', 'stick to bottom', 'streaming'],
},
{
id: 'chat.sticky-user-header',
page: 'chat',
+8
View File
@@ -674,6 +674,7 @@ interface UIStore {
eventStreamStatus: EventStreamStatus;
eventStreamHint: string | null;
showReasoningTraces: boolean;
streamingAutoFollowEnabled: boolean;
sessionRecapEnabled: boolean;
sessionSuggestionEnabled: boolean;
sessionGoalEnabled: boolean;
@@ -859,6 +860,7 @@ interface UIStore {
setSettingsRemoteInstancesSelectedId: (instanceId: string | null) => void;
setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void;
setShowReasoningTraces: (value: boolean) => void;
setStreamingAutoFollowEnabled: (value: boolean) => void;
setSessionRecapEnabled: (value: boolean) => void;
setSessionSuggestionEnabled: (value: boolean) => void;
setSessionGoalEnabled: (value: boolean) => void;
@@ -1027,6 +1029,7 @@ export const useUIStore = create<UIStore>()(
eventStreamStatus: 'idle',
eventStreamHint: null,
showReasoningTraces: true,
streamingAutoFollowEnabled: true,
sessionRecapEnabled: true,
sessionSuggestionEnabled: true,
sessionGoalEnabled: true,
@@ -1759,6 +1762,10 @@ export const useUIStore = create<UIStore>()(
set({ showReasoningTraces: value });
},
setStreamingAutoFollowEnabled: (value) => {
set({ streamingAutoFollowEnabled: value });
},
setSessionRecapEnabled: (value) => {
set({ sessionRecapEnabled: value });
},
@@ -2639,6 +2646,7 @@ export const useUIStore = create<UIStore>()(
isSessionCreateDialogOpen: state.isSessionCreateDialogOpen,
// Note: isSettingsDialogOpen intentionally NOT persisted
showReasoningTraces: state.showReasoningTraces,
streamingAutoFollowEnabled: state.streamingAutoFollowEnabled,
sessionRecapEnabled: state.sessionRecapEnabled,
sessionSuggestionEnabled: state.sessionSuggestionEnabled,
sessionGoalEnabled: state.sessionGoalEnabled,