diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx
index e90b415820..5c423ef05b 100644
--- a/apps/web/src/components/ui/select.tsx
+++ b/apps/web/src/components/ui/select.tsx
@@ -164,32 +164,45 @@ function SelectPopup({
);
}
-function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
+function SelectItem({
+ className,
+ children,
+ hideIndicator = false,
+ ...props
+}: SelectPrimitive.Item.Props & {
+ hideIndicator?: boolean;
+}) {
return (
-
-
-
-
+ {hideIndicator ? null : (
+
+
+
+ )}
+
{children}
diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx
index acc8763fb4..1d3c300f89 100644
--- a/apps/web/src/routes/_chat.settings.tsx
+++ b/apps/web/src/routes/_chat.settings.tsx
@@ -1,23 +1,20 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
-import { useCallback, useState } from "react";
+import { ChevronRightIcon, PlusIcon, RotateCcwIcon, XIcon } from "lucide-react";
+import { type ReactNode, useCallback, useState } from "react";
import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts";
import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
import {
getAppModelOptions,
getCustomModelsForProvider,
- getDefaultCustomModelsForProvider,
MAX_CUSTOM_MODEL_LENGTH,
MODEL_PROVIDER_SETTINGS,
patchCustomModels,
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 { APP_VERSION } from "../branding";
import { Button } from "../components/ui/button";
+import { Collapsible, CollapsibleContent } from "../components/ui/collapsible";
import { Input } from "../components/ui/input";
import {
Select,
@@ -26,9 +23,15 @@ import {
SelectTrigger,
SelectValue,
} from "../components/ui/select";
+import { SidebarTrigger } from "../components/ui/sidebar";
import { Switch } from "../components/ui/switch";
-import { APP_VERSION } from "../branding";
-import { SidebarInset } from "~/components/ui/sidebar";
+import { SidebarInset } from "../components/ui/sidebar";
+import { resolveAndPersistPreferredEditor } from "../editorPreferences";
+import { isElectron } from "../env";
+import { useTheme } from "../hooks/useTheme";
+import { serverConfigQueryOptions } from "../lib/serverReactQuery";
+import { cn } from "../lib/utils";
+import { ensureNativeApi, readNativeApi } from "../nativeApi";
const THEME_OPTIONS = [
{
@@ -54,12 +57,73 @@ const TIMESTAMP_FORMAT_LABELS = {
"24-hour": "24-hour",
} as const;
+function SettingsSection({ title, children }: { title: string; children: ReactNode }) {
+ return (
+
+
+ {title}
+
+
+ {children}
+
+
+ );
+}
+
+function SettingsRow({
+ title,
+ description,
+ status,
+ control,
+ children,
+ onClick,
+}: {
+ title: string;
+ description: string;
+ status?: ReactNode;
+ control?: ReactNode;
+ children?: ReactNode;
+ onClick?: () => void;
+}) {
+ return (
+
+
+
+
{title}
+
{description}
+ {status ?
{status}
: null}
+
+ {control ? (
+
+ {control}
+
+ ) : null}
+
+ {children}
+
+ );
+}
+
function SettingsRouteView() {
- const { theme, setTheme, resolvedTheme } = useTheme();
- const { settings, defaults, updateSettings } = useAppSettings();
+ const { theme, setTheme } = useTheme();
+ const { settings, defaults, updateSettings, resetSettings } = useAppSettings();
const serverConfigQuery = useQuery(serverConfigQueryOptions());
const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);
const [openKeybindingsError, setOpenKeybindingsError] = useState(null);
+ const [isCodexInstallOpen, setIsCodexInstallOpen] = useState(() =>
+ Boolean(settings.codexBinaryPath || settings.codexHomePath),
+ );
+ const [selectedCustomModelProvider, setSelectedCustomModelProvider] =
+ useState("codex");
const [customModelInputByProvider, setCustomModelInputByProvider] = useState<
Record
>({
@@ -69,6 +133,7 @@ function SettingsRouteView() {
const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<
Partial>
>({});
+ const [showAllCustomModels, setShowAllCustomModels] = useState(false);
const codexBinaryPath = settings.codexBinaryPath;
const codexHomePath = settings.codexHomePath;
@@ -85,6 +150,42 @@ function SettingsRouteView() {
(option) =>
option.slug === (settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL),
)?.name ?? settings.textGenerationModel;
+ const selectedCustomModelProviderSettings = MODEL_PROVIDER_SETTINGS.find(
+ (providerSettings) => providerSettings.provider === selectedCustomModelProvider,
+ )!;
+ const selectedCustomModelInput = customModelInputByProvider[selectedCustomModelProvider];
+ const selectedCustomModelError = customModelErrorByProvider[selectedCustomModelProvider] ?? null;
+ const totalCustomModels = settings.customCodexModels.length + settings.customClaudeModels.length;
+ const savedCustomModelRows = MODEL_PROVIDER_SETTINGS.flatMap((providerSettings) =>
+ getCustomModelsForProvider(settings, providerSettings.provider).map((slug) => ({
+ key: `${providerSettings.provider}:${slug}`,
+ provider: providerSettings.provider,
+ providerTitle: providerSettings.title,
+ slug,
+ })),
+ );
+ const visibleCustomModelRows = showAllCustomModels
+ ? savedCustomModelRows
+ : savedCustomModelRows.slice(0, 5);
+ const changedSettingLabels = [
+ ...(theme !== "system" ? ["Theme"] : []),
+ ...(settings.timestampFormat !== defaults.timestampFormat ? ["Time format"] : []),
+ ...(settings.enableAssistantStreaming !== defaults.enableAssistantStreaming
+ ? ["Assistant output"]
+ : []),
+ ...(settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ["New thread mode"] : []),
+ ...(settings.confirmThreadDelete !== defaults.confirmThreadDelete
+ ? ["Delete confirmation"]
+ : []),
+ ...(settings.textGenerationModel !== defaults.textGenerationModel ? ["Git writing model"] : []),
+ ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0
+ ? ["Custom models"]
+ : []),
+ ...(settings.codexBinaryPath !== defaults.codexBinaryPath ||
+ settings.codexHomePath !== defaults.codexHomePath
+ ? ["Codex install"]
+ : []),
+ ];
const openKeybindingsFile = useCallback(() => {
if (!keybindingsConfigPath) return;
@@ -173,550 +274,437 @@ function SettingsRouteView() {
[settings, updateSettings],
);
+ async function restoreDefaults() {
+ if (changedSettingLabels.length === 0) return;
+
+ const api = readNativeApi();
+ const confirmed = await (api ?? ensureNativeApi()).dialogs.confirm(
+ ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join(
+ "\n",
+ ),
+ );
+ if (!confirmed) return;
+
+ setTheme("system");
+ resetSettings();
+ setIsCodexInstallOpen(false);
+ setSelectedCustomModelProvider("codex");
+ setCustomModelInputByProvider({
+ codex: "",
+ claudeAgent: "",
+ });
+ setCustomModelErrorByProvider({});
+ }
+
return (
+ {!isElectron && (
+
+ )}
+
{isElectron && (
Settings
+
+
+
)}
-
-
-
-
-
-
Appearance
-
- Choose how T3 Code looks across the app.
-
-
-
-
-
- {THEME_OPTIONS.map((option) => {
- const selected = theme === option.value;
- return (
-
- );
- })}
-
-
-
- Active theme: {resolvedTheme}
-
+
+
+ {
+ if (value !== "system" && value !== "light" && value !== "dark") return;
+ setTheme(value);
+ }}
+ >
+
+
+ {THEME_OPTIONS.find((option) => option.value === theme)?.label ?? "System"}
+
+
+
+ {THEME_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ }
+ />
-
-
-
Timestamp format
-
- System default follows your browser or OS time format. 12-hour{" "}
- and 24-hour force the hour cycle.
-
-
+
{
- if (value !== "locale" && value !== "12-hour" && value !== "24-hour") return;
+ if (value !== "locale" && value !== "12-hour" && value !== "24-hour") {
+ return;
+ }
updateSettings({
timestampFormat: value,
});
}}
>
-
+
{TIMESTAMP_FORMAT_LABELS[settings.timestampFormat]}
-
- {TIMESTAMP_FORMAT_LABELS.locale}
- {TIMESTAMP_FORMAT_LABELS["12-hour"]}
- {TIMESTAMP_FORMAT_LABELS["24-hour"]}
+
+
+ {TIMESTAMP_FORMAT_LABELS.locale}
+
+
+ {TIMESTAMP_FORMAT_LABELS["12-hour"]}
+
+
+ {TIMESTAMP_FORMAT_LABELS["24-hour"]}
+
-
-
- {settings.timestampFormat !== defaults.timestampFormat ? (
-
-
-
- ) : null}
-
-
-
-
-
-
Codex App Server
-
- These overrides apply to new sessions and let you use a non-default Codex install.
-
-
-
-
-
-
-
-
-
-
-
Binary source
-
- {codexBinaryPath || "PATH"}
-
-
-
-
-
-
-
-
-
-
Models
-
- Save additional provider model slugs so they appear in the chat model picker and
- `/model` command suggestions.
-
-
-
-
- {MODEL_PROVIDER_SETTINGS.map((providerSettings) => {
- const provider = providerSettings.provider;
- const customModels = getCustomModelsForProvider(settings, provider);
- const customModelInput = customModelInputByProvider[provider];
- const customModelError = customModelErrorByProvider[provider] ?? null;
- return (
-
-
-
- {providerSettings.title}
-
-
- {providerSettings.description}
-
-
-
-
-
-
-
-
-
-
- {customModelError ? (
-
{customModelError}
- ) : null}
-
-
-
-
Saved custom models: {customModels.length}
- {customModels.length > 0 ? (
-
- ) : null}
-
-
- {customModels.length > 0 ? (
-
- {customModels.map((slug) => (
-
-
- {slug}
-
-
-
- ))}
-
- ) : (
-
- No custom models saved yet.
-
- )}
-
-
-
- );
- })}
-
-
-
-
-
-
Git
-
- Configure the model used for generating commit messages, PR titles, and branch
- names.
-
-
+ aria-label="Stream assistant messages"
+ />
+ }
+ />
-
-
-
Text generation model
-
- Model used for auto-generated git content.
-
-
-
-
-
- {settings.textGenerationModel !== defaults.textGenerationModel ? (
-
-
-
- ) : null}
-
-
-
-
-
Threads
-
- Choose the default workspace mode for newly created draft threads.
-
-
-
-
-
-
Default to New worktree
-
- New threads start in New worktree mode instead of Local.
-
-
-
- updateSettings({
- defaultThreadEnvMode: checked ? "worktree" : "local",
- })
- }
- aria-label="Default new threads to New worktree mode"
- />
-
-
- {settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? (
-
-
-
- ) : null}
-
+
+ {selectedGitTextGenerationModelLabel}
+
+
+ {gitTextGenerationModelOptions.map((option) => (
+
+ {option.name}
+
+ ))}
+
+
+ }
+ />
+
+
+
+
+
+
{
+ const value = event.target.value;
+ setCustomModelInputByProvider((existing) => ({
+ ...existing,
+ [selectedCustomModelProvider]: value,
+ }));
+ if (selectedCustomModelError) {
+ setCustomModelErrorByProvider((existing) => ({
+ ...existing,
+ [selectedCustomModelProvider]: null,
+ }));
+ }
+ }}
+ onKeyDown={(event) => {
+ if (event.key !== "Enter") return;
+ event.preventDefault();
+ addCustomModel(selectedCustomModelProvider);
+ }}
+ placeholder={selectedCustomModelProviderSettings.example}
+ spellCheck={false}
+ />
+
+
-
-
-
Responses
-
- Control how assistant output is rendered during a turn.
-
-
+ {selectedCustomModelError ? (
+ {selectedCustomModelError}
+ ) : null}
+
+ {totalCustomModels > 0 ? (
+
+
+ {visibleCustomModelRows.map((row) => (
+
+
+ {row.providerTitle}
+
+
+ {row.slug}
+
+
+
+ ))}
+
-
-
-
Stream assistant messages
-
- Show token-by-token output while a response is in progress.
-
+ {savedCustomModelRows.length > 5 ? (
+
+ ) : null}
+
+ ) : null}
-
- updateSettings({
- enableAssistantStreaming: Boolean(checked),
- })
+
+
+
+
+
+ setIsCodexInstallOpen((open) => !open)}
+ control={
+
}
- aria-label="Stream assistant messages"
- />
-
-
- {settings.enableAssistantStreaming !== defaults.enableAssistantStreaming ? (
-
-
-
- ) : null}
-
-
-
-
-
Keybindings
-
- Open the persisted keybindings.json file to edit advanced bindings
- directly.
-
-
+ >
+
+
+
+
-
-
-
-
Config file path
-
+
+
+
+
+
+
+
+
+
{keybindingsConfigPath ?? "Resolving keybindings path..."}
-
-
+
+ {openKeybindingsError ? (
+
{openKeybindingsError}
+ ) : (
+
Opens in your preferred editor.
+ )}
+ >
+ }
+ control={
-
-
-
- Opens in your preferred editor selection.
-
- {openKeybindingsError ? (
-
{openKeybindingsError}
- ) : null}
-
-
-
-
-
-
Safety
-
- Additional guardrails for destructive local actions.
-
-
-
-
-
-
Confirm thread deletion
-
- Ask for confirmation before deleting a thread and its chat history.
-
-
-
- updateSettings({
- confirmThreadDelete: Boolean(checked),
- })
- }
- aria-label="Confirm thread deletion"
- />
-
-
- {settings.confirmThreadDelete !== defaults.confirmThreadDelete ? (
-
-
-
- ) : null}
-
-
-
-
About
-
- Application version and environment information.
-
-
-
-
-
-
Version
-
- Current version of the application.
-
-
-
{APP_VERSION}
-
-
+ }
+ />
+
+
{APP_VERSION}
+ }
+ />
+