From 6e8573f86d1b9d4934e9bd2a24d95556d205c43e Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 20 Aug 2026 12:54:59 +0000 Subject: [PATCH 1/4] feat(site): add provider icon to chat model selector Show the AI provider icon to the left of the selected model name in the Coder Agents chat composer, matching the workspace pill. Reuses the existing ProviderIcon mapping (configured icon URL wins, then the provider glyph, then a generic building icon). The img overrides undo Button's default-size rules so the icon renders at 12px like the workspace pill's icon. --- .../ChatElements/ModelSelector.stories.tsx | 60 +++++++++++++++++++ .../components/ChatElements/ModelSelector.tsx | 11 +++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx index 1a59413fb42..0b8742e8d77 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx @@ -92,6 +92,66 @@ export const WithSelectedValue: Story = { }, }; +// The trigger leads with the selected model's provider icon, which is +// presentational (alt="") and excluded from the accessible name. +export const SelectedValueShowsProviderIcon: Story = { + args: { + options: allModels, + value: "anthropic/claude-sonnet-4", + }, + play: async ({ canvasElement }) => { + const trigger = within(canvasElement).getByRole("combobox", { + name: "Claude Sonnet 4", + }); + const icon = trigger.querySelector("img"); + expect(icon).not.toBeNull(); + expect(icon).toHaveAttribute("src", "/icon/anthropic.svg"); + }, +}; + +// A configured provider icon URL wins over the built-in provider mapping. +export const SelectedValueShowsCustomProviderIcon: Story = { + args: { + options: [ + { + ...MockModelSelectorOption, + id: "anthropic-hyper/claude-opus-4", + provider: "anthropic", + providerId: "provider-anthropic-hyper", + providerLabel: "Hyper", + providerIcon: "/icon/coder.svg", + model: "claude-opus-4-20250514", + displayName: "Claude Opus 4", + }, + ], + value: "anthropic-hyper/claude-opus-4", + }, + play: async ({ canvasElement }) => { + const trigger = within(canvasElement).getByRole("combobox", { + name: "Claude Opus 4", + }); + expect(trigger.querySelector("img")).toHaveAttribute( + "src", + "/icon/coder.svg", + ); + }, +}; + +// No icon renders when no model is selected. +export const PlaceholderShowsNoIcon: Story = { + args: { + value: "", + }, + play: async ({ canvasElement }) => { + const trigger = within(canvasElement).getByRole("combobox", { + name: "Select model", + }); + expect(trigger.querySelector("img")).toBeNull(); + // Only the chevron svg renders; no Building2 fallback icon. + expect(trigger.querySelectorAll("svg")).toHaveLength(1); + }, +}; + export const CustomTriggerLabel: Story = { args: { options: openAIModels, diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx index 2633ce070b9..f5cd8e4d2c6 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx @@ -163,11 +163,20 @@ export const ModelSelector: FC = ({ type="button" variant="subtle" className={cn( - "h-7 md:h-auto min-w-0 shrink justify-start gap-0.5 rounded-full border-0 bg-surface-secondary px-1 py-0.5 text-xs font-medium shadow-none transition-colors hover:bg-surface-tertiary hover:text-content-primary focus:ring-0 focus-visible:ring-2 focus-visible:ring-content-link md:w-auto md:shrink-0 md:gap-1.5 [&>svg]:!size-3.5 [&>svg]:p-0 [&>svg]:shrink-0 [&>svg]:transition [&>svg]:hover:text-content-primary", + // The img overrides undo Button's default-size img rules + // (size-icon-lg, p-0.5) so the provider icon renders at 12px. + "h-7 md:h-auto min-w-0 shrink justify-start gap-0.5 rounded-full border-0 bg-surface-secondary px-1 py-0.5 text-xs font-medium shadow-none transition-colors hover:bg-surface-tertiary hover:text-content-primary focus:ring-0 focus-visible:ring-2 focus-visible:ring-content-link md:w-auto md:shrink-0 md:gap-1.5 [&>svg]:!size-3.5 [&>svg]:p-0 [&>svg]:shrink-0 [&>svg]:transition [&>svg]:hover:text-content-primary [&>img]:!size-3 [&>img]:!p-0", className, )} onTouchStart={onTriggerTouchStart} > + {selectedModel && ( + + )} {triggerLabel} From 2662d94ee6202d87fbcca476de25c6a94d5aa906 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 20 Aug 2026 13:05:46 +0000 Subject: [PATCH 2/4] fix(site): align model selector pill padding with workspace and MCP pills The model selector trigger used px-1 + gap-1.5 while the workspace and MCP pills use px-2 + gap-1, making the provider icon's insets uneven with its neighbors. Match them. --- .../AgentsPage/components/ChatElements/ModelSelector.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx index f5cd8e4d2c6..fb86a86668f 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx @@ -163,9 +163,10 @@ export const ModelSelector: FC = ({ type="button" variant="subtle" className={cn( - // The img overrides undo Button's default-size img rules + // px-2 + gap-1 match the workspace and MCP pills. The img + // overrides undo Button's default-size img rules // (size-icon-lg, p-0.5) so the provider icon renders at 12px. - "h-7 md:h-auto min-w-0 shrink justify-start gap-0.5 rounded-full border-0 bg-surface-secondary px-1 py-0.5 text-xs font-medium shadow-none transition-colors hover:bg-surface-tertiary hover:text-content-primary focus:ring-0 focus-visible:ring-2 focus-visible:ring-content-link md:w-auto md:shrink-0 md:gap-1.5 [&>svg]:!size-3.5 [&>svg]:p-0 [&>svg]:shrink-0 [&>svg]:transition [&>svg]:hover:text-content-primary [&>img]:!size-3 [&>img]:!p-0", + "h-7 md:h-auto min-w-0 shrink justify-start gap-1 rounded-full border-0 bg-surface-secondary px-2 py-0.5 text-xs font-medium shadow-none transition-colors hover:bg-surface-tertiary hover:text-content-primary focus:ring-0 focus-visible:ring-2 focus-visible:ring-content-link md:w-auto md:shrink-0 [&>svg]:!size-3.5 [&>svg]:p-0 [&>svg]:shrink-0 [&>svg]:transition [&>svg]:hover:text-content-primary [&>img]:!size-3 [&>img]:!p-0", className, )} onTouchStart={onTriggerTouchStart} From afffed7790c424c85f3cd31b5f2e16a52e5de60c Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 20 Aug 2026 14:09:29 +0100 Subject: [PATCH 3/4] Update site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx --- .../pages/AgentsPage/components/ChatElements/ModelSelector.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx index fb86a86668f..dc0cc440fb4 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx @@ -163,9 +163,6 @@ export const ModelSelector: FC = ({ type="button" variant="subtle" className={cn( - // px-2 + gap-1 match the workspace and MCP pills. The img - // overrides undo Button's default-size img rules - // (size-icon-lg, p-0.5) so the provider icon renders at 12px. "h-7 md:h-auto min-w-0 shrink justify-start gap-1 rounded-full border-0 bg-surface-secondary px-2 py-0.5 text-xs font-medium shadow-none transition-colors hover:bg-surface-tertiary hover:text-content-primary focus:ring-0 focus-visible:ring-2 focus-visible:ring-content-link md:w-auto md:shrink-0 [&>svg]:!size-3.5 [&>svg]:p-0 [&>svg]:shrink-0 [&>svg]:transition [&>svg]:hover:text-content-primary [&>img]:!size-3 [&>img]:!p-0", className, )} From d66dd07c25c2376813260b52c1561c9bcc30d461 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 20 Aug 2026 13:24:03 +0000 Subject: [PATCH 4/4] test(site): use testid for provider icon stories and drop restating comments Address Codex review: query the presentational provider icon by a data-testid (the FE10-sanctioned hook for non-semantic elements) instead of tag selectors, and remove comments that restate the story assertions (FE4). --- .../ChatElements/ModelSelector.stories.tsx | 43 +++++++++---------- .../components/ChatElements/ModelSelector.tsx | 12 +++--- 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx index 0b8742e8d77..a67b5737a47 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx @@ -92,24 +92,22 @@ export const WithSelectedValue: Story = { }, }; -// The trigger leads with the selected model's provider icon, which is -// presentational (alt="") and excluded from the accessible name. export const SelectedValueShowsProviderIcon: Story = { args: { options: allModels, value: "anthropic/claude-sonnet-4", }, play: async ({ canvasElement }) => { - const trigger = within(canvasElement).getByRole("combobox", { - name: "Claude Sonnet 4", - }); - const icon = trigger.querySelector("img"); - expect(icon).not.toBeNull(); - expect(icon).toHaveAttribute("src", "/icon/anthropic.svg"); + const canvas = within(canvasElement); + expect( + canvas.getByRole("combobox", { name: "Claude Sonnet 4" }), + ).toBeInTheDocument(); + expect( + canvas.getByTestId("model-selector-trigger-icon"), + ).toBeInTheDocument(); }, }; -// A configured provider icon URL wins over the built-in provider mapping. export const SelectedValueShowsCustomProviderIcon: Story = { args: { options: [ @@ -127,28 +125,27 @@ export const SelectedValueShowsCustomProviderIcon: Story = { value: "anthropic-hyper/claude-opus-4", }, play: async ({ canvasElement }) => { - const trigger = within(canvasElement).getByRole("combobox", { - name: "Claude Opus 4", - }); - expect(trigger.querySelector("img")).toHaveAttribute( - "src", - "/icon/coder.svg", - ); + const canvas = within(canvasElement); + expect( + canvas.getByRole("combobox", { name: "Claude Opus 4" }), + ).toBeInTheDocument(); + const icon = canvas.getByTestId("model-selector-trigger-icon"); + expect(icon.querySelector("img")).toHaveAttribute("src", "/icon/coder.svg"); }, }; -// No icon renders when no model is selected. export const PlaceholderShowsNoIcon: Story = { args: { value: "", }, play: async ({ canvasElement }) => { - const trigger = within(canvasElement).getByRole("combobox", { - name: "Select model", - }); - expect(trigger.querySelector("img")).toBeNull(); - // Only the chevron svg renders; no Building2 fallback icon. - expect(trigger.querySelectorAll("svg")).toHaveLength(1); + const canvas = within(canvasElement); + expect( + canvas.getByRole("combobox", { name: "Select model" }), + ).toBeInTheDocument(); + expect( + canvas.queryByTestId("model-selector-trigger-icon"), + ).not.toBeInTheDocument(); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx index dc0cc440fb4..450e0ce5775 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx @@ -169,11 +169,13 @@ export const ModelSelector: FC = ({ onTouchStart={onTriggerTouchStart} > {selectedModel && ( - + + + )} {triggerLabel}