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: "三项价格需一并填写",