feat: make the tool-definition default sort a plugin setting

New 'Tool Schemas - Default sort' row in Settings -> Plugins -> dsh-context
(ahead of the File Activity one): size, call count, or name, persisted per
user through the dsh-context settings namespace. The Context browser reads
it as the mount-time default for its tools toolbar; in-toolbar toggling
stays session-local and never writes back. The default default is now call
count. Degradation mirrors the existing preferences: the host schema is
loose (a stale persisted value falls back to the default), the client drops
values outside the vocabulary, and a host without the settings surface (or
a section missing the field) keeps the schema default, so the tools list
stays fully functional either way.
This commit is contained in:
Bowen Liang committed 2026-09-13 16:47:20 +08:00
1 parent 4dded7add5
commit b6020146df
15 files changed
+169 -44

No files matched your search

+5 -1
View File
@@ -6,6 +6,7 @@ import { CATS, CAT_COLOR, partsOf } from '../categories'
import { dnaOf } from '../dna'
import type { DnaItem } from '../dna'
import type { ContentFetcher, ConversationNodeLike, HeaderFetcher } from '../services'
import type { ContextSettings, DefaultToolSort } from '../settings'
import type { ViewKit } from '../viewkit'
import { blockSummaryOf, callSummaryOf, parseCallArgs } from '../callSummary'
import type { DetailState } from '../timelineSource'
@@ -581,6 +582,7 @@ const DNA_MIN_BAND = 0.35
export function makeContextBrowser(
kit: ViewKit,
StackedBar: (props: StackedBarProps) => ReactElement,
settings: ContextSettings,
): (props: ContextBrowserProps) => ReactElement {
const { t, fmt, fmtTime, catLabel } = kit
const DetailNote = makeDetailNote(kit)
@@ -602,7 +604,9 @@ export function makeContextBrowser(
// it, while step picks (setOpenCat(null) below) keep it so the same lens
// compares epochs.
const [rowQuery, setRowQuery] = useState('')
const [toolSort, setToolSort] = useState<'size' | 'count' | 'name'>('size')
// Mount-time default from the plugin settings card; in-toolbar toggling
// stays mount-local and never writes back.
const [toolSort, setToolSort] = useState<DefaultToolSort>(() => settings.defaultToolSort())
// DNA mode: the composition bar redraws as ONE band per context item in prompt order (dna.ts), hovered/clicked per item.
const [dna, setDna] = useState(false)
const [dnaKey, setDnaKey] = useState<string | null>(null)
+7 -2
View File
@@ -9,6 +9,7 @@ import { measureDock } from '../dockMeasure'
import { headlineOf } from '../headline'
import { modalStoreOf, takePendingConsume } from '../modalStore'
import type { ClientCtx, SessionStandardProps, SessionsFace } from '../services'
import type { ContextSettings } from '../settings'
import { contextBreakdownOf, contextPressureOf, conversationNodesOf, headersOf, imageLoaderOf, projectionOf } from '../services'
import { makeContentFetcher, makeHeaderFetcher, useHistoryFace } from '../historyPage'
import { useTimelineSource } from '../timelineSource'
@@ -24,12 +25,16 @@ export interface ContextModalProps extends SessionStandardProps {
useContextModal?: (sel: (open: boolean) => boolean) => boolean
}
export function makeContextModal(ctx: ClientCtx, kit: ViewKit): (props: ContextModalProps) => ReactElement | null {
export function makeContextModal(
ctx: ClientCtx,
kit: ViewKit,
settings: ContextSettings,
): (props: ContextModalProps) => ReactElement | null {
const { t } = kit
const StackedBar = makeStackedBar(kit)
const Legend = makeLegend(kit)
const CurrentComposition = makeCurrentComposition(kit, StackedBar, Legend)
const ContextBrowser = makeContextBrowser(kit, StackedBar)
const ContextBrowser = makeContextBrowser(kit, StackedBar, settings)
const ErrorBoundary = makeErrorBoundary(t)
function ContextModalBody(props: ContextModalProps): ReactElement | null {
+1 -1
View File
@@ -65,7 +65,7 @@ export function makeContextView(
const PluginInfo = makePluginInfo(kit)
const UpgradeGate = makeUpgradeGate(kit)
const DetailNote = makeDetailNote(kit)
const ContextBrowser = makeContextBrowser(kit, StackedBar)
const ContextBrowser = makeContextBrowser(kit, StackedBar, settings)
const AgentGraph = makeAgentGraph(ctx, kit)
const ErrorBoundary = makeErrorBoundary(t)
+11
View File
@@ -128,6 +128,17 @@ export function makeSettingsCard(kit: ViewKit): (props: SettingsCardProps) => Re
]}
onPick={(id) => { props.set?.('defaultTrendMode', id) }}
/>
<PrefRow
label={t('settings.toolSort')}
value={state.toolSort}
disabled={disabled}
options={[
{ id: 'size', label: t('tool.sort.size') },
{ id: 'count', label: t('tool.sort.count') },
{ id: 'name', label: t('tool.sort.name') },
]}
onPick={(id) => { props.set?.('defaultToolSort', id) }}
/>
<PrefRow
label={t('settings.fileSort')}
value={state.fileSort}
+4 -2
View File
@@ -57,6 +57,7 @@ export const DICT_ZH: Record<string, string> = {
'placement.tab': '标签页', 'placement.sidebar': '侧边栏', 'placement.all': '全部',
'settings.gran': '趋势图 · 默认粒度',
'settings.mode': '趋势图 · 默认展示方式',
'settings.toolSort': '工具定义 · 默认排序',
'settings.fileSort': '文件活动 · 默认排序',
'settings.expand': '展开', 'settings.collapse': '收起',
'settings.readOnly': '当前环境的设置为只读',
@@ -205,7 +206,7 @@ export const DICT_ZH: Record<string, string> = {
'tool.jsonHide': '收起',
'tool.search': '按名称、描述或参数过滤…',
'tool.sort.size': '按大小', 'tool.sort.count': '按次数', 'tool.sort.name': '按名称',
'tool.sortTip': '工具排序:按 token 大小(默认)、当前步骤中的调用次数或名称',
'tool.sortTip': '工具排序:按当前步骤中的调用次数(默认)、token 大小或名称',
'tool.hitsTip': '当前展示步骤的上下文中,该工具被调用并返回结果的次数',
'tool.noMatch': '没有匹配当前过滤条件的工具',
'rich.raw': '原文',
@@ -289,6 +290,7 @@ export const DICT_EN: Record<string, string> = {
'placement.tab': 'Tab', 'placement.sidebar': 'Sidebar', 'placement.all': 'All',
'settings.gran': 'Trend Chart · Default granularity',
'settings.mode': 'Trend Chart · Default display',
'settings.toolSort': 'Tool Schemas · Default sort',
'settings.fileSort': 'File Activity · Default sort',
'settings.expand': 'Expand', 'settings.collapse': 'Collapse',
'settings.readOnly': 'Settings are read-only in this environment',
@@ -437,7 +439,7 @@ export const DICT_EN: Record<string, string> = {
'tool.jsonHide': 'Collapse',
'tool.search': 'Filter by name, description, or parameters…',
'tool.sort.size': 'By size', 'tool.sort.count': 'By calls', 'tool.sort.name': 'By name',
'tool.sortTip': 'Sort tools by token size (default), call count in the shown step, or name',
'tool.sortTip': 'Sort tools by call count in the shown step (default), token size, or name',
'tool.hitsTip': 'Times this tool was called and answered within the shown step’s context',
'tool.noMatch': 'No tools match the current filter',
'rich.raw': 'Raw',
+1 -1
View File
@@ -115,7 +115,7 @@ function apply(ctx: ClientCtx): void {
// the trigger source). The modal itself renders from the input overlay
// slot, opened per session through the hooks-compartment store.
registerContextCommand(ctx, kit)
const ContextModal = makeContextModal(ctx, kit)
const ContextModal = makeContextModal(ctx, kit, settings)
ctx.slots.inject('conversation.input.overlay', () => {
return ctx.slots.register(
{ name: 'conversation.input.overlay', id: 'context-modal', order: 10, locale: NS,
+11 -4
View File
@@ -12,11 +12,11 @@
* members keeps the dependency graph honest.
*/
import type { DefaultFileSort, DefaultGranularity, DefaultPlacement, DefaultTrendMode, SettingsField } from '../shared/types'
import type { DefaultFileSort, DefaultGranularity, DefaultPlacement, DefaultToolSort, DefaultTrendMode, 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 { DefaultFileSort, DefaultGranularity, DefaultPlacement, DefaultTrendMode, SettingsField } from '../shared/types'
export type { DefaultFileSort, DefaultGranularity, DefaultPlacement, DefaultToolSort, DefaultTrendMode, SettingsField } from '../shared/types'
/** The bound settings scope (ctx.settingsScope.bind result), as consumed. */
export interface SettingsScopeLike {
@@ -37,6 +37,7 @@ export interface SettingsState {
placement: DefaultPlacement
granularity: DefaultGranularity
mode: DefaultTrendMode
toolSort: DefaultToolSort
fileSort: DefaultFileSort
writable: boolean
}
@@ -47,6 +48,7 @@ export interface ContextSettings {
defaultPlacement(): DefaultPlacement
defaultGranularity(): DefaultGranularity
defaultTrendMode(): DefaultTrendMode
defaultToolSort(): DefaultToolSort
defaultFileSort(): DefaultFileSort
attach(scope: SettingsScopeLike): () => void
/** Persist one preference choice (local echo, then the fenced scope write). */
@@ -57,6 +59,7 @@ type Prefs = {
placement?: DefaultPlacement
granularity?: DefaultGranularity
mode?: DefaultTrendMode
toolSort?: DefaultToolSort
fileSort?: DefaultFileSort
}
@@ -67,17 +70,19 @@ function prefsOf(value: unknown): Prefs {
...(v.defaultPlacement === 'all' || v.defaultPlacement === 'tab' || v.defaultPlacement === 'sidebar' ? { placement: v.defaultPlacement } : {}),
...(v.defaultGranularity === 'step' || v.defaultGranularity === 'turn' ? { granularity: v.defaultGranularity } : {}),
...(v.defaultTrendMode === 'total' || v.defaultTrendMode === 'delta' ? { mode: v.defaultTrendMode } : {}),
...(v.defaultToolSort === 'size' || v.defaultToolSort === 'count' || v.defaultToolSort === 'name' ? { toolSort: v.defaultToolSort } : {}),
...(v.defaultFileSort === 'count' || v.defaultFileSort === 'latest' || v.defaultFileSort === 'path' ? { fileSort: v.defaultFileSort } : {}),
}
}
export function createContextSettings(): ContextSettings {
let state: SettingsState = { status: 'loading', placement: 'all', granularity: 'step', mode: 'total', fileSort: 'count', writable: false }
let state: SettingsState = { status: 'loading', placement: 'all', granularity: 'step', mode: 'total', toolSort: 'count', fileSort: 'count', 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.fileSort === state.fileSort && next.writable === state.writable) return
&& next.mode === state.mode && next.toolSort === state.toolSort && next.fileSort === state.fileSort
&& next.writable === state.writable) return
state = next
for (const listener of listeners) listener()
}
@@ -98,6 +103,7 @@ export function createContextSettings(): ContextSettings {
placement: prefs.placement ?? (rawPlacement === undefined ? state.placement : 'all'),
granularity: prefs.granularity ?? state.granularity,
mode: prefs.mode ?? state.mode,
toolSort: prefs.toolSort ?? state.toolSort,
fileSort: prefs.fileSort ?? state.fileSort,
writable: snap.writable,
})
@@ -114,6 +120,7 @@ export function createContextSettings(): ContextSettings {
defaultPlacement: () => state.placement,
defaultGranularity: () => state.granularity,
defaultTrendMode: () => state.mode,
defaultToolSort: () => state.toolSort,
defaultFileSort: () => state.fileSort,
attach(bound) {
scope = bound
+2 -1
View File
@@ -23,7 +23,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 { DefaultFileSort, DefaultGranularity, DefaultPlacement, DefaultTrendMode, PluginSettings } from '../shared/types'
export type { DefaultFileSort, DefaultGranularity, DefaultPlacement, DefaultToolSort, DefaultTrendMode, PluginSettings } from '../shared/types'
/** Section schema: also the wire envelope the browser scope validates against. */
export const SettingsSchema: z<PluginSettings> = z.object({
@@ -31,6 +31,7 @@ export const SettingsSchema: z<PluginSettings> = z.object({
defaultPlacement: z.union(['all', 'tab', 'sidebar']).default('all').loose(),
defaultGranularity: z.union(['step', 'turn']).default('step'),
defaultTrendMode: z.union(['total', 'delta']).default('total').loose(),
defaultToolSort: z.union(['size', 'count', 'name']).default('count').loose(),
defaultFileSort: z.union(['count', 'latest', 'path']).default('count').loose(),
})
+4
View File
@@ -103,6 +103,9 @@ export type DefaultTrendMode = 'total' | 'delta'
/** File Activity row order: most operations first, most-recently-touched first, or path ascending. */
export type DefaultFileSort = 'count' | 'latest' | 'path'
/** Tool-definition row order: largest schema first, most call hits first, or name ascending. */
export type DefaultToolSort = 'size' | 'count' | 'name'
/** Where the Context view is offered: the conversation tab, the right Sidebar, or both. */
export type DefaultPlacement = 'all' | 'tab' | 'sidebar'
@@ -110,6 +113,7 @@ export interface PluginSettings {
defaultPlacement: DefaultPlacement
defaultGranularity: DefaultGranularity
defaultTrendMode: DefaultTrendMode
defaultToolSort: DefaultToolSort
defaultFileSort: DefaultFileSort
}
+53 -1
View File
@@ -11,10 +11,12 @@ import { makeStackedBar } from '../../../src/client/components/stackedBar'
import { DICT_EN } from '../../../src/client/i18n'
import { UNKNOWN_TOOL_SOURCE, type ContextHeaders, type ContextTimeline, type HeaderEpochContent, type RequestRecord, type SurfaceNode } from '../../../src/shared/types'
import { headersOf, type ConversationNodeLike, type ImageLoader } from '../../../src/client/services'
import { createContextSettings } from '../../../src/client/settings'
import { click, flush, hover, makeKit, mount, query, queryAll, text, unhover, type Mounted } from '../helpers/kit'
const kit = makeKit()
const Browser = makeContextBrowser(kit, makeStackedBar(kit))
const settings = createContextSettings()
const Browser = makeContextBrowser(kit, makeStackedBar(kit), settings)
// Category row order is CATS: system, tools, user, inject, assistant, tool.
const ROW = { system: 0, tools: 1, user: 2, inject: 3, assistant: 4, tool: 5 } as const
@@ -642,6 +644,7 @@ describe('ContextBrowser tool schemas', () => {
const lazyProps = () => props({ data, headers, fetchHeader: () => Promise.resolve(content) })
test('rows rank by token price; the schema narrowing matrix renders', async () => {
settings.set('defaultToolSort', 'size')
const m = await mount(h(Browser, lazyProps()))
await click(catRow(m, 'tools'))
await flush()
@@ -692,9 +695,11 @@ describe('ContextBrowser tool schemas', () => {
await click(elemRows(m)[0])
assert.equal(queryAll(m.container, '.lc-br-content').length, 0)
await m.unmount()
settings.set('defaultToolSort', 'count')
})
test('a text filter and the size/name sort narrow and re-rank the rows', async () => {
settings.set('defaultToolSort', 'size')
const m = await mount(h(Browser, lazyProps()))
await click(catRow(m, 'tools'))
await flush()
@@ -735,11 +740,13 @@ describe('ContextBrowser tool schemas', () => {
await click(sortBtns[0])
assert.deepEqual(names(), ['mega', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'theta', 'rho', 'omega'])
await m.unmount()
settings.set('defaultToolSort', 'count')
})
test('count sort ranks by call hits on the shown surface; collapsed rows carry the tally', async () => {
// Sizes anti-correlate with hits so the count ranking is visibly its own
// order; delta ties gamma's tally and wins the name tie-break.
settings.set('defaultToolSort', 'size')
const hitHeaders: ContextHeaders = { headers: [{ seq: 1, time: 1, systemTokens: 3, tools: [
{ name: 'alpha', tokens: 100 },
{ name: 'beta', tokens: 10 },
@@ -782,6 +789,49 @@ describe('ContextBrowser tool schemas', () => {
assert.deepEqual(names(), ['beta', 'alpha', 'delta', 'gamma'])
assert.deepEqual(hits(), ['×1', '×0', '×0', '×0'])
await m.unmount()
settings.set('defaultToolSort', 'count')
})
test('the mount-time default tool sort comes from the plugin settings', async () => {
const pair: ContextHeaders = { headers: [{ seq: 1, time: 1, systemTokens: 3, tools: [
{ name: 'zzz', tokens: 1 },
{ name: 'aaa', tokens: 100 },
] }] }
const data = tl({
current: { system: 10, tools: 101, user: 0, inject: 0, assistant: 0, tool: 10, total: 121 },
nodes: [node({ seq: 2, cat: 'tool', tool: 'zzz', tokens: 10 })],
})
const names = (m: Mounted) => elemRows(m).map(r => text(query(r, '.lc-br-preview')))
const sortBtns = (container: ParentNode) => queryAll(container, '.lc-br-toolctl .lc-gran-btn')
const mountWith = async (sort: string) => {
settings.set('defaultToolSort', sort)
const m = await mount(h(Browser, props({ data, headers: pair, fetchHeader: () => Promise.resolve({ tools: [] }) })))
await click(catRow(m, 'tools'))
return m
}
// The schema default: most call hits first (zzz ×1 over aaa ×0).
let m = await mountWith('count')
assert.ok(sortBtns(m.container)[1].className.includes('lc-gran-on'))
assert.deepEqual(names(m), ['zzz', 'aaa'])
await m.unmount()
m = await mountWith('size')
assert.ok(sortBtns(m.container)[0].className.includes('lc-gran-on'))
assert.deepEqual(names(m), ['aaa', 'zzz'])
await m.unmount()
m = await mountWith('name')
assert.ok(sortBtns(m.container)[2].className.includes('lc-gran-on'))
assert.deepEqual(names(m), ['aaa', 'zzz'])
await m.unmount()
// In-toolbar toggling never writes the preference back.
m = await mountWith('count')
await click(sortBtns(m.container)[0])
assert.equal(settings.store.getSnapshot().toolSort, 'count')
await m.unmount()
settings.set('defaultToolSort', 'count')
})
test('before the epoch content loads, the filter scans names and plugins only', async () => {
@@ -902,6 +952,7 @@ describe('ContextBrowser tool schemas', () => {
})
test('tool rows tag the registering plugin when attribution exists', async () => {
settings.set('defaultToolSort', 'size')
const attributed: ContextHeaders = {
headers: [{ seq: 1, time: 1, systemTokens: 3, tools: [
{ name: 'write', tokens: 5, plugin: '@deepseek-ai/dsh-tool-fs' },
@@ -934,6 +985,7 @@ describe('ContextBrowser tool schemas', () => {
assert.ok(chip.parentElement!.classList.contains('lc-br-elem-row'), 'plugin chip is a single frame, a direct row child')
assert.equal(queryAll(chip, '.lc-br-tag').length, 0, 'no nested tag wrapper')
await m.unmount()
settings.set('defaultToolSort', 'count')
})
test('a single-tool category opens its schema row with the category; multi stays collapsed', async () => {
+21 -19
View File
@@ -9,12 +9,14 @@ import { afterEach, describe, test, vi } from 'vitest'
import { makeContextModal } from '../../../src/client/components/contextModal'
import { modalStoreOf, setPendingConsume, takePendingConsume } from '../../../src/client/modalStore'
import { watchHistoryFaces } from '../../../src/client/historyPage'
import { createContextSettings } from '../../../src/client/settings'
import type { ContextTimeline } from '../../../src/shared/types'
import { DICT_EN } from '../../../src/client/i18n'
import { TestClientCtx, TestSessions, asClientCtx } from '../helpers/harness'
import { click, flush, hover, keydown, makeKit, mount, query, queryAll, text, unhover } from '../helpers/kit'
const kit = makeKit()
const settings = createContextSettings()
function timeline(over: Record<string, unknown> = {}): ContextTimeline {
@@ -46,7 +48,7 @@ afterEach(() => {
describe('ContextModal', () => {
test('renders null without a useContextModal hook or while closed', async () => {
const ctx = new TestClientCtx({ services: { sessions: new TestSessions() } })
const ContextModal = makeContextModal(asClientCtx(ctx), kit)
const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings)
const m1 = await mount(h(ContextModal, { sessionId: 'sm-none', useProjection: () => timeline() }))
assert.equal(m1.container.childElementCount, 0)
@@ -63,7 +65,7 @@ describe('ContextModal', () => {
test('open with no projection hook (or an empty one) shows the loading view', async () => {
const ctx = new TestClientCtx({ services: { sessions: new TestSessions() } })
const ContextModal = makeContextModal(asClientCtx(ctx), kit)
const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings)
const m1 = await mount(h(ContextModal, { sessionId: 'sm-load1', useContextModal: OPEN }))
assert.ok(text(m1.container).includes(DICT_EN.loading))
@@ -81,7 +83,7 @@ describe('ContextModal', () => {
test('a corrupt timeline projection is sanitized and still renders the composition', async () => {
const ctx = new TestClientCtx({ services: { sessions: new TestSessions() } })
const ContextModal = makeContextModal(asClientCtx(ctx), kit)
const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings)
const m = await mount(h(ContextModal, {
sessionId: 'sm-garbage',
useContextModal: OPEN,
@@ -97,7 +99,7 @@ describe('ContextModal', () => {
test('full render: subtitle variants and hover link through the composed browser', async () => {
const sessions = new TestSessions()
const ctx = new TestClientCtx({ services: { sessions } })
const ContextModal = makeContextModal(asClientCtx(ctx), kit)
const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings)
const projections: Record<string, unknown> = {
contextTimeline: timeline({
model: 'deepseek-v4-flash',
@@ -129,7 +131,7 @@ describe('ContextModal', () => {
test('subtitle degrades to the model alone, then to nothing', async () => {
const ctx = new TestClientCtx({ services: { sessions: new TestSessions() } })
const ContextModal = makeContextModal(asClientCtx(ctx), kit)
const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings)
const m1 = await mount(h(ContextModal, {
sessionId: 'sm-sub1',
@@ -153,7 +155,7 @@ describe('ContextModal', () => {
test('backdrop click without a sessionId is a no-op', async () => {
const sessions = new TestSessions()
const ctx = new TestClientCtx({ services: { sessions } })
const ContextModal = makeContextModal(asClientCtx(ctx), kit)
const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings)
const m = await mount(h(ContextModal, { useContextModal: OPEN, useProjection: () => undefined }))
await click(query(m.container, '.lc-modal-backdrop'))
assert.ok(m.container.querySelector('.lc-modal-backdrop') !== null)
@@ -164,7 +166,7 @@ describe('ContextModal', () => {
test('clicking the card itself does not close (stopPropagation)', async () => {
const sessions = new TestSessions()
const ctx = new TestClientCtx({ services: { sessions } })
const ContextModal = makeContextModal(asClientCtx(ctx), kit)
const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings)
const m = await mount(h(ContextModal, { sessionId: 'sm-card', useContextModal: OPEN, useProjection: () => undefined }))
await click(query(m.container, '.lc-modal-card'))
assert.ok(m.container.querySelector('.lc-modal-backdrop') !== null)
@@ -175,7 +177,7 @@ describe('ContextModal', () => {
test('close flips the store and bails a pending consume through the session scope', async () => {
const sessions = new TestSessions()
const ctx = new TestClientCtx({ services: { sessions } })
const ContextModal = makeContextModal(asClientCtx(ctx), kit)
const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings)
const sid = 'sm-close'
modalStoreOf(sid).set(true)
setPendingConsume(sid, { kind: 'bare-token', token: '/context' })
@@ -200,7 +202,7 @@ describe('ContextModal', () => {
test('close without a pending consume sets the store but bails nothing', async () => {
const sessions = new TestSessions()
const ctx = new TestClientCtx({ services: { sessions } })
const ContextModal = makeContextModal(asClientCtx(ctx), kit)
const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings)
const sid = 'sm-noguard'
modalStoreOf(sid).set(true)
const m = await mount(h(ContextModal, {
@@ -216,7 +218,7 @@ describe('ContextModal', () => {
test('a pending consume without the sessions service closes quietly', async () => {
const ctx = new TestClientCtx()
const ContextModal = makeContextModal(asClientCtx(ctx), kit)
const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings)
const sid = 'sm-nosvc'
modalStoreOf(sid).set(true)
setPendingConsume(sid, { kind: 'bare-token', token: '/context' })
@@ -240,7 +242,7 @@ describe('ContextModal', () => {
},
}
const ctx = new TestClientCtx({ services: { sessions } })
const ContextModal = makeContextModal(asClientCtx(ctx), kit)
const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings)
const sid = 'sm-noscope'
modalStoreOf(sid).set(true)
setPendingConsume(sid, { kind: 'span', span: { start: 0, end: 8, draftRev: 3 } })
@@ -258,7 +260,7 @@ describe('ContextModal', () => {
test('the mask docks beside the frame ancestor, follows template rewrites, and disconnects on close', async () => {
const sessions = new TestSessions()
const ctx = new TestClientCtx({ services: { sessions } })
const ContextModal = makeContextModal(asClientCtx(ctx), kit)
const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings)
const sid = 'sm-dock'
// The shell frame: the plugin matches its INLINE grid template, the one
// host anchor carried by every supported baseline (dockMeasure.ts).
@@ -299,7 +301,7 @@ describe('ContextModal', () => {
test('without a frame ancestor the mask stays full-viewport', async () => {
const ctx = new TestClientCtx({ services: { sessions: new TestSessions() } })
const ContextModal = makeContextModal(asClientCtx(ctx), kit)
const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings)
const m = await mount(h(ContextModal, {
sessionId: 'sm-noframe',
useContextModal: OPEN,
@@ -312,7 +314,7 @@ describe('ContextModal', () => {
test('Escape closes; other keys do not; the listener is removed on close', async () => {
const sessions = new TestSessions()
const ctx = new TestClientCtx({ services: { sessions } })
const ContextModal = makeContextModal(asClientCtx(ctx), kit)
const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings)
const sid = 'sm-esc'
modalStoreOf(sid).set(true)
const m = await mount(h(ContextModal, {
@@ -338,7 +340,7 @@ describe('ContextModal', () => {
test('closing restores focus to the previously focused element', async () => {
const ctx = new TestClientCtx({ services: { sessions: new TestSessions() } })
const ContextModal = makeContextModal(asClientCtx(ctx), kit)
const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings)
const sid = 'sm-focus'
const btn = document.createElement('button')
document.body.appendChild(btn)
@@ -365,7 +367,7 @@ describe('ContextModal', () => {
test('closing skips the focus restore when the previous element left the document', async () => {
const ctx = new TestClientCtx({ services: { sessions: new TestSessions() } })
const ContextModal = makeContextModal(asClientCtx(ctx), kit)
const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings)
const sid = 'sm-focus-gone'
const btn = document.createElement('button')
document.body.appendChild(btn)
@@ -388,7 +390,7 @@ describe('ContextModal', () => {
test('no focus bookkeeping when nothing was focused (activeElement not an element)', async () => {
const ctx = new TestClientCtx({ services: { sessions: new TestSessions() } })
const ContextModal = makeContextModal(asClientCtx(ctx), kit)
const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings)
const sid = 'sm-focus-null'
modalStoreOf(sid).set(true)
// Environment control (not a plugin fake): jsdom always reports body;
@@ -438,7 +440,7 @@ describe('ContextModal — the split generation', () => {
},
},
})
const ContextModal = makeContextModal(asClientCtx(ctx), kit)
const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings)
const head = timeline({
counts: { turns: 1, steps: 1, injects: 0, compactions: 0, prunes: 0 },
last: { seq: 1, total: 21, prompt: 20 },
@@ -467,7 +469,7 @@ describe('ContextModal — the split generation', () => {
const ctx = new TestClientCtx({ services: { sessions: new TestSessions() } })
// Armed before either service exists: the inject stays pending, the race.
watchHistoryFaces(asClientCtx(ctx))
const ContextModal = makeContextModal(asClientCtx(ctx), kit)
const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings)
const projections: Record<string, unknown> = {
contextTimeline: timeline(),
contextHeaders: { headers: [{ seq: 1, time: 0, systemTokens: 5, tools: [] }] },
+25 -5
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', fileSort: 'count', writable: true, ...partial }
return { status: 'ready', placement: 'all', granularity: 'step', mode: 'total', toolSort: 'count', fileSort: 'count', 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, 4)
assert.equal(selects.length, 5)
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)
@@ -107,8 +107,10 @@ describe('SettingsCard', () => {
// The granularity row follows.
assert.ok(text(selects[1]).includes(DICT_EN['gran.step']))
assert.ok(text(selects[2]).includes(DICT_EN['gran.total']))
assert.ok(text(m.container).includes(DICT_EN['settings.toolSort']))
assert.ok(text(selects[3]).includes(DICT_EN['tool.sort.count']))
assert.ok(text(m.container).includes(DICT_EN['settings.fileSort']))
assert.ok(text(selects[3]).includes(DICT_EN['files.sort.count']))
assert.ok(text(selects[4]).includes(DICT_EN['files.sort.count']))
await click(selects[1])
assert.equal(selects[1].getAttribute('aria-expanded'), 'true')
@@ -133,8 +135,25 @@ describe('SettingsCard', () => {
])
assert.equal(document.body.querySelector('[role="menu"]'), null)
// The file-sort row writes the last field.
// The tool-sort row leads the file-sort one.
await click(selects[3])
const toolItems = menuItems()
assert.deepEqual(toolItems.map(i => text(i)), [
DICT_EN['tool.sort.size'],
DICT_EN['tool.sort.count'],
DICT_EN['tool.sort.name'],
])
await click(toolItems[2]) // 'By name'
assert.deepEqual(calls, [
['defaultPlacement', 'sidebar'],
['defaultGranularity', 'turn'],
['defaultTrendMode', 'delta'],
['defaultToolSort', 'name'],
])
assert.equal(document.body.querySelector('[role="menu"]'), null)
// The file-sort row writes the last field.
await click(selects[4])
const sortItems = menuItems()
assert.deepEqual(sortItems.map(i => text(i)), [
DICT_EN['files.sort.count'],
@@ -146,6 +165,7 @@ describe('SettingsCard', () => {
['defaultPlacement', 'sidebar'],
['defaultGranularity', 'turn'],
['defaultTrendMode', 'delta'],
['defaultToolSort', 'name'],
['defaultFileSort', 'path'],
])
assert.equal(document.body.querySelector('[role="menu"]'), null)
@@ -211,7 +231,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, 4)
assert.equal(queryAll(m.container, '.lc-settings-select').length, 5)
assert.equal(scrolled.length, 1, 'the card scrolls itself into view')
assert.deepEqual(scrolled[0].arg, { block: 'nearest' })
assert.equal(scrolled[0].el, card)
+1 -1
View File
@@ -313,7 +313,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, 4)
assert.equal(selects.length, 5)
for (const s of selects) assert.ok((s as HTMLButtonElement).disabled)
await m.unmount()
ctx.dispose()
+15 -3
View File
@@ -50,12 +50,14 @@ describe('createContextSettings defaults', () => {
placement: 'all',
granularity: 'step',
mode: 'total',
toolSort: 'count',
fileSort: 'count',
writable: false,
})
assert.equal(s.defaultPlacement(), 'all')
assert.equal(s.defaultGranularity(), 'step')
assert.equal(s.defaultTrendMode(), 'total')
assert.equal(s.defaultToolSort(), 'count')
assert.equal(s.defaultFileSort(), 'count')
})
})
@@ -169,6 +171,7 @@ describe('attach', () => {
defaultPlacement: 'sidebar',
defaultGranularity: 'turn',
defaultTrendMode: 'delta',
defaultToolSort: 'name',
defaultFileSort: 'path',
},
writable: true,
@@ -179,6 +182,7 @@ describe('attach', () => {
placement: 'sidebar',
granularity: 'turn',
mode: 'delta',
toolSort: 'name',
fileSort: 'path',
writable: true,
})
@@ -207,6 +211,7 @@ describe('attach', () => {
placement: 'all',
granularity: 'step',
mode: 'total',
toolSort: 'count',
fileSort: 'count',
writable: false,
})
@@ -217,12 +222,13 @@ describe('attach', () => {
const s = createContextSettings()
s.attach(new TestSettingsScope({
status: 'ready',
value: { defaultPlacement: 'window', defaultGranularity: 'bogus', defaultTrendMode: 7, defaultFileSort: 'alpha' },
value: { defaultPlacement: 'window', defaultGranularity: 'bogus', defaultTrendMode: 7, defaultToolSort: 'alpha', defaultFileSort: 'alpha' },
writable: false,
}))
assert.equal(s.defaultPlacement(), 'all')
assert.equal(s.defaultGranularity(), 'step')
assert.equal(s.defaultTrendMode(), 'total')
assert.equal(s.defaultToolSort(), 'count')
assert.equal(s.defaultFileSort(), 'count')
})
@@ -241,22 +247,25 @@ describe('attach', () => {
const s = createContextSettings()
s.attach(new TestSettingsScope({
status: 'ready',
value: { defaultPlacement: 'all', defaultGranularity: 'step', defaultTrendMode: 'total', defaultFileSort: 'count' },
value: { defaultPlacement: 'all', defaultGranularity: 'step', defaultTrendMode: 'total', defaultToolSort: 'count', defaultFileSort: 'count' },
writable: false,
}))
assert.equal(s.defaultPlacement(), 'all')
assert.equal(s.defaultGranularity(), 'step')
assert.equal(s.defaultTrendMode(), 'total')
assert.equal(s.defaultToolSort(), 'count')
assert.equal(s.defaultFileSort(), 'count')
})
test('missing fields keep the current state', () => {
const s = createContextSettings()
s.set('defaultPlacement', 'tab')
s.set('defaultToolSort', 'size')
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')
assert.equal(s.defaultTrendMode(), 'total')
assert.equal(s.defaultToolSort(), 'size', 'the in-session tool sort survives a section without the field')
assert.equal(s.defaultFileSort(), 'latest')
})
@@ -278,8 +287,11 @@ describe('attach', () => {
scope.emit({ status: 'ready', value: { defaultPlacement: 'sidebar' }, writable: true })
assert.equal(calls, 4)
assert.equal(s.defaultPlacement(), 'sidebar')
scope.emit({ status: 'ready', value: { defaultFileSort: 'path' }, writable: false })
scope.emit({ status: 'ready', value: { defaultToolSort: 'name' }, writable: true })
assert.equal(calls, 5)
assert.equal(s.defaultToolSort(), 'name')
scope.emit({ status: 'ready', value: { defaultFileSort: 'path' }, writable: false })
assert.equal(calls, 6)
assert.equal(s.store.getSnapshot().writable, false)
})
+8 -3
View File
@@ -69,6 +69,7 @@ describe('installSettings', () => {
defaultPlacement: 'all',
defaultGranularity: 'step',
defaultTrendMode: 'total',
defaultToolSort: 'count',
defaultFileSort: 'count',
}, 'schema defaults resolve')
})
@@ -80,6 +81,7 @@ describe('installSettings', () => {
defaultPlacement: 'all',
defaultGranularity: 'turn',
defaultTrendMode: 'total',
defaultToolSort: 'count',
defaultFileSort: 'count',
}, 'the update resolves over the schema defaults')
assert.deepEqual(provider.doc['dsh-context'], { defaultGranularity: 'turn' }, 'the provider persisted the section')
@@ -89,6 +91,7 @@ describe('installSettings', () => {
defaultPlacement: 'sidebar',
defaultGranularity: 'turn',
defaultTrendMode: 'delta',
defaultToolSort: 'count',
defaultFileSort: 'path',
}, 'every preference field resolves independently')
@@ -98,25 +101,27 @@ describe('installSettings', () => {
)
// The loose fields degrade instead of rejecting: a stale file sort or
// placement resolves to the default.
await ctx.settings.update(ns, { defaultFileSort: 'net', defaultPlacement: 'window' })
await ctx.settings.update(ns, { defaultFileSort: 'net', defaultPlacement: 'window', defaultToolSort: 'net' })
assert.deepEqual(ctx.settings.get(ns), {
defaultPlacement: 'all',
defaultGranularity: 'turn',
defaultTrendMode: 'delta',
defaultToolSort: 'count',
defaultFileSort: 'count',
}, 'a stale value degrades to the schema default')
assert.deepEqual(
provider.doc['dsh-context'],
{ defaultPlacement: 'window', defaultGranularity: 'turn', defaultTrendMode: 'delta', defaultFileSort: 'net' },
{ defaultPlacement: 'window', defaultGranularity: 'turn', defaultTrendMode: 'delta', defaultToolSort: 'net', defaultFileSort: 'net' },
'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', defaultFileSort: 'alpha' } })
const { ctx } = await boot({ 'dsh-context': { defaultPlacement: 'window', defaultTrendMode: 'net', defaultToolSort: 'alpha', defaultFileSort: 'alpha' } })
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')
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.defaultGranularity, 'step')
})