640 lines · 23.6 KB · TypeScript
RawDownload
"use client"; import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, StarIcon, XIcon } from "lucide-react";import { useEffect, useMemo, useRef, useState } from "react";import {  ProviderDriverKind,  type ProviderInstanceId,  type ServerProviderModel,} from "@t3tools/contracts";import { type CustomModelDefinition, normalizeCustomModelSlug } from "@t3tools/shared/model"; import { cn } from "../../lib/utils";import { sortModelsForProviderInstance } from "../../modelOrdering";import { MAX_CUSTOM_MODEL_LENGTH } from "../../modelSelection";import { Button } from "../ui/button";import { Input } from "../ui/input";import { Switch } from "../ui/switch";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";import { CustomModelEditor } from "./CustomModelEditor"; /** * Placeholder text for the "add a custom model" input, keyed by driver * kind. Mirrors the prior hardcoded switch in `SettingsPanels.tsx` so the * UX is unchanged — only the owning component has moved. */const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial<Record<ProviderDriverKind, string>> = {  [ProviderDriverKind.make("codex")]: "gpt-6.7-codex-ultra-preview",  [ProviderDriverKind.make("claudeAgent")]: "claude-sonnet-5",  [ProviderDriverKind.make("cursor")]: "claude-sonnet-4-6",  [ProviderDriverKind.make("opencode")]: "openai/gpt-5",}; /** Above this many models the list gets a filter input. */const FILTER_THRESHOLD = 8; /** * Short capability words shown after a model's slug. Claude and Cursor report * fast mode as a boolean `fastMode` option; Codex reports it as a * `serviceTier` select whose fast tier is labelled "Fast" (catalog id * `priority`, or `fast` from the speed-tier fallback), matching the composer. */function describeModelCapabilities(model: ServerProviderModel): string[] {  const descriptors = model.capabilities?.optionDescriptors ?? [];  const labels: string[] = [];  const hasFastMode = descriptors.some(    (descriptor) =>      descriptor.id === "fastMode" ||      (descriptor.id === "serviceTier" &&        descriptor.type === "select" &&        descriptor.options.some((option) => option.id === "fast" || option.label === "Fast")),  );  if (hasFastMode) labels.push("Fast mode");  if (descriptors.some((descriptor) => descriptor.id === "thinking")) labels.push("Thinking");  if (    descriptors.some(      (descriptor) =>        descriptor.type === "select" &&        (descriptor.id === "reasoningEffort" ||          descriptor.id === "effort" ||          descriptor.id === "reasoning" ||          descriptor.id === "variant"),    )  ) {    labels.push("Reasoning");  }  return labels;} /** * Display order for the models list: favorites first (in user order), then * visible models, then hidden ones. Hidden models sink so the list reads * top-down as "what the picker shows"; moves only swap rows within the same * group, and the resulting display order is what gets persisted as * `modelOrder`. */export function groupModelsForDisplay<  T extends { readonly slug: string; readonly isCustom: boolean },>(  models: ReadonlyArray<T>,  options: {    readonly favoriteModels: ReadonlySet<string>;    readonly hiddenModels: ReadonlySet<string>;    readonly modelOrder: ReadonlyArray<string>;  },): T[] {  const ordered = sortModelsForProviderInstance(models, {    favoriteModels: options.favoriteModels,    groupFavorites: true,    modelOrder: options.modelOrder,  });  const isHidden = (model: T) => !model.isCustom && options.hiddenModels.has(model.slug);  return [    ...ordered.filter((model) => options.favoriteModels.has(model.slug)),    ...ordered.filter((model) => !options.favoriteModels.has(model.slug) && !isHidden(model)),    ...ordered.filter((model) => !options.favoriteModels.has(model.slug) && isHidden(model)),  ];} export function nextHiddenModelsForBulkToggle(  models: ReadonlyArray<Pick<ServerProviderModel, "slug" | "isCustom">>,  hiddenModels: ReadonlyArray<string>,): string[] {  const builtInSlugs = models.filter((model) => !model.isCustom).map((model) => model.slug);  const builtInSlugSet = new Set(builtInSlugs);  const allBuiltInModelsHidden = builtInSlugs.every((slug) => hiddenModels.includes(slug));   if (allBuiltInModelsHidden) {    return hiddenModels.filter((slug) => !builtInSlugSet.has(slug));  }   return [...new Set([...hiddenModels, ...builtInSlugs])];} interface ProviderModelsSectionProps {  /** Identifier used to namespace input ids within the DOM. */  readonly instanceId: ProviderInstanceId;  /**   * Driver kind for slug normalization + input placeholder. `null` when   * the section is rendered without enough provider metadata.   */  readonly driverKind: ProviderDriverKind | null;  /**   * The live model list to display. Includes both built-in (probe-reported)   * and custom entries, distinguished by `isCustom`.   */  readonly models: ReadonlyArray<ServerProviderModel>;  /**   * The persisted custom-model list for this instance, resolved. Drives   * dedup, and is the list we hand back (with an entry appended / replaced /   * removed) via `onChange`.   */  readonly customModels: ReadonlyArray<CustomModelDefinition>;  /** Server-returned model slugs hidden from the model picker. */  readonly hiddenModels: ReadonlyArray<string>;  /** Model slugs favorited for this provider instance. */  readonly favoriteModels: ReadonlyArray<string>;  /** Explicit user-authored model ordering for this provider instance. */  readonly modelOrder: ReadonlyArray<string>;  /**   * Commit the new custom-model list. Caller is responsible for routing the   * write to the correct storage (legacy `settings.providers[kind]` vs.   * `providerInstances[id].config`).   */  readonly onChange: (next: ReadonlyArray<CustomModelDefinition>) => void;  readonly onHiddenModelsChange: (next: ReadonlyArray<string>) => void;  readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void;  readonly onModelOrderChange: (next: ReadonlyArray<string>) => void;} /** * Shared "Models" section rendered on both the built-in default and custom * provider-instance cards. Owns its own input + error local state so two * cards on screen don't fight over the input value. * * Validation mirrors the pre-consolidation logic in `SettingsPanels`: *   - empty / whitespace → "Enter a model slug." *   - duplicate of a non-custom (probe-reported) slug → "already built in" *   - exceeds `MAX_CUSTOM_MODEL_LENGTH` → length error *   - duplicate of an already-saved custom slug → already-saved error */export function ProviderModelsSection({  instanceId,  driverKind,  models,  customModels,  hiddenModels,  favoriteModels,  modelOrder,  onChange,  onHiddenModelsChange,  onFavoriteModelsChange,  onModelOrderChange,}: ProviderModelsSectionProps) {  const [input, setInput] = useState("");  const [isAdding, setIsAdding] = useState(false);  const [filter, setFilter] = useState("");  const [error, setError] = useState<string | null>(null);  // Slug of the custom model whose inline editor is open, if any.  const [editingSlug, setEditingSlug] = useState<string | null>(null);  const listRef = useRef<HTMLDivElement>(null);  // Slug of a just-added custom model, scrolled into view once its row exists.  const scrollToSlugRef = useRef<string | null>(null);  const hiddenModelSet = useMemo(() => new Set(hiddenModels), [hiddenModels]);  const favoriteModelSet = useMemo(() => new Set(favoriteModels), [favoriteModels]);  const displayModels = useMemo(    () =>      groupModelsForDisplay(models, {        favoriteModels: favoriteModelSet,        hiddenModels: hiddenModelSet,        modelOrder,      }),    [favoriteModelSet, hiddenModelSet, modelOrder, models],  );  const favoriteCount = displayModels.filter((model) => favoriteModelSet.has(model.slug)).length;  const hiddenCount = displayModels.filter(    (model) => !model.isCustom && hiddenModelSet.has(model.slug),  ).length;  const builtInModels = useMemo(() => models.filter((model) => !model.isCustom), [models]);  const allBuiltInModelsHidden =    builtInModels.length > 0 && builtInModels.every((model) => hiddenModelSet.has(model.slug));  const showFilter = models.length > FILTER_THRESHOLD;  const normalizedFilter = filter.trim().toLowerCase();  const isFiltering = showFilter && normalizedFilter.length > 0;  const visibleModels = isFiltering    ? displayModels.filter(        (model) =>          model.name.toLowerCase().includes(normalizedFilter) ||          model.slug.toLowerCase().includes(normalizedFilter),      )    : displayModels;   // The parent commits the new custom model and hands back an updated  // `models` list, so the row can only be scrolled to after that render.  useEffect(() => {    const slug = scrollToSlugRef.current;    if (slug === null) return;    const row = listRef.current?.querySelector<HTMLElement>(      `[data-model-slug="${CSS.escape(slug)}"]`,    );    if (!row) return;    scrollToSlugRef.current = null;    row.scrollIntoView({ block: "nearest" });  }, [displayModels]);   const handleAdd = () => {    if (driverKind === "antigravity") return;    const normalized = normalizeCustomModelSlug(input);    if (!normalized) {      setError("Enter a model slug.");      return;    }    if (models.some((model) => !model.isCustom && model.slug === normalized)) {      setError("That model is already built in.");      return;    }    if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) {      setError(`Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`);      return;    }    if (customModels.some((entry) => entry.slug === normalized)) {      setError("That custom model is already saved.");      return;    }     // Clear the filter so the new row renders even when it does not match,    // which is also what lets the pending scroll target resolve and clear.    scrollToSlugRef.current = normalized;    setFilter("");    onChange([...customModels, { slug: normalized, name: normalized, capabilities: null }]);    setInput("");    setError(null);    setIsAdding(false);  };   const cancelAdd = () => {    setInput("");    setError(null);    setIsAdding(false);  };   const handleRemove = (slug: string) => {    if (editingSlug === slug) setEditingSlug(null);    onChange(customModels.filter((entry) => entry.slug !== slug));    onModelOrderChange(modelOrder.filter((model) => model !== slug));    onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug));    setError(null);  };   const handleSaveEdit = (next: CustomModelDefinition) => {    onChange(customModels.map((entry) => (entry.slug === next.slug ? next : entry)));    setEditingSlug(null);  };   const setHidden = (slug: string, hidden: boolean) => {    if (hidden === hiddenModelSet.has(slug)) return;    onHiddenModelsChange(      hidden ? [...hiddenModels, slug] : hiddenModels.filter((model) => model !== slug),    );  };   const handleToggleFavorite = (slug: string) => {    if (favoriteModelSet.has(slug)) {      onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug));      return;    }    onFavoriteModelsChange([...favoriteModels, slug]);  };   // Rows only trade places with a neighbour in the same group (favorites,  // visible, hidden), and the display order is persisted as the new order.  const groupOf = (model: (typeof displayModels)[number]) =>    favoriteModelSet.has(model.slug)      ? "favorite"      : !model.isCustom && hiddenModelSet.has(model.slug)        ? "hidden"        : "visible";  const handleMove = (slug: string, direction: -1 | 1) => {    const index = displayModels.findIndex((model) => model.slug === slug);    const nextIndex = index + direction;    if (index < 0 || nextIndex < 0 || nextIndex >= displayModels.length) return;    if (groupOf(displayModels[index]!) !== groupOf(displayModels[nextIndex]!)) return;    const next = displayModels.map((model) => model.slug);    [next[index], next[nextIndex]] = [next[nextIndex]!, next[index]!];    onModelOrderChange(next);  };   type DisplayModel = (typeof displayModels)[number];   const starButton = (model: DisplayModel, isFavorite: boolean) => (    <Tooltip>      <TooltipTrigger        render={          <Button            size="icon-micro"            variant="ghost"            className={cn(              "[--control-icon-color:currentColor]",              isFavorite                ? "text-yellow-500 hover:text-yellow-600"                : "text-muted-foreground/40 hover:text-muted-foreground",            )}            onClick={() => handleToggleFavorite(model.slug)}            aria-label={`${isFavorite ? "Remove" : "Add"} ${model.name} ${              isFavorite ? "from" : "to"            } favorites`}          />        }      >        <StarIcon className={cn("size-3", isFavorite && "fill-current")} />      </TooltipTrigger>      <TooltipPopup side="top">        {isFavorite ? "Remove from favorites" : "Add to favorites"}      </TooltipPopup>    </Tooltip>  );   // Reorder and remove stay in the row at all times (dimmed when unavailable)  // so ordering is discoverable without hovering.  const rowActions = (    model: DisplayModel,    options: {      readonly isHidden: boolean;      readonly canMoveUp: boolean;      readonly canMoveDown: boolean;    },  ) => (    <span className="flex shrink-0 items-center justify-end gap-0.5">      {!options.isHidden && !isFiltering ? (        <>          <Tooltip>            <TooltipTrigger              render={                <Button                  size="icon-micro"                  variant="ghost-muted"                  disabled={!options.canMoveUp}                  onClick={() => handleMove(model.slug, -1)}                  aria-label={`Move ${model.name} up`}                />              }            >              <ArrowUpIcon className="size-3" />            </TooltipTrigger>            <TooltipPopup side="top">Move up</TooltipPopup>          </Tooltip>          <Tooltip>            <TooltipTrigger              render={                <Button                  size="icon-micro"                  variant="ghost-muted"                  disabled={!options.canMoveDown}                  onClick={() => handleMove(model.slug, 1)}                  aria-label={`Move ${model.name} down`}                />              }            >              <ArrowDownIcon className="size-3" />            </TooltipTrigger>            <TooltipPopup side="top">Move down</TooltipPopup>          </Tooltip>        </>      ) : null}      {model.isCustom ? (        <>          <Tooltip>            <TooltipTrigger              render={                <Button                  size="icon-micro"                  variant="ghost-muted"                  aria-label={`Edit ${model.slug}`}                  onClick={() =>                    setEditingSlug((current) => (current === model.slug ? null : model.slug))                  }                />              }            >              <PencilIcon className="size-3" />            </TooltipTrigger>            <TooltipPopup side="top">Edit name and options</TooltipPopup>          </Tooltip>          <Tooltip>            <TooltipTrigger              render={                <Button                  size="icon-micro"                  variant="ghost-muted"                  aria-label={`Remove ${model.slug}`}                  onClick={() => handleRemove(model.slug)}                />              }            >              <XIcon className="size-3" />            </TooltipTrigger>            <TooltipPopup side="top">Remove custom model</TooltipPopup>          </Tooltip>        </>      ) : null}    </span>  );   const pickerTooltip = (model: DisplayModel, isHidden: boolean) =>    model.isCustom      ? "Custom models are always shown in the picker"      : isHidden        ? "Hidden from picker"        : "Shown in picker";   // The trigger is a wrapper span: a disabled switch gets no pointer events,  // so it could not open the tooltip itself.  const pickerSwitch = (model: DisplayModel, isHidden: boolean) => (    <Tooltip>      <TooltipTrigger render={<span className="flex shrink-0 items-center" />}>        <Switch          size="sm"          checked={!isHidden}          disabled={model.isCustom}          onCheckedChange={(checked) => setHidden(model.slug, !checked)}          aria-label={`Show ${model.name} in the model picker`}        />      </TooltipTrigger>      <TooltipPopup side="top">{pickerTooltip(model, isHidden)}</TooltipPopup>    </Tooltip>  );   const renderRow = (model: DisplayModel) => {    const capLabels = describeModelCapabilities(model);    const group = groupOf(model);    // Hidden is read from the preference itself: a favorited model can still be    // hidden, and its switch must say so even though it sits in the favorites group.    const isHidden = !model.isCustom && hiddenModelSet.has(model.slug);    const isFavorite = group === "favorite";    const index = displayModels.indexOf(model);    const previousModel = displayModels[index - 1];    const nextModel = displayModels[index + 1];    // Reordering a filtered view would be ambiguous, so arrows only show on    // the full list.    const canMoveUp =      !isFiltering && previousModel !== undefined && groupOf(previousModel) === group;    const canMoveDown = !isFiltering && nextModel !== undefined && groupOf(nextModel) === group;    const nameClassName = cn("text-xs", isHidden ? "text-muted-foreground" : "text-foreground/90");     return (      <div        key={`${instanceId}:${model.slug}`}        data-model-slug={model.slug}        className={cn(          // Actions column is at least wide enough for the four custom-row          // buttons so capability labels line up across built-in and custom rows.          "grid h-7 grid-cols-[1.5rem_minmax(0,1fr)_auto_minmax(5.5rem,auto)_auto] items-center gap-2 rounded-md px-2 transition-colors hover:bg-muted/30",          isHidden && "opacity-50",        )}      >        {starButton(model, isFavorite)}        <span className="flex min-w-0 items-baseline gap-2">          <span className={cn(nameClassName, "truncate")}>{model.name}</span>          {model.name !== model.slug ? (            <code className="truncate font-mono text-[11px] text-muted-foreground/70">              {model.slug}            </code>          ) : null}          {model.isCustom ? (            <span className="text-[11px] text-muted-foreground/70">custom</span>          ) : null}        </span>        {/*          Always a grid item so the columns line up across rows; the text          itself drops out on phone widths where it would starve the name.        */}        <span className="text-[11px] text-muted-foreground/70">          {capLabels.length > 0 ? (            <span className="hidden sm:inline">{capLabels.join(" · ")}</span>          ) : null}        </span>        {rowActions(model, { isHidden, canMoveUp, canMoveDown })}        {pickerSwitch(model, isHidden)}      </div>    );  };

Lines 1–500 of 640.