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 && ( +
+
+ + Settings +
+ +
+
+
+ )} + {isElectron && (
Settings +
+ +
)}
-
-
-

Settings

-

- Configure app-level preferences for this device. -

-
- -
-
-

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} + } + /> +