feat(desktop): redesign the models settings pages - #4682
Conversation
The 设置 → 模型 surface had four levels that each spoke a different dialect: a list with a bare count header, a catalog of 59 flat rows behind a category picker, an add form that sprang a second step on the user after they thought they were done, and a detail page laid out on a bespoke two-column grid with the enabled models hidden inside a MultiSelector and every capability declaration expanded into a wall of controls. Rebuild all four on the settings kit the other pages already use, following Astryx's own settings and settings-sidebar templates: - List: a SettingsSection with the add action in its header; an empty list shows the recommended providers as rows so a first run is one click from a provider's form. - Catalog: one search field over everything, and the providers below it as labeled groups (推荐 / 模型计划 / API / 聚合 / 本地). The category picker is gone because scrolling past a heading is the filter; typing collapses the groups into one flat result list. - Add (managed API-key route): a Stepper names the two steps up front (密钥 → 选择模型). The model step is a CheckboxList with 全选 / 清空, a filter past eight models, and a 默认模型 selector limited to the ticked models; the chosen default is placed first in the saved list, which is where the Host reads it from. - Detail: SettingsSection rows throughout. 连接 holds the name / key / endpoint rows plus a 连接状态 row (StatusDot + last test + 测试连接). 模型 lists every chat-capable model as a row with an enable Switch and fact tokens (context, 视觉, 思考); models Maka cannot describe carry a 声明能力 expandable editor on their own row, and the bulk thinking menu lives in the section's action cluster and saves on tick. 高级请求设置 and 删除连接 follow as sections. The hook gains `resetDraftProfile` (per-row Cancel) and lets `saveRelayProfiles` take an explicit table so a bulk tick applies and saves in one gesture. `SettingsExpandableRow` accepts a `beforeAction` node so a row can carry its Switch beside the built-in trigger, and a ReactNode label so the default badge survives on a declaring row. The MultiSelector-based EnabledModelManager, the DetailSection grid, and the copy those relied on are removed. Generated-by: Claude Code
7f185a4 to
6e35bf4
Compare
- 推荐 shortlist: only the providers a new user can finish without leaving Maka — the account sign-ins plus OpenCode Free / Go. The keyed API providers drop their `recommendedOrder`; they stay in their groups. - Model rows: the facts (id, context, 视觉, 思考) are one dotted supporting line instead of a token per fact, so a default badge and a context pill no longer stack under one name. - 声明能力 → 补充规格 (en: Add specs), and the undescribed hint reads 「Maka 没有这个模型的规格,请补充。」 - Renderer architecture ledger regenerated for the removed EnabledModelManager module. Generated-by: Claude Code
Wording a native reader would write rather than a translation: 配置参数 for the per-model editor (with 缺少该模型的参数信息,请手动配置。 as the hint), 订阅计划 for the plans group, 取消全选 beside 全选, 未测试, 未找到 匹配的…, and shorter helper sentences on the list, empty state, and default model picker. English strings follow the same meaning. Generated-by: Claude Code
…bt ratchet CI's renderer architecture check holds the four legacy settings files this branch rewrites to main's dependency and Hook counts. Shape the new pieces to fit that budget instead of growing it: the catalog groups become List headers, the recommended shortlist becomes a mode of the catalog component so the panel calls no extra hook, the panel writes SettingsSection's header out with the Astryx primitives it already imports, the model-step filter lives in the step's state, the capability editor takes its copy from its parent, and the 添加模型 dialog is one more value of the page's single "what is open" state. The Search icon import goes with it. While here, the 连接状态 row drops its StatusDot: the status is the row's supporting text, and only a failure wears a red Token. An EmptyProviders story covers the first-run shortlist. Generated-by: Claude Code
The inventory still listed provider-enabled-model-manager.tsx, which this branch deleted, and the component lists of the rewritten settings files. Generated-by: Claude Code
The catalog category picker is gone, so nothing reads categoryLabel or the category copy any more; Knip flags the export. Generated-by: Claude Code
jackwener
left a comment
There was a problem hiding this comment.
I reviewed exact head a6958eb2c04921196ce3a04dc64aa7783d6c2c37. One P1 and one P2, both accessibility. Not approving while the P1 stands; this is a feature, so the merge decision is a human's either way.
The rebuild itself is coherent — four levels that spoke four dialects now use the settings kit the rest of Settings already uses, and the two self-imposed rules hold: Badge appears only as the default marker (list row, detail title, default model), a red Token only for a failed connection, and capability facts are ordinary prose rather than a wall of Tokens.
P1 — focus is dropped when the managed key wizard advances to model selection
provider-add-form.tsx:491-610 swaps the whole tree for a different return once managedPhase becomes models, unmounting the key field and the submit button. The level focus hook (providers-panel.tsx:302-333) only runs when level or route changes, and this step leaves level at setup, so it does not re-run.
A keyboard user who presses 验证并选择模型 therefore lands on document.body and has to Tab from the top of the settings page to reach step two. The Stepper does expose aria-current="step" and a label, but the user's focus is not on it, so nothing announces.
What makes this worth fixing rather than noting is that this PR already solves the same problem elsewhere. settings-expandable-row.tsx:41-44,96-108 deliberately moves focus into the editor on expand and back to the trigger on collapse — the same unmount-and-replace situation, handled. The wizard step transition is the one path that did not get it.
Moving focus to the first control of the model step (the filter field or the checkbox list), and back to the key field when returning, is enough.
P2 — catalog search changes results without announcing them
provider-catalog-page.tsx:126-164 replaces the category picker with a single search field. Typing collapses the labelled groups into one flat list, which is a good simplification visually, but the field carries only a hidden label — there is no live region and no result count. An empty search renders the noMatch empty state, which a screen reader user whose focus is still in the input does not hear.
The comparison is inside this PR: the managed wizard announces its selection count with role="status" (provider-add-form.tsx:522) and its busy state with aria-live="polite" (:589,640). The same treatment is missing from catalog search, and from the model filters in the detail page (provider-connection-detail.tsx:712-734) and the wizard (:542-558). Keyboard users can still Tab into the list, which is why this is a P2 rather than a P1.
A polite live region beside the search field, reporting the match count or "no matches", closes it.
Deliberately not filed
SettingsExpandableRow has no aria-expanded / aria-controls, and that is correct here rather than an omission. This is a mode replacement, not a disclosure: the trigger itself unmounts, so there is no element left to carry aria-expanded="true". Moving focus into the editor and back to the button is the right contract for that pattern, and adding the attributes would state something the DOM does not support.
Evidence boundary: this is a static review against the source and the Astryx Stepper implementation. No browser or Storybook run, so the focus behaviour above is derived from the mount/unmount structure and the hook's dependencies rather than observed. The inventory, ratchet, provider-registry and Storybook coverage are being reviewed separately and are not covered here. test was still running when I posted.
简体中文
我审的是 a6958eb2c04921196ce3a04dc64aa7783d6c2c37。一条 P1、一条 P2,都是可及性问题。 P1 未修之前我不批;这是 feature,合并与否本来也该由人决定。
重建本身是自洽的——原先四层各说各的方言,现在统一到设置页其余部分已经在用的那套组件上;而且作者自定的两条规则守住了:Badge 只用作默认标记(列表行、详情标题、默认模型),红色 Token 只用于连接失败,能力事实是普通句子而不是一堵 Token 墙。
P1:托管密钥向导推进到「选择模型」时,焦点被丢掉。
provider-add-form.tsx:491-610 在 managedPhase 变为 models 时,把整棵树换成了另一份 return,密钥输入框和提交按钮被卸载。而层级焦点钩子(providers-panel.tsx:302-333)只在 level 或 route 变化时运行,这一步 level 仍然是 setup,所以它不会重跑。
于是键盘用户按下「验证并选择模型」之后,焦点落到 document.body,必须从设置页顶部重新 Tab 才能到达第二步。Stepper 确实带了 aria-current="step" 和标签,但用户的焦点不在它上面,所以什么都不会被播报。
让这条值得修而不只是记一笔的原因是:这个 PR 自己在别处已经解决过同样的问题。 settings-expandable-row.tsx:41-44,96-108 特意在展开时把焦点送进编辑器、收起时送回触发器——同样是卸载替换的情形,那里处理了。向导的步骤切换是唯一没接上的一条路径。
把焦点移到模型步的第一个控件(筛选框或复选列表),返回时再回到密钥框,就够了。
P2:目录搜索改变了结果,却不播报。
provider-catalog-page.tsx:126-164 用一个搜索框取代了分类选择器。输入时带标题的分组会塌成一个扁平列表,视觉上是个不错的简化,但输入框只有一个隐藏标签——没有 live region,也没有结果计数。搜不到时渲染的是 noMatch 空状态,而焦点仍在输入框里的读屏用户听不到它。
对照就在这个 PR 内部:托管向导用 role="status" 播报选中计数(provider-add-form.tsx:522),用 aria-live="polite" 播报忙状态(:589,640)。同样的处理在目录搜索里没有,详情页的模型筛选(provider-connection-detail.tsx:712-734)和向导里的模型筛选(:542-558)也没有。键盘用户仍然可以 Tab 进列表,所以这是 P2 而不是 P1。
在搜索框旁加一个 polite 的 live region,报出匹配数或「未找到」,即可关闭。
特意没有报的一条
SettingsExpandableRow 没有 aria-expanded / aria-controls,这在这里是正确的,不是遗漏。它是模式替换而非 disclosure:触发器本身会被卸载,因此没有元素还能承载 aria-expanded="true"。把焦点移进编辑器再移回按钮,才是这种模式该有的契约;硬加那两个属性,等于声明一件 DOM 支撑不了的事。
证据边界:这是针对源码与 Astryx Stepper 实现的静态审查。没有跑浏览器或 Storybook,所以上面关于焦点的结论是从挂载/卸载结构和钩子依赖推出来的,不是观测到的。清单文件、架构棘轮、provider registry 与 Storybook 覆盖由另一席单独审查,不在本条范围内。我发布时 test 仍在运行。
Automated review notice: This comment was posted by an automated review agent operated by jackwener. It is not an independent human review and does not replace one.
…ounts Advancing the managed key wizard to model selection replaces the whole form while the settings route stays on `setup`, so the page's level focus never re-runs and the button the user pressed takes focus to `document.body`. A keyboard user then tabs from the top of Settings to reach step two. The step that arrives is the only thing that knows it arrived, so it owns the move, on mount: `OnboardingStepForm` focuses its own labelled region. The key step already had this through its field's `hasAutoFocus`. The three search fields rewrite their lists without moving focus and said nothing while doing it. Each now carries a live region reporting the match count, mounted before the text so the update is announced. Generated-by: Claude Code
|
Both are fixed in Focus on step advance — real, and reproducedConfirmed in the browser rather than from the structure: with focus on 验证并选择模型, clicking it leaves Two corrections to the account, neither of which changes that it needed fixing: It is pre-existing, not introduced here. The same reproduction runs identically against Severity is P2 on our scale. The reach is a normal keyboard path, but the consequence is bounded and recoverable, nothing is committed or externally visible, and the flow completes by tabbing. We reserve P1 for merge-blocking consequences. It is fixed here anyway because this PR is rebuilding the surface it lives on. Your root cause is right and worth stating precisely: focus-after-mode-swap has exactly two owners in this surface, one keyed on the route level and one on the row's The fix could not go where you suggested. The renderer debt ratchet forbids So the move belongs to the step that arrives, on mount, which is exactly when it arrives:
Filtered lists — real, fixed at all three sitesAll three confirmed: no live region tied to a result count. The We grade this P3 rather than P2: the list stays reachable by Tab, the no-match state carries a visible clear action, and nothing is persisted. Catalog search is also pre-existing, Each of the three now carries a polite live region reporting the match count, using the repository's existing
|
hqhq1025
left a comment
There was a problem hiding this comment.
Reviewed exact head 0fbfa9574f2e83df5ca988ac99abf2d916bfcfc4. I found one P2 and one P3.
This change rebuilds the four Models settings levels, changes managed onboarding selection/default behavior, replaces the detail editor layout, and makes bulk thinking-level choices save immediately. The earlier focus-loss and filtered-result announcement issues are fixed on this head.
Validation covered a clean install, Desktop production build and typecheck, renderer architecture (71/71), changed-file formatting/header/diff gates, Storybook build plus all 284 theme renders, 35 focused provider/profile tests, and real browser failure-path probes. The full Desktop suite completed with 2052 passes, 0 failures, and the same 8 cancellations in the untouched MCP OAuth deadline group. A conflict-free synthetic merge onto current main 74a20f60c9a7 passed clean install, Desktop build/typecheck, renderer architecture, and the 35 focused tests. Hosted test was still running when this review was posted.
This is a feature change, so the final product and merge decision remains with a human reviewer.
Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.
| setRelayProfilesDirty(true); | ||
| setRelayProfileDrafts((current) => applyBulkThinkingLevel(modelIds, current, level, checked)); | ||
| setRelayProfileDrafts(next); | ||
| return saveRelayProfiles(next); |
There was a problem hiding this comment.
P2 — restore the visible bulk state when the immediate save fails. This commits next to relayProfileDrafts before awaiting persistence, while saveRelayProfiles only reports the rejection and returns false. The bulk menu derives its checkmarks from that draft, so a rejected bridge.update leaves the selected level visibly checked even after reopening the menu. I reproduced this in the rendered relay-detail story by forcing bridge.update to reject: the error toast appeared, but aria-checked remained true. The user is then shown an unpersisted setting as if it were saved, and cannot retry the same add gesture without first unchecking it. Roll the draft/dirty state back on rejection, or defer the visible commit until the write succeeds.
| <EmptyState | ||
| isCompact | ||
| title={copy.onboardingNoModelsMatch} | ||
| actions={<Button variant="ghost" size="sm" label={copy.onboardingClearAll} onClick={() => setFilter('')} />} |
There was a problem hiding this comment.
P3 — label this no-results action as clearing the search. In this branch the button only calls setFilter(''), but its visible and accessible name is onboardingClearAll (取消全选 / Deselect all). With a 9-model onboarding fixture and a no-match query, I observed the button saying 取消全选; activating it only cleared the query and did not change the selection. Use the clear-search/filter copy here so the announced command matches its effect.
Summary
设置 → 模型 was four levels that each spoke a different dialect: a list with a bare count header, a catalog of 59 flat rows behind a category picker, an add form that sprang a second step on the user after they thought they were done, and a detail page on a bespoke two-column grid with the enabled models hidden inside a MultiSelector and every capability declaration expanded into a wall of controls.
This rebuilds all four levels on the settings kit the other pages already use (
SettingsSection/SettingsRow/SettingsExpandableRow), following Astryx's ownsettingsandsettings-sidebartemplates and its component guidance (rows for dense data, Stepper for multi-step flows, Badge only for the default marker, a Token only where something failed).SettingsSectionwith 添加连接 in its header. An empty list shows the recommended providers as rows, so a first run is one click from a provider's form.Listunder its own heading. The category picker is gone: scrolling past a heading is the filter, and typing collapses the groups into one flat result list. 推荐 is now only what a new user can finish without leaving Maka: the account sign-ins (Codex, Copilot, xAI Grok) and OpenCode Free / Go; the keyed API providers lose theirrecommendedOrderin the registry and stay in their own groups.Steppernames both steps up front (密钥 → 选择模型). The model step is aCheckboxListwith 全选 / 清空, a filter past eight models, and a 默认模型 selector limited to the ticked models. The chosen default is placed first in the saved list, which is where the Host reads it from.Tokenon failure — followed by the last test message and time, with 测试连接 as the row's action. 模型 lists every chat-capable model as a row with an enableSwitchand one supporting line of facts (id, context window, 视觉, 思考); models Maka cannot describe carry a 配置参数 expandable editor on their own row (Save commits the table, Cancel resets only that row; the feat(desktop): show the model's reported context window beside the declared setting #4575 "该模型声明的窗口为 N tokens · 填入" hint lives inside it), and 批量设置思考档位 sits in the section's action cluster and saves on tick. 高级请求设置 and 删除连接 follow as sections.Removed with it:
EnabledModelManager(MultiSelector), theDetailSectiongrid, the catalog category filter and its copy, and the capability copy the old layout needed. The hook gainsresetDraftProfileand letssaveRelayProfilestake an explicit table;SettingsExpandableRowgainsbeforeActionand aReactNodelabel.The renderer debt ratchet holds the four legacy files this touches (
providers-panel,provider-catalog-page,provider-add-form,provider-connection-detail) tomain's dependency and Hook counts, so the new pieces are shaped to fit it rather than grow it: the catalog groups areListheaders, the list's section header is written out in the panel, the recommended shortlist is a mode of the catalog component, the model-step filter lives in the step's state, and the capability editor takes its copy from its parent.Focus and announcements follow the same rule as the rest of the surface. The wizard's second step replaces the whole form while the route stays on
setup, so the page's level focus never re-runs; the step takes focus itself on mount, throughOnboardingStepForm, and lands on its labelled region. Each of the three search fields carries a live region reporting its match count.Story DOM contracts (
data-maka-contract,data-connection-slug,data-provider,data-card-id, row class names) and the copy the story play functions assert on are unchanged. A newEmptyProvidersstory covers the first-run shortlist.Verification
npm run format,npm run lint,check:architecture(ledger regenerated with--write, check passes againstorigin/main) — clean.tsc -p tsconfig.renderer.jsonandtsc -p tsconfig.storybook.jsoninapps/desktop— clean.Product/Settings/Providersstories rebuilt withbuild-storybookand checked in the browser: list, empty list (shortlist play passes), detail, relay detail (bulk menu play passes; 配置参数 opens the row editor), catalog, and the managed onboarding stories (验证并选择模型 → model step → 添加连接, focus lands on the step). Filter announcements checked live: empty with no filter, a count matching the rendered rows, and the no-match sentence.BEFORE is
main'sstorybook-static, AFTER is this branch's, same stories and viewport.AI use
Select exactly one:
Tool(s) and scope: Claude Code (Claude Fable 5.1) designed the layout against the Astryx CLI docs and templates, wrote the component changes, the copy, the story updates and the before/after captures; the author reviewed and directed the work.
Checklist
Does this PR entail a change in behavior?