feat(settings): trend duration curve visibility preference (default on)

The Context preferences card gains its eighth row — the trend
duration curve — served across both settings generations: the V3
`settings.register` schema and the V4 Config form (a `.volatile()`
field, so an edit never remounts the entry). The client settings store
parses it fail-open like its neighbours; the trend card's Duration
toggle seeds its mount state from it (default on), and in-chart
toggling stays mount-local and never writes back.
This commit is contained in:
bowenliang123 committed 2026-10-04 11:12:50 +08:00
1 parent 01b1532b72
commit 4dcdfafbef
15 files changed
+111 -37

No files matched your search

+1 -1
View File
@@ -174,7 +174,7 @@ A centered dialog opens with the **Current Composition** card and the **Context
## ⚙️ Settings
The **Context** preferences card holds this plugin's per-user settings — default placement, trend granularity (Step/Turn), trend mode (Total/Delta), tool and File Activity sort, and the sidebar insights entry. Where it lives depends on your dsh release:
The **Context** preferences card holds this plugin's per-user settings — default placement, trend granularity (Step/Turn), trend mode (Total/Delta), the trend duration curve (shown by default), tool and File Activity sort, and the sidebar insights entry. Where it lives depends on your dsh release:
- **dsh 0.1.7+** — the sidebar **Plugins** entry → the **dsh-context** bundle's page → its **Configuration** section (the card is served by the entry's live Config form).
- **older releases** — **Settings → Plugins → Plugin configuration** → the **Context** card.
+2 -2
View File
@@ -52,8 +52,8 @@ The fold never branches on a detected harness version. The version probe is best
- **Tool-result error mark**: V4 made the event-level `data.error` identity optional while lifting `isError` onto the message, so the fold reads the flag from BOTH spellings (`src/host/fold.ts`); a V4 result carrying only `message.isError` still flags its surface node and file-op row.
- **Image offload accounting (deferred)**: when a route rejects with `IMAGE_OFFLOAD_REQUIRED`, the harness offloads images out of the context through a message projection. The plugin's fold reads raw durable events, so an offloaded message keeps its pre-offload image count and price until compaction removes it — an over-count on a rare recovery path, not a crash. Revisit alongside the harness's own token-meter treatment if it shows up in practice.
- **User preferences (shipped)**: the V4 line (first shipped in `0.1.6-alpha.2`) retired the `settings.register` host face and the `settings.plugin.item` browser slot — plugin configuration moved to the Plugins page, derived from each loader entry's own Config schema. The plugin serves both generations from one entry schema:
- The entry `Config` (`src/host/config.ts`) is schemastery. Cordis still validates the `config:` block through its Standard Schema face on every supported line; on V4+ the SAME schema is what the settings `describe` projects — the namespace is the entry id (`dsh-context`) and the six preference fields (`.volatile()`-marked) are the served, live-editable form; the fold bounds stay ordinary entry config (their edit remounts the entry, a preference edit commits volatile-only and never remounts).
- The browser half registers its card on the seat the running line declares, by service presence: `settingsScope` + `settings.plugin.item` on V3, `configForms` + the Plugins page's keyed `plugins.bundle.config` on V4+ (`ctx.configForms.whileServed` keeps the card alive only while the Host serves the namespace). Both seats render the same six preference rows.
- The entry `Config` (`src/host/config.ts`) is schemastery. Cordis still validates the `config:` block through its Standard Schema face on every supported line; on V4+ the SAME schema is what the settings `describe` projects — the namespace is the entry id (`dsh-context`) and the eight preference fields (`.volatile()`-marked) are the served, live-editable form; the fold bounds stay ordinary entry config (their edit remounts the entry, a preference edit commits volatile-only and never remounts).
- The browser half registers its card on the seat the running line declares, by service presence: `settingsScope` + `settings.plugin.item` on V3, `configForms` + the Plugins page's keyed `plugins.bundle.config` on V4+ (`ctx.configForms.whileServed` keeps the card alive only while the Host serves the namespace). Both seats render the same eight preference rows.
- The `.volatile()` modifier exists only on the Config-form generations' schemastery; the schema builder feature-detects it, so the same bundle serves both generations.
- Deliberate relaxations against the replaced zod schema: schemastery objects merge unknown keys through instead of failing the load, and only whole-value edits are rejected by range/step checks (bounds remain `min 1, step 1`).
- Preference values persisted on the V3 line live in the settings document (`settings.yaml` section `dsh-context`); the V4 harness's own legacy import moves that section into the profile entry of the same name on first boot, so existing values carry into the new surface.
+3 -2
View File
@@ -119,8 +119,9 @@ export function makeContextView(
// toggles above, mount-local and never written back.
const [adaptive, setAdaptive] = useState(false)
// The duration overlay (the title-adjacent toggle): each bar's step active time as a curve over the
// bars, read off the right-hand quartile axis; like the two toggles above, mount-local and never written back.
const [durationCurve, setDurationCurve] = useState(true)
// bars, read off the right-hand quartile axis. The settings card's `defaultDurationCurve` seeds the mount
// state (default on); in-chart toggling stays mount-local and never writes back.
const [durationCurve, setDurationCurve] = useState(() => settings.defaultDurationCurve() === 'show')
// Strip-clicked turn: chart switches to turn granularity and scroll-centers that turn's bar, then clears via onFocusTurnHandled.
const [focusTurn, setFocusTurn] = useState<number | null>(null)
// Chat → Context jump: the assistant-action relay's one-shot request, held until the projection data is in, then resolved into a
+11 -1
View File
@@ -1,5 +1,5 @@
/**
* The dsh-context preference cards — two seats over the same seven rows. The
* The dsh-context preference cards — two seats over the same eight rows. The
* settings-section card (`settings.plugin.item`, the older harness lines)
* renders a collapsible list item in Settings → Plugins → Plugin
* configuration; the Plugins-page card (`plugins.bundle.config`, the
@@ -114,6 +114,16 @@ function PreferenceRows(props: { t: Translate; state: SettingsState; set?: Setti
]}
onPick={(id) => { set?.('defaultTrendMode', id) }}
/>
<PrefRow
label={t('settings.durationCurve')}
value={state.durationCurve}
disabled={disabled}
options={[
{ id: 'show', label: t('insightsEntry.show') },
{ id: 'hide', label: t('insightsEntry.hide') },
]}
onPick={(id) => { set?.('defaultDurationCurve', id) }}
/>
<PrefRow
label={t('settings.deltaBase')}
value={state.deltaBase}
+2
View File
@@ -82,6 +82,7 @@ export const DICT_ZH: Record<string, string> = {
'insightsEntry.hide': '隐藏',
'settings.gran': '趋势图 · 默认粒度',
'settings.mode': '趋势图 · 默认展示方式',
'settings.durationCurve': '趋势图 · 耗时曲线',
'settings.deltaBase': '上下文浏览器 · 对比基线',
'settings.toolSort': '工具定义 · 默认排序',
'settings.fileSort': '文件活动 · 默认排序',
@@ -486,6 +487,7 @@ export const DICT_EN: Record<string, string> = {
'insightsEntry.hide': 'Hide',
'settings.gran': 'Trend Chart · Default granularity',
'settings.mode': 'Trend Chart · Default display',
'settings.durationCurve': 'Trend Chart · Duration curve',
'settings.deltaBase': 'Context Browser · Diff baseline',
'settings.toolSort': 'Tool Schemas · Default sort',
'settings.fileSort': 'File Activity · Default sort',
+11 -4
View File
@@ -12,11 +12,11 @@
* members keeps the dependency graph honest.
*/
import type { DefaultDeltaBase, DefaultFileSort, DefaultGranularity, DefaultPlacement, DefaultToolSort, DefaultTrendMode, InsightsEntry, SettingsField } from '../shared/types'
import type { DefaultDeltaBase, DefaultFileSort, DefaultGranularity, DefaultDurationCurve, DefaultPlacement, DefaultToolSort, DefaultTrendMode, InsightsEntry, SettingsField } from '../shared/types'
// The preference vocabulary is declared once in shared/types.ts; re-exported
// here so client-side consumers keep their canonical import path.
export type { DefaultDeltaBase, DefaultFileSort, DefaultGranularity, DefaultPlacement, DefaultToolSort, DefaultTrendMode, InsightsEntry, SettingsField } from '../shared/types'
export type { DefaultDeltaBase, DefaultFileSort, DefaultGranularity, DefaultDurationCurve, DefaultPlacement, DefaultToolSort, DefaultTrendMode, InsightsEntry, SettingsField } from '../shared/types'
/** The bound settings scope (ctx.settingsScope.bind result), as consumed. */
export interface SettingsScopeLike {
@@ -52,6 +52,7 @@ export interface SettingsState {
toolSort: DefaultToolSort
fileSort: DefaultFileSort
insightsEntry: InsightsEntry
durationCurve: DefaultDurationCurve
writable: boolean
}
@@ -65,6 +66,7 @@ export interface ContextSettings {
defaultToolSort(): DefaultToolSort
defaultFileSort(): DefaultFileSort
insightsEntry(): InsightsEntry
defaultDurationCurve(): DefaultDurationCurve
attach(scope: SettingsScopeLike): () => void
/** Persist one preference choice (local echo, then the fenced scope write). */
set(field: SettingsField, value: string): void
@@ -78,6 +80,7 @@ type Prefs = {
toolSort?: DefaultToolSort
fileSort?: DefaultFileSort
insightsEntry?: InsightsEntry
durationCurve?: DefaultDurationCurve
}
function prefsOf(value: unknown): Prefs {
@@ -91,17 +94,19 @@ function prefsOf(value: unknown): Prefs {
...(v.defaultToolSort === 'size' || v.defaultToolSort === 'count' || v.defaultToolSort === 'name' ? { toolSort: v.defaultToolSort } : {}),
...(v.defaultFileSort === 'count' || v.defaultFileSort === 'latest' || v.defaultFileSort === 'path' ? { fileSort: v.defaultFileSort } : {}),
...(v.insightsEntry === 'show' || v.insightsEntry === 'hide' ? { insightsEntry: v.insightsEntry } : {}),
...(v.defaultDurationCurve === 'show' || v.defaultDurationCurve === 'hide' ? { durationCurve: v.defaultDurationCurve } : {}),
}
}
export function createContextSettings(): ContextSettings {
let state: SettingsState = { status: 'loading', placement: 'all', granularity: 'step', mode: 'total', deltaBase: 'step', toolSort: 'count', fileSort: 'count', insightsEntry: 'show', writable: false }
let state: SettingsState = { status: 'loading', placement: 'all', granularity: 'step', mode: 'total', deltaBase: 'step', toolSort: 'count', fileSort: 'count', insightsEntry: 'show', durationCurve: 'show', writable: false }
let scope: SettingsScopeLike | undefined
const listeners = new Set<() => void>()
const publish = (next: SettingsState): void => {
if (next.status === state.status && next.placement === state.placement && next.granularity === state.granularity
&& next.mode === state.mode && next.deltaBase === state.deltaBase && next.toolSort === state.toolSort
&& next.fileSort === state.fileSort && next.insightsEntry === state.insightsEntry && next.writable === state.writable) return
&& next.fileSort === state.fileSort && next.insightsEntry === state.insightsEntry && next.durationCurve === state.durationCurve
&& next.writable === state.writable) return
state = next
for (const listener of listeners) listener()
}
@@ -127,6 +132,7 @@ export function createContextSettings(): ContextSettings {
toolSort: prefs.toolSort ?? state.toolSort,
fileSort: prefs.fileSort ?? state.fileSort,
insightsEntry: prefs.insightsEntry ?? (raw?.insightsEntry === undefined ? state.insightsEntry : 'show'),
durationCurve: prefs.durationCurve ?? (raw?.defaultDurationCurve === undefined ? state.durationCurve : 'show'),
writable: snap.writable,
})
return { placement: prefs.placement, insightsEntry: prefs.insightsEntry }
@@ -146,6 +152,7 @@ export function createContextSettings(): ContextSettings {
defaultToolSort: () => state.toolSort,
defaultFileSort: () => state.fileSort,
insightsEntry: () => state.insightsEntry,
defaultDurationCurve: () => state.durationCurve,
attach(bound) {
scope = bound
sync(bound)
+4 -1
View File
@@ -17,7 +17,7 @@
*/
import z from '@deepseek-ai/schemastery'
import type { DefaultFileSort, DefaultGranularity, DefaultPlacement, DefaultTrendMode, DefaultToolSort, InsightsEntry } from '../shared/types'
import type { DefaultFileSort, DefaultGranularity, DefaultDurationCurve, DefaultPlacement, DefaultTrendMode, DefaultToolSort, InsightsEntry } from '../shared/types'
export interface Config {
/** Cap on kept per-step request records (the hard step backstop). */
@@ -47,6 +47,8 @@ export interface Config {
defaultFileSort?: DefaultFileSort
/** Whether the Context Insights panel's sidebar entry is offered. */
insightsEntry?: InsightsEntry
/** Whether the trend chart mounts with the duration curve overlaid. */
defaultDurationCurve?: DefaultDurationCurve
}
/** The fold's retention/slice bounds, as the schema resolves them. */
@@ -100,6 +102,7 @@ export const Config = z.object({
defaultToolSort: volatileField(z.union(['size', 'count', 'name']).default('count').loose()),
defaultFileSort: volatileField(z.union(['count', 'latest', 'path']).default('count').loose()),
insightsEntry: volatileField(z.union(['show', 'hide']).default('show').loose()),
defaultDurationCurve: volatileField(z.union(['show', 'hide']).default('show').loose()),
})
/** Resolve the fold's retention bounds (the schema fills every default). */
+2 -1
View File
@@ -27,7 +27,7 @@ export const SETTINGS_NAMESPACE = 'dsh-context'
// The preference vocabulary is declared once in shared/types.ts; re-exported
// here so host-side consumers keep their canonical import path.
export type { DefaultDeltaBase, DefaultFileSort, DefaultGranularity, DefaultPlacement, DefaultToolSort, DefaultTrendMode, InsightsEntry, PluginSettings } from '../shared/types'
export type { DefaultDeltaBase, DefaultFileSort, DefaultGranularity, DefaultDurationCurve, DefaultPlacement, DefaultToolSort, DefaultTrendMode, InsightsEntry, PluginSettings } from '../shared/types'
/** Section schema: also the wire envelope the browser scope validates against. */
export const SettingsSchema = z.object({
@@ -41,6 +41,7 @@ export const SettingsSchema = z.object({
// Loose so a stale value also reads as the default (`show`): a config
// problem must never take the panel's entry away.
insightsEntry: z.union(['show', 'hide']).default('show').loose(),
defaultDurationCurve: z.union(['show', 'hide']).default('show').loose(),
})
/** Serve the namespace while a settings provider with the register face is composed; inert otherwise. */
+4
View File
@@ -164,6 +164,9 @@ export type DefaultPlacement = 'all' | 'tab' | 'sidebar'
/** Whether the Context Insights panel's sidebar entry is offered at all. */
export type InsightsEntry = 'show' | 'hide'
/** Whether the trend chart mounts with the per-request duration curve overlaid. */
export type DefaultDurationCurve = 'show' | 'hide'
export interface PluginSettings {
defaultPlacement: DefaultPlacement
defaultGranularity: DefaultGranularity
@@ -172,6 +175,7 @@ export interface PluginSettings {
defaultToolSort: DefaultToolSort
defaultFileSort: DefaultFileSort
insightsEntry: InsightsEntry
defaultDurationCurve: DefaultDurationCurve
}
/** The section fields the settings card edits, as the Host schema names them. */
+6 -4
View File
@@ -514,9 +514,9 @@ describe('ContextView — interactions', () => {
assert.equal(group.getAttribute('title'), DICT_EN['trend.durationTip'])
const adaptiveGroup = buttonByText(m.container, DICT_EN['trend.adaptive']).parentElement as HTMLElement
assert.equal(group.previousElementSibling, adaptiveGroup, 'Duration rides right of the adaptive switch')
// The overlay mounts ON by default: the curve spans the two stamped bars and the right axis reads
// the 2s peak (jsdom's zero-width viewport keeps the whole-log scale, adaptive off anyway).
assert.ok(msBtn().className.includes('lc-gran-on'), 'on at mount')
// The settings default (show) mounts the overlay ON: the curve spans the two stamped bars and the
// right axis reads the 2s peak (jsdom's zero-width viewport keeps the whole-log scale, adaptive off anyway).
assert.ok(msBtn().className.includes('lc-gran-on'), 'on at mount (the settings default)')
assert.equal(queryAll(m.container, '.lc-duration polyline').length, 1)
assert.equal(text(query(m.container, '.lc-axis-r .lc-axis-top')), '2.0s')
assert.equal(text(query(m.container, '.lc-axis-r .lc-axis-mid')), '1.0s')
@@ -1201,7 +1201,7 @@ describe('ContextView — locale and settings', () => {
getSnapshot: () => ({
status: 'ready',
writable: true,
value: { defaultGranularity: 'turn', defaultTrendMode: 'delta', defaultFileSort: 'path' },
value: { defaultGranularity: 'turn', defaultTrendMode: 'delta', defaultFileSort: 'path', defaultDurationCurve: 'hide' },
}),
subscribe: () => () => {},
set: async () => {},
@@ -1229,6 +1229,8 @@ describe('ContextView — locale and settings', () => {
}))
assert.ok(buttonByText(m.container, DICT_EN['gran.turn']).className.includes('lc-gran-on'))
assert.ok(buttonByText(m.container, DICT_EN['gran.delta']).className.includes('lc-gran-on'))
// The 'hide' curve preference mounts the Duration toggle off (the schema default mounts it on).
assert.ok(!buttonByText(m.container, DICT_EN['trend.duration']).className.includes('lc-gran-on'))
// Turn aggregation applies at mount: two bars (turn 1 aggregate + turn-less).
assert.equal(queryAll(m.container, '.lc-bar').length, 2)
// The File Activity card opens sorted by the 'path' preference, not by op count.
+32 -12
View File
@@ -21,7 +21,7 @@ function hookFor(state: SettingsState) {
}
function stateOf(partial: Partial<SettingsState> = {}): SettingsState {
return { status: 'ready', placement: 'all', granularity: 'step', mode: 'total', deltaBase: 'step', toolSort: 'count', fileSort: 'count', insightsEntry: 'show', writable: true, ...partial }
return { status: 'ready', placement: 'all', granularity: 'step', mode: 'total', deltaBase: 'step', toolSort: 'count', fileSort: 'count', insightsEntry: 'show', durationCurve: 'show', writable: true, ...partial }
}
/** Menu items portaled into document.body while a select is open. */
@@ -65,7 +65,7 @@ describe('SettingsCard', () => {
assert.equal(head.getAttribute('aria-label'), `${DICT_EN['settings.collapse']}: ${DICT_EN['settings.title']}`)
assert.ok(card.className.includes('lc-settings-open'))
const selects = queryAll(m.container, '.lc-settings-select')
assert.equal(selects.length, 7)
assert.equal(selects.length, 8)
assert.ok(selects.every(s => (s as HTMLButtonElement).disabled))
// Loading is not ready: no read-only note.
assert.equal(m.container.querySelector('.lc-settings-note'), null)
@@ -120,13 +120,16 @@ describe('SettingsCard', () => {
// The granularity row follows.
assert.ok(text(selects[2]).includes(DICT_EN['gran.step']))
assert.ok(text(selects[3]).includes(DICT_EN['gran.total']))
// The duration-curve row follows the trend-mode one, reusing the entry's show/hide labels.
assert.ok(text(m.container).includes(DICT_EN['settings.durationCurve']))
assert.ok(text(selects[4]).includes(DICT_EN['insightsEntry.show']))
// The delta-baseline row leads the tool-sort one, reusing the toolbar's option labels.
assert.ok(text(m.container).includes(DICT_EN['settings.deltaBase']))
assert.ok(text(selects[4]).includes(DICT_EN['browser.base.step']))
assert.ok(text(selects[5]).includes(DICT_EN['browser.base.step']))
assert.ok(text(m.container).includes(DICT_EN['settings.toolSort']))
assert.ok(text(selects[5]).includes(DICT_EN['tool.sort.count']))
assert.ok(text(selects[6]).includes(DICT_EN['tool.sort.count']))
assert.ok(text(m.container).includes(DICT_EN['settings.fileSort']))
assert.ok(text(selects[6]).includes(DICT_EN['files.sort.count']))
assert.ok(text(selects[7]).includes(DICT_EN['files.sort.count']))
await click(selects[2])
assert.equal(selects[2].getAttribute('aria-expanded'), 'true')
@@ -156,8 +159,22 @@ describe('SettingsCard', () => {
])
assert.equal(document.body.querySelector('[role="menu"]'), null)
// The delta-baseline row writes its field through the toolbar's vocabulary.
// The duration-curve row writes its field through the same show/hide vocabulary.
await click(selects[4])
const curveItems = menuItems()
assert.deepEqual(curveItems.map(i => text(i)), [DICT_EN['insightsEntry.show'], DICT_EN['insightsEntry.hide']])
await click(curveItems[1]) // 'Hide'
assert.deepEqual(calls, [
['defaultPlacement', 'sidebar'],
['insightsEntry', 'hide'],
['defaultGranularity', 'turn'],
['defaultTrendMode', 'delta'],
['defaultDurationCurve', 'hide'],
])
assert.equal(document.body.querySelector('[role="menu"]'), null)
// The delta-baseline row writes its field through the toolbar's vocabulary.
await click(selects[5])
const baseItems = menuItems()
assert.deepEqual(baseItems.map(i => text(i)), [DICT_EN['browser.base.step'], DICT_EN['browser.base.turn']])
await click(baseItems[1]) // 'prev turn'
@@ -166,12 +183,13 @@ describe('SettingsCard', () => {
['insightsEntry', 'hide'],
['defaultGranularity', 'turn'],
['defaultTrendMode', 'delta'],
['defaultDurationCurve', 'hide'],
['defaultDeltaBase', 'turn'],
])
assert.equal(document.body.querySelector('[role="menu"]'), null)
// The tool-sort row leads the file-sort one.
await click(selects[5])
await click(selects[6])
const toolItems = menuItems()
assert.deepEqual(toolItems.map(i => text(i)), [
DICT_EN['tool.sort.size'],
@@ -184,13 +202,14 @@ describe('SettingsCard', () => {
['insightsEntry', 'hide'],
['defaultGranularity', 'turn'],
['defaultTrendMode', 'delta'],
['defaultDurationCurve', 'hide'],
['defaultDeltaBase', 'turn'],
['defaultToolSort', 'name'],
])
assert.equal(document.body.querySelector('[role="menu"]'), null)
// The file-sort row writes the last field.
await click(selects[6])
await click(selects[7])
const sortItems = menuItems()
assert.deepEqual(sortItems.map(i => text(i)), [
DICT_EN['files.sort.count'],
@@ -203,6 +222,7 @@ describe('SettingsCard', () => {
['insightsEntry', 'hide'],
['defaultGranularity', 'turn'],
['defaultTrendMode', 'delta'],
['defaultDurationCurve', 'hide'],
['defaultDeltaBase', 'turn'],
['defaultToolSort', 'name'],
['defaultFileSort', 'path'],
@@ -270,7 +290,7 @@ describe('SettingsCard', () => {
const card = query(m.container, '.lc-settings-card')
assert.ok(card.className.includes('lc-settings-open'))
assert.equal(query(m.container, '.lc-settings-head').getAttribute('aria-expanded'), 'true')
assert.equal(queryAll(m.container, '.lc-settings-select').length, 7)
assert.equal(queryAll(m.container, '.lc-settings-select').length, 8)
assert.equal(scrolled.length, 1, 'the card scrolls itself into view')
assert.deepEqual(scrolled[0].arg, { block: 'nearest' })
assert.equal(scrolled[0].el, card)
@@ -322,7 +342,7 @@ describe('PluginConfigCard (the Plugins-page seat)', () => {
await m2.unmount()
})
test('renders the seven rows flat — no card chrome, no expand request consumption', async () => {
test('renders the eight rows flat — no card chrome, no expand request consumption', async () => {
requestCardExpand()
const m = await mount(h(PluginConfigCard, { useContextSettings: hookFor(stateOf({ status: 'loading', writable: false })) }))
assert.equal(m.container.querySelector('.lc-settings-card'), null, 'no settings-section chrome')
@@ -330,7 +350,7 @@ describe('PluginConfigCard (the Plugins-page seat)', () => {
assert.equal(m.container.querySelector('.lc-settings-head'), null)
// The rows render immediately: no expand/collapse leg.
const selects = queryAll<HTMLButtonElement>(m.container, '.lc-settings-select')
assert.equal(selects.length, 7)
assert.equal(selects.length, 8)
assert.ok(selects.every(s => s.disabled), 'loading is not ready: the rows are disabled')
assert.equal(m.container.querySelector('.lc-settings-note'), null)
await m.unmount()
@@ -354,7 +374,7 @@ describe('PluginConfigCard (the Plugins-page seat)', () => {
assert.equal(writable.container.querySelector('.lc-settings-note'), null)
const enabled = queryAll<HTMLButtonElement>(writable.container, '.lc-settings-select')
assert.ok(enabled.every(s => !s.disabled))
await click(enabled[6])
await click(enabled[7]) // the file-sort row trails
const items = menuItems()
assert.deepEqual(items.map(i => text(i)), [
DICT_EN['files.sort.count'],
+2 -2
View File
@@ -363,7 +363,7 @@ describe('client entry: configForms inject (the Config-form generation)', () =>
const m = await mount(h(el.type as never, {
useContextSettings: <T,>(sel: (state: SettingsState) => T): T => sel(store.getSnapshot()),
}))
assert.equal(queryAll(m.container, '.lc-settings-select').length, 7)
assert.equal(queryAll(m.container, '.lc-settings-select').length, 8)
assert.equal(m.container.querySelector('.lc-settings-head'), null, 'no settings-section chrome on this seat')
await m.unmount()
disposeRegistration()
@@ -453,7 +453,7 @@ describe('client entry: settings card slot', () => {
assert.ok(query(m.container, '.lc-settings-card'))
await click(query(m.container, '.lc-settings-head'))
const selects = queryAll(m.container, '.lc-settings-select')
assert.equal(selects.length, 7)
assert.equal(selects.length, 8)
for (const s of selects) assert.ok((s as HTMLButtonElement).disabled)
await m.unmount()
ctx.dispose()
+18 -1
View File
@@ -54,6 +54,7 @@ describe('createContextSettings defaults', () => {
toolSort: 'count',
fileSort: 'count',
insightsEntry: 'show',
durationCurve: 'show',
writable: false,
})
assert.equal(s.defaultPlacement(), 'all')
@@ -63,6 +64,7 @@ describe('createContextSettings defaults', () => {
assert.equal(s.defaultToolSort(), 'count')
assert.equal(s.defaultFileSort(), 'count')
assert.equal(s.insightsEntry(), 'show')
assert.equal(s.defaultDurationCurve(), 'show')
})
})
@@ -205,6 +207,7 @@ describe('attach', () => {
defaultToolSort: 'name',
defaultFileSort: 'path',
insightsEntry: 'hide',
defaultDurationCurve: 'hide',
},
writable: true,
})
@@ -218,6 +221,7 @@ describe('attach', () => {
toolSort: 'name',
fileSort: 'path',
insightsEntry: 'hide',
durationCurve: 'hide',
writable: true,
})
})
@@ -249,6 +253,7 @@ describe('attach', () => {
toolSort: 'count',
fileSort: 'count',
insightsEntry: 'show',
durationCurve: 'show',
writable: false,
})
}
@@ -258,7 +263,7 @@ describe('attach', () => {
const s = createContextSettings()
s.attach(new TestSettingsScope({
status: 'ready',
value: { defaultPlacement: 'window', defaultGranularity: 'bogus', defaultTrendMode: 7, defaultDeltaBase: 'bogus', defaultToolSort: 'alpha', defaultFileSort: 'alpha', insightsEntry: 42 },
value: { defaultPlacement: 'window', defaultGranularity: 'bogus', defaultTrendMode: 7, defaultDeltaBase: 'bogus', defaultToolSort: 'alpha', defaultFileSort: 'alpha', insightsEntry: 42, defaultDurationCurve: 'always' },
writable: false,
}))
assert.equal(s.defaultPlacement(), 'all')
@@ -268,6 +273,7 @@ describe('attach', () => {
assert.equal(s.defaultToolSort(), 'count')
assert.equal(s.defaultFileSort(), 'count')
assert.equal(s.insightsEntry(), 'show')
assert.equal(s.defaultDurationCurve(), 'show')
})
test('an invalid scope placement degrades to all instead of keeping the current one', () => {
@@ -292,6 +298,15 @@ describe('attach', () => {
assert.equal(s.insightsEntry(), 'show')
})
test('an invalid scope duration-curve value degrades to show instead of keeping the current one', () => {
const s = createContextSettings()
s.set('defaultDurationCurve', 'hide')
assert.equal(s.defaultDurationCurve(), 'hide')
const scope = new TestSettingsScope({ status: 'ready', value: { defaultDurationCurve: 'always' }, writable: false })
s.attach(scope)
assert.equal(s.defaultDurationCurve(), 'show')
})
test('explicit schema-default values are accepted', () => {
const s = createContextSettings()
s.attach(new TestSettingsScope({
@@ -313,6 +328,7 @@ describe('attach', () => {
s.set('defaultPlacement', 'tab')
s.set('defaultToolSort', 'size')
s.set('insightsEntry', 'hide')
s.set('defaultDurationCurve', 'hide')
s.attach(new TestSettingsScope({ status: 'ready', value: { defaultFileSort: 'latest' }, writable: false }))
assert.equal(s.defaultPlacement(), 'tab', 'the in-session choice survives a section without the field')
assert.equal(s.defaultGranularity(), 'step')
@@ -320,6 +336,7 @@ describe('attach', () => {
assert.equal(s.defaultToolSort(), 'size', 'the in-session tool sort survives a section without the field')
assert.equal(s.defaultFileSort(), 'latest')
assert.equal(s.insightsEntry(), 'hide', 'the in-session entry choice survives a section without the field')
assert.equal(s.defaultDurationCurve(), 'hide', 'the in-session curve choice survives a section without the field')
})
test('scope updates republish to subscribers', () => {
+3 -1
View File
@@ -34,6 +34,7 @@ describe('Config validator (cordis Standard Schema face)', () => {
defaultToolSort: 'count',
defaultFileSort: 'count',
insightsEntry: 'show',
defaultDurationCurve: 'show',
})
assert.deepEqual(resolveBounds(undefined), DEFAULT_BOUNDS)
assert.deepEqual(resolveBounds({}), DEFAULT_BOUNDS)
@@ -72,7 +73,7 @@ describe('Config validator (cordis Standard Schema face)', () => {
describe('preference fields (the Config-form generation surface)', () => {
test('every preference is volatile-marked; the bounds are not', () => {
const dict = Config.dict ?? {}
for (const pref of ['defaultPlacement', 'defaultGranularity', 'defaultTrendMode', 'defaultDeltaBase', 'defaultToolSort', 'defaultFileSort', 'insightsEntry']) {
for (const pref of ['defaultPlacement', 'defaultGranularity', 'defaultTrendMode', 'defaultDeltaBase', 'defaultToolSort', 'defaultFileSort', 'insightsEntry', 'defaultDurationCurve']) {
assert.equal((dict[pref] as z['dict'] extends undefined ? never : { meta?: { volatile?: boolean } }).meta?.volatile, true, pref)
}
for (const bound of ['maxRequestSteps', 'maxKeptTurns', 'maxEvents', 'maxNodes', 'maxArchiveNodes', 'maxFileOps']) {
@@ -89,6 +90,7 @@ describe('preference fields (the Config-form generation surface)', () => {
assert.equal(deref(resolved.defaultToolSort), 'count')
assert.equal(deref(resolved.defaultFileSort), 'count')
assert.equal(deref(resolved.insightsEntry), 'show')
assert.equal(deref(resolved.defaultDurationCurve), 'show')
})
test('a stored preference survives the resolve', () => {
+10 -5
View File
@@ -73,6 +73,7 @@ describe('installSettings', () => {
defaultToolSort: 'count',
defaultFileSort: 'count',
insightsEntry: 'show',
defaultDurationCurve: 'show',
}, 'schema defaults resolve')
})
@@ -87,10 +88,11 @@ describe('installSettings', () => {
defaultToolSort: 'count',
defaultFileSort: 'count',
insightsEntry: 'show',
defaultDurationCurve: 'show',
}, 'the update resolves over the schema defaults')
assert.deepEqual(provider.doc['dsh-context'], { defaultGranularity: 'turn' }, 'the provider persisted the section')
await ctx.settings.update(ns, { defaultPlacement: 'sidebar', defaultTrendMode: 'delta', defaultDeltaBase: 'turn', defaultFileSort: 'path', insightsEntry: 'hide' })
await ctx.settings.update(ns, { defaultPlacement: 'sidebar', defaultTrendMode: 'delta', defaultDeltaBase: 'turn', defaultFileSort: 'path', insightsEntry: 'hide', defaultDurationCurve: 'hide' })
assert.deepEqual(ctx.settings.get(ns), {
defaultPlacement: 'sidebar',
defaultGranularity: 'turn',
@@ -99,6 +101,7 @@ describe('installSettings', () => {
defaultToolSort: 'count',
defaultFileSort: 'path',
insightsEntry: 'hide',
defaultDurationCurve: 'hide',
}, 'every preference field resolves independently')
await assert.rejects(
@@ -106,8 +109,8 @@ describe('installSettings', () => {
'an unknown granularity fails validation before anything persists',
)
// The loose fields degrade instead of rejecting: a stale file sort,
// placement, tool sort, delta baseline, or insights entry resolves to the default.
await ctx.settings.update(ns, { defaultFileSort: 'net', defaultPlacement: 'window', defaultToolSort: 'net', defaultDeltaBase: 'net', insightsEntry: 'gone' })
// placement, tool sort, delta baseline, insights entry, or curve choice resolves to the default.
await ctx.settings.update(ns, { defaultFileSort: 'net', defaultPlacement: 'window', defaultToolSort: 'net', defaultDeltaBase: 'net', insightsEntry: 'gone', defaultDurationCurve: 'always' })
assert.deepEqual(ctx.settings.get(ns), {
defaultPlacement: 'all',
defaultGranularity: 'turn',
@@ -116,16 +119,17 @@ describe('installSettings', () => {
defaultToolSort: 'count',
defaultFileSort: 'count',
insightsEntry: 'show',
defaultDurationCurve: 'show',
}, 'a stale value degrades to the schema default')
assert.deepEqual(
provider.doc['dsh-context'],
{ defaultPlacement: 'window', defaultGranularity: 'turn', defaultTrendMode: 'delta', defaultToolSort: 'net', defaultDeltaBase: 'net', defaultFileSort: 'net', insightsEntry: 'gone' },
{ defaultPlacement: 'window', defaultGranularity: 'turn', defaultTrendMode: 'delta', defaultToolSort: 'net', defaultDeltaBase: 'net', defaultFileSort: 'net', insightsEntry: 'gone', defaultDurationCurve: 'always' },
'the stale value stays raw in storage and degrades at read',
)
})
test('a stale persisted preference degrades to the default (loose)', async () => {
const { ctx } = await boot({ 'dsh-context': { defaultPlacement: 'window', defaultTrendMode: 'net', defaultToolSort: 'alpha', defaultDeltaBase: 'net', defaultFileSort: 'alpha', insightsEntry: 'gone' } })
const { ctx } = await boot({ 'dsh-context': { defaultPlacement: 'window', defaultTrendMode: 'net', defaultToolSort: 'alpha', defaultDeltaBase: 'net', defaultFileSort: 'alpha', insightsEntry: 'gone', defaultDurationCurve: 'always' } })
const value = ctx.settings.get(ns) as PluginSettings
assert.equal(value.defaultPlacement, 'all', 'the stale placement falls back instead of breaking the section')
assert.equal(value.defaultTrendMode, 'total', 'the stale value falls back instead of breaking the section')
@@ -133,6 +137,7 @@ describe('installSettings', () => {
assert.equal(value.defaultToolSort, 'count', 'the stale tool sort falls back instead of breaking the section')
assert.equal(value.defaultFileSort, 'count', 'the stale file sort falls back instead of breaking the section')
assert.equal(value.insightsEntry, 'show', 'the stale insights entry falls back to visible instead of breaking the section')
assert.equal(value.defaultDurationCurve, 'show', 'the stale curve choice falls back to visible instead of breaking the section')
assert.equal(value.defaultGranularity, 'step')
})