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:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "三项价格需一并填写",
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
Reference in New Issue
Block a user