From a20445e4edd6e1e45a1ab4c55650f16b99755469 Mon Sep 17 00:00:00 2001 From: Joob1n Date: Sat, 22 Aug 2026 01:34:05 +0800 Subject: [PATCH 1/2] feat(desktop): declare one thinking level across a relay's models at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A relay usually fronts one model family that accepts the same `reasoning_effort` values, but the capability section only offered a per-model menu: declaring `high` on eight models was eight menus × one tick, for a single fact about the relay. The section now opens with one control that writes into every enabled model's row. It is per level, not per level set — ticking `high` adds it everywhere and unticking removes it everywhere, leaving every other level on every row exactly as the user left it. Replacing the whole set would have been one fewer concept and would silently discard what a row already declared, which is the destructive reading of a control whose only job is to save clicks. The box ticks only at full coverage, so a level that 3 of 5 models declare reads as unticked with its count beside it: the next click then means "give it to everyone" rather than "take it from the rows that have it", and the count is the only thing separating partial coverage from none. Edits land in the same local draft the rows use and commit through the same 保存能力声明. Also names the context-window field per model. It was the one control in the section without a model in its accessible name, so every row exposed the same `上下文窗口(tokens)` spinbutton — the AX audit flags it as soon as a story renders a relay with more than one enabled model, which is the ordinary case and had no story until this one. Generated-by: Claude Opus 5 --- .../__tests__/relay-thinking-bulk.test.ts | 159 ++++++++++++++++++ .../locales/settings-provider-copy.ts | 11 ++ .../settings/provider-connection-detail.tsx | 64 ++++++- .../renderer/settings/relay-thinking-bulk.ts | 134 +++++++++++++++ .../settings/use-connection-detail.ts | 28 ++- .../settings/provider-settings.stories.tsx | 55 +++++- 6 files changed, 437 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/relay-thinking-bulk.test.ts create mode 100644 apps/desktop/src/renderer/settings/relay-thinking-bulk.ts diff --git a/apps/desktop/src/main/__tests__/relay-thinking-bulk.test.ts b/apps/desktop/src/main/__tests__/relay-thinking-bulk.test.ts new file mode 100644 index 0000000000..220c2d12c5 --- /dev/null +++ b/apps/desktop/src/main/__tests__/relay-thinking-bulk.test.ts @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + applyBulkThinkingLevel, + bulkThinkingLevelStates, + relayProfileWithThinkingLevels, +} from '../../renderer/settings/relay-thinking-bulk.js'; +import { DECLARABLE_RELAY_THINKING_LEVELS } from '@maka/core/model-thinking'; +import type { RelayModelProfile } from '@maka/core/model-thinking'; + +const MODELS = ['alpha', 'beta', 'gamma']; + +test('a level nobody declares reads as absent, and one everybody declares ticks the box', () => { + const draft: Record = { + alpha: { thinkingLevels: ['high'] }, + beta: { thinkingLevels: ['high'] }, + gamma: { thinkingLevels: ['high'] }, + }; + const states = bulkThinkingLevelStates(MODELS, draft, ['high', 'low']); + assert.deepEqual(states[0], { level: 'high', declaredCount: 3, total: 3, checked: true }); + assert.deepEqual(states[1], { level: 'low', declaredCount: 0, total: 3, checked: false }); +}); + +test('partial coverage does not tick the box — only the count separates it from none', () => { + // The box is the affordance for "give this to everyone". Ticking at + // partial coverage would make the next click take the level AWAY from the + // rows that have it, which is the opposite of what the user just asked for. + const draft: Record = { + alpha: { thinkingLevels: ['high'] }, + beta: { thinkingLevels: ['high'] }, + }; + const [state] = bulkThinkingLevelStates(MODELS, draft, ['high']); + assert.equal(state?.checked, false); + assert.equal(state?.declaredCount, 2); + assert.equal(state?.total, 3); +}); + +test('a repeated model id is one model, not two', () => { + const draft: Record = { alpha: { thinkingLevels: ['high'] } }; + const [state] = bulkThinkingLevelStates(['alpha', 'alpha'], draft, ['high']); + assert.deepEqual(state, { level: 'high', declaredCount: 1, total: 1, checked: true }); +}); + +test('an empty selection ticks nothing rather than reading as fully covered', () => { + // 0 === 0 is the trap: `declaredCount === total` is true of an empty + // selection, which would present every level as declared everywhere. + for (const state of bulkThinkingLevelStates([], {}, DECLARABLE_RELAY_THINKING_LEVELS)) { + assert.equal(state.checked, false); + assert.equal(state.total, 0); + } +}); + +test('ticking a level adds it to every model, including ones with no entry yet', () => { + const next = applyBulkThinkingLevel(MODELS, { alpha: { vision: true } }, 'high', true); + assert.deepEqual(next, { + alpha: { vision: true, thinkingLevels: ['high'] }, + beta: { thinkingLevels: ['high'] }, + gamma: { thinkingLevels: ['high'] }, + }); +}); + +test('a bulk add leaves the levels a model already declared alone', () => { + const next = applyBulkThinkingLevel( + MODELS, + { alpha: { thinkingLevels: ['low', 'medium'] } }, + 'high', + true, + ); + assert.deepEqual(next.alpha?.thinkingLevels, ['low', 'medium', 'high']); +}); + +test('ticking a level a model already has does not duplicate it', () => { + const next = applyBulkThinkingLevel( + ['alpha'], + { alpha: { thinkingLevels: ['high'] } }, + 'high', + true, + ); + assert.deepEqual(next.alpha?.thinkingLevels, ['high']); +}); + +test('unticking removes only that level, and only from the selection', () => { + const next = applyBulkThinkingLevel( + ['alpha', 'beta'], + { + alpha: { thinkingLevels: ['low', 'high'] }, + beta: { thinkingLevels: ['high'] }, + gamma: { thinkingLevels: ['high'] }, + }, + 'high', + false, + ); + assert.deepEqual(next.alpha?.thinkingLevels, ['low']); + // beta held nothing but `high`: an entry with no keys left is not an + // entry, or the row keeps reading as declared and 保存 stays armed. + assert.equal('beta' in next, false); + // gamma is outside the selection — a bulk edit is scoped to the rows the + // control sits above. + assert.deepEqual(next.gamma?.thinkingLevels, ['high']); +}); + +test('unticking keeps the other declarations on a model whose levels it empties', () => { + const next = applyBulkThinkingLevel( + ['alpha'], + { alpha: { thinkingLevels: ['high'], vision: true, contextWindow: 128_000 } }, + 'high', + false, + ); + assert.deepEqual(next.alpha, { vision: true, contextWindow: 128_000 }); +}); + +test('a bulk edit does not reshuffle the draft under the rows being edited', () => { + const next = applyBulkThinkingLevel( + ['gamma', 'alpha'], + { alpha: { vision: true }, beta: { vision: false }, gamma: { vision: true } }, + 'high', + true, + ); + assert.deepEqual(Object.keys(next), ['alpha', 'beta', 'gamma']); +}); + +test('a model id colliding with a prototype key stores an entry, not a prototype write', () => { + // Ids come off the relay's /models response. `draft['constructor']` on a + // plain object answers with Object's constructor rather than "absent", + // and assigning `__proto__` writes through the prototype. + const ids = ['__proto__', 'constructor', 'toString']; + const next = applyBulkThinkingLevel(ids, {}, 'high', true); + for (const id of ids) { + assert.deepEqual(Object.getOwnPropertyDescriptor(next, id)?.value, { + thinkingLevels: ['high'], + }); + } + assert.equal(({} as Record).thinkingLevels, undefined); + // And the read side sees all three as declaring it, rather than answering + // "absent" for keys that resolve on Object.prototype. + const [state] = bulkThinkingLevelStates(ids, next, ['high']); + assert.deepEqual(state, { level: 'high', declaredCount: 3, total: 3, checked: true }); +}); + +test('an emptied declaration collapses to undefined so the caller drops the key', () => { + assert.equal(relayProfileWithThinkingLevels({ thinkingLevels: ['high'] }, []), undefined); + assert.equal(relayProfileWithThinkingLevels({ thinkingLevels: ['high'] }, undefined), undefined); + assert.deepEqual(relayProfileWithThinkingLevels({ vision: true }, ['high']), { + vision: true, + thinkingLevels: ['high'], + }); +}); + +test('clearing a level a model never declared leaves the draft untouched', () => { + const draft: Record = { alpha: { vision: true } }; + const next = applyBulkThinkingLevel(MODELS, draft, 'high', false); + assert.deepEqual(next, { alpha: { vision: true } }); +}); + +test('the bulk edit does not mutate the draft it was handed', () => { + const draft: Record = { alpha: { thinkingLevels: ['low'] } }; + applyBulkThinkingLevel(MODELS, draft, 'high', true); + assert.deepEqual(draft, { alpha: { thinkingLevels: ['low'] } }); +}); diff --git a/apps/desktop/src/renderer/locales/settings-provider-copy.ts b/apps/desktop/src/renderer/locales/settings-provider-copy.ts index 57fb39e33e..bc9ad3a411 100644 --- a/apps/desktop/src/renderer/locales/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-provider-copy.ts @@ -15,6 +15,11 @@ const zhCapabilitiesCopy = { thinkingEffortHelp: '勾选需要的思考强度档位,不勾选即为不声明。', thinkingUndeclared: '未声明', thinkingSelectedCount: (count: number) => `已选择 ${count} 个`, + thinkingBulk: '批量设置思考档位', + thinkingBulkHelp: '勾选写入下方全部已启用模型,取消勾选则从全部模型移除;其余档位不受影响。', + thinkingBulkTrigger: '应用到全部模型', + thinkingBulkCoverage: (declared: number, total: number) => + declared === 0 ? '全部未声明' : `${declared}/${total} 个模型`, visionInput: '视觉输入(vision)', visionInputHelp: '「自动」跟随内置元数据;「启用/禁用」是显式声明,覆盖自动判断。', visionAuto: '自动', @@ -34,6 +39,12 @@ const enCapabilitiesCopy = { thinkingEffortHelp: 'Tick the thinking levels this model supports; none ticked means undeclared.', thinkingUndeclared: 'Undeclared', thinkingSelectedCount: (count: number) => `${count} selected`, + thinkingBulk: 'Set thinking levels for all models', + thinkingBulkHelp: + 'Ticking adds the level to every enabled model below; unticking removes it from all of them. Other levels are left alone.', + thinkingBulkTrigger: 'Apply to all models', + thinkingBulkCoverage: (declared: number, total: number) => + declared === 0 ? 'On no model' : `On ${declared} of ${total} models`, visionInput: 'Vision input', visionInputHelp: 'Auto follows built-in metadata; Enabled/Disabled overrides it explicitly.', visionAuto: 'Auto', diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index b6c632abc7..bc6766cbae 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -58,6 +58,7 @@ import { savedRequestHeaderDrafts, type RequestHeaderDraft, } from './request-customization-editor'; +import { bulkThinkingLevelStates } from './relay-thinking-bulk'; export function ConnectionDetail(props: ConnectionDetailProps) { const defaults = PROVIDER_DEFAULTS[props.connection.providerType]; @@ -155,6 +156,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { relayProfileDraft, hasRelayProfileChanges, setDraftThinkingLevels, + setDraftThinkingLevelForAll, setDraftVision, setDraftContextWindow, setDraftServiceTier, @@ -186,6 +188,10 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { !hasModelMetadata(connection.providerType, modelId), ); const showsCapabilities = capabilityModelIds.length > 0; + // The bulk control shares the 思考档位 row's relay gate — it edits exactly + // that row — and needs repetition to be worth a control at all: with one + // row it would be a second widget doing what the row under it already does. + const showsThinkingBulk = isRelay && capabilityModelIds.length > 1; // One row is a form at a time, the way the settings-sidebar template does it. // Opening a row discards the other's draft: leaving an abandoned draft in // state meant it reappeared when the user came back to that row, and — until @@ -568,6 +574,56 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { + {/* One control for the whole table, above the rows it edits. A + relay usually fronts one model family that accepts the same + reasoning_effort values, and declaring that per row was + models × levels clicks for a single fact. */} + {showsThinkingBulk && ( + + + {/* The declarable vocabulary, which is the whole of what + a draft can hold: the seed sanitizes through + `normalizeRelayModelProfiles`, so `off` — a disable + wire no generic relay is presumed to speak — cannot + reach a row here either. */} + {bulkThinkingLevelStates( + capabilityModelIds, + relayProfileDraft, + DECLARABLE_RELAY_THINKING_LEVELS, + ).map((state) => ( + { + setDraftThinkingLevelForAll(capabilityModelIds, state.level, checked); + }} + isDisabled={allActionsBusy} + /> + ))} + + + )} {capabilityModelIds.map((modelId, modelIndex) => { const declared: RelayModelProfile | undefined = relayProfileDraft[modelId]; // Vision resolves to one of three states: absent (Auto), @@ -598,7 +654,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { ); return ( - {modelIndex > 0 && } + {(modelIndex > 0 || showsThinkingBulk) && } {modelId} {/* One row per declaration: label + what it does on the left, one compact control on the right (the 模型功能 @@ -677,7 +733,11 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { setDraftContextWindow(modelId, value ?? undefined) } diff --git a/apps/desktop/src/renderer/settings/relay-thinking-bulk.ts b/apps/desktop/src/renderer/settings/relay-thinking-bulk.ts new file mode 100644 index 0000000000..5e6a3de60d --- /dev/null +++ b/apps/desktop/src/renderer/settings/relay-thinking-bulk.ts @@ -0,0 +1,134 @@ +import type { RelayModelProfile, ThinkingLevel } from '@maka/core/model-thinking'; + +/** + * Bulk thinking-level editing for the capability section's relay-profile + * draft. + * + * A relay commonly fronts a whole family of models that accept the same + * `reasoning_effort` values, and the per-model rows made declaring that an + * N-models × M-levels click exercise. This module is the shared, testable + * half of the one control that does it in one gesture; the hook owns the + * state and the component owns the menu. + * + * The bulk control is **per level**, not per level *set*: ticking `high` + * adds `high` everywhere and unticking removes it everywhere, leaving every + * other level on every model exactly as the user left it. A replace-the-set + * design would have been one fewer concept, but it silently discards + * whatever a row already declared — the destructive reading of a control + * whose whole point is to save clicks. + */ + +/** + * Pin or clear one model's declared levels, shared by the single-row setter + * and the bulk path so both agree on what an emptied declaration *is*. + * + * An entry with no keys left is not an entry: `{}` would keep a row reading + * as declared (and `hasRelayProfileChanges` reading as dirty) after the user + * cleared its last field, so it collapses to `undefined` and the caller drops + * the key. + */ +export function relayProfileWithThinkingLevels( + current: RelayModelProfile | undefined, + levels: readonly ThinkingLevel[] | undefined, +): RelayModelProfile | undefined { + if (levels === undefined || levels.length === 0) { + if (!current) return current; + const { thinkingLevels: _dropped, ...rest } = current; + return Object.keys(rest).length > 0 ? rest : undefined; + } + return { ...(current ?? {}), thinkingLevels: [...levels] }; +} + +/** + * How much of the current selection declares one level. `declaredCount` is + * what the menu shows; `checked` is the box, and it is `true` only at full + * coverage — a partially covered level reads as unticked with its count + * beside it, so one more click means "give it to everyone" rather than + * "take it from the rows that have it". + */ +export interface BulkThinkingLevelState { + readonly level: ThinkingLevel; + readonly declaredCount: number; + readonly total: number; + readonly checked: boolean; +} + +function draftIndex( + draft: Readonly>, +): ReadonlyMap { + // Object.entries, not `draft[modelId]`: model ids come from the relay and + // may collide with prototype keys, where a plain index read answers with + // `Object.prototype.constructor` instead of "absent". + return new Map(Object.entries(draft)); +} + +export function bulkThinkingLevelStates( + modelIds: readonly string[], + draft: Readonly>, + levels: readonly ThinkingLevel[], +): readonly BulkThinkingLevelState[] { + const index = draftIndex(draft); + // A duplicated id is one model, not two: it must not inflate the + // denominator past what the rows below actually show. + const targets = [...new Set(modelIds)]; + return levels.map((level) => { + const declaredCount = targets.filter((modelId) => + index.get(modelId)?.thinkingLevels?.includes(level), + ).length; + return { + level, + declaredCount, + total: targets.length, + checked: targets.length > 0 && declaredCount === targets.length, + }; + }); +} + +/** + * Add or remove one level across every model in the current selection, + * returning the next draft. + * + * Models outside `modelIds` keep their entries untouched. The draft may hold + * declarations for models the user has since disabled — reads prune against + * the enabled set rather than the draft doing it — and a bulk edit is not + * the place to decide those are gone. + */ +export function applyBulkThinkingLevel( + modelIds: readonly string[], + draft: Readonly>, + level: ThinkingLevel, + checked: boolean, +): Record { + const targets = new Set(modelIds); + const entries: [string, RelayModelProfile][] = []; + const placed = new Set(); + const next = (current: RelayModelProfile | undefined): RelayModelProfile | undefined => { + const declared = current?.thinkingLevels ?? []; + if (checked) { + return declared.includes(level) + ? current + : relayProfileWithThinkingLevels(current, [...declared, level]); + } + return relayProfileWithThinkingLevels( + current, + declared.filter((existing) => existing !== level), + ); + }; + // Existing entries first, in place: a bulk edit must not reshuffle the + // draft under the rows the user is looking at. + for (const [modelId, profile] of Object.entries(draft)) { + placed.add(modelId); + const updated = targets.has(modelId) ? next(profile) : profile; + if (updated !== undefined) entries.push([modelId, updated]); + } + for (const modelId of modelIds) { + if (placed.has(modelId)) continue; + placed.add(modelId); + const updated = next(undefined); + if (updated !== undefined) entries.push([modelId, updated]); + } + // fromEntries, not assignment onto an object literal, for the same reason + // the core normalizer uses it: a relay-supplied `__proto__` key would + // otherwise write through the prototype instead of storing an entry. + return Object.fromEntries(entries); +} diff --git a/apps/desktop/src/renderer/settings/use-connection-detail.ts b/apps/desktop/src/renderer/settings/use-connection-detail.ts index b3016aee1f..639dc2bc65 100644 --- a/apps/desktop/src/renderer/settings/use-connection-detail.ts +++ b/apps/desktop/src/renderer/settings/use-connection-detail.ts @@ -23,6 +23,7 @@ import { useMountedRef, useToast, useUiLocale } from '@maka/ui'; import { getProviderSettingsCopy } from '../locales/settings-provider-copy'; import { connectionChipStatus } from './provider-connection-status'; import { relayProfileDraftReseedPlan, relayProfileDraftSeed } from './relay-profile-draft'; +import { applyBulkThinkingLevel, relayProfileWithThinkingLevels } from './relay-thinking-bulk'; import { useKeyedActionGuard } from './use-action-guard'; import type { OAuthLoginFlowBridge } from './use-oauth-login-flow'; import { @@ -378,16 +379,24 @@ export function useConnectionDetail(props: ConnectionDetailProps) { // One shape for all three fields: the field setter pins or removes its key, // and an entry with no keys left IS the undeclared state — storing it would - // keep the row looking edited after the user emptied every field. + // keep the row looking edited after the user emptied every field. The rule + // lives in relay-thinking-bulk so the row setter and the bulk control + // cannot drift on what an emptied declaration collapses to. function setDraftThinkingLevels(modelId: string, levels: ThinkingLevel[] | undefined): void { - updateRelayProfileDraft(modelId, (current) => { - if (levels === undefined || levels.length === 0) { - if (!current) return current; - const { thinkingLevels: _dropped, ...rest } = current; - return Object.keys(rest).length > 0 ? rest : undefined; - } - return { ...(current ?? {}), thinkingLevels: levels }; - }); + updateRelayProfileDraft(modelId, (current) => relayProfileWithThinkingLevels(current, levels)); + } + + // The same edit across every enabled model, as ONE state update rather than + // a loop of per-model setters: a bulk tick is a single user gesture, and + // committing it in N steps would let a re-render land mid-way and paint a + // half-applied table. + function setDraftThinkingLevelForAll( + modelIds: readonly string[], + level: ThinkingLevel, + checked: boolean, + ): void { + setRelayProfilesDirty(true); + setRelayProfileDrafts((current) => applyBulkThinkingLevel(modelIds, current, level, checked)); } // Tri-state vision: undefined = Auto (relay/metadata decides), true/false @@ -723,6 +732,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) { relayProfilesDirty, hasRelayProfileChanges, setDraftThinkingLevels, + setDraftThinkingLevelForAll, setDraftVision, setDraftContextWindow, setDraftServiceTier, diff --git a/apps/desktop/stories/settings/provider-settings.stories.tsx b/apps/desktop/stories/settings/provider-settings.stories.tsx index 25131b56fa..483278ccba 100644 --- a/apps/desktop/stories/settings/provider-settings.stories.tsx +++ b/apps/desktop/stories/settings/provider-settings.stories.tsx @@ -28,7 +28,14 @@ const meta = { export default meta; type Story = StoryObj; -type AutoOpenTarget = 'detail' | 'detail-static' | 'add' | 'catalog' | 'oauth' | 'xai-device'; +type AutoOpenTarget = + | 'detail' + | 'detail-static' + | 'detail-relay' + | 'add' + | 'catalog' + | 'oauth' + | 'xai-device'; function makeConnection(input: { slug: string; @@ -120,6 +127,34 @@ const staticCatalogConnections = [ }, ]; +// A custom relay fronting one model family. Capability declarations are a +// relay-only surface — a built-in provider's thinking support comes from +// bundled metadata — and the family shares one `reasoning_effort` vocabulary, +// which is the case the bulk control exists for. `deepseek-r2` already +// declares two levels so the story shows partial coverage, not just the +// all-or-nothing ends. +const relayConnections = [ + { + ...makeConnection({ + slug: 'relay-house', + name: 'House Relay', + providerType: 'openai-compatible', + baseUrl: 'https://relay.example.com/v1', + defaultModel: 'deepseek-r2', + lastTestStatus: 'verified', + models: [ + { id: 'deepseek-r2' }, + { id: 'deepseek-v4' }, + { id: 'qwen3-max-thinking' }, + { id: 'kimi-k2.6' }, + ], + modelSource: 'fetched', + }), + enabledModelIds: ['deepseek-r2', 'deepseek-v4', 'qwen3-max-thinking', 'kimi-k2.6'], + relayModelProfiles: { 'deepseek-r2': { thinkingLevels: ['low', 'high'] as const } }, + }, +]; + const problemConnections = [ configuredConnections[0], makeConnection({ @@ -371,10 +406,11 @@ function reachCatalog(root: HTMLElement): HTMLElement | null { } function clickAutoOpenTarget(root: HTMLElement, target: AutoOpenTarget): boolean { - if (target === 'detail' || target === 'detail-static') { + if (target === 'detail' || target === 'detail-static' || target === 'detail-relay') { // ListItem's clickable surface is an invisible button inside the row, so // the row is located by its slug hook and the button taken from within it. - const slug = target === 'detail' ? 'zai-live' : 'ark-plan'; + const slug = + target === 'detail' ? 'zai-live' : target === 'detail-static' ? 'ark-plan' : 'relay-house'; const row = root.querySelector(`[data-connection-slug="${slug}"]`); const detailButton = row?.querySelector('button') ?? null; detailButton?.click(); @@ -452,6 +488,19 @@ export const StaticCatalogConnectionDetail: Story = { ), }; +// Real path: 设置 → 模型 → click a custom relay — the capability section with +// several enabled models, where 批量设置思考档位 sits above the per-model rows it +// writes into. Opening its menu shows each level's coverage across the table: +// `low` and `high` on 1 of 4, everything else on none. +export const RelayConnectionDetail: Story = { + render: () => ( + + ), +}; + // Real path: 设置 → 模型 → 添加连接 — level two, the provider catalog. export const AddConnectionCatalog: Story = { render: () => ( From bb06b8c99a02b931f74eef77b28381a21ec97bc8 Mon Sep 17 00:00:00 2001 From: Joob1n Date: Sat, 22 Aug 2026 13:53:20 +0800 Subject: [PATCH 2/2] fix(desktop): expose partial model coverage to assistive technology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review was right that the visible `n/total` never reached a screen reader. The menu item supplies its own `aria-label`, which replaces the accessible name the visible description would otherwise have joined, and the component does not wire `description` to `aria-describedby`. Read straight off the rendered story before the fix, `low` — declared by one of four models — and `minimal` — declared by none — were indistinguishable: low aria-label="批量设置思考档位 low" aria-checked=false minimal aria-label="批量设置思考档位 minimal" aria-checked=false Both unchecked, both identically named. Coverage is the only thing that separates them, and it was the one part that did not survive. The item now carries `aria-description`. Confirmed against the real Chromium accessibility tree through CDP, not only the DOM attribute: 批量设置思考档位 low description="1/4 个模型" 批量设置思考档位 minimal description="全部未声明" The assertion lives in the story's own `play`, which the storybook smoke already runs before reading the AX tree, so it is checked on every CI run that builds the catalog. Removing `aria-description` fails that story. Generated-by: Claude Opus 5 --- .../settings/provider-connection-detail.tsx | 12 +++++++++ .../settings/provider-settings.stories.tsx | 27 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index bc6766cbae..ef6c5998d4 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -614,6 +614,18 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { present as the same empty box. */ description={copy.thinkingBulkCoverage(state.declaredCount, state.total)} aria-label={`${copy.thinkingBulk} ${state.level}`} + /* The item's `description` is visible text only — the + component does not wire it to `aria-describedby`, + and this item's own `aria-label` replaces the name + the description would otherwise have joined. Without + this, "1/4 个模型" and "全部未声明" both reach a screen + reader as an unchecked box with the same name, which + is exactly the partial state the count exists to + show. */ + aria-description={copy.thinkingBulkCoverage( + state.declaredCount, + state.total, + )} value={state.checked} onChange={(checked) => { setDraftThinkingLevelForAll(capabilityModelIds, state.level, checked); diff --git a/apps/desktop/stories/settings/provider-settings.stories.tsx b/apps/desktop/stories/settings/provider-settings.stories.tsx index 483278ccba..b30e1b8b8a 100644 --- a/apps/desktop/stories/settings/provider-settings.stories.tsx +++ b/apps/desktop/stories/settings/provider-settings.stories.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, type ReactNode } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, within } from 'storybook/test'; import { Layout, LayoutContent, LayoutHeader } from '@astryxdesign/core'; import { ToastProvider } from '@maka/ui'; import type { @@ -499,6 +500,32 @@ export const RelayConnectionDetail: Story = { autoOpen="detail-relay" /> ), + // Opens the batch menu and asserts that partial coverage reaches assistive + // technology, not only the eye. The item carries its own `aria-label`, which + // replaces the accessible name the visible description would otherwise have + // joined — and the menu item does not wire `description` to + // `aria-describedby`. Without an explicit description, "1/4 个模型" and + // "全部未声明" both reach a screen reader as an unchecked box with the same + // name, which is exactly the state the count exists to distinguish. + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + const trigger = await body.findByRole('button', { name: /批量设置思考档位/ }); + await userEvent.click(trigger); + + // `low` is declared by one of the four models; `minimal` by none. + const partial = await body.findByRole('menuitemcheckbox', { + name: '批量设置思考档位 low', + }); + const none = await body.findByRole('menuitemcheckbox', { + name: '批量设置思考档位 minimal', + }); + + // Both are unchecked — coverage is the only thing separating them. + await expect(partial).toHaveAttribute('aria-checked', 'false'); + await expect(none).toHaveAttribute('aria-checked', 'false'); + await expect(partial).toHaveAttribute('aria-description', '1/4 个模型'); + await expect(none).toHaveAttribute('aria-description', '全部未声明'); + }, }; // Real path: 设置 → 模型 → 添加连接 — level two, the provider catalog.