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,
);
});
}
+106
View File
@@ -0,0 +1,106 @@
---
created: 2026-09-16
status: implemented
requirements:
- REQ-UI-COMPOSER-PROVIDER-ICON-001
system_design:
- ../../specs/ui/system-design/composer-provider-icon.md
legacy_specs: []
---
# Composer provider icon implementation plan
## Overview and scope
One sequential work order adds a small leading CLI logo to the composer model
picker on desktop and phone. Reuse `AgentLogo` and existing identity data.
Exclude provider switching, settings changes, model-row branding and backend work.
## Technical approach
Follow the [design](../../specs/ui/system-design/composer-provider-icon.md):
optional provider icon slot in the trigger and popover heading, session identity resolution, explicit composer opt-in.
## ASCII UI preview
### UI-01: Closed composer model picker, desktop and phone
```text
Before: [ Model name / Low v ]
After: [ <CLI> Model name / Low v ]
Narrow: [ <CLI> Long model... v ]
```
`<CLI>` represents the actual 14px provider logo, not visible text. Leading logo,
truncatable summary, trailing chevron is the required order; sample text and ASCII
spacing are illustrative. Phone uses the same ordering inside its existing toolbar
and touch hit area. Missing identity uses the before state; loading/failed logo
uses a terminal glyph in the same slot. Open popover: `<CLI> Model` heads the model group, using the same provider icon. Model rows and option navigation remain unchanged.
Maps to AC-UI-COMPOSER-PROVIDER-ICON-001.1 through .3.
## Tests
Add `components/task/model-selector-provider-icon.test.tsx` for snapshot priority,
profile fallback, unknown identity, session changes and same-provider model changes
(AC .1 and .2). Extend `components/model-config-selector.test.tsx` for optional
slot rendering and unchanged accessible name (AC .3). Desktop and mobile E2E prove both composer presentations opt in; existing toolbar component tests guard unrelated controls.
## E2E tests
Add `tests/chat/model-selector-provider-icon.spec.ts` (chromium): loaded provider
logo, model change retaining logo, failed-logo fallback, keyboard use, long-label
containment and light/dark appearance. Extend `tests/chat/mobile-model-selector.spec.ts`
(mobile-chrome) to assert icon, touch model selection, >=44px hit target,
long-label containment and zero horizontal document overflow. These cover AC .1-.3.
Use existing isolated fixtures; capture the trigger for visual comparison with UI-01.
## Work orders
- [x] [Task 01: Add composer provider icon](task-01-provider-icon.md)
## Verification
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps/web && pnpm exec vitest run components/model-config-selector.test.tsx components/task/model-selector.test.ts components/task/model-selector-consecutive-switch.test.tsx components/task/model-selector-provider-icon.test.tsx components/task/chat/chat-input-toolbar.test.tsx components/task/chat/chat-input-toolbar-composer.test.tsx)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm exec eslint components/model-config-selector.tsx components/model-config-selector-content.tsx components/task/model-selector-provider.ts components/task/model-selector.tsx components/task/chat/chat-input-toolbar-desktop.tsx components/task/chat/chat-input-toolbar-mobile.tsx)
make -C apps/backend build-dev
(cd apps/web && pnpm build:e2e)
make -C apps/backend e2e-plugin-ui e2e-plugin-package
(cd apps/web && pnpm e2e:run --host --no-build --project chromium tests/chat/model-selector-provider-icon.spec.ts)
(cd apps/web && pnpm e2e:run --host --no-build --project mobile-chrome tests/chat/mobile-model-selector.spec.ts)
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.py --all
git diff --check
```
## Verification results
Completed 2026-09-16; revalidated against the updated base on 2026-09-17.
- Frozen workspace install passed.
- Listed focused Vitest command passed: 6 files, 89 tests, including provider identity and model-switch regression coverage.
- `pnpm run typecheck` passed. Targeted ESLint and Prettier checks passed.
- `make -C apps/backend build-dev`, `pnpm build:e2e` from `apps/web`, and fixture plugin build/package passed. Existing Vite chunk warnings remain.
- Desktop runner command with `--host --no-build`: 2 passed. Covers both icon placements, loaded logo, failed-logo fallback, keyboard opening, model switching and long-label containment.
- Mobile runner command with `--host --no-build`: 1 passed. Covers both placements, 44px trigger, touch configuration selection and viewport containment. Existing test captures the mobile result.
- Desktop screenshots inspected. Browser evidence caught shared button CSS shrinking the terminal fallback; explicit `size-3.5` fixes it. A desktop backend-readiness timeout passed on rerun; the initial mobile heading locator was corrected before its passing run.
- Catalog validation, specification lint and `git diff --check` passed.
Full remote-platform build was stopped because local browser checks require only
host helpers. All browser runs used freshly rebuilt frontend assets and an isolated
runtime. Fresh desktop and phone PR screenshots were captured with temporary specs, inspected, and retained outside the production diff. The temporary capture specs were removed.
PR review remediation: dynamic snapshots carry `agent_id` without `agent_name`.
Resolve that ID through the existing profile catalog rather than using it as a
logo name. The new regression failed before the fix and passes afterward.
Memoized provider JSX preserves the selector's existing memo boundary; heading
assertions now report missing accessible labels explicitly. The focused suite
passes 89 tests after these changes. Typecheck and targeted lint pass.
## Risks
Snapshot values are untyped and require a nonempty string check. Long summaries
can crowd the new icon; preserve nonshrinking glyphs and text truncation. Logo
fallback must not be mistaken for proof that the branded image loaded.
@@ -0,0 +1,115 @@
---
id: "01-provider-icon"
title: "Add composer provider icon"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-COMPOSER-PROVIDER-ICON-001
acceptance_criteria:
- AC-UI-COMPOSER-PROVIDER-ICON-001.1
- AC-UI-COMPOSER-PROVIDER-ICON-001.2
- AC-UI-COMPOSER-PROVIDER-ICON-001.3
system_design:
- ../../specs/ui/system-design/composer-provider-icon.md
---
# Task 01: Add composer provider icon
## Summary and scope
Add the session CLI's existing logo before the model summary and in the open popover in both composer
presentations. Implement the optional shared trigger slot and explicit composer
opt-in. Exclude backend changes, non-composer consumers and model-menu redesign.
## Acceptance
1. Correct session provider logo, snapshot priority and profile fallback pass AC .1.
2. Unknown identity and failed logo remain usable under AC .2.
3. Desktop and phone preserve selection, accessibility and containment under AC .3.
## ASCII UI preview
### UI-01: Closed composer model picker, desktop and phone
```text
Before: [ Model name / Low v ]
After: [ <CLI> Model name / Low v ]
Narrow: [ <CLI> Long model... v ]
```
`<CLI>` represents the actual 14px provider logo, not visible text. Leading logo,
truncatable summary, trailing chevron is the required order; sample text and ASCII
spacing are illustrative. Phone uses the same ordering inside its existing toolbar
and touch hit area. Missing identity uses the before state; loading/failed logo
uses a terminal glyph in the same slot. Open popover: `<CLI> Model` heads the model group, using the same provider icon. Model rows and option navigation remain unchanged.
Maps to AC-UI-COMPOSER-PROVIDER-ICON-001.1 through .3.
Full preview and test mapping: [plan](plan.md#ascii-ui-preview).
## Files likely touched
- `apps/web/components/model-config-selector.tsx` and its `.test.tsx`.
- `apps/web/components/task/model-selector.tsx`, `model-selector-provider.ts`, and new `model-selector-provider-icon.test.tsx`.
- `apps/web/components/task/chat/chat-input-toolbar-desktop.tsx`, `chat-input-toolbar-mobile.tsx`, and adjacent toolbar tests.
- `apps/web/e2e/tests/chat/model-selector-provider-icon.spec.ts` (new) and `mobile-model-selector.spec.ts`.
## Inputs and dependencies
Read the linked requirement/design, UI-01, scoped web guidance and existing
`AgentLogo`. No preceding work orders. Apply TDD to identity resolution and prop
wiring, using the exact component/browser cases described in the plan.
## Verification
Commands run from repository root. Build local development binaries and fresh frontend assets explicitly, then run the guarded E2E runner without redundant cross-platform builds.
Run desktop and mobile sequentially. Inspect captured glyphs in both themes.
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps/web && pnpm exec vitest run components/model-config-selector.test.tsx components/task/model-selector.test.ts components/task/model-selector-consecutive-switch.test.tsx components/task/model-selector-provider-icon.test.tsx components/task/chat/chat-input-toolbar.test.tsx components/task/chat/chat-input-toolbar-composer.test.tsx)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm exec eslint components/model-config-selector.tsx components/model-config-selector-content.tsx components/task/model-selector-provider.ts components/task/model-selector.tsx components/task/chat/chat-input-toolbar-desktop.tsx components/task/chat/chat-input-toolbar-mobile.tsx)
make -C apps/backend build-dev
(cd apps/web && pnpm build:e2e)
make -C apps/backend e2e-plugin-ui e2e-plugin-package
(cd apps/web && pnpm e2e:run --host --no-build --project chromium tests/chat/model-selector-provider-icon.spec.ts)
(cd apps/web && pnpm e2e:run --host --no-build --project mobile-chrome tests/chat/mobile-model-selector.spec.ts)
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.py --all
git diff --check
```
## Risks
Untyped snapshots, delayed profile hydration, and crowded phone labels. Do not
infer provider from the model string or duplicate the logo registry.
## Parallelism
Sequential.
## Results
Completed 2026-09-16; revalidated against the updated base on 2026-09-17.
- Frozen workspace install passed.
- Listed focused Vitest command passed: 6 files, 89 tests, including provider identity and model-switch regression coverage.
- `pnpm run typecheck` passed. Targeted ESLint and Prettier checks passed.
- `make -C apps/backend build-dev`, `pnpm build:e2e` from `apps/web`, and fixture plugin build/package passed. Existing Vite chunk warnings remain.
- Desktop runner command with `--host --no-build`: 2 passed. Covers both icon placements, loaded logo, failed-logo fallback, keyboard opening, model switching and long-label containment.
- Mobile runner command with `--host --no-build`: 1 passed. Covers both placements, 44px trigger, touch configuration selection and viewport containment. Existing test captures the mobile result.
- Desktop screenshots inspected. Browser evidence caught shared button CSS shrinking the terminal fallback; explicit `size-3.5` fixes it. A desktop backend-readiness timeout passed on rerun; the initial mobile heading locator was corrected before its passing run.
- Catalog validation, specification lint and `git diff --check` passed.
Full remote-platform build was stopped because local browser checks require only
host helpers. All browser runs used freshly rebuilt frontend assets and an isolated
runtime. Fresh desktop and phone PR screenshots were captured with temporary specs, inspected, and retained outside the production diff. The temporary capture specs were removed.
PR review remediation: dynamic snapshots carry `agent_id` without `agent_name`.
Resolve that ID through the existing profile catalog rather than using it as a
logo name. The new regression failed before the fix and passes afterward.
Memoized provider JSX preserves the selector's existing memo boundary; heading
assertions now report missing accessible labels explicitly. The focused suite
passes 89 tests after these changes. Typecheck and targeted lint pass.
+2
View File
@@ -7,6 +7,8 @@ description: "Install agent CLIs and create profiles for models, modes, flags, s
An **agent** is Kandev's integration with a coding-agent CLI. A **profile** is its reusable launch configuration. Create separate profiles when model, credentials, or permissions need different trust boundaries.
The composer model picker shows the session's agent CLI logo beside the selected model and beside **Model** in the open popover. This identifies the CLI running the session, even when it supports models from several vendors.
Agent authentication is separate from repository and integration credentials.
## Quick path
@@ -0,0 +1,33 @@
---
status: active
system: ui
created: 2026-09-16
owners:
- kandev
---
# Composer provider icon
## Overview
Users need to recognize the CLI agent serving the composer's selected model.
UI owns this presentation-only contract; configured agent identity remains owned
by the agent system. A CLI provider is the agent application, not the model vendor.
## Requirements
### REQ-UI-COMPOSER-PROVIDER-ICON-001: Visible CLI identity
The closed composer model picker shall identify its session's CLI agent with a
small leading icon while retaining the existing model and configuration summary.
#### Acceptance criteria
- **AC-UI-COMPOSER-PROVIDER-ICON-001.1:** When session agent identity is available, the closed composer picker shall display that CLI agent's logo before the model name, and the open popover shall display the same logo beside its Model heading. Changing models within the same agent shall retain the agent logo; switching sessions shall reflect the newly selected session.
- **AC-UI-COMPOSER-PROVIDER-ICON-001.2:** When the logo cannot load, the picker shall show a neutral terminal fallback without blocking model selection. When agent identity is unavailable, it shall retain the existing text-only trigger without guessing from the model name.
- **AC-UI-COMPOSER-PROVIDER-ICON-001.3:** On desktop and phone, the icon shall remain visible without shrinking, the model summary shall truncate when necessary, and the chevron and model selection shall remain usable. Phone/coarse-pointer hit targets shall remain at least 44px, with no document horizontal overflow. Keyboard interaction and the trigger's accessible name shall remain intact.
## Out of scope
Changing provider identity, model selection semantics, model-list rows, settings
pickers, provider switching, backend APIs, or unrelated composer controls.
@@ -0,0 +1,57 @@
---
status: current
system: ui
requirements:
- REQ-UI-COMPOSER-PROVIDER-ICON-001
---
# Composer provider icon design
## Purpose and boundaries
Add a presentation adornment to the existing session model picker, reusing agent
identity and logo infrastructure. No persistence or backend changes are needed.
This complements the [ACP summary](../requirements/acp-model-configuration-summary.md)
without changing its textual summary rules.
## Components and data flow
- `apps/web/components/task/model-selector.tsx`: resolve the current session's
nonempty string `agent_profile_snapshot.agent_name`, then map snapshot
`agent_id` through `agentProfiles.items` to its canonical `agent_name` for dynamic
routes. Fall back to the logical profile only when neither resolves. Snapshot identity survives
deleted/edited profiles. Never use a database UUID or infer identity from model text.
- Add an optional `showAgentIcon` prop to `ModelSelector`, default false. Enable
it in both `chat-input-toolbar-desktop.tsx` and `chat-input-toolbar-mobile.tsx`
so other consumers do not change accidentally.
- `apps/web/components/model-config-selector.tsx`: accept an optional
`providerIcon: ReactNode`, thread it into `ModelConfigSelectorTrigger`, and
render it before the existing truncated text and beside the existing localized Model heading in the popover. Leave it absent by default.
- Use `AgentLogo` at 14px inside a nonshrinking, decorative `aria-hidden` wrapper.
Reuse existing spacing tokens and theme-aware logo fetching. No new tooltip or
nested interactive element. Preserve the existing accessible button name.
## Failure behavior
Unknown identity omits the icon. Known identity uses the existing `AgentLogo`
terminal placeholder during loading and on failure. React updates the adornment
from the current session identity, without retaining another session's provider.
## Desktop and phone
The nearest shipped mobile exemplar is the current chat toolbar's `ModelSelector`
inside `chat-input-toolbar-mobile.tsx`, with its `max-w-[56vw]` bounded trigger.
Keep the existing picker/popover and phone toolbar composition. Add only the
leading glyph. Preserve 28px fine-pointer trigger height and coarse-pointer 44px
minimums. Text yields width before either glyph. No new scroll region is added.
## Requirement mapping and evidence
| Criteria | Design / evidence |
| --- | --- |
| AC-UI-COMPOSER-PROVIDER-ICON-001.1 | Session identity resolution; unit coverage for snapshot priority, profile fallback, session changes and same-agent model changes |
| AC-UI-COMPOSER-PROVIDER-ICON-001.2 | Unknown identity and existing logo fallback; component tests and failed-logo browser check |
| AC-UI-COMPOSER-PROVIDER-ICON-001.3 | Optional leading slot; desktop and mobile rendered checks for truncation, hit area, keyboard/touch selection and overflow |
Public agent/profile documentation explains the CLI logo in both picker states. No new user-facing copy is
planned; any copy introduced during implementation must follow localization rules.