feat(web): chat model picker lists key-configured models first, with a show-all expander (#22)

Co-authored-by: Alice <alice@prismshadow.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-22 22:04:24 +08:00
committed by GitHub
parent 636b25b63f
commit 849f2fa6d2
5 changed files with 211 additions and 10 deletions
+60 -7
View File
@@ -7,7 +7,8 @@
* indicator) + Model + send (up arrow);
* In draft state (Session not yet created), when models/onChangeModel are supplied, the model
* selector sits to the left of the send button (provider logo + name, popup opens **downward**,
* with a top quick-search box, and an internal scroll cap to avoid overflowing the screen) — once
* with a top quick-search box, an internal scroll cap to avoid overflowing the screen, and a
* configured-key-first list with a bottom "show all" row — see ModelSelect) — once
* the Session is created the model is locked, and the same spot switches to a read-only
* logo + name display;
* `/` opens the slash command menu (`/compact` compresses context, replacing the button; each
@@ -56,7 +57,7 @@ import { GlyphIcon } from "../../components/ui/glyph-icon";
import { SkillIcon } from "../skills/skill-icon-view";
import { ZoomableImage } from "../../components/ui/image-zoom";
import { ProviderLogo } from "../../components/ui/provider-logo";
import { matchesQuery, orderModelsLikeLibrary, sameModelRef } from "../models/model-grouping";
import { hasConfiguredKey, sameModelRef, visibleChatModels } from "../models/model-grouping";
import { filterAgents, matchMention, splitLeadingMention } from "./agent-mentions";
import { matchSlash, removeSlashToken } from "./slash-token";
import {
@@ -206,6 +207,13 @@ function modelLabel(m: ModelInfo): string {
return m.displayName ?? m.modelId;
}
/**
* "No key" marker for the model dropdown's key-less rows: a key struck through by a prohibition
* slash (24x24 line art, grayscale via currentColor, matching the approval-mode icon style).
*/
const NO_KEY_ICON =
"M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4M2 2l20 20";
/**
* Model selector (draft state only; docked to the left of the send button): both the button and
* candidate items show the provider logo. The menu opens **downward** — the draft card is
@@ -214,6 +222,12 @@ function modelLabel(m: ModelInfo): string {
* scroll (max-h-56) so it never overflows the browser's viewport height no matter how many models
* there are. On narrow screens only the logo remains (name hidden); list items mark the project
* default.
* By default only models with a configured API key are listed (stored masked key — the same
* standard as the model page's key status; `envKey` is merely the NAME of a fallback env var and
* doesn't count), with the selected and the default model always visible even without a key; a
* muted bottom row reveals the remaining key-less models (marked by a struck-through key icon,
* with the "no key" text in its title) without closing the menu or changing the selection. When
* no model has a key at all, everything is listed directly.
*/
function ModelSelect({
models,
@@ -231,12 +245,21 @@ function ModelSelect({
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
// Expanded "show all" state: collapses back to key-configured models on each open.
const [showAll, setShowAll] = useState(false);
const current = models.find((m) => sameModelRef(m, value));
// Display rule matches the model page's card: display name, or falls back to the upstream id (grouping is already conveyed by the provider logo).
const label = current ? modelLabel(current) : (value?.modelId ?? "…");
// Dropdown order mirrors the model library page: provider groups in MODEL_PROVIDERS order
// (user-defined groups after, custom last), in-group order preserved.
const filtered = orderModelsLikeLibrary(models).filter((m) => matchesQuery(m, query));
// (user-defined groups after, custom last), in-group order preserved. By default the list
// keeps only key-configured models (selected/default always included; lists everything when
// no model has a key); the query filters what's visible.
const visible = visibleChatModels(models, { showAll, query, selected: value, defaultModel });
// How many models the key filter hides under the current query (0 when expanded): drives the bottom "show all" row.
const hiddenCount = showAll
? 0
: visibleChatModels(models, { showAll: true, query, selected: value, defaultModel }).length -
visible.length;
return (
<Dropdown
open={open}
@@ -251,7 +274,11 @@ function ModelSelect({
onClick={() => {
const next = !open;
setOpen(next);
if (next) setQuery(""); // Always start from the full list each time it opens
if (next) {
// Each open starts from the unsearched, collapsed (configured-only) list.
setQuery("");
setShowAll(false);
}
}}
className="flex h-8 max-w-44 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs text-gray-500 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-800 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
@@ -292,10 +319,10 @@ function ModelSelect({
/>
</div>
<div className="max-h-56 overflow-y-auto">
{filtered.length === 0 && (
{visible.length === 0 && (
<p className="px-3 py-1.5 text-xs text-gray-400">{S.models.noSearchResults}</p>
)}
{filtered.map((m) => (
{visible.map((m) => (
<button
key={`${m.provider}:${m.modelId}`}
type="button"
@@ -311,6 +338,18 @@ function ModelSelect({
>
<ProviderLogo provider={m.provider} className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{modelLabel(m)}</span>
{/* Key-less rows (visible via show-all / selected / default / no-key-at-all) carry a
struck-through key icon (the "no key" text lives in the title/aria-label). */}
{!hasConfiguredKey(m) && (
<span
role="img"
title={S.models.noKey}
aria-label={S.models.noKey}
className="shrink-0 text-gray-400 dark:text-gray-500"
>
<GlyphIcon d={NO_KEY_ICON} size={13} />
</span>
)}
{sameModelRef(m, defaultModel) && (
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
{S.models.default}
@@ -322,6 +361,20 @@ function ModelSelect({
</button>
))}
</div>
{/* Bottom expander row (pinned below the scroll area, mirroring the search box on top):
reveals the models hidden by the configured-key filter in place — the menu stays open
and the selection is untouched. */}
{hiddenCount > 0 && (
<div className="border-t border-gray-100 dark:border-gray-800">
<button
type="button"
onClick={() => setShowAll(true)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-gray-400 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
{S.models.showModelsWithoutKey(hiddenCount)}
</button>
</div>
)}
</Dropdown>
);
}
@@ -98,3 +98,48 @@ export function groupModelRows<T extends ModelRowLike>(
export function orderModelsLikeLibrary<T extends ModelRowLike>(rows: T[]): T[] {
return groupModelRows(rows, "").flatMap((g) => g.rows);
}
/** Row shape for the configured-key filter: adds the read-only credential display (the DTO's ModelInfo is a superset). */
export interface ModelCredentialRowLike extends ModelRowLike {
credential?: { apiKeyMasked?: string };
}
/**
* Whether the model has an API key configured: judged solely by the stored (masked) key — the
* same standard as the chat credential guide and the model page's key status. `envKey` is only
* the NAME of a fallback environment variable; its presence says nothing about whether that
* variable is actually set, so it never counts as configured.
*/
export function hasConfiguredKey(m: ModelCredentialRowLike): boolean {
return !!m.credential?.apiKeyMasked;
}
export interface VisibleChatModelsOptions {
/** true = list every model (the dropdown's expanded "show all" state). */
showAll: boolean;
query: string;
/** Currently selected model: always visible even without a configured key (the active choice must never be invisible). */
selected?: ModelRefValue | null;
/** Project default model: always visible even without a configured key. */
defaultModel?: ModelRefValue | null;
}
/**
* Candidate list for the chat model dropdown: library order → keep only key-configured models
* (plus the selected and the default model, unless showAll) → the query then filters whatever
* is visible. When NO model has a configured key, the key filter degrades to showAll
* (everything listed), so the dropdown is never uselessly empty.
*/
export function visibleChatModels<T extends ModelCredentialRowLike>(
models: T[],
{ showAll, query, selected, defaultModel }: VisibleChatModelsOptions,
): T[] {
const ordered = orderModelsLikeLibrary(models);
const keep =
showAll || !ordered.some(hasConfiguredKey)
? ordered
: ordered.filter(
(m) => hasConfiguredKey(m) || sameModelRef(m, selected) || sameModelRef(m, defaultModel),
);
return keep.filter((m) => matchesQuery(m, query));
}
+2
View File
@@ -382,6 +382,8 @@ export const en: Strings = {
readOnlyHint: "Members have read-only access; only owners can change models and credentials",
empty: "No models configured yet",
noKey: "No key",
showModelsWithoutKey: (n: number): string =>
`Show model${n === 1 ? "" : "s"} without a key (${n})`,
pendingSave: "(pending save)",
modelIdExists: "This model id already exists",
pricingAllOrNone: "Fill all three prices",
+2
View File
@@ -359,6 +359,8 @@ export const zh = {
readOnlyHint: "member 只读;模型与 credential 修改仅 owner 可执行",
empty: "尚未配置任何模型",
noKey: "未配置 key",
/** Chat model dropdown's bottom expander row: reveals the models hidden by the configured-key filter. */
showModelsWithoutKey: (n: number): string => `显示未配置 key 的模型(${n} 个)`,
pendingSave: "(待保存)",
modelIdExists: "该模型 id 已存在",
pricingAllOrNone: "三项价格需一并填写",
+102 -3
View File
@@ -6,16 +6,20 @@
* provider not in the catalog becomes a custom-built group — each forms its own
* group, sorted by name and appended after custom; empty groups are hidden, except the
* custom group, which is always shown when there's no search query, hosting the generic
* "add model" entry point.
* "add model" entry point. Also covers the chat dropdown's visibility rule (visibleChatModels):
* key-configured models only by default (a stored masked key, judged by hasConfiguredKey),
* selected/default always visible, everything listed when nothing is configured or on showAll.
*/
import { describe, expect, it } from "vitest";
import { MODEL_PROVIDERS } from "@prismshadow/penguin-core/model-catalog";
import {
groupModelRows,
orderModelsLikeLibrary,
hasConfiguredKey,
matchesQuery,
orderModelsLikeLibrary,
visibleChatModels,
} from "../src/features/models/model-grouping";
import type { ModelRowLike } from "../src/features/models/model-grouping";
import type { ModelCredentialRowLike, ModelRowLike } from "../src/features/models/model-grouping";
const rows: ModelRowLike[] = [
{ provider: "anthropic", modelId: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6" },
@@ -131,6 +135,101 @@ describe("groupModelRows", () => {
});
});
describe("hasConfiguredKey", () => {
it("only a stored (masked) key counts as configured", () => {
expect(
hasConfiguredKey({
provider: "anthropic",
modelId: "m",
credential: { apiKeyMasked: "sk-a***xyz" },
}),
).toBe(true);
expect(hasConfiguredKey({ provider: "anthropic", modelId: "m" })).toBe(false);
expect(hasConfiguredKey({ provider: "anthropic", modelId: "m", credential: {} })).toBe(false);
// envKey is merely the NAME of a fallback env var (nothing says the var is actually set): never counts.
const envOnly = { provider: "anthropic", modelId: "m", envKey: "ANTHROPIC_API_KEY" };
expect(hasConfiguredKey(envOnly)).toBe(false);
});
});
describe("visibleChatModels", () => {
const configured = (provider: string, modelId: string): ModelCredentialRowLike => ({
provider,
modelId,
credential: { apiKeyMasked: "sk-***" },
});
const keyless = (provider: string, modelId: string): ModelCredentialRowLike => ({
provider,
modelId,
});
const pool: ModelCredentialRowLike[] = [
keyless("deepseek", "deepseek-v4"),
configured("anthropic", "claude-sonnet-4-6"),
keyless("anthropic", "claude-opus-4-8"),
configured("moonshot", "kimi-k2.6"),
keyless("custom", "my-proxy"),
];
it("by default lists only key-configured models, in library order", () => {
expect(visibleChatModels(pool, { showAll: false, query: "" }).map((m) => m.modelId)).toEqual([
"claude-sonnet-4-6",
"kimi-k2.6",
]);
});
it("showAll lists everything, still in library order", () => {
expect(visibleChatModels(pool, { showAll: true, query: "" }).map((m) => m.modelId)).toEqual([
"deepseek-v4",
"claude-sonnet-4-6",
"claude-opus-4-8",
"kimi-k2.6",
"my-proxy",
]);
});
it("the selected and the default model stay visible even without a key", () => {
const visible = visibleChatModels(pool, {
showAll: false,
query: "",
selected: { provider: "anthropic", modelId: "claude-opus-4-8" },
defaultModel: { provider: "deepseek", modelId: "deepseek-v4" },
});
expect(visible.map((m) => m.modelId)).toEqual([
"deepseek-v4", // default, key-less — kept
"claude-sonnet-4-6",
"claude-opus-4-8", // selected, key-less — kept
"kimi-k2.6",
]);
});
it("when no model has a configured key, everything is listed (never an empty dropdown)", () => {
const none = [keyless("anthropic", "a"), keyless("moonshot", "b")];
expect(visibleChatModels(none, { showAll: false, query: "" }).map((m) => m.modelId)).toEqual([
"a",
"b",
]);
});
it("the query filters what's visible: hidden key-less models only match once showAll", () => {
expect(visibleChatModels(pool, { showAll: false, query: "opus" })).toEqual([]);
expect(visibleChatModels(pool, { showAll: true, query: "opus" }).map((m) => m.modelId)).toEqual(
["claude-opus-4-8"],
);
// The query also narrows the configured-only view.
expect(
visibleChatModels(pool, { showAll: false, query: "kimi" }).map((m) => m.modelId),
).toEqual(["kimi-k2.6"]);
// ...and a key-less selected model kept by the exception is still searchable.
expect(
visibleChatModels(pool, {
showAll: false,
query: "opus",
selected: { provider: "anthropic", modelId: "claude-opus-4-8" },
}).map((m) => m.modelId),
).toEqual(["claude-opus-4-8"]);
});
});
describe("orderModelsLikeLibrary", () => {
it("flattens to the library page's order: built-in provider order, user groups after, custom last", () => {
const rows: ModelRowLike[] = [