From cdc856fca5b2f6b65003a9f3964fd67885452c02 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 3 Mar 2026 09:28:58 -0800 Subject: [PATCH] Extract slash model option filtering into shared app settings helper - 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 tests --- apps/web/src/appSettings.test.ts | 16 +++++++++++++++- apps/web/src/appSettings.ts | 18 ++++++++++++++++++ apps/web/src/components/ChatView.tsx | 22 +++++++--------------- 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 8eb8b69377..b5dd31d308 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { getAppModelOptions, normalizeCustomModelSlugs } from "./appSettings"; +import { getAppModelOptions, getSlashModelOptions, normalizeCustomModelSlugs } from "./appSettings"; describe("normalizeCustomModelSlugs", () => { it("normalizes aliases, removes built-ins, and deduplicates values", () => { @@ -40,3 +40,17 @@ describe("getAppModelOptions", () => { }); }); }); + +describe("getSlashModelOptions", () => { + it("includes saved custom model slugs for /model command suggestions", () => { + const options = getSlashModelOptions(["custom/internal-model"], "", "gpt-5.3-codex"); + + expect(options.some((option) => option.slug === "custom/internal-model")).toBe(true); + }); + + it("filters slash-model suggestions across built-in and custom model names", () => { + const options = getSlashModelOptions(["openai/gpt-oss-120b"], "oss", "gpt-5.3-codex"); + + expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]); + }); +}); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 48a31c7a7c..2977b7fb3d 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -105,6 +105,24 @@ export function getAppModelOptions( return options; } +export function getSlashModelOptions( + customModels: readonly string[], + query: string, + selectedModel?: string | null, +): AppModelOption[] { + const normalizedQuery = query.trim().toLowerCase(); + const options = getAppModelOptions(customModels, selectedModel); + if (!normalizedQuery) { + return options; + } + + return options.filter((option) => { + const searchSlug = option.slug.toLowerCase(); + const searchName = option.name.toLowerCase(); + return searchSlug.includes(normalizedQuery) || searchName.includes(normalizedQuery); + }); +} + function emitChange(): void { for (const listener of listeners) { listener(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index abd8bbc202..a6f7ecc9dd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -118,7 +118,7 @@ import { Toggle } from "./ui/toggle"; import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -import { getAppModelOptions, useAppSettings } from "../appSettings"; +import { getAppModelOptions, getSlashModelOptions, useAppSettings } from "../appSettings"; import { type ComposerImageAttachment, type DraftThreadEnvMode, @@ -632,6 +632,10 @@ export default function ChatView({ threadId }: ChatViewProps) { () => getAppModelOptions(settings.customCodexModels, selectedModel), [selectedModel, settings.customCodexModels], ); + const slashModelOptions = useMemo( + () => getSlashModelOptions(settings.customCodexModels, composerTrigger?.query ?? "", selectedModel), + [composerTrigger?.query, selectedModel, settings.customCodexModels], + ); const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; const isPreparingWorktree = sendPhase === "preparing-worktree"; @@ -907,26 +911,14 @@ export default function ChatView({ threadId }: ChatViewProps) { ]; } - return modelOptions - .map(({ slug, name }) => ({ - slug, - name, - searchSlug: slug.toLowerCase(), - searchName: name.toLowerCase(), - })) - .filter(({ searchSlug, searchName }) => { - const query = composerTrigger.query.trim().toLowerCase(); - if (!query) return true; - return searchSlug.includes(query) || searchName.includes(query); - }) - .map(({ slug, name }) => ({ + return slashModelOptions.map(({ slug, name }) => ({ id: `model:${slug}`, type: "model" as const, model: slug, label: name, description: slug, })); - }, [composerTrigger, modelOptions, workspaceEntries]); + }, [composerTrigger, slashModelOptions, workspaceEntries]); const composerMenuOpen = Boolean(composerTrigger); const activeComposerMenuItem = useMemo( () =>