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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user