diff --git a/src/client/components/browser.tsx b/src/client/components/browser.tsx index aa7a6f3..8a46c2c 100644 --- a/src/client/components/browser.tsx +++ b/src/client/components/browser.tsx @@ -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(() => 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(null) diff --git a/src/client/components/contextModal.tsx b/src/client/components/contextModal.tsx index 981f195..3a9b332 100644 --- a/src/client/components/contextModal.tsx +++ b/src/client/components/contextModal.tsx @@ -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 { diff --git a/src/client/components/contextView.tsx b/src/client/components/contextView.tsx index 4eb48de..35bf7b9 100644 --- a/src/client/components/contextView.tsx +++ b/src/client/components/contextView.tsx @@ -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) diff --git a/src/client/components/settingsCard.tsx b/src/client/components/settingsCard.tsx index 54b2a30..69f69c3 100644 --- a/src/client/components/settingsCard.tsx +++ b/src/client/components/settingsCard.tsx @@ -128,6 +128,17 @@ export function makeSettingsCard(kit: ViewKit): (props: SettingsCardProps) => Re ]} onPick={(id) => { props.set?.('defaultTrendMode', id) }} /> + { props.set?.('defaultToolSort', id) }} + /> = { '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 = { '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 = { '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 = { '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', diff --git a/src/client/index.ts b/src/client/index.ts index 1f0d72a..3446cfa 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -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, diff --git a/src/client/settings.ts b/src/client/settings.ts index 050978a..c5cc114 100644 --- a/src/client/settings.ts +++ b/src/client/settings.ts @@ -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 diff --git a/src/host/settings.ts b/src/host/settings.ts index 60ca2a7..138e6ce 100644 --- a/src/host/settings.ts +++ b/src/host/settings.ts @@ -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 = z.object({ @@ -31,6 +31,7 @@ export const SettingsSchema: z = 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(), }) diff --git a/src/shared/types.ts b/src/shared/types.ts index 69da298..6f37b9e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 } diff --git a/tests/client/components/browser.spec.ts b/tests/client/components/browser.spec.ts index e8b4a0b..7d9c7b0 100644 --- a/tests/client/components/browser.spec.ts +++ b/tests/client/components/browser.spec.ts @@ -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 () => { diff --git a/tests/client/components/contextModal.spec.ts b/tests/client/components/contextModal.spec.ts index 6a73e07..a30be94 100644 --- a/tests/client/components/contextModal.spec.ts +++ b/tests/client/components/contextModal.spec.ts @@ -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 = {}): 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 = { 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 = { contextTimeline: timeline(), contextHeaders: { headers: [{ seq: 1, time: 0, systemTokens: 5, tools: [] }] }, diff --git a/tests/client/components/settingsCard.spec.ts b/tests/client/components/settingsCard.spec.ts index fb16629..a498534 100644 --- a/tests/client/components/settingsCard.spec.ts +++ b/tests/client/components/settingsCard.spec.ts @@ -21,7 +21,7 @@ function hookFor(state: SettingsState) { } function stateOf(partial: Partial = {}): 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) diff --git a/tests/client/index.spec.ts b/tests/client/index.spec.ts index 5f908aa..05f7ef8 100644 --- a/tests/client/index.spec.ts +++ b/tests/client/index.spec.ts @@ -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() diff --git a/tests/client/settings.spec.ts b/tests/client/settings.spec.ts index c408c56..7218244 100644 --- a/tests/client/settings.spec.ts +++ b/tests/client/settings.spec.ts @@ -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) }) diff --git a/tests/host/settings.spec.ts b/tests/host/settings.spec.ts index 1c4f120..d8baaf7 100644 --- a/tests/host/settings.spec.ts +++ b/tests/host/settings.spec.ts @@ -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') })