authorgravatar for 73941998+UtkarshUsername@users.noreply.github.comUtkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> 2026-09-09 23:36:53+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-09 11:06:53-07:00
loge16b8b059c9f5ff6dfed1addecffb831c6aee043
tree6828d4f45db215973379a92c3f505a04315ec134
parenta29a7cc5853e16d4ca7e07acf364836f71b6101a
signature Signed by PGP key B5690EEEBB952194

feat(web): add provider model bulk toggle (#10947)


2 files changed, 56 insertions(+), 6 deletions(-)

apps/web/src/components/settings/ProviderModelsSection.test.ts+18-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import type { ServerProviderModel } from "@t3tools/contracts";2import type { ServerProviderModel } from "@t3tools/contracts";
33
4import { groupModelsForDisplay } from "./ProviderModelsSection";4import { groupModelsForDisplay, nextHiddenModelsForBulkToggle } from "./ProviderModelsSection";
55
6function model(slug: string, isCustom = false): ServerProviderModel {6function model(slug: string, isCustom = false): ServerProviderModel {
7 return { slug, name: slug, isCustom, capabilities: null };7 return { slug, name: slug, isCustom, capabilities: null };
...@@ -21,3 +21,20 @@ describe("groupModelsForDisplay", () => {...@@ -21,3 +21,20 @@ describe("groupModelsForDisplay", () => {
21 expect(display.map((entry) => entry.slug)).toEqual(["c", "d", "b", "custom", "a"]);21 expect(display.map((entry) => entry.slug)).toEqual(["c", "d", "b", "custom", "a"]);
22 });22 });
23});23});
24
25describe("nextHiddenModelsForBulkToggle", () => {
26 it("hides every built-in model without hiding custom models", () => {
27 const models = [model("a"), model("b"), model("custom", true)];
28
29 expect(nextHiddenModelsForBulkToggle(models, ["a"])).toEqual(["a", "b"]);
30 });
31
32 it("shows every built-in model while preserving unrelated hidden entries", () => {
33 const models = [model("a"), model("b"), model("custom", true)];
34
35 expect(nextHiddenModelsForBulkToggle(models, ["a", "b", "legacy", "custom"])).toEqual([
36 "legacy",
37 "custom",
38 ]);
39 });
40});
apps/web/src/components/settings/ProviderModelsSection.tsx+38-5
...@@ -96,6 +96,21 @@ export function groupModelsForDisplay<...@@ -96,6 +96,21 @@ export function groupModelsForDisplay<
96 ];96 ];
97}97}
9898
99export function nextHiddenModelsForBulkToggle(
100 models: ReadonlyArray<Pick<ServerProviderModel, "slug" | "isCustom">>,
101 hiddenModels: ReadonlyArray<string>,
102): string[] {
103 const builtInSlugs = models.filter((model) => !model.isCustom).map((model) => model.slug);
104 const builtInSlugSet = new Set(builtInSlugs);
105 const allBuiltInModelsHidden = builtInSlugs.every((slug) => hiddenModels.includes(slug));
106
107 if (allBuiltInModelsHidden) {
108 return hiddenModels.filter((slug) => !builtInSlugSet.has(slug));
109 }
110
111 return [...new Set([...hiddenModels, ...builtInSlugs])];
112}
113
99interface ProviderModelsSectionProps {114interface ProviderModelsSectionProps {
100 /** Identifier used to namespace input ids within the DOM. */115 /** Identifier used to namespace input ids within the DOM. */
101 readonly instanceId: ProviderInstanceId;116 readonly instanceId: ProviderInstanceId;
...@@ -181,6 +196,8 @@ export function ProviderModelsSection({...@@ -181,6 +196,8 @@ export function ProviderModelsSection({
181 (model) => !model.isCustom && hiddenModelSet.has(model.slug),196 (model) => !model.isCustom && hiddenModelSet.has(model.slug),
182 ).length;197 ).length;
183 const builtInModels = useMemo(() => models.filter((model) => !model.isCustom), [models]);198 const builtInModels = useMemo(() => models.filter((model) => !model.isCustom), [models]);
199 const allBuiltInModelsHidden =
200 builtInModels.length > 0 && builtInModels.every((model) => hiddenModelSet.has(model.slug));
184 const showFilter = models.length > FILTER_THRESHOLD;201 const showFilter = models.length > FILTER_THRESHOLD;
185 const normalizedFilter = filter.trim().toLowerCase();202 const normalizedFilter = filter.trim().toLowerCase();
186 const isFiltering = showFilter && normalizedFilter.length > 0;203 const isFiltering = showFilter && normalizedFilter.length > 0;
...@@ -502,11 +519,27 @@ export function ProviderModelsSection({...@@ -502,11 +519,27 @@ export function ProviderModelsSection({
502 aria-label="Filter models"519 aria-label="Filter models"
503 />520 />
504 ) : null}521 ) : null}
505 <span className="text-xs text-muted-foreground">522 <div className="flex items-center gap-2">
506 {models.length} model{models.length === 1 ? "" : "s"}523 {builtInModels.length > 0 ? (
507 {favoriteCount > 0 ? ` · ${favoriteCount} favorite${favoriteCount === 1 ? "" : "s"}` : ""}524 <Button
508 {hiddenCount > 0 ? ` · ${hiddenCount} hidden` : ""}525 type="button"
509 </span>526 size="xs"
527 variant="ghost-muted"
528 onClick={() =>
529 onHiddenModelsChange(nextHiddenModelsForBulkToggle(models, hiddenModels))
530 }
531 >
532 {allBuiltInModelsHidden ? "Enable all" : "Disable all"}
533 </Button>
534 ) : null}
535 <span className="text-xs text-muted-foreground">
536 {models.length} model{models.length === 1 ? "" : "s"}
537 {favoriteCount > 0
538 ? ` · ${favoriteCount} favorite${favoriteCount === 1 ? "" : "s"}`
539 : ""}
540 {hiddenCount > 0 ? ` · ${hiddenCount} hidden` : ""}
541 </span>
542 </div>
510 </div>543 </div>
511 <div544 <div
512 ref={listRef}545 ref={listRef}