feat: add an always-show scrollbars preference

Adds a device-local setting to keep overlay scrollbars visible.
Surfaces the setting in visual settings and settings search.
Updates scrollbar behavior and tests for the new preference.
This commit is contained in:
Bohdan Triapitsyn
2026-09-07 23:16:36 +03:00
parent 7b206b1014
commit 2ec0238e5c
24 changed files with 273 additions and 14 deletions
@@ -176,6 +176,7 @@ const VisualSectionContent: React.FC = () => {
'terminalFontSize',
'editorFontSize',
'spacing',
'scrollbars',
'inputBarOffset',
]} />;
};
@@ -300,7 +300,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
return mode === 'markdown' ? 'markdown' : 'plain';
};
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'inputHistoryScope' | 'inputHistoryLimit' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'largeTextPaste' | 'enterToSend' | 'reportUsage' | 'autoSaveEnabled' | 'sessionTabs';
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'scrollbars' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'inputHistoryScope' | 'inputHistoryLimit' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'largeTextPaste' | 'enterToSend' | 'reportUsage' | 'autoSaveEnabled' | 'sessionTabs';
const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [
{ id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' },
@@ -454,6 +454,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
);
const dockBadgeEnabled = useUIStore(state => state.dockBadgeEnabled);
const setDockBadgeEnabled = useUIStore(state => state.setDockBadgeEnabled);
const alwaysShowScrollbars = useUIStore(state => state.alwaysShowScrollbars === true);
const setAlwaysShowScrollbars = useUIStore(state => state.setAlwaysShowScrollbars);
const showWindowControlsPosition = usesFramelessElectronChrome();
const desktopWindowControlsPosition = useUIStore((state) => state.desktopWindowControlsPosition);
const setDesktopWindowControlsPosition = useUIStore((state) => state.setDesktopWindowControlsPosition);
@@ -682,7 +684,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const hasAppearanceSettings = isVSCode
? hasLocalizationSettings
: (shouldShow('theme') || showWindowControlsPositionSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart'));
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('editorFontSize') || shouldShow('spacing') || (shouldShow('inputBarOffset') && isMobile);
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('editorFontSize') || shouldShow('spacing') || (shouldShow('scrollbars') && !hasThemeSettings) || (shouldShow('inputBarOffset') && isMobile);
const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || ((shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode) || shouldShow('fileEditorKeymap') || shouldShow('autoSaveEnabled') || (shouldShow('sessionTabs') && !isVSCode && !isMobile);
const hasBehaviorSettings = shouldShow('mermaidRendering')
|| (shouldShow('sessionGoal') && !isVSCode)
@@ -896,6 +898,16 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
};
}, [setMobileKeyboardMode, showMobileKeyboardModeSetting, showPwaInstallNameSetting, showPwaOrientationSetting]);
const scrollbarSetting = shouldShow('scrollbars') ? (
<SettingsCheckboxRow
checked={alwaysShowScrollbars}
onChange={setAlwaysShowScrollbars}
label={t('settings.openchamber.visual.field.alwaysShowScrollbars')}
info={t('settings.openchamber.visual.field.alwaysShowScrollbarsHint')}
settingsItem="appearance.scrollbars"
/>
) : null;
return (
<div className="space-y-0">
@@ -1005,6 +1017,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
/>
</SettingsInset>
)}
{scrollbarSetting && <SettingsInset>{scrollbarSetting}</SettingsInset>}
</SettingsSection>
)}
@@ -1482,6 +1495,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
)}
</SettingsTwoColumn>
) : null}
{!hasThemeSettings && scrollbarSetting}
</SettingsSection>
)}
@@ -4,6 +4,7 @@ import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { OverlayScrollbar } from './OverlayScrollbar';
import { useUIStore } from '@/stores/useUIStore';
class TestResizeObserver implements ResizeObserver {
static instances: TestResizeObserver[] = [];
@@ -65,6 +66,7 @@ describe('OverlayScrollbar', () => {
};
beforeEach(() => {
useUIStore.getState().setAlwaysShowScrollbars(false);
windowInstance = new Window();
pendingFrames = new Map();
nextFrameId = 1;
@@ -140,9 +142,117 @@ describe('OverlayScrollbar', () => {
afterEach(async () => {
await act(async () => root.unmount());
useUIStore.getState().setAlwaysShowScrollbars(false);
windowInstance.close();
});
test('starts hidden and hides again after scrolling by default', async () => {
await renderScrollbar({ hideDelayMs: 10 });
const scrollbar = host.querySelector<HTMLElement>('.overlay-scrollbar');
expect(scrollbar?.dataset.visible).toBe('false');
scroller.dispatchEvent(new window.Event('scroll'));
expect(scrollbar?.dataset.visible).toBe('true');
await new Promise((resolve) => setTimeout(resolve, 30));
expect(scrollbar?.dataset.visible).toBe('false');
});
test('shows overflowing thumbs immediately and keeps them visible without user input', async () => {
useUIStore.getState().setAlwaysShowScrollbars(true);
await renderScrollbar({ hideDelayMs: 10, suppressVisibility: true, userIntentOnly: true });
const scrollbar = host.querySelector<HTMLElement>('.overlay-scrollbar');
expect(scrollbar?.dataset.visible).toBe('true');
expect(host.querySelector<HTMLElement>('[data-overlay-scrollbar-thumb="vertical"]')?.hidden).toBe(false);
expect(host.querySelector<HTMLElement>('[data-overlay-scrollbar-thumb="horizontal"]')?.hidden).toBe(true);
verticalLayoutReads = 0;
scrollbarCommits = 0;
scrollTop = 200;
for (let i = 0; i < 100; i += 1) scroller.dispatchEvent(new window.Event('scroll'));
expect(pendingFrames.size).toBe(1);
await flushFrames();
await new Promise((resolve) => setTimeout(resolve, 30));
expect(scrollbar?.dataset.visible).toBe('true');
expect(host.querySelector<HTMLElement>('[data-overlay-scrollbar-thumb="vertical"]')?.style.transform).toBe('translate3d(0, 34px, 0)');
expect(verticalLayoutReads).toBe(0);
expect(scrollbarCommits).toBe(0);
});
test('updates mounted scrollbars and cancels an existing hide timer when the preference changes', async () => {
await renderScrollbar({ hideDelayMs: 10 });
const scrollbar = host.querySelector<HTMLElement>('.overlay-scrollbar');
scroller.dispatchEvent(new window.Event('scroll'));
await act(async () => useUIStore.getState().setAlwaysShowScrollbars(true));
await flushFrames();
await new Promise((resolve) => setTimeout(resolve, 30));
expect(scrollbar?.dataset.visible).toBe('true');
expect(TestResizeObserver.instances).toHaveLength(1);
expect(TestResizeObserver.instances[0]?.disconnectCount).toBe(1);
await act(async () => useUIStore.getState().setAlwaysShowScrollbars(false));
expect(scrollbar?.dataset.visible).toBe('false');
scroller.dispatchEvent(new window.Event('scroll'));
expect(scrollbar?.dataset.visible).toBe('true');
await new Promise((resolve) => setTimeout(resolve, 30));
expect(scrollbar?.dataset.visible).toBe('false');
});
test('keeps non-overflowing axes hidden and responds to content resizing in always-visible mode', async () => {
useUIStore.getState().setAlwaysShowScrollbars(true);
let contentHeight = 100;
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, get: () => contentHeight });
Object.defineProperty(scroller, 'scrollWidth', { configurable: true, get: () => 300 });
await renderScrollbar({ disableHorizontal: false });
const vertical = host.querySelector<HTMLElement>('[data-overlay-scrollbar-thumb="vertical"]');
const horizontal = host.querySelector<HTMLElement>('[data-overlay-scrollbar-thumb="horizontal"]');
expect(vertical?.hidden).toBe(true);
expect(horizontal?.hidden).toBe(false);
contentHeight = 500;
TestResizeObserver.instances[0]?.trigger();
await flushFrames();
expect(vertical?.hidden).toBe(false);
contentHeight = 100;
TestResizeObserver.instances[0]?.trigger();
await flushFrames();
expect(vertical?.hidden).toBe(true);
});
test('drops stale thumbs and pending work when the scrolling element disappears or is replaced', async () => {
useUIStore.getState().setAlwaysShowScrollbars(true);
await renderScrollbar();
const scrollbar = host.querySelector<HTMLElement>('.overlay-scrollbar');
const vertical = host.querySelector<HTMLElement>('[data-overlay-scrollbar-thumb="vertical"]');
scroller.dispatchEvent(new window.Event('scroll'));
expect(pendingFrames.size).toBe(1);
containerRef.current = null;
await renderScrollbar();
expect(pendingFrames.size).toBe(0);
expect(scrollbar?.dataset.visible).toBe('false');
expect(vertical?.hidden).toBe(true);
scroller.dispatchEvent(new window.Event('scroll'));
expect(pendingFrames.size).toBe(0);
const replacement = document.createElement('div');
Object.defineProperties(replacement, {
clientHeight: { value: 100 },
scrollHeight: { value: 100 },
});
containerRef.current = replacement;
await renderScrollbar();
expect(scrollbar?.dataset.visible).toBe('true');
expect(vertical?.hidden).toBe(true);
containerRef.current = scroller;
await renderScrollbar();
expect(vertical?.hidden).toBe(false);
expect(scrollbar?.dataset.visible).toBe('true');
replacement.dispatchEvent(new window.Event('scroll'));
expect(pendingFrames.size).toBe(0);
scroller.dispatchEvent(new window.Event('scroll'));
expect(pendingFrames.size).toBe(1);
});
test('moves the thumb without rereading layout during steady scrolling', async () => {
await renderScrollbar();
scrollbarCommits = 0;
@@ -213,6 +323,7 @@ describe('OverlayScrollbar', () => {
});
test('uses the rendered thumb travel when dragging', async () => {
useUIStore.getState().setAlwaysShowScrollbars(true);
await renderScrollbar();
const thumb = host.querySelector<HTMLElement>('[data-overlay-scrollbar-thumb="vertical"]');
if (!thumb) throw new Error('OverlayScrollbar did not render its vertical thumb');
@@ -1,8 +1,9 @@
import React from "react";
import { cn } from "@/lib/utils";
import { useUIStore } from "@/stores/useUIStore";
type OverlayScrollbarProps = {
/** The authoritative scrolling element. Its identity must stay stable while mounted. */
/** The scrolling element. Container replacement is picked up on the next React commit. */
containerRef: React.RefObject<HTMLElement | null>;
/** Minimum thumb length in CSS pixels, capped to the available track. */
minThumbSize?: number;
@@ -13,16 +14,16 @@ type OverlayScrollbarProps = {
disableHorizontal?: boolean;
/** Tracks direct-child replacement so newly mounted content remains size-observed. */
observeMutations?: boolean;
/** Hides the scrollbar during programmatic motion, except while the user is dragging it. */
/** Hides programmatic motion unless dragging or the device preference keeps scrollbars visible. */
suppressVisibility?: boolean;
/** Shows the scrollbar only after recent wheel, touch, keyboard, or thumb input. */
/** Requires recent user input in the default auto-hide mode. */
userIntentOnly?: boolean;
};
type ScrollbarOptions = Required<Pick<
OverlayScrollbarProps,
"minThumbSize" | "hideDelayMs" | "disableHorizontal" | "observeMutations" | "suppressVisibility" | "userIntentOnly"
>>;
>> & { alwaysVisible: boolean };
// The inset is part of both rendering and drag math; changing it must preserve that shared track.
const TRACK_INSET = 8;
@@ -83,11 +84,11 @@ function bindScrollbar(
};
const scheduleHide = () => {
if (pointerOverThumb || drag || hideTimerId !== null) return;
if (options.alwaysVisible || pointerOverThumb || drag || hideTimerId !== null) return;
const hide = () => {
hideTimerId = null;
if (pointerOverThumb || drag) return;
if (options.alwaysVisible || pointerOverThumb || drag) return;
const delay = hideDeadlineMs - performance.now();
if (delay > 0) {
hideTimerId = setTimeout(hide, delay);
@@ -160,7 +161,7 @@ function bindScrollbar(
// Scroll visibility is separate from positioning so hidden programmatic scrolling does no DOM work.
const onScroll = () => {
const shouldShow = drag
const shouldShow = options.alwaysVisible || drag
|| (!options.suppressVisibility
&& (!options.userIntentOnly
|| performance.now() - lastUserIntentTimeMs <= USER_INTENT_DURATION_MS));
@@ -298,12 +299,13 @@ function bindScrollbar(
setMutationObservation(options.observeMutations);
setUserIntentListeners(options.userIntentOnly);
root.dataset.visible = "false";
setVisible(options.alwaysVisible);
scheduleUpdate(true);
// Props update policy in place; only mounting and unmounting bind browser resources.
return {
update(nextOptions: ScrollbarOptions) {
const visibilityChanged = nextOptions.alwaysVisible !== options.alwaysVisible;
const mustMeasure = nextOptions.disableHorizontal !== options.disableHorizontal
|| nextOptions.minThumbSize !== options.minThumbSize;
if (nextOptions.observeMutations !== options.observeMutations) {
@@ -316,11 +318,25 @@ function bindScrollbar(
options = nextOptions;
if (mustMeasure) scheduleUpdate(true);
if (options.disableHorizontal) horizontalThumb.hidden = true;
if (options.suppressVisibility && !drag) setVisible(false);
if (options.alwaysVisible) {
if (hideTimerId !== null) {
clearTimeout(hideTimerId);
hideTimerId = null;
}
if (visibilityChanged) scheduleUpdate();
setVisible(true);
} else if (visibilityChanged) {
setVisible(Boolean(drag || pointerOverThumb));
} else if (options.suppressVisibility && !drag) {
setVisible(false);
}
},
disconnect() {
if (frameId !== null) cancelAnimationFrame(frameId);
if (hideTimerId !== null) clearTimeout(hideTimerId);
setVisible(false);
verticalThumb.hidden = true;
horizontalThumb.hidden = true;
container.removeEventListener("scroll", onScroll);
setUserIntentListeners(false);
root.removeEventListener("pointerdown", onPointerDown);
@@ -345,12 +361,14 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
suppressVisibility = false,
userIntentOnly = false,
}) => {
const alwaysVisible = useUIStore((state) => state.alwaysShowScrollbars === true);
const rootRef = React.useRef<HTMLDivElement>(null);
const verticalThumbRef = React.useRef<HTMLDivElement>(null);
const horizontalThumbRef = React.useRef<HTMLDivElement>(null);
const bindingRef = React.useRef<ReturnType<typeof bindScrollbar> | null>(null);
const boundContainerRef = React.useRef<HTMLElement | null>(null);
const optionsRef = React.useRef<ScrollbarOptions>({
alwaysVisible,
minThumbSize,
hideDelayMs,
disableHorizontal,
@@ -359,6 +377,7 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
userIntentOnly,
});
optionsRef.current = {
alwaysVisible,
minThumbSize,
hideDelayMs,
disableHorizontal,
@@ -397,6 +416,7 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
React.useLayoutEffect(() => {
bindingRef.current?.update({
alwaysVisible,
minThumbSize,
hideDelayMs,
disableHorizontal,
@@ -404,7 +424,7 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
suppressVisibility,
userIntentOnly,
});
}, [disableHorizontal, hideDelayMs, minThumbSize, observeMutations, suppressVisibility, userIntentOnly]);
}, [alwaysVisible, disableHorizontal, hideDelayMs, minThumbSize, observeMutations, suppressVisibility, userIntentOnly]);
return (
<div ref={rootRef} className={cn("overlay-scrollbar", className)} aria-hidden="true">
@@ -16,6 +16,7 @@ import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { Tooltip, TooltipTrigger } from '@/components/ui/tooltip';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { AgentsSidebar } from '@/components/sections/agents/AgentsSidebar';
import { AgentsPage } from '@/components/sections/agents/AgentsPage';
import { BehaviorPage } from '@/components/sections/behavior/BehaviorPage';
@@ -841,7 +842,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
</div>
{/* Scrollable nav items */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
<ScrollableOverlay outerClassName="flex-1 min-h-0" disableHorizontal>
<div className="flex flex-col gap-0.5 px-4 pt-4 pb-2">
{hasSearchQuery ? (
settingsSearchResults.length > 0 ? (() => {
@@ -956,7 +957,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
));
})()}
</div>
</div>
</ScrollableOverlay>
{/* Footer */}
<div className="overflow-hidden transition-opacity duration-150 opacity-100">
@@ -1,5 +1,7 @@
import { linearIntegrationI18n } from './linear-integration.i18n';
export const settingsDict = {
'settings.openchamber.visual.field.alwaysShowScrollbars': 'Bildlaufleisten immer anzeigen',
'settings.openchamber.visual.field.alwaysShowScrollbarsHint': 'Bildlaufleisten bleiben sichtbar, damit du sie ohne vorheriges Scrollen ziehen kannst. Gilt nur auf diesem Gerät.',
'settings.providers.page.openCodeGo.title': 'OpenCode Go Nutzungsverfolgung',
'settings.providers.page.openCodeGo.description': 'Verbinden Sie das OpenCode Go Dashboard, um rollierenden, wöchentlichen und monatlichen Verbrauch anzuzeigen.',
'settings.providers.page.openCodeGo.workspaceId': 'Workspace-ID',
@@ -1,5 +1,7 @@
import { linearIntegrationI18n } from './linear-integration.i18n';
export const settingsDict = {
'settings.openchamber.visual.field.alwaysShowScrollbars': 'Always show scrollbars',
'settings.openchamber.visual.field.alwaysShowScrollbarsHint': 'Keep scrollbars visible so you can drag them without scrolling first. Applies on this device only.',
'settings.providers.page.openCodeGo.title': 'OpenCode Go usage tracking',
'settings.providers.page.openCodeGo.description': 'Connect the OpenCode Go dashboard to show rolling, weekly, and monthly quota.',
'settings.providers.page.openCodeGo.workspaceId': 'Workspace ID',
@@ -1,5 +1,7 @@
import { linearIntegrationI18n } from './linear-integration.i18n';
export const settingsDict = {
'settings.openchamber.visual.field.alwaysShowScrollbars': 'Mostrar siempre las barras de desplazamiento',
'settings.openchamber.visual.field.alwaysShowScrollbarsHint': 'Mantén las barras visibles para poder arrastrarlas sin desplazarte primero. Solo se aplica en este dispositivo.',
'settings.providers.page.openCodeGo.title': 'Seguimiento de uso de OpenCode Go',
'settings.providers.page.openCodeGo.description': 'Conecta el panel de OpenCode Go para ver las cuotas móvil, semanal y mensual.',
'settings.providers.page.openCodeGo.workspaceId': 'ID del espacio de trabajo',
@@ -1,5 +1,7 @@
import { linearIntegrationI18n } from './linear-integration.i18n';
export const settingsDict = {
'settings.openchamber.visual.field.alwaysShowScrollbars': 'Toujours afficher les barres de défilement',
'settings.openchamber.visual.field.alwaysShowScrollbarsHint': 'Gardez les barres visibles pour pouvoir les faire glisser sans commencer par faire défiler le contenu. Uniquement sur cet appareil.',
'settings.providers.page.openCodeGo.title': 'Suivi de lutilisation dOpenCode Go',
'settings.providers.page.openCodeGo.description': 'Connectez le tableau de bord OpenCode Go pour afficher les quotas glissant, hebdomadaire et mensuel.',
'settings.providers.page.openCodeGo.workspaceId': 'ID de lespace de travail',
@@ -1,5 +1,7 @@
import { linearIntegrationI18n } from './linear-integration.i18n';
export const settingsDict = {
'settings.openchamber.visual.field.alwaysShowScrollbars': 'スクロールバーを常に表示',
'settings.openchamber.visual.field.alwaysShowScrollbarsHint': 'スクロールバーを常に表示し、先にスクロールしなくてもドラッグできるようにします。このデバイスにのみ適用されます。',
'settings.providers.page.openCodeGo.title': 'OpenCode Go 使用量追跡',
'settings.providers.page.openCodeGo.description': 'OpenCode Go ダッシュボードを接続して、ローリング、週間、月間のクォータを表示します。',
'settings.providers.page.openCodeGo.workspaceId': 'ワークスペース ID',
@@ -1,5 +1,7 @@
import { linearIntegrationI18n } from './linear-integration.i18n';
export const settingsDict = {
'settings.openchamber.visual.field.alwaysShowScrollbars': '스크롤바 항상 표시',
'settings.openchamber.visual.field.alwaysShowScrollbarsHint': '먼저 스크롤하지 않고도 드래그할 수 있도록 스크롤바를 항상 표시합니다. 이 기기에만 적용됩니다.',
'settings.providers.page.openCodeGo.title': 'OpenCode Go 사용량 추적',
'settings.providers.page.openCodeGo.description': 'OpenCode Go 대시보드를 연결하여 롤링, 주간 및 월간 할당량을 표시합니다.',
'settings.providers.page.openCodeGo.workspaceId': '워크스페이스 ID',
@@ -1,5 +1,7 @@
import { linearIntegrationI18n } from './linear-integration.i18n';
export const settingsDict = {
'settings.openchamber.visual.field.alwaysShowScrollbars': 'Zawsze pokazuj paski przewijania',
'settings.openchamber.visual.field.alwaysShowScrollbarsHint': 'Paski przewijania pozostają widoczne, aby można było je przeciągać bez wcześniejszego przewijania. Dotyczy tylko tego urządzenia.',
'settings.providers.page.openCodeGo.title': 'Śledzenie użycia OpenCode Go',
'settings.providers.page.openCodeGo.description': 'Połącz panel OpenCode Go, aby wyświetlać limity kroczące, tygodniowe i miesięczne.',
'settings.providers.page.openCodeGo.workspaceId': 'ID przestrzeni roboczej',
@@ -1,5 +1,7 @@
import { linearIntegrationI18n } from './linear-integration.i18n';
export const settingsDict = {
'settings.openchamber.visual.field.alwaysShowScrollbars': 'Sempre mostrar barras de rolagem',
'settings.openchamber.visual.field.alwaysShowScrollbarsHint': 'Mantenha as barras visíveis para poder arrastá-las sem precisar rolar primeiro. Aplica-se apenas a este dispositivo.',
'settings.providers.page.openCodeGo.title': 'Monitoramento de uso do OpenCode Go',
'settings.providers.page.openCodeGo.description': 'Conecte o painel do OpenCode Go para exibir as cotas móvel, semanal e mensal.',
'settings.providers.page.openCodeGo.workspaceId': 'ID do workspace',
@@ -1,5 +1,7 @@
import { linearIntegrationI18n } from './linear-integration.i18n';
export const settingsDict = {
'settings.openchamber.visual.field.alwaysShowScrollbars': 'Kaydırma çubuklarını her zaman göster',
'settings.openchamber.visual.field.alwaysShowScrollbarsHint': 'Önce kaydırma yapmadan sürükleyebilmeniz için kaydırma çubuklarını görünür tutar. Yalnızca bu cihazda geçerlidir.',
'settings.providers.page.openCodeGo.title': 'OpenCode Go kullanım takibi',
'settings.providers.page.openCodeGo.description': 'Kayan, haftalık ve aylık kotayı göstermek için OpenCode Go kontrol panelini bağlayın.',
'settings.providers.page.openCodeGo.workspaceId': 'Çalışma alanı ID\'si',
@@ -1,5 +1,7 @@
import { linearIntegrationI18n } from './linear-integration.i18n';
export const settingsDict = {
'settings.openchamber.visual.field.alwaysShowScrollbars': 'Завжди показувати смуги прокручування',
'settings.openchamber.visual.field.alwaysShowScrollbarsHint': 'Залишати смуги прокручування видимими, щоб можна було перетягнути повзунок без попереднього прокручування. Лише на цьому пристрої.',
'settings.providers.page.openCodeGo.title': 'Відстеження використання OpenCode Go',
'settings.providers.page.openCodeGo.description': 'Підключіть панель OpenCode Go, щоб бачити ковзну, тижневу та місячну квоту.',
'settings.providers.page.openCodeGo.workspaceId': 'ID робочого простору',
@@ -1,5 +1,7 @@
import { linearIntegrationI18n } from './linear-integration.i18n';
export const settingsDict = {
'settings.openchamber.visual.field.alwaysShowScrollbars': '始终显示滚动条',
'settings.openchamber.visual.field.alwaysShowScrollbarsHint': '保持滚动条可见,无需先滚动即可拖动。仅在此设备上生效。',
'settings.providers.page.openCodeGo.title': 'OpenCode Go 用量跟踪',
'settings.providers.page.openCodeGo.description': '连接 OpenCode Go 控制面板以显示滚动、每周和每月配额。',
'settings.providers.page.openCodeGo.workspaceId': '工作区 ID',
@@ -1,5 +1,7 @@
import { linearIntegrationI18n } from './linear-integration.i18n';
export const settingsDict = {
'settings.openchamber.visual.field.alwaysShowScrollbars': '一律顯示捲軸',
'settings.openchamber.visual.field.alwaysShowScrollbarsHint': '保持捲軸可見,不必先捲動就能拖曳。僅在此裝置上生效。',
'settings.providers.page.openCodeGo.title': 'OpenCode Go 用量追蹤',
'settings.providers.page.openCodeGo.description': '連接 OpenCode Go 控制面板以顯示滾動、每週和每月配額。',
'settings.providers.page.openCodeGo.workspaceId': '工作區 ID',
+1
View File
@@ -544,6 +544,7 @@ export const LOCAL_DEVICE_KEYS = [
'linearIssueListPriority',
'showTerminalQuickKeysOnDesktop',
'dockBadgeEnabled',
'alwaysShowScrollbars',
'agentMemoryViewedAt',
'projectContextSidebarWidth',
] as const;
@@ -17,6 +17,17 @@ const runtimeCtx = {
};
describe('settings search', () => {
test('finds the scrollbar preference on every surface', () => {
for (const context of [runtimeCtx, { ...runtimeCtx, isDesktop: true }, { ...runtimeCtx, isVSCode: true }, { ...runtimeCtx, isMobile: true }]) {
const results = buildSettingsSearchResults({
query: 'scrollbar',
runtimeCtx: context,
t,
getPageTitle: (page) => page,
});
expect(results.find((result) => result.id === 'appearance.scrollbars')?.page).toBe('appearance');
}
});
test('finds Linear connect on the integrations page', () => {
const results = buildSettingsSearchResults({
query: 'linear',
+7
View File
@@ -75,6 +75,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
// Electron shell (isMac already implies isDesktopShell), local or remote host.
isAvailable: (ctx) => ctx.isMac,
},
{
id: 'appearance.scrollbars',
page: 'appearance',
titleKey: 'settings.openchamber.visual.field.alwaysShowScrollbars',
descriptionKey: 'settings.openchamber.visual.field.alwaysShowScrollbarsHint',
keywords: ['scrollbar', 'scrollbars', 'scroll', 'mouse', 'wheel', 'accessibility', 'always visible'],
},
{
id: 'appearance.pwa-install-name',
page: 'appearance',
@@ -0,0 +1,54 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { useUIStore } from './useUIStore';
import { AUTO_SAVE_KEYS, buildSettingsRegistrySnapshot, parseSettingsDocument } from '@/lib/settings/registry';
const originalOptions = useUIStore.persist.getOptions();
const originalState = useUIStore.getState();
afterEach(() => {
useUIStore.persist.setOptions(originalOptions);
useUIStore.setState(originalState, true);
});
describe('scrollbar preference', () => {
test('defaults to auto-hide when an existing install has no preference', async () => {
expect(useUIStore.getInitialState().alwaysShowScrollbars).toBe(false);
useUIStore.persist.setOptions({ storage: {
getItem: () => ({ version: originalOptions.version, state: { dockBadgeEnabled: false } }),
setItem: () => undefined,
removeItem: () => undefined,
} });
useUIStore.setState(useUIStore.getInitialState(), true);
await useUIStore.persist.rehydrate();
expect(useUIStore.getState().alwaysShowScrollbars).toBe(false);
expect(useUIStore.getState().dockBadgeEnabled).toBe(false);
});
for (const enabled of [true, false]) {
test(`round-trips ${enabled} through the persisted store`, async () => {
let saved: Parameters<NonNullable<typeof originalOptions.storage>['setItem']>[1] = {
state: useUIStore.getInitialState(), version: originalOptions.version,
};
useUIStore.persist.setOptions({ storage: {
getItem: () => saved,
setItem: (_name, value) => { saved = value; },
removeItem: () => undefined,
} });
useUIStore.getState().setAlwaysShowScrollbars(enabled);
useUIStore.persist.setOptions({ storage: {
getItem: () => saved,
setItem: () => undefined,
removeItem: () => undefined,
} });
useUIStore.getState().setAlwaysShowScrollbars(!enabled);
await useUIStore.persist.rehydrate();
expect(useUIStore.getState().alwaysShowScrollbars).toBe(enabled);
});
}
test('stays local to the device rather than syncing to other surfaces', () => {
expect(buildSettingsRegistrySnapshot().fields.alwaysShowScrollbars).toEqual({ scope: 'device', local: true });
expect(AUTO_SAVE_KEYS).not.toContain('alwaysShowScrollbars');
expect(parseSettingsDocument({ alwaysShowScrollbars: true })).toEqual({});
});
});
+7
View File
@@ -901,6 +901,7 @@ interface UIStore {
notifyOnSubtasks: boolean;
// Desktop dock badge showing the count of sessions with unseen activity (macOS).
dockBadgeEnabled: boolean;
alwaysShowScrollbars: boolean;
// Event toggles (which events trigger notifications)
notifyOnCompletion: boolean;
@@ -1111,6 +1112,7 @@ interface UIStore {
setSessionTabsEnabled: (value: boolean) => void;
setNotifyOnSubtasks: (value: boolean) => void;
setDockBadgeEnabled: (value: boolean) => void;
setAlwaysShowScrollbars: (value: boolean) => void;
setNotifyOnCompletion: (value: boolean) => void;
setNotifyOnError: (value: boolean) => void;
setNotifyOnQuestion: (value: boolean) => void;
@@ -1274,6 +1276,7 @@ export const useUIStore = create<UIStore>()(
notificationMode: 'hidden-only',
notifyOnSubtasks: true,
dockBadgeEnabled: true,
alwaysShowScrollbars: false,
// Event toggles (which events trigger notifications)
notifyOnCompletion: true,
@@ -2556,6 +2559,9 @@ export const useUIStore = create<UIStore>()(
setDockBadgeEnabled: (value) => {
set({ dockBadgeEnabled: value });
},
setAlwaysShowScrollbars: (value) => {
set({ alwaysShowScrollbars: value });
},
setNotifyOnCompletion: (value) => { set({ notifyOnCompletion: value }); },
setNotifyOnError: (value) => { set({ notifyOnError: value }); },
@@ -3039,6 +3045,7 @@ export const useUIStore = create<UIStore>()(
sessionTabsEnabled: state.sessionTabsEnabled,
notifyOnSubtasks: state.notifyOnSubtasks,
dockBadgeEnabled: state.dockBadgeEnabled,
alwaysShowScrollbars: state.alwaysShowScrollbars,
notifyOnCompletion: state.notifyOnCompletion,
notifyOnError: state.notifyOnError,
notifyOnQuestion: state.notifyOnQuestion,
@@ -682,6 +682,10 @@
"scope": "device",
"local": true
},
"alwaysShowScrollbars": {
"scope": "device",
"local": true
},
"agentMemoryViewedAt": {
"scope": "device",
"local": true
@@ -682,6 +682,10 @@
"scope": "device",
"local": true
},
"alwaysShowScrollbars": {
"scope": "device",
"local": true
},
"agentMemoryViewedAt": {
"scope": "device",
"local": true