780 lines · 31.6 KB · TypeScript
RawDownload
import { createFileRoute } from "@tanstack/react-router";import { useQuery } from "@tanstack/react-query";import { useCallback, useState } from "react";import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts";import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";import { getAppModelOptions, MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings";import { resolveAndPersistPreferredEditor } from "../editorPreferences";import { isElectron } from "../env";import { useTheme } from "../hooks/useTheme";import { serverConfigQueryOptions } from "../lib/serverReactQuery";import { ensureNativeApi } from "../nativeApi";import { Button } from "../components/ui/button";import { Input } from "../components/ui/input";import {  Select,  SelectItem,  SelectPopup,  SelectTrigger,  SelectValue,} from "../components/ui/select";import { Switch } from "../components/ui/switch";import { APP_VERSION } from "../branding";import { SidebarInset } from "~/components/ui/sidebar"; const THEME_OPTIONS = [  {    value: "system",    label: "System",    description: "Match your OS appearance setting.",  },  {    value: "light",    label: "Light",    description: "Always use the light theme.",  },  {    value: "dark",    label: "Dark",    description: "Always use the dark theme.",  },] as const; const MODEL_PROVIDER_SETTINGS: Array<{  provider: ProviderKind;  title: string;  description: string;  placeholder: string;  example: string;}> = [  {    provider: "codex",    title: "Codex",    description: "Save additional Codex model slugs for the picker and `/model` command.",    placeholder: "your-codex-model-slug",    example: "gpt-6.7-codex-ultra-preview",  },  {    provider: "claudeAgent",    title: "Claude",    description: "Save additional Claude model slugs for the picker and `/model` command.",    placeholder: "your-claude-model-slug",    example: "claude-sonnet-5-0",  },] as const; const TIMESTAMP_FORMAT_LABELS = {  locale: "System default",  "12-hour": "12-hour",  "24-hour": "24-hour",} as const; function getCustomModelsForProvider(  settings: ReturnType<typeof useAppSettings>["settings"],  provider: ProviderKind,) {  switch (provider) {    case "claudeAgent":      return settings.customClaudeModels;    case "codex":    default:      return settings.customCodexModels;  }} function getDefaultCustomModelsForProvider(  defaults: ReturnType<typeof useAppSettings>["defaults"],  provider: ProviderKind,) {  switch (provider) {    case "claudeAgent":      return defaults.customClaudeModels;    case "codex":    default:      return defaults.customCodexModels;  }} function patchCustomModels(provider: ProviderKind, models: string[]) {  switch (provider) {    case "claudeAgent":      return { customClaudeModels: models };    case "codex":    default:      return { customCodexModels: models };  }} function SettingsRouteView() {  const { theme, setTheme, resolvedTheme } = useTheme();  const { settings, defaults, updateSettings } = useAppSettings();  const serverConfigQuery = useQuery(serverConfigQueryOptions());  const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);  const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null);  const [customModelInputByProvider, setCustomModelInputByProvider] = useState<    Record<ProviderKind, string>  >({    codex: "",    claudeAgent: "",  });  const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<    Partial<Record<ProviderKind, string | null>>  >({});   const codexBinaryPath = settings.codexBinaryPath;  const codexHomePath = settings.codexHomePath;  const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;  const availableEditors = serverConfigQuery.data?.availableEditors;   const gitTextGenerationModelOptions = getAppModelOptions(    "codex",    settings.customCodexModels,    settings.textGenerationModel,  );  const selectedGitTextGenerationModelLabel =    gitTextGenerationModelOptions.find(      (option) =>        option.slug === (settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL),    )?.name ?? settings.textGenerationModel;   const openKeybindingsFile = useCallback(() => {    if (!keybindingsConfigPath) return;    setOpenKeybindingsError(null);    setIsOpeningKeybindings(true);    const api = ensureNativeApi();    const editor = resolveAndPersistPreferredEditor(availableEditors ?? []);    if (!editor) {      setOpenKeybindingsError("No available editors found.");      setIsOpeningKeybindings(false);      return;    }    void api.shell      .openInEditor(keybindingsConfigPath, editor)      .catch((error) => {        setOpenKeybindingsError(          error instanceof Error ? error.message : "Unable to open keybindings file.",        );      })      .finally(() => {        setIsOpeningKeybindings(false);      });  }, [availableEditors, keybindingsConfigPath]);   const addCustomModel = useCallback(    (provider: ProviderKind) => {      const customModelInput = customModelInputByProvider[provider];      const customModels = getCustomModelsForProvider(settings, provider);      const normalized = normalizeModelSlug(customModelInput, provider);      if (!normalized) {        setCustomModelErrorByProvider((existing) => ({          ...existing,          [provider]: "Enter a model slug.",        }));        return;      }      if (getModelOptions(provider).some((option) => option.slug === normalized)) {        setCustomModelErrorByProvider((existing) => ({          ...existing,          [provider]: "That model is already built in.",        }));        return;      }      if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) {        setCustomModelErrorByProvider((existing) => ({          ...existing,          [provider]: `Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`,        }));        return;      }      if (customModels.includes(normalized)) {        setCustomModelErrorByProvider((existing) => ({          ...existing,          [provider]: "That custom model is already saved.",        }));        return;      }       updateSettings(patchCustomModels(provider, [...customModels, normalized]));      setCustomModelInputByProvider((existing) => ({        ...existing,        [provider]: "",      }));      setCustomModelErrorByProvider((existing) => ({        ...existing,        [provider]: null,      }));    },    [customModelInputByProvider, settings, updateSettings],  );   const removeCustomModel = useCallback(    (provider: ProviderKind, slug: string) => {      const customModels = getCustomModelsForProvider(settings, provider);      updateSettings(        patchCustomModels(          provider,          customModels.filter((model) => model !== slug),        ),      );      setCustomModelErrorByProvider((existing) => ({        ...existing,        [provider]: null,      }));    },    [settings, updateSettings],  );   return (    <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">      <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">        {isElectron && (          <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">            <span className="text-xs font-medium tracking-wide text-muted-foreground/70">              Settings            </span>          </div>        )}         <div className="flex-1 overflow-y-auto p-6">          <div className="mx-auto flex w-full max-w-3xl flex-col gap-6">            <header className="space-y-1">              <h1 className="text-2xl font-semibold tracking-tight text-foreground">Settings</h1>              <p className="text-sm text-muted-foreground">                Configure app-level preferences for this device.              </p>            </header>             <section className="rounded-2xl border border-border bg-card p-5">              <div className="mb-4">                <h2 className="text-sm font-medium text-foreground">Appearance</h2>                <p className="mt-1 text-xs text-muted-foreground">                  Choose how T3 Code looks across the app.                </p>              </div>               <div className="space-y-4">                <div className="space-y-2" role="radiogroup" aria-label="Theme preference">                  {THEME_OPTIONS.map((option) => {                    const selected = theme === option.value;                    return (                      <button                        key={option.value}                        type="button"                        role="radio"                        aria-checked={selected}                        className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${                          selected                            ? "border-primary/60 bg-primary/8 text-foreground"                            : "border-border bg-background text-muted-foreground hover:bg-accent"                        }`}                        onClick={() => setTheme(option.value)}                      >                        <span className="flex flex-col">                          <span className="text-sm font-medium">{option.label}</span>                          <span className="text-xs">{option.description}</span>                        </span>                        {selected ? (                          <span className="rounded bg-primary/14 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary">                            Selected                          </span>                        ) : null}                      </button>                    );                  })}                </div>                 <p className="text-xs text-muted-foreground">                  Active theme: <span className="font-medium text-foreground">{resolvedTheme}</span>                </p>                 <div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2">                  <div>                    <p className="text-sm font-medium text-foreground">Timestamp format</p>                    <p className="text-xs text-muted-foreground">                      System default follows your browser or OS time format. <code>12-hour</code>{" "}                      and <code>24-hour</code> force the hour cycle.                    </p>                  </div>                  <Select                    value={settings.timestampFormat}                    onValueChange={(value) => {                      if (value !== "locale" && value !== "12-hour" && value !== "24-hour") return;                      updateSettings({                        timestampFormat: value,                      });                    }}                  >                    <SelectTrigger className="w-40" aria-label="Timestamp format">                      <SelectValue>{TIMESTAMP_FORMAT_LABELS[settings.timestampFormat]}</SelectValue>                    </SelectTrigger>                    <SelectPopup align="end">                      <SelectItem value="locale">{TIMESTAMP_FORMAT_LABELS.locale}</SelectItem>                      <SelectItem value="12-hour">{TIMESTAMP_FORMAT_LABELS["12-hour"]}</SelectItem>                      <SelectItem value="24-hour">{TIMESTAMP_FORMAT_LABELS["24-hour"]}</SelectItem>                    </SelectPopup>                  </Select>                </div>                 {settings.timestampFormat !== defaults.timestampFormat ? (                  <div className="flex justify-end">                    <Button                      size="xs"                      variant="outline"                      onClick={() =>                        updateSettings({                          timestampFormat: defaults.timestampFormat,                        })                      }                    >                      Restore default                    </Button>                  </div>                ) : null}              </div>            </section>             <section className="rounded-2xl border border-border bg-card p-5">              <div className="mb-4">                <h2 className="text-sm font-medium text-foreground">Codex App Server</h2>                <p className="mt-1 text-xs text-muted-foreground">                  These overrides apply to new sessions and let you use a non-default Codex install.                </p>              </div>               <div className="space-y-4">                <label htmlFor="codex-binary-path" className="block space-y-1">                  <span className="text-xs font-medium text-foreground">Codex binary path</span>                  <Input                    id="codex-binary-path"                    value={codexBinaryPath}                    onChange={(event) => updateSettings({ codexBinaryPath: event.target.value })}                    placeholder="codex"                    spellCheck={false}                  />                  <span className="text-xs text-muted-foreground">                    Leave blank to use <code>codex</code> from your PATH.                  </span>                </label>                 <label htmlFor="codex-home-path" className="block space-y-1">                  <span className="text-xs font-medium text-foreground">CODEX_HOME path</span>                  <Input                    id="codex-home-path"                    value={codexHomePath}                    onChange={(event) => updateSettings({ codexHomePath: event.target.value })}                    placeholder="/Users/you/.codex"                    spellCheck={false}                  />                  <span className="text-xs text-muted-foreground">                    Optional custom Codex home/config directory.                  </span>                </label>                 <div className="flex flex-col gap-3 text-xs text-muted-foreground sm:flex-row sm:items-start sm:justify-between">                  <div className="min-w-0 flex-1">                    <p>Binary source</p>                    <p className="mt-1 break-all font-mono text-[11px] text-foreground">                      {codexBinaryPath || "PATH"}                    </p>                  </div>                  <Button                    size="xs"                    variant="outline"                    className="self-start"                    onClick={() =>                      updateSettings({                        codexBinaryPath: defaults.codexBinaryPath,                        codexHomePath: defaults.codexHomePath,                      })                    }                  >                    Reset codex overrides                  </Button>                </div>              </div>            </section>             <section className="rounded-2xl border border-border bg-card p-5">              <div className="mb-4">                <h2 className="text-sm font-medium text-foreground">Models</h2>                <p className="mt-1 text-xs text-muted-foreground">                  Save additional provider model slugs so they appear in the chat model picker and                  `/model` command suggestions.                </p>              </div>               <div className="space-y-5">                {MODEL_PROVIDER_SETTINGS.map((providerSettings) => {                  const provider = providerSettings.provider;                  const customModels = getCustomModelsForProvider(settings, provider);                  const customModelInput = customModelInputByProvider[provider];                  const customModelError = customModelErrorByProvider[provider] ?? null;                  return (                    <div                      key={provider}                      className="rounded-xl border border-border bg-background/50 p-4"                    >                      <div className="mb-4">                        <h3 className="text-sm font-medium text-foreground">                          {providerSettings.title}                        </h3>                        <p className="mt-1 text-xs text-muted-foreground">                          {providerSettings.description}                        </p>                      </div>                       <div className="space-y-4">                        <div className="flex flex-col gap-3 sm:flex-row sm:items-start">                          <label                            htmlFor={`custom-model-slug-${provider}`}                            className="block flex-1 space-y-1"                          >                            <span className="text-xs font-medium text-foreground">                              Custom model slug                            </span>                            <Input                              id={`custom-model-slug-${provider}`}                              value={customModelInput}                              onChange={(event) => {                                const value = event.target.value;                                setCustomModelInputByProvider((existing) => ({                                  ...existing,                                  [provider]: value,                                }));                                if (customModelError) {                                  setCustomModelErrorByProvider((existing) => ({                                    ...existing,                                    [provider]: null,                                  }));                                }                              }}                              onKeyDown={(event) => {                                if (event.key !== "Enter") return;                                event.preventDefault();                                addCustomModel(provider);                              }}                              placeholder={providerSettings.placeholder}                              spellCheck={false}                            />                            <span className="text-xs text-muted-foreground">                              Example: <code>{providerSettings.example}</code>                            </span>                          </label>                           <Button                            className="sm:mt-6"                            type="button"                            onClick={() => addCustomModel(provider)}                          >                            Add model                          </Button>                        </div>                         {customModelError ? (                          <p className="text-xs text-destructive">{customModelError}</p>                        ) : null}                         <div className="space-y-2">                          <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">                            <p>Saved custom models: {customModels.length}</p>                            {customModels.length > 0 ? (                              <Button                                size="xs"                                variant="outline"                                onClick={() =>                                  updateSettings(                                    patchCustomModels(provider, [                                      ...getDefaultCustomModelsForProvider(defaults, provider),                                    ]),                                  )                                }                              >                                Reset custom models                              </Button>                            ) : null}                          </div>                           {customModels.length > 0 ? (                            <div className="space-y-2">                              {customModels.map((slug) => (                                <div

Lines 1–500 of 780.