| author | |
| committer | |
| log | 09b32ac9dd43baab32b87c91f47f2a1651d1b1e8 |
| tree | 3a7336a1274931a4c2e18d1bddf2ddf773a33cc1 |
| parent | 253684e280d5250bef80891552a0ea9704701484 |
- add `getSlashModelOptions` to centralize `/model` suggestion filtering
- include custom saved model slugs in slash-command suggestions
- update ChatView to consume shared helper and add focused tests3 files changed, 40 insertions(+), 16 deletions(-)
apps/web/src/appSettings.test.ts+15-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | import { getAppModelOptions, normalizeCustomModelSlugs } from "./appSettings"; | |
| 3 | import { getAppModelOptions, getSlashModelOptions, normalizeCustomModelSlugs } from "./appSettings"; | |
| 4 | 4 | |
| 5 | 5 | describe("normalizeCustomModelSlugs", () => { |
| 6 | 6 | it("normalizes aliases, removes built-ins, and deduplicates values", () => { |
| ... | ... | @@ -40,3 +40,17 @@ describe("getAppModelOptions", () => { |
| 40 | 40 | }); |
| 41 | 41 | }); |
| 42 | 42 | }); |
| 43 | ||
| 44 | describe("getSlashModelOptions", () => { | |
| 45 | it("includes saved custom model slugs for /model command suggestions", () => { | |
| 46 | const options = getSlashModelOptions(["custom/internal-model"], "", "gpt-5.3-codex"); | |
| 47 | ||
| 48 | expect(options.some((option) => option.slug === "custom/internal-model")).toBe(true); | |
| 49 | }); | |
| 50 | ||
| 51 | it("filters slash-model suggestions across built-in and custom model names", () => { | |
| 52 | const options = getSlashModelOptions(["openai/gpt-oss-120b"], "oss", "gpt-5.3-codex"); | |
| 53 | ||
| 54 | expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]); | |
| 55 | }); | |
| 56 | }); |
apps/web/src/appSettings.ts+18| ... | ... | @@ -105,6 +105,24 @@ export function getAppModelOptions( |
| 105 | 105 | return options; |
| 106 | 106 | } |
| 107 | 107 | |
| 108 | export function getSlashModelOptions( | |
| 109 | customModels: readonly string[], | |
| 110 | query: string, | |
| 111 | selectedModel?: string | null, | |
| 112 | ): AppModelOption[] { | |
| 113 | const normalizedQuery = query.trim().toLowerCase(); | |
| 114 | const options = getAppModelOptions(customModels, selectedModel); | |
| 115 | if (!normalizedQuery) { | |
| 116 | return options; | |
| 117 | } | |
| 118 | ||
| 119 | return options.filter((option) => { | |
| 120 | const searchSlug = option.slug.toLowerCase(); | |
| 121 | const searchName = option.name.toLowerCase(); | |
| 122 | return searchSlug.includes(normalizedQuery) || searchName.includes(normalizedQuery); | |
| 123 | }); | |
| 124 | } | |
| 125 | ||
| 108 | 126 | function emitChange(): void { |
| 109 | 127 | for (const listener of listeners) { |
| 110 | 128 | listener(); |
apps/web/src/components/ChatView.tsx+7-15| ... | ... | @@ -118,7 +118,7 @@ import { Toggle } from "./ui/toggle"; |
| 118 | 118 | import { SidebarTrigger } from "./ui/sidebar"; |
| 119 | 119 | import { newCommandId, newMessageId } from "~/lib/utils"; |
| 120 | 120 | import { readNativeApi } from "~/nativeApi"; |
| 121 | import { getAppModelOptions, useAppSettings } from "../appSettings"; | |
| 121 | import { getAppModelOptions, getSlashModelOptions, useAppSettings } from "../appSettings"; | |
| 122 | 122 | import { |
| 123 | 123 | type ComposerImageAttachment, |
| 124 | 124 | type DraftThreadEnvMode, |
| ... | ... | @@ -632,6 +632,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 632 | 632 | () => getAppModelOptions(settings.customCodexModels, selectedModel), |
| 633 | 633 | [selectedModel, settings.customCodexModels], |
| 634 | 634 | ); |
| 635 | const slashModelOptions = useMemo( | |
| 636 | () => getSlashModelOptions(settings.customCodexModels, composerTrigger?.query ?? "", selectedModel), | |
| 637 | [composerTrigger?.query, selectedModel, settings.customCodexModels], | |
| 638 | ); | |
| 635 | 639 | const phase = derivePhase(activeThread?.session ?? null); |
| 636 | 640 | const isSendBusy = sendPhase !== "idle"; |
| 637 | 641 | const isPreparingWorktree = sendPhase === "preparing-worktree"; |
| ... | ... | @@ -907,26 +911,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 907 | 911 | ]; |
| 908 | 912 | } |
| 909 | 913 | |
| 910 | return modelOptions | |
| 911 | .map(({ slug, name }) => ({ | |
| 912 | slug, | |
| 913 | name, | |
| 914 | searchSlug: slug.toLowerCase(), | |
| 915 | searchName: name.toLowerCase(), | |
| 916 | })) | |
| 917 | .filter(({ searchSlug, searchName }) => { | |
| 918 | const query = composerTrigger.query.trim().toLowerCase(); | |
| 919 | if (!query) return true; | |
| 920 | return searchSlug.includes(query) || searchName.includes(query); | |
| 921 | }) | |
| 922 | .map(({ slug, name }) => ({ | |
| 914 | return slashModelOptions.map(({ slug, name }) => ({ | |
| 923 | 915 | id: `model:${slug}`, |
| 924 | 916 | type: "model" as const, |
| 925 | 917 | model: slug, |
| 926 | 918 | label: name, |
| 927 | 919 | description: slug, |
| 928 | 920 | })); |
| 929 | }, [composerTrigger, modelOptions, workspaceEntries]); | |
| 921 | }, [composerTrigger, slashModelOptions, workspaceEntries]); | |
| 930 | 922 | const composerMenuOpen = Boolean(composerTrigger); |
| 931 | 923 | const activeComposerMenuItem = useMemo( |
| 932 | 924 | () => |