feat: show CLI provider icons in composer model picker (#3760)

This commit is contained in:
José Almeida
2026-09-17 15:04:54 +02:00
committed by GitHub
parent 79894dc506
commit e8e8bcff64
15 changed files with 595 additions and 5 deletions
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect, useRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { IconCheck, IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
@@ -200,6 +200,7 @@ function ConfigOptionSubSelector({
}
export type ModelConfigSelectorContentProps = {
providerIcon?: ReactNode;
activeConfig: SelectConfigOption | undefined;
modelOptions: ModelSelectorOption[];
currentModelValue: string;
@@ -211,7 +212,19 @@ export type ModelConfigSelectorContentProps = {
configOptionsLoading: boolean;
};
/** Adds provider branding while preserving the model group label. */
function ModelGroupHeading({ providerIcon }: { providerIcon?: ReactNode }) {
const { t } = useTranslation();
return (
<span className="flex items-center gap-1.5">
{providerIcon}
{t("agents:modelHeading")}
</span>
);
}
export function ModelConfigSelectorContent({
providerIcon,
activeConfig,
modelOptions,
currentModelValue,
@@ -259,7 +272,7 @@ export function ModelConfigSelectorContent({
{showModelFilter && <CommandInput placeholder={t("agents:filterModels")} className="h-8" />}
<CommandList className="max-h-60">
<CommandEmpty>{t("agents:noModelsFound")}</CommandEmpty>
<CommandGroup heading={t("agents:modelHeading")}>
<CommandGroup heading={<ModelGroupHeading providerIcon={providerIcon} />}>
{orderedModelOptions.map((model) => (
<ModelRow
key={model.id}
@@ -28,6 +28,27 @@ const makeModelOptions = (count: number) =>
}));
describe("ModelConfigSelector", () => {
it("shows the provider icon in the trigger and open model heading", () => {
render(
<ModelConfigSelector
modelOptions={[{ id: "sonnet", name: "Sonnet" }]}
currentModel="sonnet"
onModelChange={() => {}}
providerIcon={<svg data-testid="provider-glyph" />}
/>,
);
const trigger = screen.getByRole("button", { name: modelSettingsButtonName });
expect(within(trigger).getByTestId("provider-glyph")).toBeTruthy();
fireEvent.click(trigger);
const group = screen.getByRole("group", { name: "Model" });
const labelId = group.getAttribute("aria-labelledby");
expect(labelId).not.toBeNull();
const heading = document.getElementById(labelId!);
expect(heading).not.toBeNull();
expect(within(heading!).getByTestId("provider-glyph")).toBeTruthy();
expect(screen.getByRole("option", { name: "Sonnet" })).toBeTruthy();
});
it("passes custom trigger classes to the button", () => {
render(
<ModelConfigSelector
+14 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { memo, useState } from "react";
import { memo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { IconChevronDown } from "@tabler/icons-react";
@@ -194,6 +194,7 @@ export type ModelConfigSelectorProps = {
popoverAlign?: "start" | "center" | "end";
popoverSide?: "top" | "bottom";
triggerClassName?: string;
providerIcon?: ReactNode;
triggerSummary?: "all" | "changed";
configBaseline?: Record<string, string>;
/** Optional suffix appended to the trigger's model label (e.g. "(fallback)"). */
@@ -210,7 +211,13 @@ export type ModelConfigSelectorProps = {
type ModelConfigSelectorTriggerProps = Pick<
ModelConfigSelectorProps,
"ariaLabel" | "disabled" | "placeholder" | "triggerClassName" | "triggerTitle" | "variant"
| "ariaLabel"
| "disabled"
| "placeholder"
| "triggerClassName"
| "triggerTitle"
| "variant"
| "providerIcon"
> & {
label: string;
details?: TriggerDetail[];
@@ -224,6 +231,7 @@ function ModelConfigSelectorTrigger({
placeholder,
triggerClassName,
triggerTitle,
providerIcon,
variant,
}: ModelConfigSelectorTriggerProps) {
const compact = variant === "compact";
@@ -241,6 +249,7 @@ function ModelConfigSelectorTrigger({
disabled={disabled}
title={triggerTitle}
>
{providerIcon}
<span className="truncate">{label || placeholder}</span>
<IconChevronDown className="h-3.5 w-3.5 shrink-0 opacity-70" />
</Button>
@@ -286,6 +295,7 @@ export const ModelConfigSelector = memo(function ModelConfigSelector({
popoverAlign = "end",
popoverSide = "bottom",
triggerClassName: customTriggerClassName,
providerIcon,
triggerSummary = "all",
configBaseline,
currentModelSuffix,
@@ -341,6 +351,7 @@ export const ModelConfigSelector = memo(function ModelConfigSelector({
triggerClassName={customTriggerClassName}
triggerTitle={triggerTitle}
variant={variant}
providerIcon={providerIcon}
/>
<PopoverContent
align={popoverAlign}
@@ -348,6 +359,7 @@ export const ModelConfigSelector = memo(function ModelConfigSelector({
className="w-[min(24rem,calc(100vw-1rem))] max-h-[min(32rem,calc(100vh-1rem))] gap-2 overflow-hidden p-2"
>
<ModelConfigSelectorContent
providerIcon={providerIcon}
activeConfig={activeConfig}
modelOptions={modelOptions}
currentModelValue={currentModelValue}
@@ -119,7 +119,7 @@ function buildCollapsibleItems(props: DesktopToolbarProps): ToolbarItemConfig[]
{
id: "model",
section: "right",
render: () => <ModelSelector sessionId={props.sessionId} />,
render: () => <ModelSelector sessionId={props.sessionId} showAgentIcon />,
},
{
id: "enhance",
@@ -108,6 +108,7 @@ function MobileDefaultLeftActions(props: MobileToolbarProps) {
</div>
<div data-testid="toolbar-item-model">
<ModelSelector
showAgentIcon
sessionId={props.sessionId}
triggerClassName="max-w-[56vw] min-w-0 overflow-hidden"
/>
@@ -0,0 +1,80 @@
import type { TaskSession } from "@/lib/types/http";
import { describe, expect, it } from "vitest";
import { resolveModelSelectorAgentName } from "./model-selector-provider";
describe("composer provider identity", () => {
const profiles = [{ id: "profile-1", agent_id: "agent-uuid", agent_name: "codex" }];
it("uses session snapshot identity ahead of an edited profile", () => {
expect(
resolveModelSelectorAgentName(
{
agent_profile_id: "profile-1" as TaskSession["agent_profile_id"],
agent_profile_snapshot: { agent_name: "claude" },
},
profiles,
),
).toBe("claude");
});
it.each([undefined, null, "", " ", 42])(
"falls back to the matching profile for snapshot %s",
(agent_name) => {
expect(
resolveModelSelectorAgentName(
{
agent_profile_id: "profile-1" as TaskSession["agent_profile_id"],
agent_profile_snapshot: { agent_name },
},
profiles,
),
).toBe("codex");
},
);
it("resolves a dynamic session's concrete agent ID through the profile catalog", () => {
expect(
resolveModelSelectorAgentName(
{
agent_profile_id: "dynamic-profile" as TaskSession["agent_profile_id"],
agent_profile_snapshot: { id: "profile-1", agent_id: "agent-uuid" },
},
[...profiles, { id: "dynamic-profile", agent_id: "dynamic", agent_name: "dynamic" }],
),
).toBe("codex");
});
it("does not infer a CLI from the model or agent UUID", () => {
expect(
resolveModelSelectorAgentName(
{
agent_profile_id: "deleted" as TaskSession["agent_profile_id"],
agent_profile_snapshot: { model: "gpt-5", agent_id: "unknown-agent-uuid" },
},
profiles,
),
).toBeNull();
expect(resolveModelSelectorAgentName(null, profiles)).toBeNull();
});
it("resolves each session independently and ignores model changes", () => {
for (const model of ["gpt-5", "sonnet"]) {
expect(
resolveModelSelectorAgentName(
{
agent_profile_snapshot: { agent_name: "opencode", model },
},
profiles,
),
).toBe("opencode");
}
expect(
resolveModelSelectorAgentName(
{
agent_profile_snapshot: { agent_name: "claude" },
},
profiles,
),
).toBe("claude");
});
});
@@ -0,0 +1,18 @@
import type { TaskSession } from "@/lib/types/http";
type SessionIdentity = Pick<TaskSession, "agent_profile_id" | "agent_profile_snapshot">;
/** Snapshot identity takes precedence over the session's editable logical profile. */
export function resolveModelSelectorAgentName(
session: SessionIdentity | null,
profiles: ReadonlyArray<{ id: string; agent_id: string; agent_name: string }>,
): string | null {
const snapshotName = session?.agent_profile_snapshot?.agent_name;
if (typeof snapshotName === "string" && snapshotName.trim()) return snapshotName;
const snapshotAgentId = session?.agent_profile_snapshot?.agent_id;
if (typeof snapshotAgentId === "string" && snapshotAgentId.trim()) {
const snapshotProfile = profiles.find((profile) => profile.agent_id === snapshotAgentId);
if (snapshotProfile?.agent_name) return snapshotProfile.agent_name;
}
return profiles.find((profile) => profile.id === session?.agent_profile_id)?.agent_name || null;
}
@@ -3,6 +3,8 @@
import { memo, useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
import { AgentLogo } from "@/components/agent-logo";
import { resolveModelSelectorAgentName } from "./model-selector-provider";
import {
configOptionToModelOptions,
@@ -36,6 +38,7 @@ type SessionModelsEntry = {
type ModelSelectorProps = {
sessionId: string | null;
triggerClassName?: string;
showAgentIcon?: boolean;
};
const debug = createDebugLogger("model-selector:gate");
@@ -504,6 +507,7 @@ function useModelSelectorState(sessionId: string | null) {
export const ModelSelector = memo(function ModelSelector({
sessionId,
triggerClassName,
showAgentIcon = false,
}: ModelSelectorProps) {
const { t } = useTranslation();
const {
@@ -518,6 +522,23 @@ export const ModelSelector = memo(function ModelSelector({
handleModelChange,
handleConfigChange,
} = useModelSelectorState(sessionId);
const agentName = useAppStore((state) =>
showAgentIcon
? resolveModelSelectorAgentName(
sessionId ? (state.taskSessions.items[sessionId] ?? null) : null,
state.agentProfiles.items,
)
: null,
);
const providerIcon = useMemo(
() =>
agentName ? (
<span aria-hidden="true" className="flex shrink-0" data-testid="model-provider-icon">
<AgentLogo agentName={agentName} size={14} className="size-3.5 shrink-0" />
</span>
) : undefined,
[agentName],
);
const modelConfig = configOptions.find(isModelConfigOption);
// Explicit "using fallback" signal: annotate the trigger so the user sees
// the session is not on the configured start model.
@@ -556,6 +577,7 @@ export const ModelSelector = memo(function ModelSelector({
return (
<ModelConfigSelector
providerIcon={providerIcon}
modelOptions={modelOptions}
currentModel={currentModel}
configOptions={configOptions}
@@ -120,7 +120,12 @@ test.describe("Mobile chat model selector", () => {
const trigger = leftActions.getByRole("button", { name: "Session model settings" });
await expect(trigger).toHaveText("Mock Fast", { timeout: 15_000 });
await expect(trigger.getByTestId("model-provider-icon")).toBeVisible();
expect((await trigger.boundingBox())!.height).toBeGreaterThanOrEqual(44);
await trigger.tap();
await expect(
testPage.locator("[cmdk-group-heading]").getByTestId("model-provider-icon"),
).toBeVisible();
const modelListbox = testPage.getByRole("listbox");
await expect(modelListbox.getByRole("option", { name: /Mock Smart/ })).toBeVisible({
timeout: 5_000,
@@ -0,0 +1,105 @@
import type { AppState } from "../../../lib/state/store";
import { test, expect } from "../../fixtures/test-base";
import { watchWs } from "../../helpers/causal-waits";
import { SessionPage } from "../../pages/session-page";
// @covers AC-UI-COMPOSER-PROVIDER-ICON-001.1, .2, .3
for (const failLogo of [false, true]) {
test(`composer provider appears in trigger and popover (logo failure: ${failLogo})`, async ({
testPage,
apiClient,
seedData,
prCapture,
}, testInfo) => {
const ws = watchWs(testPage);
await testPage.addInitScript(
(theme) => localStorage.setItem("theme", theme),
failLogo ? "light" : "dark",
);
await testPage.route("**/api/v1/agents/*/logo?*", (route) =>
failLogo
? route.fulfill({ status: 404 })
: route.fulfill({
contentType: "image/svg+xml",
path: "../backend/internal/agent/agents/logos/codex_dark.svg",
}),
);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Composer provider icon",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 30_000 });
const trigger = testPage.getByRole("button", { name: "Session model settings" });
const icon = trigger.getByTestId("model-provider-icon");
await expect(icon.locator(failLogo ? "svg" : "img")).toBeVisible();
await trigger.focus();
await testPage.keyboard.press("Enter");
const group = testPage.getByRole("group", { name: "Model", exact: true });
await expect(
testPage.locator("[cmdk-group-heading]").getByTestId("model-provider-icon"),
).toBeVisible();
await expect(group.getByRole("option", { name: /Mock Smart/ })).toBeVisible();
await testPage.screenshot({ path: testInfo.outputPath("provider-picker.png") });
await prCapture.screenshot(`composer-provider-${failLogo ? "fallback" : "logo"}`, {
caption: "CLI identity in the composer and open model picker",
});
const updated = ws.waitForEvent("session.models_updated", {
where: (payload) =>
payload.session_id === task.session_id && payload.current_model_id === "mock-smart",
});
await group.getByRole("option", { name: /Mock Smart/ }).click();
await updated;
await expect(trigger).toContainText("Mock Smart");
await expect(icon).toBeVisible();
await testPage.keyboard.press("Escape");
await expect(testPage.getByRole("listbox")).toBeHidden();
await testPage.evaluate((sessionId) => {
const store = (
window as unknown as {
__KANDEV_E2E_STORE__: {
getState: () => AppState;
setState: (state: Partial<AppState>) => void;
};
}
).__KANDEV_E2E_STORE__;
const state = store.getState();
const entry = state.sessionModels.bySessionId[sessionId!];
const name =
"A very long model name that should truncate before pushing the provider logo out of view";
store.setState({
sessionModels: {
bySessionId: {
...state.sessionModels.bySessionId,
[sessionId!]: {
...entry,
models: entry.models.map((model) => ({ ...model, name })),
configOptions: entry.configOptions.map((option) => ({
...option,
options: option.options?.map((value) => ({ ...value, name })),
})),
},
},
},
});
}, task.session_id);
await expect(trigger).toContainText("A very long model name");
const bounds = await icon.boundingBox();
const triggerBounds = (await trigger.boundingBox())!;
expect(bounds!.x).toBeGreaterThanOrEqual(triggerBounds.x);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(triggerBounds.x + triggerBounds.width);
expect(bounds?.width).toBe(14);
expect(await testPage.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(
true,
);
});
}