三件事一起落地(用户 brief),各自独立可验证:
1) 后台任务输出改为常驻浮动窗
- 新增 src/client/FloatingWindow.tsx + .module.css:可复用组件,标题栏(拖拽手柄 +
复制/关闭)、内容区(自滚动,长输出不再跑出视口)、可选底部行、三个拉伸柄
(右/下/右下)。生命周期与 AnchoredPopover 相反:外部点击、失焦、锚点离屏都不关,
只有关闭按钮与 Escape 结束;尺寸只在挂载时量一次(流式输出帧不会挪动读者摆好的窗);
拖拽保持整窗在视口内(放不下才退化到保留 48px 可达);视口变化时夹回。
- 手势复用既有范式:移动走 window 级 pointermove(同 AnchoredPopover),拉伸走
setPointerCapture + createFrameBatcher(同 EditorHost 的面板拉伸)。
- JobOutputPopoverContent → JobOutputWindow;顺带修掉「跟随最新」从不滚动的 bug
(原先滚的是 TerminalBlock 外面那个永不滚动的 div),并清掉随旧弹窗死掉的
.popPre / .jobPopPre 规则。节点/工作流详情与任务窗保持原样。
2) 节点卡统一
- 删除两条左侧竖条(层级条 .cardTop::before、当前会话条 .nodeCurrent::before);
层级改由上段极淡底色承担(7/5/3.5/2.5% → 9/7/5/3.5% 补偿对比度);
当前会话改为 2px accent 描边(「边框加粗高亮」)。
- 圆角 4px → 8px(与浮动窗同一家族);成员/工作流/聚合按用户选择保留类型区分。
- 2px 而非 1.5px:真机 recon 发现 Chromium 在 DPR 1 下把亚像素边框向下取整,
1.5px 的「加粗」完全看不出(实测 borderTopWidth: "1px")。几何不受影响
(border-box + min-height,卡片外框 247×134 与预算逐像素一致)。
3) 手机设置
- 两个新偏好 mobileNoAutoOpen / mobileDefaultTree(默认都开),落到 prefs-shared、
PrefsSchema(volatile 自动)、parsePrefs 校验与设置页新增的「手机」分组。
- 窄屏(沿用 768px 断点)时:新子代理与新后台任务都不再自动激活任务页;
任务页默认树状图(页内切换仍是临时覆盖)。宽屏或开关关掉后行为完全不变。
- 5 条词条 ×20 份词典;外部插件指南的 PrefsSchema 键清单同步。
验证:typecheck / lint ✅;pnpm test 1254 passed / 9 skipped(新增 14 例);
挂载 lane 7/7(真实 DSH 0.1.7-rc.2);3080 真机自测通过(长输出窗口内部滚动
5459px/449px、拖拽 424→304、拉伸 1008×540→918×480、外部点击不关、Escape 关、
卡片 8px/2px/无竖条、窄屏不自动弹出且默认树状图)。
(cherry picked from commit 7d9b428c6c34d28ad2d60c2086496eb533902273)
435 lines
18 KiB
TypeScript
435 lines
18 KiB
TypeScript
/**
|
||
* Side card settings section render tests: the section is DECLARATIVE —
|
||
* every small card (icon, title, type id, extensions, on/off state) derives
|
||
* from the sidebar service's tab/viewer registries instead of hardcoded
|
||
* copy. The toggles are CARDS in a responsive grid: the card's main area is
|
||
* the switch, the visual state IS the state (highlighted = enabled),
|
||
* announced via `aria-pressed`, and the check badge sits at the far right.
|
||
* The general rows follow the DSH settings-row recipe with custom SWITCHES
|
||
* (real checkboxes driving a styled track). Features that declare related
|
||
* settings carry a gear corner button whose popup rows (switch controls)
|
||
* are tested through the extracted FeatureSettingsRows component (the Modal
|
||
* portal renders only while open).
|
||
*
|
||
* Rendered with renderToString (mount effects — the settings RPC sync — do
|
||
* not run in SSR; the initial store prefs are the render input).
|
||
*/
|
||
import { beforeAll, describe, expect, it } from 'vitest'
|
||
import { renderToString } from 'react-dom/server'
|
||
import { createElement } from 'react'
|
||
import { createSidebarStore, type SidebarStore } from '../src/client/state.ts'
|
||
// The copy assertions below are English; pin the browser language so the
|
||
// module-level t()/isZh() resolve en regardless of the host system locale
|
||
// (vitest 4.1.11+/jsdom follow the OS locale — zh-CN on this machine).
|
||
beforeAll(() => {
|
||
Object.defineProperty(navigator, 'language', {
|
||
value: 'en-US',
|
||
configurable: true,
|
||
})
|
||
})
|
||
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
|
||
import type { TabDescriptor } from '../src/client/service.ts'
|
||
import { FeatureSettingsRows, mergePluginSetting, SettingsBody, SideCardSection, type SideCardSectionProps } from '../src/client/SideCardSection.tsx'
|
||
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
|
||
|
||
/** One tab + one viewer + the subagent-style nested toggle under a tab. */
|
||
function mount(): { store: SidebarStore; service: BetterSidebarService } {
|
||
const store = createSidebarStore()
|
||
const service = createBetterSidebarService(store)
|
||
service.registerTab({
|
||
id: 'explorer',
|
||
title: () => 'Explorer',
|
||
icon: () => createElement('svg', { 'data-icon': 'explorer' }),
|
||
order: 10,
|
||
component: () => null,
|
||
})
|
||
service.registerTab({
|
||
id: 'subagent',
|
||
title: () => 'Subagents',
|
||
icon: () => createElement('svg', { 'data-icon': 'subagent' }),
|
||
order: 30,
|
||
settings: {
|
||
toggles: [{
|
||
key: 'autoOpenSubagent',
|
||
title: () => 'Auto-open Subagents',
|
||
desc: () => 'Expand on new subagent',
|
||
}],
|
||
},
|
||
component: () => null,
|
||
})
|
||
service.registerFileViewer({
|
||
id: 'image',
|
||
title: () => 'Image',
|
||
icon: () => createElement('svg', { 'data-icon': 'image' }),
|
||
exts: ['png', 'jpg'],
|
||
fetchStrategy: 'mediaUrl',
|
||
component: () => null,
|
||
})
|
||
return { store, service }
|
||
}
|
||
|
||
function renderSection(store: SidebarStore, service: BetterSidebarService): string {
|
||
return renderToString(createElement(
|
||
SideCardSection,
|
||
{ store, service } as unknown as SideCardSectionProps,
|
||
))
|
||
}
|
||
|
||
/** Count `aria-pressed` occurrences of one value in the rendered HTML. */
|
||
function pressedCount(html: string, value: string): number {
|
||
return html.match(new RegExp(`aria-pressed="${value}"`, 'g'))?.length ?? 0
|
||
}
|
||
|
||
describe('SideCardSection declarative inventory', () => {
|
||
it('renders one small card per registered tab: icon + title + type id + pressed state', () => {
|
||
const { store, service } = mount()
|
||
const html = renderSection(store, service)
|
||
expect(html).toContain('data-icon="explorer"')
|
||
expect(html).toContain('>Explorer<')
|
||
// The type id is the card's desc (the declarative "type" surface).
|
||
expect(html).toContain('>explorer<')
|
||
expect(html).toContain('data-icon="subagent"')
|
||
expect(html).toContain('>Subagents<')
|
||
// Default prefs: the general switch is off (agentOpenTools defaults off),
|
||
// and both tabs + the image viewer cards are pressed (3 aria-pressed
|
||
// cards). The mobile switches are NOT cards — they are rows in the 手机
|
||
// group, and their own defaults (both on) show up as checked checkboxes.
|
||
// The nested auto-open toggle is NOT an inline card (it lives in the popup).
|
||
expect(pressedCount(html, 'true')).toBe(3)
|
||
expect(pressedCount(html, 'false')).toBe(0)
|
||
// The custom switches are real checkboxes: the two mobile adaptations are
|
||
// on by default, agentOpenTools is not.
|
||
// The two mobile adaptations are on by default.
|
||
expect(html.match(/checked=""/g)?.length ?? 0).toBe(2)
|
||
expect(html).not.toContain('Auto-open Subagents')
|
||
})
|
||
|
||
it('renders the section intro and group headings with inventory counts', () => {
|
||
const { store, service } = mount()
|
||
const html = renderSection(store, service)
|
||
expect(html).toContain('Manage what the side card shows and how it behaves')
|
||
// Group headings carry the inventory count badge (2 tabs, 1 viewer).
|
||
expect(html).toContain('>Sidebar content</span><span')
|
||
expect(html).toContain('>File viewers</span><span')
|
||
expect(html).toContain('>2</span>')
|
||
expect(html).toContain('>1</span>')
|
||
})
|
||
|
||
it('renders the mobile group: heading + both narrow-viewport switches, on by default', () => {
|
||
const { store, service } = mount()
|
||
const html = renderSection(store, service)
|
||
expect(html).toContain('>Mobile</div>')
|
||
expect(html).toContain('auto-open on narrow screens')
|
||
expect(html).toContain('Default the Tasks page to the tree')
|
||
// Both are ON by default; disarming one shows up as an unchecked box.
|
||
expect(html).toContain('checked=""')
|
||
store.setPrefs({ ...store.getPrefs(), mobileNoAutoOpen: false, mobileDefaultTree: false })
|
||
const off = renderSection(store, service)
|
||
expect(off).toContain('>Mobile</div>')
|
||
expect(off).not.toContain('checked=""')
|
||
})
|
||
|
||
it('renders one small card per registered viewer: icon + title + exts', () => {
|
||
const { store, service } = mount()
|
||
const html = renderSection(store, service)
|
||
expect(html).toContain('data-icon="image"')
|
||
expect(html).toContain('>Image<')
|
||
// The covered extensions are the card's desc.
|
||
expect(html).toContain('png · jpg')
|
||
})
|
||
|
||
it('renders the gear corner button on features that declare related settings', () => {
|
||
const { store, service } = mount()
|
||
const html = renderSection(store, service)
|
||
// Subagents declares a toggle → its card carries the settings gear
|
||
// (aria-label = "<title> Feature settings"); Explorer and Image declare
|
||
// none → no gear. The position-compat row is a DROPDOWN (no gear unless
|
||
// the custom scheme is active), so the total is 1.
|
||
expect(html.match(/aria-label="[^"]*Feature settings"/g)?.length).toBe(1)
|
||
expect(html).toContain('Subagents Feature settings')
|
||
})
|
||
|
||
it('renders the two dashed "add plugin" cards (tab grid + viewer grid)', () => {
|
||
const { store, service } = mount()
|
||
const html = renderSection(store, service)
|
||
// The tab grid's dashed card (tab registration).
|
||
expect(html).toContain('Add tab plugins')
|
||
expect(html).toContain('Register a new sidebar page')
|
||
// The viewer grid's dashed card (file-previewer registration).
|
||
expect(html).toContain('Add preview plugins')
|
||
expect(html).toContain('Register a file-type preview')
|
||
// The add cards are plain buttons (open the modals), never switches:
|
||
// they carry no aria-pressed, so the pressed-card counts stay untouched.
|
||
expect(pressedCount(html, 'true')).toBe(3)
|
||
})
|
||
|
||
it('a disabled feature renders pressed=false', () => {
|
||
const { store, service } = mount()
|
||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { subagent: false }, viewersEnabled: { image: false } })
|
||
const html = renderSection(store, service)
|
||
expect(html).toContain('>Subagents<')
|
||
expect(html).toContain('>Image<')
|
||
expect(pressedCount(html, 'false')).toBe(2)
|
||
// The explorer card stays pressed.
|
||
expect(pressedCount(html, 'true')).toBe(1)
|
||
// The two mobile adaptations are on by default.
|
||
expect(html.match(/checked=""/g)?.length ?? 0).toBe(2)
|
||
})
|
||
|
||
it('hides the gear of a disabled feature (its related settings are dormant)', () => {
|
||
const { store, service } = mount()
|
||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { subagent: false } })
|
||
const html = renderSection(store, service)
|
||
// The disabled Subagents card loses its gear; the position-compat row
|
||
// is a dropdown (default auto → no gear either).
|
||
expect(html).not.toContain('Feature settings')
|
||
})
|
||
|
||
it('renders the position-compat mode row as a scheme dropdown: auto default, custom keeps the gear', () => {
|
||
const { store, service } = mount()
|
||
let html = renderSection(store, service)
|
||
// The general row renders its title and description; the scheme is the
|
||
// conservative auto by default and the row is the shared SelectMenu
|
||
// dropdown (the closed anchor shows the picked option — NOT a native
|
||
// <select>).
|
||
expect(html).toContain('Position compatibility mode')
|
||
expect(html).toContain('Pick the title-bar compatibility scheme: auto-detect (default, conservative) / DSH official web / known desktop shells / custom (shift distance + custom CSS)')
|
||
expect(html).not.toContain('<select')
|
||
expect(html).toContain('>Auto-detect<')
|
||
// Three general-row switches remain (agentOpenTools off by default, plus
|
||
// the two mobile adaptations on) — the scheme row is a dropdown, not a
|
||
// switch.
|
||
expect(html.match(/type="checkbox"/g)?.length).toBe(3)
|
||
// The two mobile adaptations are on by default (agentOpenTools is not).
|
||
expect(html.match(/checked=""/g)?.length ?? 0).toBe(2)
|
||
// Auto (default) needs no further settings → no gear.
|
||
expect(html).not.toContain('Position compatibility mode Feature settings')
|
||
|
||
// The custom scheme keeps its settings button (the px + CSS popup);
|
||
// the anchor now shows the picked custom option.
|
||
store.setPrefs({ ...store.getPrefs(), titleBarScheme: 'custom', titleBarCompat: true })
|
||
html = renderSection(store, service)
|
||
expect(html).toContain('>Custom<')
|
||
expect(html).toContain('aria-label="Position compatibility mode Feature settings"')
|
||
})
|
||
})
|
||
|
||
describe('FeatureSettingsRows (the secondary settings popup body)', () => {
|
||
const prefs: typeof SIDEBAR_PREFS_DEFAULTS = {
|
||
...SIDEBAR_PREFS_DEFAULTS,
|
||
autoOpenSubagent: false,
|
||
}
|
||
const toggles = [{
|
||
key: 'autoOpenSubagent',
|
||
title: () => 'Auto-open Subagents',
|
||
desc: () => 'Expand on new subagent',
|
||
}]
|
||
|
||
it('renders one switch row per declared toggle with its current value', () => {
|
||
const html = renderToString(createElement(FeatureSettingsRows, {
|
||
toggles,
|
||
prefs,
|
||
onToggle: () => {},
|
||
}))
|
||
expect(html).toContain('Auto-open Subagents')
|
||
expect(html).toContain('Expand on new subagent')
|
||
// The row's switch is a real checkbox (aria-label = the toggle title)
|
||
// reflecting the prefs value (false here → unchecked).
|
||
expect(html).toContain('aria-label="Auto-open Subagents"')
|
||
expect(html).not.toContain('checked=""')
|
||
})
|
||
|
||
it('checks the row when the pref is on', () => {
|
||
const html = renderToString(createElement(FeatureSettingsRows, {
|
||
toggles,
|
||
prefs: { ...prefs, autoOpenSubagent: true },
|
||
onToggle: () => {},
|
||
}))
|
||
expect(html).toContain('checked=""')
|
||
})
|
||
|
||
it('renders a text row as an input seeded with the pref value', () => {
|
||
const html = renderToString(createElement(FeatureSettingsRows, {
|
||
toggles: [{
|
||
key: 'titleBarPresetId',
|
||
type: 'text',
|
||
title: () => 'Preset id',
|
||
desc: () => 'Named shell preset',
|
||
placeholder: '"JetBrains Mono", monospace',
|
||
}],
|
||
prefs: { ...prefs, titleBarPresetId: '"JetBrains Mono", monospace' },
|
||
onToggle: () => {},
|
||
onCommit: () => '',
|
||
}))
|
||
expect(html).toContain('Preset id')
|
||
expect(html).toContain('placeholder=""JetBrains Mono", monospace"')
|
||
// The input carries the pref value (no switch for text rows).
|
||
expect(html).toContain('value=""JetBrains Mono", monospace"')
|
||
expect(html).not.toContain('type="checkbox"')
|
||
})
|
||
|
||
it('renders a number row with the pref value, the declared bounds and a unit suffix (non-default bounds)', () => {
|
||
const html = renderToString(createElement(FeatureSettingsRows, {
|
||
toggles: [{
|
||
key: 'titleBarStripPx',
|
||
type: 'number',
|
||
title: () => 'Shift distance',
|
||
min: 9,
|
||
max: 32,
|
||
unit: 'px',
|
||
}],
|
||
prefs: { ...prefs, titleBarStripPx: 18 },
|
||
onToggle: () => {},
|
||
onCommit: () => '18',
|
||
}))
|
||
expect(html).toContain('Shift distance')
|
||
expect(html).toContain('type="number"')
|
||
expect(html).toContain('value="18"')
|
||
expect(html).toContain('min="9"')
|
||
expect(html).toContain('max="32"')
|
||
expect(html).toContain('px')
|
||
expect(html).not.toContain('type="checkbox"')
|
||
})
|
||
|
||
it('renders the title-bar strip row: the pref value, the 0–120 bounds and the px suffix', () => {
|
||
const html = renderToString(createElement(FeatureSettingsRows, {
|
||
toggles: [{
|
||
key: 'titleBarStripPx',
|
||
type: 'number',
|
||
title: () => 'Shift distance',
|
||
desc: () => 'Title-bar strip height in px',
|
||
min: 0,
|
||
max: 120,
|
||
unit: 'px',
|
||
}],
|
||
prefs: { ...prefs, titleBarStripPx: 64 },
|
||
onToggle: () => {},
|
||
onCommit: () => '64',
|
||
}))
|
||
expect(html).toContain('Shift distance')
|
||
expect(html).toContain('Title-bar strip height in px')
|
||
expect(html).toContain('type="number"')
|
||
expect(html).toContain('value="64"')
|
||
expect(html).toContain('min="0"')
|
||
expect(html).toContain('max="120"')
|
||
expect(html).toContain('px')
|
||
expect(html).not.toContain('type="checkbox"')
|
||
})
|
||
})
|
||
|
||
describe('mergePluginSetting (v0.12.0, codex review fix)', () => {
|
||
it('sequential merges are additive — a later write never drops an earlier key', () => {
|
||
// Simulates two same-tick updatePluginSetting calls: each merge spreads
|
||
// the map it was GIVEN, so building from the latest optimistic map
|
||
// preserves both keys (the pre-fix code spread the stale render-time
|
||
// prefs twice and the second write dropped the first key).
|
||
let map: Record<string, Record<string, unknown>> = {}
|
||
map = mergePluginSetting(map, 'my-plugin:db', 'pageSize', 25)
|
||
map = mergePluginSetting(map, 'my-plugin:db', 'theme', 'dark')
|
||
expect(map['my-plugin:db']).toEqual({ pageSize: 25, theme: 'dark' })
|
||
// A second descriptor's blob stays independent.
|
||
map = mergePluginSetting(map, 'other:view', 'refresh', true)
|
||
expect(map['my-plugin:db']).toEqual({ pageSize: 25, theme: 'dark' })
|
||
expect(map['other:view']).toEqual({ refresh: true })
|
||
// Overwriting one key keeps the sibling keys.
|
||
map = mergePluginSetting(map, 'my-plugin:db', 'pageSize', 50)
|
||
expect(map['my-plugin:db']).toEqual({ pageSize: 50, theme: 'dark' })
|
||
})
|
||
})
|
||
|
||
describe('FeatureSettingsRows valueSource (v0.12.0, independent CR fix)', () => {
|
||
it('plugin rows read from their OWN value source — a plugin key colliding with a host pref never reads the host value', () => {
|
||
const prefs = { ...SIDEBAR_PREFS_DEFAULTS, agentOpenTools: true }
|
||
const toggle = { key: 'agentOpenTools', title: 'My flag' }
|
||
// valueOf returns undefined (the plugin never wrote this key): the row
|
||
// must render UNCHECKED even though the host pref agentOpenTools is true.
|
||
let html = renderToString(createElement(FeatureSettingsRows, {
|
||
toggles: [toggle],
|
||
prefs,
|
||
onToggle: () => {},
|
||
valueSource: () => undefined,
|
||
}))
|
||
expect(html).not.toContain('checked=""')
|
||
// The plugin wrote `true` into its own blob: the row is checked.
|
||
html = renderToString(createElement(FeatureSettingsRows, {
|
||
toggles: [toggle],
|
||
prefs,
|
||
onToggle: () => {},
|
||
valueSource: () => true,
|
||
}))
|
||
expect(html).toContain('checked=""')
|
||
// Without valueOf the row falls back to the prefs face (host semantics).
|
||
html = renderToString(createElement(FeatureSettingsRows, {
|
||
toggles: [toggle],
|
||
prefs,
|
||
onToggle: () => {},
|
||
}))
|
||
expect(html).toContain('checked=""')
|
||
})
|
||
})
|
||
|
||
describe('SettingsBody rows + custom render panel (open-with seam)', () => {
|
||
it('renders the declarative rows AND the custom panel when both are declared', () => {
|
||
const { store, service } = mount()
|
||
const feature = {
|
||
id: 'demo',
|
||
title: () => 'Demo',
|
||
component: () => null,
|
||
settings: {
|
||
toggles: [{
|
||
key: 'autoOpenSubagent',
|
||
title: () => 'Auto row',
|
||
desc: () => 'row description',
|
||
}],
|
||
render: () => createElement('div', { 'data-custom-panel': true }, 'custom panel'),
|
||
},
|
||
} as unknown as TabDescriptor
|
||
const html = renderToString(createElement(SettingsBody, {
|
||
feature,
|
||
prefs: SIDEBAR_PREFS_DEFAULTS,
|
||
store,
|
||
service,
|
||
onToggle: () => {},
|
||
onCommit: () => '',
|
||
onSelectValue: () => {},
|
||
onPluginToggle: () => {},
|
||
onPluginCommit: () => '',
|
||
onPluginSelectValue: () => {},
|
||
onPluginWrite: () => {},
|
||
onClose: () => {},
|
||
}))
|
||
expect(html).toContain('Auto row')
|
||
expect(html).toContain('row description')
|
||
expect(html).toContain('data-custom-panel')
|
||
expect(html).toContain('custom panel')
|
||
})
|
||
|
||
it('renders ONLY the custom panel when no rows are declared (unchanged behavior)', () => {
|
||
const { store, service } = mount()
|
||
const feature = {
|
||
id: 'demo',
|
||
title: () => 'Demo',
|
||
component: () => null,
|
||
settings: {
|
||
render: () => createElement('div', { 'data-custom-panel': true }, 'custom panel'),
|
||
},
|
||
} as unknown as TabDescriptor
|
||
const html = renderToString(createElement(SettingsBody, {
|
||
feature,
|
||
prefs: SIDEBAR_PREFS_DEFAULTS,
|
||
store,
|
||
service,
|
||
onToggle: () => {},
|
||
onCommit: () => '',
|
||
onSelectValue: () => {},
|
||
onPluginToggle: () => {},
|
||
onPluginCommit: () => '',
|
||
onPluginSelectValue: () => {},
|
||
onPluginWrite: () => {},
|
||
onClose: () => {},
|
||
}))
|
||
expect(html).toContain('custom panel')
|
||
expect(html).not.toContain('Auto row')
|
||
})
|
||
})
|