From 2ec0238e5cd2419b16f4a83fef209adf8d8a0b41 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 7 Sep 2026 23:16:36 +0300 Subject: [PATCH] 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. --- .../sections/openchamber/OpenChamberPage.tsx | 1 + .../openchamber/OpenChamberVisualSettings.tsx | 18 ++- .../components/ui/OverlayScrollbar.test.tsx | 111 ++++++++++++++++++ .../ui/src/components/ui/OverlayScrollbar.tsx | 40 +++++-- .../ui/src/components/views/SettingsView.tsx | 5 +- .../ui/src/lib/i18n/messages/de.settings.ts | 2 + .../ui/src/lib/i18n/messages/en.settings.ts | 2 + .../ui/src/lib/i18n/messages/es.settings.ts | 2 + .../ui/src/lib/i18n/messages/fr.settings.ts | 2 + .../ui/src/lib/i18n/messages/ja.settings.ts | 2 + .../ui/src/lib/i18n/messages/ko.settings.ts | 2 + .../ui/src/lib/i18n/messages/pl.settings.ts | 2 + .../src/lib/i18n/messages/pt-BR.settings.ts | 2 + .../ui/src/lib/i18n/messages/tr.settings.ts | 2 + .../ui/src/lib/i18n/messages/uk.settings.ts | 2 + .../src/lib/i18n/messages/zh-CN.settings.ts | 2 + .../src/lib/i18n/messages/zh-TW.settings.ts | 2 + packages/ui/src/lib/settings/registry.ts | 1 + packages/ui/src/lib/settings/search.test.ts | 11 ++ packages/ui/src/lib/settings/search.ts | 7 ++ .../src/stores/useUIStore.scrollbars.test.ts | 54 +++++++++ packages/ui/src/stores/useUIStore.ts | 7 ++ packages/vscode/src/settings-registry.json | 4 + .../lib/opencode/settings-registry.json | 4 + 24 files changed, 273 insertions(+), 14 deletions(-) create mode 100644 packages/ui/src/stores/useUIStore.scrollbars.test.ts diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 57ecdcb8..47dc7cbd 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -176,6 +176,7 @@ const VisualSectionContent: React.FC = () => { 'terminalFontSize', 'editorFontSize', 'spacing', + 'scrollbars', 'inputBarOffset', ]} />; }; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index cdc26387..45a18da1 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -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 ); 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 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 }; }, [setMobileKeyboardMode, showMobileKeyboardModeSetting, showPwaInstallNameSetting, showPwaOrientationSetting]); + const scrollbarSetting = shouldShow('scrollbars') ? ( + + ) : null; + return (
@@ -1005,6 +1017,7 @@ export const OpenChamberVisualSettings: React.FC /> )} + {scrollbarSetting && {scrollbarSetting}} )} @@ -1482,6 +1495,7 @@ export const OpenChamberVisualSettings: React.FC )} ) : null} + {!hasThemeSettings && scrollbarSetting} )} diff --git a/packages/ui/src/components/ui/OverlayScrollbar.test.tsx b/packages/ui/src/components/ui/OverlayScrollbar.test.tsx index 39693798..e75ecf14 100644 --- a/packages/ui/src/components/ui/OverlayScrollbar.test.tsx +++ b/packages/ui/src/components/ui/OverlayScrollbar.test.tsx @@ -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('.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('.overlay-scrollbar'); + expect(scrollbar?.dataset.visible).toBe('true'); + expect(host.querySelector('[data-overlay-scrollbar-thumb="vertical"]')?.hidden).toBe(false); + expect(host.querySelector('[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('[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('.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('[data-overlay-scrollbar-thumb="vertical"]'); + const horizontal = host.querySelector('[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('.overlay-scrollbar'); + const vertical = host.querySelector('[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('[data-overlay-scrollbar-thumb="vertical"]'); if (!thumb) throw new Error('OverlayScrollbar did not render its vertical thumb'); diff --git a/packages/ui/src/components/ui/OverlayScrollbar.tsx b/packages/ui/src/components/ui/OverlayScrollbar.tsx index ccf687f7..6e183e3a 100644 --- a/packages/ui/src/components/ui/OverlayScrollbar.tsx +++ b/packages/ui/src/components/ui/OverlayScrollbar.tsx @@ -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; /** 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>; +>> & { 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 = ({ suppressVisibility = false, userIntentOnly = false, }) => { + const alwaysVisible = useUIStore((state) => state.alwaysShowScrollbars === true); const rootRef = React.useRef(null); const verticalThumbRef = React.useRef(null); const horizontalThumbRef = React.useRef(null); const bindingRef = React.useRef | null>(null); const boundContainerRef = React.useRef(null); const optionsRef = React.useRef({ + alwaysVisible, minThumbSize, hideDelayMs, disableHorizontal, @@ -359,6 +377,7 @@ export const OverlayScrollbar: React.FC = ({ userIntentOnly, }); optionsRef.current = { + alwaysVisible, minThumbSize, hideDelayMs, disableHorizontal, @@ -397,6 +416,7 @@ export const OverlayScrollbar: React.FC = ({ React.useLayoutEffect(() => { bindingRef.current?.update({ + alwaysVisible, minThumbSize, hideDelayMs, disableHorizontal, @@ -404,7 +424,7 @@ export const OverlayScrollbar: React.FC = ({ suppressVisibility, userIntentOnly, }); - }, [disableHorizontal, hideDelayMs, minThumbSize, observeMutations, suppressVisibility, userIntentOnly]); + }, [alwaysVisible, disableHorizontal, hideDelayMs, minThumbSize, observeMutations, suppressVisibility, userIntentOnly]); return ( {/* Scrollable nav items */} -
+
{hasSearchQuery ? ( settingsSearchResults.length > 0 ? (() => { @@ -956,7 +957,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile )); })()}
-
+ {/* Footer */}
diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index b87ccaa1..b306c788 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index c79b05bf..4f4381a6 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 38c74eae..585d0aa4 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index f9b0e193..1c2e864b 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -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 l’utilisation d’OpenCode 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 l’espace de travail', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 15589dfe..11398ca1 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index eb8bf3ec..fde2bab3 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 25195886..fa9d0f24 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 58c912d4..1522807b 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/tr.settings.ts b/packages/ui/src/lib/i18n/messages/tr.settings.ts index c68c8f59..a9902f4d 100644 --- a/packages/ui/src/lib/i18n/messages/tr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr.settings.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 4bbc4f35..baa1e2f2 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -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 робочого простору', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index ea6ccb7c..61cbef4d 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index ad5d3964..067883b8 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -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', diff --git a/packages/ui/src/lib/settings/registry.ts b/packages/ui/src/lib/settings/registry.ts index 42f700cf..80badb7d 100644 --- a/packages/ui/src/lib/settings/registry.ts +++ b/packages/ui/src/lib/settings/registry.ts @@ -544,6 +544,7 @@ export const LOCAL_DEVICE_KEYS = [ 'linearIssueListPriority', 'showTerminalQuickKeysOnDesktop', 'dockBadgeEnabled', + 'alwaysShowScrollbars', 'agentMemoryViewedAt', 'projectContextSidebarWidth', ] as const; diff --git a/packages/ui/src/lib/settings/search.test.ts b/packages/ui/src/lib/settings/search.test.ts index b22ed6cc..c6329ea5 100644 --- a/packages/ui/src/lib/settings/search.test.ts +++ b/packages/ui/src/lib/settings/search.test.ts @@ -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', diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 8324c965..fd35bdb7 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -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', diff --git a/packages/ui/src/stores/useUIStore.scrollbars.test.ts b/packages/ui/src/stores/useUIStore.scrollbars.test.ts new file mode 100644 index 00000000..784cf717 --- /dev/null +++ b/packages/ui/src/stores/useUIStore.scrollbars.test.ts @@ -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['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({}); + }); +}); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 6d54f43a..8a515c1e 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -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()( 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()( 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()( sessionTabsEnabled: state.sessionTabsEnabled, notifyOnSubtasks: state.notifyOnSubtasks, dockBadgeEnabled: state.dockBadgeEnabled, + alwaysShowScrollbars: state.alwaysShowScrollbars, notifyOnCompletion: state.notifyOnCompletion, notifyOnError: state.notifyOnError, notifyOnQuestion: state.notifyOnQuestion, diff --git a/packages/vscode/src/settings-registry.json b/packages/vscode/src/settings-registry.json index bf6356aa..3b319f08 100644 --- a/packages/vscode/src/settings-registry.json +++ b/packages/vscode/src/settings-registry.json @@ -682,6 +682,10 @@ "scope": "device", "local": true }, + "alwaysShowScrollbars": { + "scope": "device", + "local": true + }, "agentMemoryViewedAt": { "scope": "device", "local": true diff --git a/packages/web/server/lib/opencode/settings-registry.json b/packages/web/server/lib/opencode/settings-registry.json index bf6356aa..3b319f08 100644 --- a/packages/web/server/lib/opencode/settings-registry.json +++ b/packages/web/server/lib/opencode/settings-registry.json @@ -682,6 +682,10 @@ "scope": "device", "local": true }, + "alwaysShowScrollbars": { + "scope": "device", + "local": true + }, "agentMemoryViewedAt": { "scope": "device", "local": true