fix(site): align AI Settings model provider dropdowns - #27391
Conversation
Make the Add model and All providers dropdowns render provider options identically: provider icon beside the label, sorted alphabetically by display label via deriveProviderStates. Hide the decorative provider icons from the accessibility tree so each option's accessible name is just its label instead of the label repeated (was "Anthropic Anthropic").
|
/coder-agents-review |
|
Chat: Review posted | View chat Review historydeep-review v0.9.0 | Round 4 | Last posted: Round 4, 22 findings (4 P3, 4 P4, 5 Nit, 9 Note), COMMENT. Review Finding inventoryFinding inventory: PR #27391Findings
Contested and acknowledgedCRF-1 (Note, AddModelPage.tsx:52) - Alphabetical sort changes Add-model default pre-selection
CRF-4 (Note, providerStates.ts:193) - localeCompare omits explicit locale
CRF-8 (P3, ModelsPageView.tsx:225) - "All providers" row alignment
CRF-13 (Note, ModelForm.stories.tsx:144) - dropped render-level order assertion
CRF-16 (Note, ModelsPageView.stories.tsx:72) - dropped icon-render assertion
CRF-17 (P3, ChatModelAdminPanel/ProviderIcon.tsx:35) - provider dropped from compaction badge accessible name
Round logRound 4Churn guard: PROCEED (3 addressed CRF-14/15/18, 2 acknowledged CRF-16/CRF-17, 0 silent). Author pushed 3951dfc: dropped the redundant Netero advisory (no findings) + 14 panel (bisky, ging-ts, ging-react, gon, hisoka, komugi, leorio, mafu-san, mafuuu, nami, pariston, meruem, zoro + kite wildcard). Panel verified CRF-14/15/18 complete; the consolidation is a strict superset (no provider regresses, admin gains copilot/vercel), confirmed by 6+ reviewers. Dispositions: CRF-16 closed-accept (Lost Pixel job covers the list-row icon). CRF-17 RE-RAISED to P3 (Pariston; premise false, provider info removed vs base; panel split 6 accept vs P3). Needs a human decision, not a settled agent-accept. New findings (2 Nit, 2 Note): CRF-19 Nit (redundant alt="" comment, Gon P2 downgraded), CRF-20 Nit (admin export missing the CRF-15 doc comment; Gon/Leorio/Mafuuu), CRF-21 Note (dropdown icon-render unguarded; Mafu-san/Bisky), CRF-22 Note (consolidation left divergent fallback glyphs; openrouter/openai-compat unmapped; Meruem). Body notes: recurring over-length commit subjects (Leorio), getProviderIcon normalization asymmetry (Pariston, latent), unrelated failing chat stories in the worktree (Nami). Highest severity P3, event=COMMENT. Round 3Churn guard: PROCEED (4 addressed CRF-9/10/11/12, 2 contested CRF-8/CRF-13, 0 silent). Author pushed e9f36b4 implementing the CRF-11 structural fix: ProviderIcon (ProvidersPage variant) decorative-by-default ( Netero advisory (no findings) + 13 panel (bisky, ging-ts, ging-react, gon, hisoka, komugi, leorio, mafu-san, mafuuu, nami, pariston, meruem + zoro wildcard). Whole panel independently traced both ProviderIcon variants' callers and confirmed none renders the icon as its sole accessible content; source fix verified complete (getProviderName gone, no dangling refs, tests bite at the two previously-buggy sites). CRF-9/10/11/12 confirmed resolved at source. Contested closed: CRF-8 accepted unanimously (design judgment, incl. original finder Hisoka); CRF-13 accepted with a record correction. New (1 P4, 1 Nit, 3 Notes): CRF-14 Nit (variants diverge), CRF-15 Note (decorative contract belongs in a doc comment on the export; no opt-in/guard), CRF-16 Note (dropped getByAltText not replaced), CRF-17 Note (admin-panel alt="" drops provider from compaction badge accessible name), CRF-18 P4 (pre-existing drifted duplicate icon maps). Body notes: commit subject 84>72 (Leorio), invariant untested at owner story (Meruem), no end-to-end sort-to-render story (Bisky). Highest severity P4, event=COMMENT. Round 2Churn guard: PROCEED (5 addressed, 1 acknowledged CRF-1, 1 contested CRF-4, 0 silent). Author pushed 4bb418c, diff grew to +72/-34 across 10 files: R1 fixes plus new a11y wraps in ProvidersPageView, ProviderRow, ModelSelector, UserCompactionThresholdSettings, and new exact accessible-name assertions in ModelsPageView/ModelForm stories. Panel re-reviews the delta. Workspace outage mid-round hit ~8 of 13 reviewers; all were resumed in place after recovery and completed. Netero advisory + 12 panel (bisky, ging-ts, ging-react, gon, hisoka, komugi, leorio, mafu-san, mafuuu, nami, pariston + meruem wildcard). Prior findings verified: CRF-2 fixed (Bisky mutation-tested both sites, guards bite), CRF-3/CRF-5 fixed, CRF-6/CRF-7 wraps applied (ProviderRow name preserved via AvatarData title, so no regression). CRF-4 contested -> panel accepts (cosmetic) with corrected record. CRF-1 acknowledged, no ticket (human call). New findings: CRF-8 P3 (All-providers row misalignment, verified against SelectItem CSS), CRF-9 P4 (4 new wraps untested; Mafu-san P3 vs Bisky/Netero/Leorio Note, settled P4 tracking the P4/Note site severity), CRF-10 P4 (ModelRow missed sibling on in-scope page, verified; Mafu-san P3 vs 3 Notes, settled P4), CRF-11 P3 (decorative-by-default ProviderIcon structural alternative, preserved), CRF-12 Nit (UserCompaction wrapper missing flex class), CRF-13 Note (dropped order assertion). Stale PR description ("icons unchanged elsewhere"; "ProviderRow relies on name") raised as a body process note (Meruem, Pariston, Netero). Highest severity P3, event=COMMENT. Round 1Netero first pass: no P0-P3, 2 Notes (CRF-1, CRF-2). Clean mechanical floor. Panel of 12 (bisky, ging-ts, ging-react, gon, hisoka, komugi, leorio, mafu-san, mafuuu, nami, pariston + knov wildcard). About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
This is a tight, well-scoped a11y and ordering fix. The double-announcement diagnosis is correct and verified by multiple reviewers: ProviderIcon renders an <img alt={name}>, so beside a text label Radix folded the provider name into each option's accessible name ("Anthropic Anthropic"), and wrapping the icon in aria-hidden is the right, minimal fix applied consistently to all three call sites. The rewritten unit test genuinely exercises the sort (its inputs are deliberately out of order), and the commit message and PR description are exemplary, quoting the actual symptom so the next reader knows what broke.
Severity count: 1 P3, 1 P4, 1 Nit, 4 Notes. No P0-P1, so this is a comment-level review.
Worth addressing before merge (P3): the only accessible-name assertion guards the filter Select, which is the site that never had the double-announcement bug. The two sites that did have it (the Add-model DropdownMenuItem and ModelFormProviderSelect) are fixed but untested, so a future removal of aria-hidden there regresses silently.
Process observations:
- The panel split on the two added comments. Gon flagged them as restating the code (originally P2); Leorio, reviewing the same lines, called them exemplary and said "keep writing them like this." Both carry a genuine why, so this sits at Nit, surfaced for your call.
- Sibling instances of the same double-announce class exist outside this PR's scope: the "Add provider" DropdownMenuItem on the Providers page is the identical bug in an identical widget (CRF-6), plus lower-impact spots (CRF-7). Whether the class fix stops at the Models-page boundary is a human decision.
Hisoka, on trying to break it: "I came to fight this diff. It held up. Silence is a rating."
site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPage.tsx:52
Note [CRF-1] Alphabetical sort also changes the Add-model default provider pre-selection. (Netero, Pariston)
selectedProviderState falls back to providerStates.find(canManageProviderModels) when no provider query param is set. Because deriveProviderStates now returns alphabetical-by-label order, the pre-selected provider changes from the first configured provider to the alphabetically-first manageable one. Pariston named the root cause: the sort lives in the shared data module (#/modules/aiModels/), not the view, so a presentational change leaks into selection logic. UpdateModelPage selects by model id and is unaffected. Alphabetical is at least as predictable as the old slug order, so this is a Note; if you want ordering to stay purely presentational, sort at the render site and pick the Add-model default explicitly.
🤖
site/src/modules/aiModels/providerStates.ts:87
Note [CRF-5] deriveProviderStates gained a load-bearing ordering contract with no doc comment. (Leorio)
The return order is now a real contract (alphabetical by label), and a caller already depends on it (CRF-1's .find(canManageProviderModels) default). The exported signature says only readonly ProviderState[] and the function has no doc comment. Leorio suggests one line above the declaration so callers reading the signature know the order is defined.
🤖
site/src/pages/AISettingsPage/ProvidersPage/ProvidersPageView.tsx:65
P4 [CRF-6] Same double-announce bug in the "Add provider" DropdownMenuItem, one page over. (Mafuuu)
The Providers page renders <ProviderIcon provider={entry.value} /><span>{entry.label}</span> in a DropdownMenuItem with no aria-hidden, the exact structure and bug this PR fixes in the Models page (confirmed in the current file). Same class, same widget type, adjacent page; the fix is the same one-line wrap. Out of this PR's stated scope, so this needs a human decision: fix it here, or file an issue so the class fix does not stop at the page boundary.
🤖
site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx:254
Note [CRF-7] Lower-impact siblings of the double-announce class. (Mafuuu, Pariston)
The same ProviderIcon + text-label pattern recurs in ModelSelector.tsx's CommandGroup heading, UserCompactionThresholdSettings.tsx:286 (icon in a badge beside the model name), and ProviderRow. Both reviewers rate these lower impact than dropdown options (a heading and a table cell are not single named widgets), and AIBridge's ProviderFilter is unaffected (no alt). Worth knowing when scoping a follow-up so the decorative-icon pattern gets one shared treatment rather than per-site patches.
🤖
🤖 This review was automatically generated with Coder Agents.
…ccessible names Address review feedback on the provider dropdown alignment: - Wrap provider icons in aria-hidden at the remaining sibling call sites (Add provider menu, model selector group heading, compaction badge, provider row avatar) so adjacent labels are not announced twice. - Add accessible-name assertions for the Add model menu and the model form provider select, the two sites that had the double-announcement. - Document the alphabetical ordering contract on deriveProviderStates.
|
Addressing the remaining body-only items (all in 4bb418c unless noted): CRF-1 (default pre-selection): Not changing. The alphabetical default is at least as predictable as the prior slug order, and keeping the sort in the shared module is what makes the three dropdowns consistent. CRF-5 (ordering contract): Added a one-line doc comment above CRF-6 (Add provider menu): Fixed. Wrapped the CRF-7 (lower-impact siblings): Verified all three were genuine (icon name redundantly announced beside a visible label) and fixed them: 61 story tests pass across the six affected story files; |
|
/coder-agents-review |
There was a problem hiding this comment.
Round 2. Strong follow-through: all seven round-1 findings were addressed or consciously accepted, and the two fixes that mattered most were verified by experiment, not taken on faith. Bisky mutation-tested the CRF-2 guards (removing an aria-hidden wrap flips the option name to "OpenAI OpenAI"/"Anthropic Anthropic" and fails the story), and the panel confirmed the CRF-6/CRF-7 wraps preserve each element's accessible name (ProviderRow now takes its name from AvatarData title, not the icon). CRF-3 and CRF-5 are cleanly resolved.
CRF-4 (contested): the panel accepts leaving bare localeCompare, the impact is cosmetic and bare is the house convention. One correction for the record though: the "all provider labels are ASCII" premise does not fully hold. label resolves to a user-settable display_name, so an operator can enter non-ASCII names today. It stays cosmetic (client SPA, deterministic per runtime), so no change is needed, but the reasoning behind the decision was slightly off.
The round-2 diff generalized the a11y fix well beyond the reported scope, and that generalization surfaced new findings. Severity count for this round: 2 P3, 2 P4, 1 Nit, 1 Note.
The theme across the new findings is class-not-instance. The per-site aria-hidden approach already leaked: it wrapped four off-page siblings but missed ModelRow (CRF-10) on the very Models page this PR is named after, and four of the new wraps shipped with no regression test (CRF-9), the same untested-fix shape CRF-2 flagged. Meruem and Pariston both point at the root cause (CRF-11): ProviderIcon exposes its name by default, so correctness depends on every caller remembering the wrapper. Making it decorative-by-default (the pattern Avatar already uses with alt="") would delete all seven wrappers, make the ModelRow miss impossible, and stop the class from recurring. Worth considering as a follow-up shape rather than growing this PR.
One process note: the PR description is now stale relative to the delivered diff. It says the icons are "unchanged elsewhere" and that ProviderRow "relies on" the icon's name, both contradicted by this round's changes to ProviderRow, ModelSelector, and UserCompactionThresholdSettings (raised by Meruem, Pariston, and Netero). Worth a quick update so a future reader isn't misled about scope. CRF-1 remains accepted without a ticket; recording that the alphabetical-default tradeoff is a human's call to make explicit.
Hisoka, after trying to break it: "I came to fight the diff. It mostly held."
site/src/pages/AISettingsPage/ModelsPage/components/ModelRow.tsx:41
P4 [CRF-10] ModelRow's ProviderIcon is the one sibling of the class left unwrapped, on the page this PR is named after. (Mafu-san P3, Nami Note, Pariston Note, Meruem Note)
Confirmed in the current file: ModelRow renders <Avatar><ProviderIcon .../></Avatar> beside the model display name with no aria-hidden, and the provider already has its own column (providerLabel), so a screen reader announces the provider name redundantly on every row. It uses the same name-exposing ProviderIcon as ProviderRow, which the author wrapped this round. CRF-7 enumerated three siblings and the author fixed those three; this is the closest analog to ProviderRow and it was never in the list. Instance impact is minor verbosity (not the literal double-announce), so P4, but it is the missed in-scope member of the exact class this PR set out to close, and there is no follow-up round. Fix: wrap the icon in an aria-hidden span, matching ProviderRow.
🤖
🤖 This review was automatically generated with Coder Agents.
ProviderIcon exposed its accessible name by default, so suppressing the double-announcement depended on every caller wrapping it in aria-hidden. Render the icon decorative (alt="", aria-hidden) since every caller shows the provider name as adjacent visible text, closing the double-announcement class at the source. Apply the same treatment to the single-caller ChatModelAdminPanel ProviderIcon and drop the now-dead per-call-site wrappers.
|
Round-2 wrap-up (commit e9f36b4): CRF-11 done (structural). CRF-8, CRF-13: consciously not fixed (replies in their threads). The "All providers" row intentionally reads as a distinct clear-filter action, and rendered option order is already pinned by the PR description updated to reflect the decorative-by-default source fix; the earlier "icons unchanged elsewhere"/"ProviderRow relies on the name" wording was stale. 74 story tests pass across the eight affected story files (including the round-1 accessible-name guards); |
|
/coder-agents-review |
There was a problem hiding this comment.
Round 3. This is the right fix, done at the right layer. The author took the CRF-11 structural route instead of another per-site patch: ProviderIcon is now decorative-by-default (alt=""/aria-hidden, lucide fallback already hidden), the getProviderName lookup table is deleted, all seven per-site wrappers are gone, and the admin-panel variant got the same treatment. The whole panel independently traced every caller of both ProviderIcon components and confirmed the one risk this change introduces (an icon left as its sole accessible content, now nameless) does not land anywhere: every caller renders the provider or model name as adjacent visible text, in its own column, or via AvatarData title. getProviderName is deleted with no dangling references, and the two exact-name story guards still bite at the sites that actually had the double-announce. CRF-9, CRF-10, and CRF-12 are resolved as consequences.
Both contested findings are accepted after panel review. CRF-8 (the ragged "All providers" row): accepted unanimously, including by Hisoka who raised it. The offset is cosmetic on a non-selectable reset row, and marking a clear-filter action as visually distinct from the options is a defensible choice; one aria-hidden spacer would align it if design ever wants that. CRF-13 (dropped render-level order assertion): accepted, with one correction to the record. The transitive coverage pins the alphabetical sort only at the data layer; the sort-to-render wiring is untested, and the removed assertion actually pinned args-passthrough order, not the alphabetical property. The residual gap is latent and low-signal, so no change is needed, but "duplicate coverage" undersells what a render assertion would catch.
New findings this round are all minor: 1 P4, 1 Nit, 3 Notes, none blocking. The through-line worth a look: the decorative default was applied to both ProviderIcon variants under one rationale ("callers render the name beside the icon") that fits the ProvidersPage variant cleanly but not the admin-panel one, whose single caller (the compaction badge) shows only the model name, so alt="" there drops the provider from the accessible name rather than de-duplicating it (CRF-17). It is acceptable (model names self-identify), but it is a real behavior change under a mismatched rationale. Relatedly, the new global contract ("the icon is nameless; callers must supply the name") rests on a comment inside the function body with no opt-in prop or mechanical guard (CRF-15), and the two variants now diverge on how they express the decorative intent (CRF-14). Touching both variants also surfaced a pre-existing bug: their icon-source maps have already drifted, so the same provider shows a real logo on one surface and a fallback glyph on the other (CRF-18, P4, human decision on whether to consolidate now).
Process notes: the intermediate commit subject is 84 chars (over 72), moot if this squash-merges; the decorative invariant is guarded only transitively by two consumer stories rather than at ProviderIcon's own story; and no story asserts the full deriveProviderStates-to-render order end to end. All optional.
Meruem, on the fix: "This is the class fix the panel recommended, not an instance patch."
🤖 This review was automatically generated with Coder Agents.
…contract - Drop the redundant aria-hidden from the shared ProviderIcon (alt="" already removes the image from the accessibility tree), matching the admin-panel variant. - Lift the decorative contract to a doc comment on the exported symbol. - Consolidate the two drifted icon-source maps: the admin-panel ProviderIcon now shares getProviderIcon, which gains gemini so both surfaces resolve the same providers.
|
Round-3 follow-up (commit 3951dfc): Fixed: CRF-14 (dropped redundant Consciously not fixed: CRF-16 (icon rendering is covered by visual regression testing) and CRF-17 (compaction badge stays decorative; acknowledged as an intentional tradeoff, not the double-announce rationale). All five threads have in-place replies. Story tests pass; |
|
/coder-agents-review |
There was a problem hiding this comment.
Round 4. The three actionable round-3 items are cleanly addressed: the redundant aria-hidden is gone so both variants express "decorative" the same way (CRF-14), the decorative contract now lives in a doc comment on the export (CRF-15), and the two provider-icon maps are consolidated into the shared getProviderIcon (CRF-18). The panel verified the consolidation independently and it is a strict superset of the deleted admin map, so no provider regresses to the fallback and the admin panel actually gains real copilot/vercel logos. The CRF-16 decline also checks out: the Lost Pixel CI job snapshots the Models Default story, so a broken list-row icon surfaces as a pixel diff.
Severity this round: 1 P3 (a re-raise), 2 Nits, 2 Notes. No P0-P1.
The one item that needs a human decision is CRF-17, which I'm re-raising to P3. The badge stays decorative on the acknowledgment that "the model name self-identifies the provider (claude-/gpt-)", but Pariston showed that premise does not hold: an Azure model reads as "gpt-4" and a Bedrock model as "anthropic.claude-..." in the accessible name (actively misleading about the provider), display_name is a free-form admin field, and two configs with the same display_name on different providers become two badges a screen-reader user cannot tell apart, which matters because the thresholds are set per row. Relative to the PR base, the admin badge previously announced the provider (alt=" logo") and this PR removes it. Six reviewers still rate it acceptable as a low-stakes, self-configured settings table with the provider visible to sighted users, so it is survivable, hence P3 not higher. But since an agent cannot accept a known a11y gap as permanent, this is a human call: keep it decorative and accept the gap on the correct low-stakes reasoning, or restore the provider (an aria-label carrying the provider, or adjacent visible provider text as the shared ProviderIcon doc comment already asks of callers).
The rest are minor and optional: two Nits (a redundant one-line comment, and the admin ProviderIcon export missing the same decorative doc comment its sibling just gained) and two Notes (the dropdown icons the PR headlines are not directly guarded, since the name assertions are icon-agnostic and Lost Pixel snapshots the closed dropdown; and the consolidation left the unmapped-provider fallback glyph divergent, so openrouter/openai-compat still render a building glyph on one surface and a server glyph on the other).
Process notes: the per-commit subjects keep running over 72 chars (84 in R2, 77 here), moot if this squash-merges; getProviderIcon is now called both with and without normalizeProvider, latent today since provider values are lowercase slugs; and three chat stories (AgentChatPage, AgentChatPageView, Tool) fail in the review worktree but are in edit/tracing paths this PR does not touch, worth a quick confirm they are unrelated.
Pariston, re-raising CRF-17: "the acknowledgment was justified by a premise the code does not support."
🤖 This review was automatically generated with Coder Agents.
The badge icon is decorative, which dropped the provider from the badge's accessible name. The model name does not reliably identify the configured provider (Azure gpt-4, Bedrock anthropic.claude-*, and duplicate display_name collisions), so give the badge an aria-label carrying provider + model. Also add the decorative doc comment to the admin-panel ProviderIcon and drop a redundant comment in the shared one.
|
Round-4 follow-up (commit 4e0b930): CRF-17 — fixed (accessible name restored). You were right to re-raise this; my earlier "model name self-identifies the provider" premise does not hold. Nits — fixed. Added the decorative doc comment to the admin-panel Notes — not changed. Dropdown icons remain guarded by the accessible-name assertions plus Lost Pixel snapshots (per CRF-16), and the unmapped-provider fallback glyph still diverges (building vs server for openrouter/openai-compat), cosmetic and out of scope here. Compaction and icon story tests pass; |
Use Building2Icon for unmapped providers in the admin-panel ProviderIcon to match the shared component, closing the residual cross-surface inconsistency left after consolidating the icon-source maps.
| {iconPath ? ( | ||
| <ExternalImage src={iconPath} alt="" className="size-3/5" /> | ||
| ) : ( | ||
| <Building2Icon className="size-3/5 text-content-secondary" /> |
There was a problem hiding this comment.
So if there is no provider icon, what are we showing? Do we have a test for this?
There was a problem hiding this comment.
Just for context: This appears to only be used in the Coder Agents model compaction personal settings page.
If there is no ProviderIcon we're showing a building icon. Same fallback as used in the AISettingsPage.
When we eventually get round to redesigning the compaction page (it does not look good) we'll likely bin this entire component.
As for test coverage on this, it appears we do not have any coverage but I'm not entirely convinced we need it here.
"Add model" dropdown before vs after
Provider filter dropdown before vs after
The provider dropdowns on the AI Settings Models page rendered options inconsistently and in an unclear order. The "Add model" and "All providers" selects now render provider options identically: the provider icon beside its label, sorted alphabetically by display label.
deriveProviderStatespreviously returned providers in an emergent order (configured providers by internal slug, then catalog providers, then orphans), which read as unordered. It now sorts by display label, so every consumer (Models list filter, Add model, Update model) is alphabetical. The "All providers" filter select also gained the same icon + label markup the Add model select already used.Provider icons are now decorative-by-default. Radix computes each option's accessible name from the item text content, which previously included the icon's
alt/aria-label and announced the provider name twice (e.g. "Anthropic Anthropic").ProviderIconexposed its accessible name by default, so correctness depended on every caller remembering to suppress it. The icon now rendersalt=""/aria-hidden(lucide's fallback already does), since every caller renders the provider name as adjacent visible text. This closes the double-announcement class at the source across the Models page, Providers page, model selector, and compaction badge, rather than at each call site. The separateChatModelAdminPanel/ProviderIcon(single decorative caller) got the same treatment.Implementation notes
site/src/modules/aiModels/providerStates.ts: sort derived states bylabel; documented the ordering contract.site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.tsx: add icon + label markup to the filter select so it matches the Add model select.site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.tsx: make the icon decorative-by-default (alt=""/aria-hidden); removed the now-deadgetProviderNamehelper. Removed the redundant per-call-sitearia-hiddenwrappers this replaces.site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderIcon.tsx: same decorative treatment (alt="").getByAltTextassertion that depended on the old icon alt.