feat: show CLI provider icons in composer model picker (#3760)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user