From 849f2fa6d209ff872cefccc67eaaca8452c5a5b5 Mon Sep 17 00:00:00 2001 From: Yaowei Zheng Date: Wed, 22 Jul 2026 22:04:24 +0800 Subject: [PATCH] feat(web): chat model picker lists key-configured models first, with a show-all expander (#22) Co-authored-by: Alice Co-authored-by: Claude Fable 5 --- packages/web/src/features/chat/chat-input.tsx | 67 +++++++++-- .../web/src/features/models/model-grouping.ts | 45 ++++++++ packages/web/src/lib/strings-en.ts | 2 + packages/web/src/lib/strings.ts | 2 + packages/web/test/model-grouping.test.ts | 105 +++++++++++++++++- 5 files changed, 211 insertions(+), 10 deletions(-) diff --git a/packages/web/src/features/chat/chat-input.tsx b/packages/web/src/features/chat/chat-input.tsx index 2364ac0..f43760b 100644 --- a/packages/web/src/features/chat/chat-input.tsx +++ b/packages/web/src/features/chat/chat-input.tsx @@ -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 ( { 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({ />
- {filtered.length === 0 && ( + {visible.length === 0 && (

{S.models.noSearchResults}

)} - {filtered.map((m) => ( + {visible.map((m) => ( ))}
+ {/* 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 && ( +
+ +
+ )}
); } diff --git a/packages/web/src/features/models/model-grouping.ts b/packages/web/src/features/models/model-grouping.ts index 344b5fe..a7b93ef 100644 --- a/packages/web/src/features/models/model-grouping.ts +++ b/packages/web/src/features/models/model-grouping.ts @@ -98,3 +98,48 @@ export function groupModelRows( export function orderModelsLikeLibrary(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( + 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)); +} diff --git a/packages/web/src/lib/strings-en.ts b/packages/web/src/lib/strings-en.ts index e155429..df18073 100644 --- a/packages/web/src/lib/strings-en.ts +++ b/packages/web/src/lib/strings-en.ts @@ -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", diff --git a/packages/web/src/lib/strings.ts b/packages/web/src/lib/strings.ts index e1d564e..740f90b 100644 --- a/packages/web/src/lib/strings.ts +++ b/packages/web/src/lib/strings.ts @@ -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: "三项价格需一并填写", diff --git a/packages/web/test/model-grouping.test.ts b/packages/web/test/model-grouping.test.ts index 652f1ab..d3efc23 100644 --- a/packages/web/test/model-grouping.test.ts +++ b/packages/web/test/model-grouping.test.ts @@ -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[] = [