feat(settings): add a scrollbar minimap switch under Appearance

chatMinimapEnabled defaults to on; turning it off restores the native message scrollbar while keeping continuous history and DOM windowing.
This commit is contained in:
mesalogo
2026-10-12 21:26:39 +08:00
parent 4edc34c625
commit b22be5146b
17 changed files with 127 additions and 5 deletions
@@ -14,4 +14,4 @@
- **历史窗口**:允许滚动访问的消息范围。第一阶段扩大为当前已加载会话的全部消息。
- **DOM 窗口**:实际挂载消息组件的范围。继续由消息高度估算和最多约 60 条挂载消息控制。
- **minimap**:替代消息区原生滚动条的 canvas 全局概览,高度与滚动区一致,悬停时展开为较宽的缩略长图;不等于完整渲染所有消息。
- **minimap**:替代消息区原生滚动条的 canvas 全局概览,高度与滚动区一致,悬停时展开为较宽的缩略长图;不等于完整渲染所有消息。设置界面中称为“滚动条缩略图”(设置 › 外观),对应应用设置 `chatMinimapEnabled`,默认开启;关闭后恢复原生滚动条,历史窗口不受影响。
@@ -73,3 +73,10 @@
- 验证:minimap、历史窗口、时间线定向测试 69/69(新增“行测量不��发重画”和“滑块对齐可见行的概览区段”用例);web 类型检查与 lint 通过;`npm run build:bundle` 通过。
- `npm run perf:app` 连续两次通过。第二次结果:长会话打开、长会话滚动、`stream-seeded`、`stream-and-type` 均无长任务,帧 p95 8.5 ms;24 次切换后 DOM 2,449、堆 23.2 MB,后半段增长 0.2 MB。第一次运行中长会话滚动帧 p95 为 16.6 ms,`stream-seeded` 有 1 个 62 ms 长任务,第二次未复现,按单次噪声记录。
- 未验证:真实桌面端滚动时的视觉稳定性需人工确认。
### 2026-10-12 外观设置开关
- 新增应用设置 `chatMinimapEnabled`(`z.boolean().default(true)`),旧设置文件缺省时读为开启,无需升级设置版本;`application.update` 配置工具复用同一更新 schema,自动接受该字段。
- 设置 › 外观新增开关“滚动条缩略图”/“Scrollbar minimap”,位于“透明磨砂特效”之后,保存后立即生效。
- 关闭时 `ChatHistoryPane` 不挂载 `ChatMinimap`,也不再设置 `data-minimap`,原生滚动条随之恢复;历史窗口(完整已加载消息)和约 60 条 DOM 窗口不变。
- 验证:设置存储(缺省默认、两种取值的持久化与重载、非法值拒绝)、外观开关(中英文)、窗格开关切换用例均通过;相关 8 个测试文件 410 项中修正配置快照夹具后全部通过;`App.test.tsx` 342/350,8 个失败与此前相同;web/node 类型检查(除既有 `desktop-storage-electron-fixture.ts`)与改动文件 lint 通过。
@@ -84,6 +84,20 @@ describe('ApplicationSettingsStore', () => {
expect(applicationSettingsSchema.parse({ ...defaultApplicationSettings, transparentFrostedEffectEnabled: undefined }).transparentFrostedEffectEnabled).toBe(true)
})
it('defaults a missing scrollbar minimap setting to on and persists both choices', async () => {
const { filePath, store } = await createStore()
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version: 12, lastSeenReleaseNotesVersion: null }
delete legacy.chatMinimapEnabled
await writeFile(filePath, JSON.stringify(legacy))
expect((await store.get()).chatMinimapEnabled).toBe(true)
for (const chatMinimapEnabled of [false, true]) {
expect((await store.update({ chatMinimapEnabled })).chatMinimapEnabled).toBe(chatMinimapEnabled)
await store.update({ checkUpdatesOnStartup: false })
expect((await createApplicationSettingsStore(filePath).get()).chatMinimapEnabled).toBe(chatMinimapEnabled)
}
await expect(store.update({ chatMinimapEnabled: 'false' })).rejects.toThrow()
})
it('persists and publishes both frosted glass choices across unrelated updates and reloads', async () => {
const { filePath, store } = await createStore()
expect((await store.get()).transparentFrostedEffectEnabled).toBe(true)
+4
View File
@@ -252,6 +252,7 @@ export const defaultApplicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: true,
chatMinimapEnabled: true,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -295,6 +296,7 @@ export class ApplicationSettingsStore {
| 'conversationHtmlRenderingEnabled'
| 'desktopNotificationsEnabled'
| 'transparentFrostedEffectEnabled'
| 'chatMinimapEnabled'
| 'localToolEnvironment'
| 'version'
| 'applicationNavigation'
@@ -532,6 +534,7 @@ export class ApplicationSettingsStore {
checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
desktopNotificationsEnabled: stored.desktopNotificationsEnabled,
transparentFrostedEffectEnabled: stored.transparentFrostedEffectEnabled,
chatMinimapEnabled: stored.chatMinimapEnabled,
updateSource: stored.updateSource,
modelDownloadSource: stored.modelDownloadSource,
localToolEnvironment: stored.localToolEnvironment,
@@ -581,6 +584,7 @@ export class ApplicationSettingsStore {
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
desktopNotificationsEnabled: next.desktopNotificationsEnabled,
transparentFrostedEffectEnabled: next.transparentFrostedEffectEnabled,
chatMinimapEnabled: next.chatMinimapEnabled,
updateSource: next.updateSource,
modelDownloadSource: next.modelDownloadSource,
localToolEnvironment: next.localToolEnvironment,
+15
View File
@@ -1256,6 +1256,7 @@ function installRemoteProjectsSetting(enabled: boolean): {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -3486,6 +3487,7 @@ describe("App", () => {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -3591,6 +3593,7 @@ describe("App", () => {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -3608,6 +3611,7 @@ describe("App", () => {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -3711,6 +3715,7 @@ describe("App", () => {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -3728,6 +3733,7 @@ describe("App", () => {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -6430,6 +6436,7 @@ describe("App", () => {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -14663,6 +14670,7 @@ describe("App", () => {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -14680,6 +14688,7 @@ describe("App", () => {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -14734,6 +14743,7 @@ describe("App", () => {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -14795,6 +14805,7 @@ describe("App", () => {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -14833,6 +14844,7 @@ describe("App", () => {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -14850,6 +14862,7 @@ describe("App", () => {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -14913,6 +14926,7 @@ describe("App", () => {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -14973,6 +14987,7 @@ describe("App", () => {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
+4
View File
@@ -6511,6 +6511,8 @@ function App(): React.JSX.Element {
const settingsPanelActions = useStableHandlers({
onTransparentFrostedEffectEnabledChange: (enabled: boolean) =>
updateApplicationSettings({ transparentFrostedEffectEnabled: enabled }),
onChatMinimapEnabledChange: (enabled: boolean) =>
updateApplicationSettings({ chatMinimapEnabled: enabled }),
onRetryApplicationSettings: () => void reloadApplicationSettings(),
onBrandingPreferencesChange: (preferences: Parameters<typeof saveBrandingPreferences>[0]) => {
if (!saveBrandingPreferences(preferences)) {
@@ -7069,6 +7071,7 @@ function App(): React.JSX.Element {
conversationHtmlRenderingEnabled={
conversationHtmlRenderingEnabled
}
chatMinimapEnabled={applicationSettings?.chatMinimapEnabled ?? true}
conversationId={conversationId}
key={conversationId}
loadHistory={ensureConversationHistory}
@@ -7394,6 +7397,7 @@ function App(): React.JSX.Element {
{...settingsPanelActions}
appearanceTheme={appearanceTheme}
transparentFrostedEffectEnabled={applicationSettings?.transparentFrostedEffectEnabled ?? true}
chatMinimapEnabled={applicationSettings?.chatMinimapEnabled ?? true}
applicationSettingsPending={applicationSettingsPending}
applicationSettingsLocked={applicationSettingsUnconfirmed || !applicationSettings}
applicationSettingsError={applicationSettingsError}
+13
View File
@@ -181,6 +181,19 @@ afterEach(() => {
vi.unstubAllGlobals()
})
describe('ChatHistoryPane scrollbar minimap setting', () => {
it('replaces the native scrollbar only while the minimap is enabled', () => {
const messages = makeMessages(5)
const { container, rerender, props } = setup(messages)
const pane = container.querySelector<HTMLElement>('.chat-history-pane')!
expect(pane).toHaveAttribute('data-minimap', 'true')
expect(container.querySelector('.chat-minimap')).not.toBeNull()
rerender(<ChatHistoryPane {...props} chatMinimapEnabled={false} />)
expect(pane).not.toHaveAttribute('data-minimap')
expect(container.querySelector('.chat-minimap')).toBeNull()
})
})
describe('ChatHistoryPane windowing', () => {
it('restores disclosures after eviction by more than eight row interactions with bounded DOM', async () => {
const messages = makeMessages(2_000).map(message => ({ ...message, role: 'assistant' as const, reasoning: 'Reasoning' }))
+5 -1
View File
@@ -152,6 +152,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
active,
artifactById,
conversationHtmlRenderingEnabled,
chatMinimapEnabled = true,
conversation,
locale,
onCopyMessage,
@@ -177,6 +178,8 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
active: boolean;
artifactById: ReadonlyMap<string, AssistantArtifact>;
conversationHtmlRenderingEnabled: boolean;
/** Replaces the native scrollbar with the minimap; off restores the scrollbar. */
chatMinimapEnabled?: boolean;
conversation: Conversation;
locale: TimeFormatLocale;
onCopyMessage: (content: string, kind?: 'tool') => Promise<boolean>;
@@ -1071,6 +1074,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
className="chat-history-pane"
data-active={active ? "true" : "false"}
data-conversation-id={conversation.id}
data-minimap={chatMinimapEnabled ? "true" : undefined}
hidden={!active}
inert={!active}
>
@@ -1142,7 +1146,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
/>
</div>
</section>
{active && (
{active && chatMinimapEnabled && (
<ChatMinimap
messages={messages}
rowHeights={heights}
@@ -79,6 +79,7 @@ const otherTodo: MagicTodoItem = { ...todo, id: '00000000-0000-4000-8000-0000000
const settings: ApplicationSettings = {
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings, applicationNavigation: defaultApplicationNavigation,
localInferenceEnabled: true, deviceSharingEnabled: false, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false,
+28
View File
@@ -520,6 +520,7 @@ let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -815,6 +816,7 @@ describe('SettingsPanel runtime files', () => {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -1594,6 +1596,32 @@ describe('SettingsPanel runtime files', () => {
expect(onChange).toHaveBeenLastCalledWith(true)
})
it.each([
['zh-CN', '外观', '滚动条缩略图'],
['en-US', 'Appearance', 'Scrollbar minimap']
] as const)('exposes the scrollbar minimap Switch under Appearance in %s', async (locale, category, label) => {
await changeUiLocale(locale)
const onChange = vi.fn(async () => true)
const props = {
...heartbeatSettingsProps,
open: true,
onClearLocalData: vi.fn(async () => {}),
onClose: vi.fn(),
onSaved: vi.fn(),
onChatMinimapEnabledChange: onChange
}
const { rerender } = render(<SettingsPanel {...props} />)
fireEvent.click(screen.getByRole('tab', { name: category }))
const toggle = screen.getByRole('switch', { name: label })
expect(toggle).toBeChecked()
fireEvent.click(toggle)
expect(onChange).toHaveBeenCalledExactlyOnceWith(false)
rerender(<SettingsPanel {...props} chatMinimapEnabled={false} />)
expect(toggle).not.toBeChecked()
fireEvent.click(toggle)
expect(onChange).toHaveBeenLastCalledWith(true)
})
it.each([
['zh-CN', '平台功能', '桌面通知'],
['en-US', 'Platform features', 'Desktop notifications']
+20
View File
@@ -243,6 +243,8 @@ type SettingsPanelProps = {
applicationSettingsLocked?: boolean
applicationSettingsError?: string
onTransparentFrostedEffectEnabledChange?: (enabled: boolean) => Promise<boolean>
chatMinimapEnabled?: boolean
onChatMinimapEnabledChange?: (enabled: boolean) => Promise<boolean>
onRetryApplicationSettings?: () => void
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
brandingPreferences?: BrandingPreferences
@@ -658,6 +660,8 @@ function SettingsPanelView({
applicationSettingsLocked = false,
applicationSettingsError,
onTransparentFrostedEffectEnabledChange,
chatMinimapEnabled = true,
onChatMinimapEnabledChange,
onRetryApplicationSettings,
onAppearanceThemeChange = () => {},
brandingPreferences = defaultBrandingPreferences,
@@ -2380,6 +2384,22 @@ function SettingsPanelView({
<p id="frosted-glass-description" className="settings-panel__description">
{t('appearance.frostedGlass.description')}
</p>
<label className="toggle-row">
<span>{t('appearance.chatMinimap.label')}</span>
<input
type="checkbox"
role="switch"
checked={chatMinimapEnabled}
disabled={applicationSettingsPending || applicationSettingsLocked || !onChatMinimapEnabledChange}
aria-describedby="chat-minimap-description"
onChange={(event) => {
void onChatMinimapEnabledChange?.(event.target.checked)
}}
/>
</label>
<p id="chat-minimap-description" className="settings-panel__description">
{t('appearance.chatMinimap.description')}
</p>
{applicationSettingsError && (
<div role="alert">
<p>{applicationSettingsError}</p>
@@ -25,6 +25,7 @@ describe('UpdateSettingsSection', () => {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -876,6 +876,10 @@ export const settings = {
description: 'Applies to the top bar and expanded runtime checklist. Takes effect as soon as it is saved.',
retry: 'Retry'
},
chatMinimap: {
label: 'Scrollbar minimap',
description: 'Replaces the message scrollbar with an overview of the conversation that expands on hover; click or drag to jump. Takes effect as soon as it is saved.'
},
theme: {
title: 'Interface theme',
description: 'Applies immediately and is saved on this device',
@@ -811,6 +811,10 @@ export const settings = {
description: '应用于顶栏和展开的运行待办卡片,保存后立即生效。',
retry: '重试'
},
chatMinimap: {
label: '滚动条缩略图',
description: '在消息区右侧用缩略图代替滚动条,悬停时展开,可点击或拖动定位。保存后立即生效。'
},
theme: {
title: '界面主题',
description: '选择后立即应用,并保存在此设备',
+3 -3
View File
@@ -7343,12 +7343,12 @@ button > svg {
color: var(--danger);
}
/* The minimap replaces the native scrollbar of the message scroller. */
.chat-history-pane > .chat {
/* When enabled, the minimap replaces the native scrollbar of the message scroller. */
.chat-history-pane[data-minimap='true'] > .chat {
scrollbar-width: none;
}
.chat-history-pane > .chat::-webkit-scrollbar {
.chat-history-pane[data-minimap='true'] > .chat::-webkit-scrollbar {
display: none;
}
@@ -60,6 +60,7 @@ const applicationPreferencesSchema = z
checkUpdatesOnStartup: z.boolean(),
desktopNotificationsEnabled: z.boolean().default(true),
transparentFrostedEffectEnabled: z.boolean().default(true),
chatMinimapEnabled: z.boolean().default(true),
updateSource: updateSourceSchema,
modelDownloadSource: modelDownloadSourceSchema,
localToolEnvironment: localToolEnvironmentSettingsSchema,
@@ -94,6 +95,7 @@ export const applicationSettingsUpdateSchema = applicationPreferencesSchema
applicationNavigation: applicationNavigationSchema.optional(),
desktopNotificationsEnabled: z.boolean().optional(),
transparentFrostedEffectEnabled: z.boolean().optional(),
chatMinimapEnabled: z.boolean().optional(),
localInferenceEnabled: z.boolean().optional(),
deviceSharingEnabled: z.boolean().optional(),
magicNotesEnabled: z.boolean().optional(),
@@ -154,6 +154,7 @@ describe('GoodBuddy configuration contracts', () => {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,