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:
@@ -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 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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user