diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 70c56e84b..a28cd2f35 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -559,7 +559,7 @@ export default function Sidebar() { const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); const isOnSubPage = - pathname === "/settings" || + pathname.startsWith("/settings") || pathname === "/pr-review" || pathname === "/merge-conflicts" || pathname === "/sme-chat"; diff --git a/apps/web/src/components/settings/SettingsRouteContext.tsx b/apps/web/src/components/settings/SettingsRouteContext.tsx new file mode 100644 index 000000000..b83e77fd9 --- /dev/null +++ b/apps/web/src/components/settings/SettingsRouteContext.tsx @@ -0,0 +1,273 @@ +import { type ReactNode, createContext, useCallback, useContext, useMemo, useState } from "react"; + +import { DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@okcode/contracts"; + +import { DEFAULT_PR_REVIEW_REQUEST_CHANGES_TONE, useAppSettings } from "../../appSettings"; +import { DEFAULT_COLOR_THEME, useTheme } from "../../hooks/useTheme"; +import { readNativeApi, ensureNativeApi } from "../../nativeApi"; +import { + clearFontOverride, + clearFontSizeOverride, + clearRadiusOverride, + clearStoredCustomTheme, + getStoredFontOverride, + getStoredFontSizeOverride, + getStoredRadiusOverride, + removeCustomTheme, + setStoredFontOverride, + setStoredFontSizeOverride, + setStoredRadiusOverride, +} from "../../lib/customTheme"; + +type ThemeState = ReturnType; + +interface SettingsRouteContextValue { + theme: ThemeState["theme"]; + setTheme: ThemeState["setTheme"]; + colorTheme: ThemeState["colorTheme"]; + setColorTheme: ThemeState["setColorTheme"]; + fontFamily: ThemeState["fontFamily"]; + setFontFamily: ThemeState["setFontFamily"]; + settingsState: ReturnType; + radiusOverride: number | null; + setRadiusOverride: (value: number | null) => void; + fontOverride: string; + setFontOverride: (value: string) => void; + fontSizeOverride: number | null; + setFontSizeOverride: (value: number | null) => void; + changedSettingLabels: readonly string[]; + restoreDefaults: () => Promise; +} + +const SettingsRouteContext = createContext(null); + +export function SettingsRouteContextProvider({ children }: { children: ReactNode }) { + const { theme, setTheme, colorTheme, setColorTheme, fontFamily, setFontFamily } = useTheme(); + const settingsState = useAppSettings(); + const { settings, defaults, resetSettings } = settingsState; + const [radiusOverrideState, setRadiusOverrideState] = useState(() => + getStoredRadiusOverride(), + ); + const [fontOverrideState, setFontOverrideState] = useState( + () => getStoredFontOverride() ?? "", + ); + const [fontSizeOverrideState, setFontSizeOverrideState] = useState(() => + getStoredFontSizeOverride(), + ); + + const setRadiusOverride = useCallback((value: number | null) => { + setRadiusOverrideState(value); + if (value === null) { + clearRadiusOverride(); + return; + } + setStoredRadiusOverride(value); + }, []); + + const setFontOverride = useCallback((value: string) => { + setFontOverrideState(value); + if (value.trim()) { + setStoredFontOverride(value); + return; + } + clearFontOverride(); + }, []); + + const setFontSizeOverride = useCallback((value: number | null) => { + setFontSizeOverrideState(value); + if (value === null) { + clearFontSizeOverride(); + return; + } + setStoredFontSizeOverride(value); + }, []); + + const currentGitTextGenerationModel = + settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL; + const defaultGitTextGenerationModel = + defaults.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL; + const isGitTextGenerationModelDirty = + currentGitTextGenerationModel !== defaultGitTextGenerationModel; + const isInstallSettingsDirty = + settings.claudeBinaryPath !== defaults.claudeBinaryPath || + settings.codexBinaryPath !== defaults.codexBinaryPath || + settings.codexHomePath !== defaults.codexHomePath; + const isOpenClawSettingsDirty = + settings.openclawGatewayUrl !== defaults.openclawGatewayUrl || + settings.openclawPassword !== defaults.openclawPassword; + + const changedSettingLabels = useMemo( + () => + [ + ...(theme !== "system" ? ["Theme"] : []), + ...(colorTheme !== DEFAULT_COLOR_THEME ? ["Color theme"] : []), + ...(fontFamily !== "inter" ? ["Font"] : []), + ...(settings.prReviewRequestChangesTone !== DEFAULT_PR_REVIEW_REQUEST_CHANGES_TONE + ? ["PR request changes button"] + : []), + ...(settings.timestampFormat !== defaults.timestampFormat ? ["Time format"] : []), + ...(settings.showStitchBorder !== defaults.showStitchBorder ? ["Stitch border"] : []), + ...(settings.enableAssistantStreaming !== defaults.enableAssistantStreaming + ? ["Assistant output"] + : []), + ...(settings.showReasoningContent !== defaults.showReasoningContent + ? ["Reasoning content"] + : []), + ...(settings.showAuthFailuresAsErrors !== defaults.showAuthFailuresAsErrors + ? ["Auth failure errors"] + : []), + ...(settings.showNotificationDetails !== defaults.showNotificationDetails + ? ["Notification details"] + : []), + ...(settings.includeDiagnosticsTipsInCopy !== defaults.includeDiagnosticsTipsInCopy + ? ["Diagnostics copy tips"] + : []), + ...(settings.openLinksExternally !== defaults.openLinksExternally + ? ["Open links externally"] + : []), + ...(settings.codeViewerAutosave !== defaults.codeViewerAutosave + ? ["Code preview autosave"] + : []), + ...(settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode + ? ["New thread mode"] + : []), + ...(settings.autoUpdateWorktreeBaseBranch !== defaults.autoUpdateWorktreeBaseBranch + ? ["Worktree base refresh"] + : []), + ...(settings.confirmThreadDelete !== defaults.confirmThreadDelete + ? ["Delete confirmation"] + : []), + ...(settings.autoDeleteMergedThreads !== defaults.autoDeleteMergedThreads + ? ["Auto-delete merged threads"] + : []), + ...(settings.autoDeleteMergedThreadsDelayMinutes !== + defaults.autoDeleteMergedThreadsDelayMinutes + ? ["Auto-delete delay"] + : []), + ...(settings.rebaseBeforeCommit !== defaults.rebaseBeforeCommit + ? ["Rebase before commit"] + : []), + ...(isGitTextGenerationModelDirty ? ["Git writing model"] : []), + ...(settings.customCodexModels.length > 0 || + settings.customClaudeModels.length > 0 || + settings.customOpenClawModels.length > 0 + ? ["Custom models"] + : []), + ...(isInstallSettingsDirty ? ["Provider installs"] : []), + ...(isOpenClawSettingsDirty ? ["OpenClaw gateway"] : []), + ...(settings.backgroundImageUrl !== defaults.backgroundImageUrl + ? ["Background image"] + : []), + ...(settings.backgroundImageOpacity !== defaults.backgroundImageOpacity + ? ["Background opacity"] + : []), + ...(settings.sidebarOpacity !== defaults.sidebarOpacity ? ["Sidebar opacity"] : []), + ...(settings.sidebarProjectRowHeight !== defaults.sidebarProjectRowHeight + ? ["Project height"] + : []), + ...(settings.sidebarThreadRowHeight !== defaults.sidebarThreadRowHeight + ? ["Thread height"] + : []), + ...(settings.sidebarFontSize !== defaults.sidebarFontSize ? ["Sidebar font size"] : []), + ...(settings.sidebarSpacing !== defaults.sidebarSpacing ? ["Sidebar spacing"] : []), + ...(radiusOverrideState !== null ? ["Border radius"] : []), + ...(fontOverrideState ? ["Font family"] : []), + ...(fontSizeOverrideState !== null ? ["Code font size"] : []), + ] as const, + [ + colorTheme, + defaults, + fontFamily, + fontOverrideState, + fontSizeOverrideState, + isGitTextGenerationModelDirty, + isInstallSettingsDirty, + isOpenClawSettingsDirty, + radiusOverrideState, + settings, + theme, + ], + ); + + const restoreDefaults = useCallback(async () => { + 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"); + setColorTheme(DEFAULT_COLOR_THEME); + setFontFamily("inter"); + resetSettings(); + + clearStoredCustomTheme(); + removeCustomTheme(); + clearRadiusOverride(); + setRadiusOverrideState(null); + clearFontOverride(); + setFontOverrideState(""); + clearFontSizeOverride(); + setFontSizeOverrideState(null); + }, [ + changedSettingLabels, + resetSettings, + setColorTheme, + setFontFamily, + setTheme, + setFontOverrideState, + setFontSizeOverrideState, + setRadiusOverrideState, + ]); + + const value = useMemo( + () => ({ + theme, + setTheme, + colorTheme, + setColorTheme, + fontFamily, + setFontFamily, + settingsState, + radiusOverride: radiusOverrideState, + setRadiusOverride, + fontOverride: fontOverrideState, + setFontOverride, + fontSizeOverride: fontSizeOverrideState, + setFontSizeOverride, + changedSettingLabels, + restoreDefaults, + }), + [ + changedSettingLabels, + colorTheme, + fontFamily, + fontOverrideState, + fontSizeOverrideState, + radiusOverrideState, + restoreDefaults, + setColorTheme, + setFontFamily, + setFontOverride, + setFontSizeOverride, + setRadiusOverride, + setTheme, + settingsState, + theme, + ], + ); + + return {children}; +} + +export function useSettingsRouteContext() { + const value = useContext(SettingsRouteContext); + if (!value) { + throw new Error("useSettingsRouteContext must be used within SettingsRouteContextProvider."); + } + return value; +} diff --git a/apps/web/src/components/settings/SettingsShell.tsx b/apps/web/src/components/settings/SettingsShell.tsx new file mode 100644 index 000000000..ede0e4555 --- /dev/null +++ b/apps/web/src/components/settings/SettingsShell.tsx @@ -0,0 +1,208 @@ +import { + CpuIcon, + GitBranchIcon, + KeyboardIcon, + PaletteIcon, + Settings2Icon, + ShieldCheckIcon, + SmartphoneIcon, + VariableIcon, + WrenchIcon, +} from "lucide-react"; +import { type ReactNode, useMemo } from "react"; +import { useNavigate } from "@tanstack/react-router"; + +import { isElectron, isMobileShell } from "../../env"; +import { Button } from "../ui/button"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { SidebarInset, SidebarTrigger } from "../ui/sidebar"; +import { cn } from "../../lib/utils"; + +export type SettingsSectionId = + | "general" + | "authentication" + | "hotkeys" + | "environment" + | "git" + | "models" + | "mobile" + | "advanced"; + +export type SettingsNavId = SettingsSectionId | "style"; + +interface SettingsNavItem { + id: SettingsNavId; + label: string; + icon: ReactNode; + hidden?: boolean; +} + +const SETTINGS_NAV_ITEMS: readonly SettingsNavItem[] = [ + { id: "general", label: "General", icon: }, + { id: "style", label: "Style", icon: }, + { + id: "authentication", + label: "Authentication", + icon: , + }, + { id: "hotkeys", label: "Hotkeys", icon: }, + { id: "environment", label: "Environment", icon: }, + { id: "git", label: "Git", icon: }, + { id: "models", label: "Models", icon: }, + { + id: "mobile", + label: "Mobile Companion", + icon: , + hidden: isMobileShell, + }, + { id: "advanced", label: "Advanced", icon: }, +]; + +function SettingsNavSidebar({ + items, + activeItem, + onSelect, +}: { + items: readonly SettingsNavItem[]; + activeItem: SettingsNavId; + onSelect: (id: SettingsNavId) => void; +}) { + return ( + + ); +} + +export function SettingsShell({ + activeItem, + changedSettingLabels, + onRestoreDefaults, + children, +}: { + activeItem: SettingsNavId; + changedSettingLabels: readonly string[]; + onRestoreDefaults: () => Promise; + children: ReactNode; +}) { + const navigate = useNavigate(); + const activeItemLabel = useMemo( + () => SETTINGS_NAV_ITEMS.find((item) => item.id === activeItem)?.label ?? "Settings", + [activeItem], + ); + + const handleSelect = (item: SettingsNavId) => { + if (item === "style") { + void navigate({ to: "/settings/style" }); + return; + } + + if (item === "general") { + void navigate({ to: "/settings", search: {} }); + return; + } + + void navigate({ to: "/settings", search: { section: item } }); + }; + + return ( + +
+ {!isElectron && ( +
+
+ +
+ Settings + / + {activeItemLabel} +
+
+ +
+
+
+ )} + + {isElectron && ( +
+
+ Settings + / + {activeItemLabel} +
+
+ +
+
+ )} + +
+ + +
+
+ +
+ +
+
{children}
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/settings/SettingsUi.tsx b/apps/web/src/components/settings/SettingsUi.tsx new file mode 100644 index 000000000..e9b4e5e05 --- /dev/null +++ b/apps/web/src/components/settings/SettingsUi.tsx @@ -0,0 +1,195 @@ +import { type ReactNode, useCallback } from "react"; + +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { cn } from "../../lib/utils"; +import { Undo2Icon } from "lucide-react"; + +export function SettingsSection({ + title, + description, + children, + actions, +}: { + title: string; + description?: string; + children: ReactNode; + actions?: ReactNode; +}) { + return ( +
+
+
+

{title}

+ {description ?

{description}

: null} +
+ {actions ?
{actions}
: null} +
+
+ {children} +
+
+ ); +} + +export function SettingsRow({ + title, + description, + status, + resetAction, + control, + children, + onClick, +}: { + title: string; + description: string; + status?: ReactNode; + resetAction?: ReactNode; + control?: ReactNode; + children?: ReactNode; + onClick?: () => void; +}) { + return ( +
+
+
+
+

{title}

+ + {resetAction} + +
+

{description}

+ {status ?
{status}
: null} +
+ {control ? ( +
+ {control} +
+ ) : null} +
+ {children} +
+ ); +} + +export function SettingResetButton({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + { + event.stopPropagation(); + onClick(); + }} + > + + + } + /> + Reset to default + + ); +} + +export function BackgroundImageSettings({ + backgroundImageUrl, + backgroundImageOpacity, + defaultBackgroundImageUrl, + defaultBackgroundImageOpacity, + updateSettings, +}: { + backgroundImageUrl: string; + backgroundImageOpacity: number; + defaultBackgroundImageUrl: string; + defaultBackgroundImageOpacity: number; + updateSettings: (patch: { backgroundImageOpacity?: number; backgroundImageUrl?: string }) => void; +}) { + const hasBackground = backgroundImageUrl.trim().length > 0; + + const handleUrlChange = useCallback( + (value: string) => { + updateSettings({ + backgroundImageUrl: value, + }); + }, + [updateSettings], + ); + + const handleOpacityChange = useCallback( + (value: number) => { + updateSettings({ backgroundImageOpacity: value }); + }, + [updateSettings], + ); + + const handleReset = useCallback(() => { + updateSettings({ + backgroundImageUrl: defaultBackgroundImageUrl, + backgroundImageOpacity: defaultBackgroundImageOpacity, + }); + }, [defaultBackgroundImageOpacity, defaultBackgroundImageUrl, updateSettings]); + + return ( + <> + + ) : null + } + control={ + handleUrlChange(e.target.value)} + placeholder="https://example.com/image.jpg" + className="w-full sm:w-56" + aria-label="Background image URL" + /> + } + /> + {hasBackground && ( + + { + const value = Number(e.target.value) / 100; + handleOpacityChange(value); + }} + className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" + aria-label="Background opacity" + /> + + {Math.round(backgroundImageOpacity * 100)}% + + + } + /> + )} + + ); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 380de4a32..6d473645e 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -19,6 +19,8 @@ import { Route as ChatPluginsRouteImport } from './routes/_chat.plugins' import { Route as ChatMergeConflictsRouteImport } from './routes/_chat.merge-conflicts' import { Route as ChatFileViewRouteImport } from './routes/_chat.file-view' import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId' +import { Route as ChatSettingsIndexRouteImport } from './routes/_chat.settings.index' +import { Route as ChatSettingsStyleRouteImport } from './routes/_chat.settings.style' const ChatRoute = ChatRouteImport.update({ id: '/_chat', @@ -69,6 +71,16 @@ const ChatThreadIdRoute = ChatThreadIdRouteImport.update({ path: '/$threadId', getParentRoute: () => ChatRoute, } as any) +const ChatSettingsIndexRoute = ChatSettingsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => ChatSettingsRoute, +} as any) +const ChatSettingsStyleRoute = ChatSettingsStyleRouteImport.update({ + id: '/style', + path: '/style', + getParentRoute: () => ChatSettingsRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute @@ -77,9 +89,11 @@ export interface FileRoutesByFullPath { '/merge-conflicts': typeof ChatMergeConflictsRoute '/plugins': typeof ChatPluginsRoute '/pr-review': typeof ChatPrReviewRoute - '/settings': typeof ChatSettingsRoute + '/settings': typeof ChatSettingsRouteWithChildren '/skills': typeof ChatSkillsRoute '/sme-chat': typeof ChatSmeChatRoute + '/settings/style': typeof ChatSettingsStyleRoute + '/settings/': typeof ChatSettingsIndexRoute } export interface FileRoutesByTo { '/$threadId': typeof ChatThreadIdRoute @@ -87,10 +101,11 @@ export interface FileRoutesByTo { '/merge-conflicts': typeof ChatMergeConflictsRoute '/plugins': typeof ChatPluginsRoute '/pr-review': typeof ChatPrReviewRoute - '/settings': typeof ChatSettingsRoute '/skills': typeof ChatSkillsRoute '/sme-chat': typeof ChatSmeChatRoute '/': typeof ChatIndexRoute + '/settings/style': typeof ChatSettingsStyleRoute + '/settings': typeof ChatSettingsIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -100,10 +115,12 @@ export interface FileRoutesById { '/_chat/merge-conflicts': typeof ChatMergeConflictsRoute '/_chat/plugins': typeof ChatPluginsRoute '/_chat/pr-review': typeof ChatPrReviewRoute - '/_chat/settings': typeof ChatSettingsRoute + '/_chat/settings': typeof ChatSettingsRouteWithChildren '/_chat/skills': typeof ChatSkillsRoute '/_chat/sme-chat': typeof ChatSmeChatRoute '/_chat/': typeof ChatIndexRoute + '/_chat/settings/style': typeof ChatSettingsStyleRoute + '/_chat/settings/': typeof ChatSettingsIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -117,6 +134,8 @@ export interface FileRouteTypes { | '/settings' | '/skills' | '/sme-chat' + | '/settings/style' + | '/settings/' fileRoutesByTo: FileRoutesByTo to: | '/$threadId' @@ -124,10 +143,11 @@ export interface FileRouteTypes { | '/merge-conflicts' | '/plugins' | '/pr-review' - | '/settings' | '/skills' | '/sme-chat' | '/' + | '/settings/style' + | '/settings' id: | '__root__' | '/_chat' @@ -140,6 +160,8 @@ export interface FileRouteTypes { | '/_chat/skills' | '/_chat/sme-chat' | '/_chat/' + | '/_chat/settings/style' + | '/_chat/settings/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -218,16 +240,44 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatThreadIdRouteImport parentRoute: typeof ChatRoute } + '/_chat/settings/': { + id: '/_chat/settings/' + path: '/' + fullPath: '/settings/' + preLoaderRoute: typeof ChatSettingsIndexRouteImport + parentRoute: typeof ChatSettingsRoute + } + '/_chat/settings/style': { + id: '/_chat/settings/style' + path: '/style' + fullPath: '/settings/style' + preLoaderRoute: typeof ChatSettingsStyleRouteImport + parentRoute: typeof ChatSettingsRoute + } } } +interface ChatSettingsRouteChildren { + ChatSettingsStyleRoute: typeof ChatSettingsStyleRoute + ChatSettingsIndexRoute: typeof ChatSettingsIndexRoute +} + +const ChatSettingsRouteChildren: ChatSettingsRouteChildren = { + ChatSettingsStyleRoute: ChatSettingsStyleRoute, + ChatSettingsIndexRoute: ChatSettingsIndexRoute, +} + +const ChatSettingsRouteWithChildren = ChatSettingsRoute._addFileChildren( + ChatSettingsRouteChildren, +) + interface ChatRouteChildren { ChatThreadIdRoute: typeof ChatThreadIdRoute ChatFileViewRoute: typeof ChatFileViewRoute ChatMergeConflictsRoute: typeof ChatMergeConflictsRoute ChatPluginsRoute: typeof ChatPluginsRoute ChatPrReviewRoute: typeof ChatPrReviewRoute - ChatSettingsRoute: typeof ChatSettingsRoute + ChatSettingsRoute: typeof ChatSettingsRouteWithChildren ChatSkillsRoute: typeof ChatSkillsRoute ChatSmeChatRoute: typeof ChatSmeChatRoute ChatIndexRoute: typeof ChatIndexRoute @@ -239,7 +289,7 @@ const ChatRouteChildren: ChatRouteChildren = { ChatMergeConflictsRoute: ChatMergeConflictsRoute, ChatPluginsRoute: ChatPluginsRoute, ChatPrReviewRoute: ChatPrReviewRoute, - ChatSettingsRoute: ChatSettingsRoute, + ChatSettingsRoute: ChatSettingsRouteWithChildren, ChatSkillsRoute: ChatSkillsRoute, ChatSmeChatRoute: ChatSmeChatRoute, ChatIndexRoute: ChatIndexRoute, diff --git a/apps/web/src/routes/_chat.settings.index.tsx b/apps/web/src/routes/_chat.settings.index.tsx new file mode 100644 index 000000000..8cc09865f --- /dev/null +++ b/apps/web/src/routes/_chat.settings.index.tsx @@ -0,0 +1,2008 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + CheckCircle2Icon, + ChevronDownIcon, + Loader2Icon, + PlusIcon, + RefreshCwIcon, + SkipForwardIcon, + XCircleIcon, + XIcon, +} from "lucide-react"; +import { type ReactNode, useCallback, useEffect, useState } from "react"; +import type { TestOpenclawGatewayHostKind, TestOpenclawGatewayResult } from "@okcode/contracts"; +import { + type BuildMetadata, + type KeybindingCommand, + type KeybindingRule, + type ProjectId, + type ProviderKind, + type ServerProviderStatus, + DEFAULT_GIT_TEXT_GENERATION_MODEL, +} from "@okcode/contracts"; +import { getModelOptions, normalizeModelSlug } from "@okcode/shared/model"; +import { validateHttpPreviewUrl } from "@okcode/shared/preview"; +import { + DEFAULT_BROWSER_PREVIEW_START_PAGE_URL, + DEFAULT_PR_REVIEW_REQUEST_CHANGES_TONE, + getAppModelOptions, + getCustomModelsForProvider, + MAX_CUSTOM_MODEL_LENGTH, + MODEL_PROVIDER_SETTINGS, + patchCustomModels, + PrReviewRequestChangesTone, + resolveBrowserPreviewStartPageUrl, +} from "../appSettings"; +import { APP_BUILD_INFO } from "../branding"; +import { Button } from "../components/ui/button"; +import { Collapsible, CollapsibleContent } from "../components/ui/collapsible"; +import { EnvironmentVariablesEditor } from "../components/EnvironmentVariablesEditor"; +import { HotkeysSettingsSection } from "../components/settings/HotkeysSettingsSection"; +import { SettingsShell, type SettingsSectionId } from "../components/settings/SettingsShell"; +import { useSettingsRouteContext } from "../components/settings/SettingsRouteContext"; +import { + SettingResetButton, + SettingsRow, + SettingsSection, +} from "../components/settings/SettingsUi"; +import { Input } from "../components/ui/input"; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "../components/ui/select"; +import { Switch } from "../components/ui/switch"; +import { resolveAndPersistPreferredEditor } from "../editorPreferences"; +import { isMobileShell } from "../env"; +import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; +import { + environmentVariablesQueryKeys, + globalEnvironmentVariablesQueryOptions, + projectEnvironmentVariablesQueryOptions, +} from "../lib/environmentVariablesReactQuery"; +import { + getSelectableThreadProviders, + isProviderReadyForThreadSelection, +} from "../lib/providerAvailability"; +import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; +import { cn } from "../lib/utils"; +import { ensureNativeApi } from "../nativeApi"; +import { useStore } from "../store"; +import { PairingLink } from "../components/mobile/PairingLink"; +import { + getProviderLabel as getProviderStatusLabelName, + getProviderStatusDescription, + getProviderStatusHeading, +} from "../components/chat/providerStatusPresentation"; + +const TIMESTAMP_FORMAT_LABELS = { + locale: "System default", + "12-hour": "12-hour", + "24-hour": "24-hour", +} as const; + +const PR_REVIEW_REQUEST_CHANGES_TONE_OPTIONS: ReadonlyArray<{ + value: PrReviewRequestChangesTone; + label: string; +}> = [ + { value: "warning", label: "Warning" }, + { value: "neutral", label: "Neutral" }, + { value: "brand", label: "Brand" }, +]; + +function describeOpenclawGatewayHostKind(hostKind: TestOpenclawGatewayHostKind): string { + switch (hostKind) { + case "loopback": + return "Loopback / same machine"; + case "tailscale": + return "Tailscale / tailnet"; + case "private": + return "Private LAN"; + case "public": + return "Public / internet-routable"; + case "unknown": + return "Unknown"; + } +} + +function describeOpenclawGatewayHealthStatus(result: TestOpenclawGatewayResult): string | null { + const diagnostics = result.diagnostics; + if (!diagnostics) return null; + switch (diagnostics.healthStatus) { + case "pass": + return diagnostics.healthDetail ? `Reachable (${diagnostics.healthDetail})` : "Reachable"; + case "fail": + return diagnostics.healthDetail ? `Failed (${diagnostics.healthDetail})` : "Failed"; + case "skip": + return diagnostics.healthDetail ?? "Skipped"; + } +} + +function formatOpenclawGatewayDebugReport(result: TestOpenclawGatewayResult): string { + const lines = [ + `OpenClaw gateway connection test: ${result.success ? "success" : "failed"}`, + `Total duration: ${result.totalDurationMs}ms`, + ]; + + if (result.error) { + lines.push(`Error: ${result.error}`); + } + + lines.push(""); + lines.push("Steps:"); + for (const step of result.steps) { + lines.push( + `- ${step.name}: ${step.status} (${step.durationMs}ms)${ + step.detail ? ` — ${step.detail}` : "" + }`, + ); + } + + if (result.serverInfo) { + lines.push(""); + lines.push("Server info:"); + if (result.serverInfo.version) { + lines.push(`- Version: ${result.serverInfo.version}`); + } + if (result.serverInfo.sessionId) { + lines.push(`- Session: ${result.serverInfo.sessionId}`); + } + } + + if (result.diagnostics) { + const diagnostics = result.diagnostics; + lines.push(""); + lines.push("Diagnostics:"); + if (diagnostics.normalizedUrl) { + lines.push(`- Endpoint: ${diagnostics.normalizedUrl}`); + } + if (diagnostics.hostKind) { + lines.push(`- Host type: ${describeOpenclawGatewayHostKind(diagnostics.hostKind)}`); + } + if (diagnostics.resolvedAddresses.length > 0) { + lines.push(`- Resolved: ${diagnostics.resolvedAddresses.join(", ")}`); + } + const healthStatus = describeOpenclawGatewayHealthStatus(result); + if (healthStatus) { + lines.push( + `- Health probe: ${healthStatus}${ + diagnostics.healthUrl ? ` at ${diagnostics.healthUrl}` : "" + }`, + ); + } + if (diagnostics.socketCloseCode !== undefined) { + lines.push( + `- Socket close: ${diagnostics.socketCloseCode}${ + diagnostics.socketCloseReason ? ` (${diagnostics.socketCloseReason})` : "" + }`, + ); + } + if (diagnostics.socketError) { + lines.push(`- Socket error: ${diagnostics.socketError}`); + } + if (diagnostics.gatewayErrorCode) { + lines.push(`- Gateway error code: ${diagnostics.gatewayErrorCode}`); + } + if (diagnostics.gatewayErrorDetailCode) { + lines.push(`- Gateway detail code: ${diagnostics.gatewayErrorDetailCode}`); + } + if (diagnostics.gatewayErrorDetailReason) { + lines.push(`- Gateway detail reason: ${diagnostics.gatewayErrorDetailReason}`); + } + if (diagnostics.gatewayRecommendedNextStep) { + lines.push(`- Gateway next step: ${diagnostics.gatewayRecommendedNextStep}`); + } + if (diagnostics.gatewayCanRetryWithDeviceToken !== undefined) { + lines.push( + `- Device-token retry available: ${diagnostics.gatewayCanRetryWithDeviceToken ? "yes" : "no"}`, + ); + } + if (diagnostics.observedNotifications.length > 0) { + lines.push(`- Gateway events: ${diagnostics.observedNotifications.join(", ")}`); + } + if (diagnostics.hints.length > 0) { + lines.push(""); + lines.push("Troubleshooting:"); + for (const hint of diagnostics.hints) { + lines.push(`- ${hint}`); + } + } + } + + return lines.join("\n"); +} + +type InstallBinarySettingsKey = "claudeBinaryPath" | "codexBinaryPath"; +type InstallProviderSettings = { + provider: ProviderKind; + title: string; + binaryPathKey: InstallBinarySettingsKey; + binaryPlaceholder: string; + binaryDescription: ReactNode; + homePathKey?: "codexHomePath"; + homePlaceholder?: string; + homeDescription?: ReactNode; +}; + +const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ + { + provider: "codex", + title: "Codex", + binaryPathKey: "codexBinaryPath", + binaryPlaceholder: "Codex binary path", + binaryDescription: ( + <> + Leave blank to use codex from your PATH. Authentication normally uses{" "} + codex login unless your Codex config points at a custom model provider. + + ), + homePathKey: "codexHomePath", + homePlaceholder: "CODEX_HOME", + homeDescription: "Optional custom Codex home and config directory.", + }, + { + provider: "claudeAgent", + title: "Claude Code", + binaryPathKey: "claudeBinaryPath", + binaryPlaceholder: "Claude Code binary path", + binaryDescription: ( + <> + Leave blank to use claude from your PATH. Authentication uses{" "} + claude auth login. + + ), + }, +]; + +const PROVIDER_AUTH_GUIDES: Record< + ProviderKind, + { + installCmd?: string; + authCmd?: string; + verifyCmd?: string; + note: string; + } +> = { + codex: { + installCmd: "npm install -g @openai/codex", + authCmd: "codex login", + verifyCmd: "codex login status", + note: "Codex stays available in thread creation when the CLI is ready and its auth is either confirmed or delegated to a custom model provider.", + }, + claudeAgent: { + installCmd: "npm install -g @anthropic-ai/claude-code", + authCmd: "claude auth login", + verifyCmd: "claude auth status", + note: "Claude Code must be installed and signed in before it appears in the thread picker.", + }, + openclaw: { + verifyCmd: "Test Connection", + note: "OpenClaw uses the gateway URL and password below rather than a local CLI login. A configured gateway unlocks it for new-thread selection.", + }, +}; + +function getAuthenticationBadgeCopy(input: { + status: ServerProviderStatus | null; + provider: ProviderKind; + openclawGatewayUrl: string; +}): { + tone: "success" | "warning" | "error"; + label: string; +} { + if ( + isProviderReadyForThreadSelection({ + provider: input.provider, + statuses: input.status ? [input.status] : [], + openclawGatewayUrl: input.openclawGatewayUrl, + }) + ) { + return { tone: "success", label: "Available in thread picker" }; + } + + if (input.status?.authStatus === "unauthenticated") { + return { tone: "error", label: "Sign-in required" }; + } + + if (input.provider === "openclaw" && input.openclawGatewayUrl.trim().length === 0) { + return { tone: "warning", label: "Gateway not configured" }; + } + + if (input.status?.available === false || input.status?.status === "error") { + return { tone: "error", label: "Unavailable" }; + } + + return { tone: "warning", label: "Needs verification" }; +} + +function AuthenticationStatusCard({ + provider, + status, + openclawGatewayUrl, +}: { + provider: ProviderKind; + status: ServerProviderStatus | null; + openclawGatewayUrl: string; +}) { + const guide = PROVIDER_AUTH_GUIDES[provider]; + const badge = getAuthenticationBadgeCopy({ status, provider, openclawGatewayUrl }); + const badgeClassName = + badge.tone === "success" + ? "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" + : badge.tone === "error" + ? "border-red-500/25 bg-red-500/10 text-red-700 dark:text-red-300" + : "border-amber-500/25 bg-amber-500/10 text-amber-700 dark:text-amber-300"; + const heading = + status !== null + ? getProviderStatusHeading(status) + : provider === "openclaw" && openclawGatewayUrl.trim().length > 0 + ? "OpenClaw gateway is configured locally" + : `${getProviderStatusLabelName(provider)} needs configuration`; + const description = + status !== null + ? getProviderStatusDescription(status) + : provider === "openclaw" && openclawGatewayUrl.trim().length > 0 + ? "OpenClaw is configured in local settings. Use Test Connection below to verify the gateway before starting a thread." + : guide.note; + + return ( +
+
+
+
+

+ {getProviderStatusLabelName(provider)} +

+ + {badge.label} + +
+

{heading}

+

{description}

+
+ {status?.checkedAt ? ( + + Checked {new Date(status.checkedAt).toLocaleString()} + + ) : null} +
+ +
+
+
Install
+ + {guide.installCmd ?? "Configured in-app"} + +
+
+
Authenticate
+ + {guide.authCmd ?? "Use gateway password"} + +
+
+
Verify
+ {guide.verifyCmd ?? "N/A"} +
+
+ +

{guide.note}

+
+ ); +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + return "Unknown error"; +} + +function BuildInfoBlock({ label, buildInfo }: { label: string; buildInfo: BuildMetadata }) { + return ( +
+
{label}
+
+
+ {buildInfo.version} + + {buildInfo.surface} + + + {buildInfo.platform}/{buildInfo.arch} + +
+
+ {buildInfo.channel} + + {buildInfo.commitHash ?? "unknown"} + + {buildInfo.buildTimestamp} +
+
+
+ ); +} + +function SettingsRouteView() { + const navigate = useNavigate(); + const { + settingsState: { settings, defaults, updateSettings }, + changedSettingLabels, + restoreDefaults, + } = useSettingsRouteContext(); + const search = Route.useSearch(); + const activeSection = search.section ?? "general"; + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const queryClient = useQueryClient(); + const trimmedBrowserPreviewStartPageUrl = settings.browserPreviewStartPageUrl.trim(); + const browserPreviewStartPageValidation = + trimmedBrowserPreviewStartPageUrl.length > 0 + ? validateHttpPreviewUrl(trimmedBrowserPreviewStartPageUrl) + : null; + const effectiveBrowserPreviewStartPageUrl = resolveBrowserPreviewStartPageUrl( + settings.browserPreviewStartPageUrl, + ); + const projects = useStore((state) => state.projects); + const [selectedProjectId, setSelectedProjectId] = useState( + () => projects[0]?.id ?? null, + ); + const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); + const [openKeybindingsError, setOpenKeybindingsError] = useState(null); + const [openInstallProviders, setOpenInstallProviders] = useState>({ + codex: Boolean(settings.codexBinaryPath || settings.codexHomePath), + claudeAgent: Boolean(settings.claudeBinaryPath), + openclaw: Boolean(settings.openclawGatewayUrl || settings.openclawPassword), + }); + const [selectedCustomModelProvider, setSelectedCustomModelProvider] = + useState("codex"); + const [customModelInputByProvider, setCustomModelInputByProvider] = useState< + Record + >({ + codex: "", + claudeAgent: "", + openclaw: "", + }); + const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< + Partial> + >({}); + const [showAllCustomModels, setShowAllCustomModels] = useState(false); + const [openclawTestResult, setOpenclawTestResult] = useState( + null, + ); + const [openclawTestLoading, setOpenclawTestLoading] = useState(false); + const { copyToClipboard: copyOpenclawDebugReport, isCopied: openclawDebugReportCopied } = + useCopyToClipboard(); + + const globalEnvironmentVariablesQuery = useQuery(globalEnvironmentVariablesQueryOptions()); + const activeProjectId = selectedProjectId ?? projects[0]?.id ?? null; + const selectedProject = projects.find((project) => project.id === activeProjectId) ?? null; + const selectedProjectEnvironmentVariablesQuery = useQuery( + projectEnvironmentVariablesQueryOptions(activeProjectId), + ); + + useEffect(() => { + if (projects.length === 0) { + if (selectedProjectId !== null) { + setSelectedProjectId(null); + } + return; + } + + if (!selectedProjectId || !projects.some((project) => project.id === selectedProjectId)) { + setSelectedProjectId(projects[0]?.id ?? null); + } + }, [projects, selectedProjectId]); + + const codexBinaryPath = settings.codexBinaryPath; + const codexHomePath = settings.codexHomePath; + const claudeBinaryPath = settings.claudeBinaryPath; + const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; + const availableEditors = serverConfigQuery.data?.availableEditors; + const providerStatuses = serverConfigQuery.data?.providers ?? []; + const selectableProviders = getSelectableThreadProviders({ + statuses: providerStatuses, + openclawGatewayUrl: settings.openclawGatewayUrl, + }); + + const gitTextGenerationModelOptions = getAppModelOptions( + "codex", + settings.customCodexModels, + settings.textGenerationModel, + ); + const currentGitTextGenerationModel = + settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL; + const defaultGitTextGenerationModel = + defaults.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL; + const isGitTextGenerationModelDirty = + currentGitTextGenerationModel !== defaultGitTextGenerationModel; + const selectedGitTextGenerationModelLabel = + gitTextGenerationModelOptions.find((option) => option.slug === currentGitTextGenerationModel) + ?.name ?? currentGitTextGenerationModel; + 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 activeProjectEnvironmentVariables = selectedProjectEnvironmentVariablesQuery.data?.entries; + 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 isInstallSettingsDirty = + settings.claudeBinaryPath !== defaults.claudeBinaryPath || + settings.codexBinaryPath !== defaults.codexBinaryPath || + settings.codexHomePath !== defaults.codexHomePath; + const isOpenClawSettingsDirty = + settings.openclawGatewayUrl !== defaults.openclawGatewayUrl || + settings.openclawPassword !== defaults.openclawPassword; + + 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 replaceKeybindingRules = useCallback( + async (command: KeybindingCommand, rules: readonly KeybindingRule[]) => { + const api = ensureNativeApi(); + await api.server.replaceKeybindingRules({ command, rules: [...rules] }); + await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); + }, + [queryClient], + ); + + const refreshProviderStatuses = useCallback(async () => { + await queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); + }, [queryClient]); + + const saveGlobalEnvironmentVariables = useCallback( + async (entries: ReadonlyArray<{ key: string; value: string }>) => { + const api = ensureNativeApi(); + const result = await api.server.saveGlobalEnvironmentVariables({ entries }); + queryClient.setQueryData(environmentVariablesQueryKeys.global(), result); + return result.entries; + }, + [queryClient], + ); + + const saveProjectEnvironmentVariables = useCallback( + async (entries: ReadonlyArray<{ key: string; value: string }>) => { + if (!selectedProject) { + throw new Error("Select a project before saving project variables."); + } + const api = ensureNativeApi(); + const result = await api.server.saveProjectEnvironmentVariables({ + projectId: selectedProject.id, + entries, + }); + queryClient.setQueryData(environmentVariablesQueryKeys.project(selectedProject.id), result); + return result.entries; + }, + [queryClient, selectedProject], + ); + + const testOpenclawGateway = useCallback(async () => { + if (openclawTestLoading) return; + setOpenclawTestLoading(true); + setOpenclawTestResult(null); + try { + const api = ensureNativeApi(); + const result = await api.server.testOpenclawGateway({ + gatewayUrl: settings.openclawGatewayUrl, + password: settings.openclawPassword || undefined, + }); + setOpenclawTestResult(result); + } catch (err) { + setOpenclawTestResult({ + success: false, + steps: [], + totalDurationMs: 0, + error: err instanceof Error ? err.message : "Unexpected error during test.", + }); + } finally { + setOpenclawTestLoading(false); + } + }, [openclawTestLoading, settings.openclawGatewayUrl, settings.openclawPassword]); + + const handleCopyOpenclawDebugReport = useCallback(() => { + if (!openclawTestResult) return; + copyOpenclawDebugReport(formatOpenclawGatewayDebugReport(openclawTestResult), undefined); + }, [copyOpenclawDebugReport, openclawTestResult]); + + 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 ( + +
+ {activeSection === "general" && ( + + void navigate({ to: "/settings/style" })} + control={ + + } + /> + + + updateSettings({ + prReviewRequestChangesTone: DEFAULT_PR_REVIEW_REQUEST_CHANGES_TONE, + }) + } + /> + ) : null + } + control={ + + } + /> + + + updateSettings({ + timestampFormat: defaults.timestampFormat, + }) + } + /> + ) : null + } + control={ + + } + /> + + + updateSettings({ + showStitchBorder: defaults.showStitchBorder, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + showStitchBorder: Boolean(checked), + }) + } + aria-label="Show stitch border" + /> + } + /> + + + updateSettings({ + enableAssistantStreaming: defaults.enableAssistantStreaming, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + enableAssistantStreaming: Boolean(checked), + }) + } + aria-label="Stream assistant messages" + /> + } + /> + + + updateSettings({ + showReasoningContent: defaults.showReasoningContent, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + showReasoningContent: Boolean(checked), + }) + } + aria-label="Show reasoning content in work log" + /> + } + /> + + + updateSettings({ + showAuthFailuresAsErrors: defaults.showAuthFailuresAsErrors, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + showAuthFailuresAsErrors: Boolean(checked), + }) + } + aria-label="Show authentication failures as thread errors" + /> + } + /> + + + updateSettings({ + showNotificationDetails: defaults.showNotificationDetails, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + showNotificationDetails: Boolean(checked), + }) + } + aria-label="Show notification details by default" + /> + } + /> + + + updateSettings({ + includeDiagnosticsTipsInCopy: defaults.includeDiagnosticsTipsInCopy, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + includeDiagnosticsTipsInCopy: Boolean(checked), + }) + } + aria-label="Include diagnostics tips in copied text" + /> + } + /> + + + updateSettings({ + openLinksExternally: defaults.openLinksExternally, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + openLinksExternally: Boolean(checked), + }) + } + aria-label="Open links externally" + /> + } + /> + + + Blank uses the default start page:{" "} + {DEFAULT_BROWSER_PREVIEW_START_PAGE_URL} + + ) : browserPreviewStartPageValidation?.ok ? ( + <> + New blank preview tabs will open at{" "} + {browserPreviewStartPageValidation.url}. + + ) : ( + <> + + Invalid URL. Falling back to{" "} + {DEFAULT_BROWSER_PREVIEW_START_PAGE_URL}. + + + Effective start page: {effectiveBrowserPreviewStartPageUrl} + + + ) + } + resetAction={ + settings.browserPreviewStartPageUrl !== defaults.browserPreviewStartPageUrl ? ( + + updateSettings({ + browserPreviewStartPageUrl: defaults.browserPreviewStartPageUrl, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + browserPreviewStartPageUrl: event.target.value, + }) + } + placeholder={DEFAULT_BROWSER_PREVIEW_START_PAGE_URL} + aria-label="Browser preview start page" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + className="w-full sm:w-72" + /> + } + /> + + + updateSettings({ + codeViewerAutosave: defaults.codeViewerAutosave, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + codeViewerAutosave: Boolean(checked), + }) + } + aria-label="Enable code preview autosave" + /> + } + /> + + + updateSettings({ + defaultThreadEnvMode: defaults.defaultThreadEnvMode, + }) + } + /> + ) : null + } + control={ + + } + /> + + + updateSettings({ + autoUpdateWorktreeBaseBranch: defaults.autoUpdateWorktreeBaseBranch, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + autoUpdateWorktreeBaseBranch: Boolean(checked), + }) + } + aria-label="Refresh base branch before creating new worktrees" + /> + } + /> + + + updateSettings({ + confirmThreadDelete: defaults.confirmThreadDelete, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + confirmThreadDelete: Boolean(checked), + }) + } + aria-label="Confirm thread deletion" + /> + } + /> + + + updateSettings({ + autoDeleteMergedThreads: defaults.autoDeleteMergedThreads, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + autoDeleteMergedThreads: Boolean(checked), + }) + } + aria-label="Auto-delete merged threads" + /> + } + /> + + {settings.autoDeleteMergedThreads ? ( + + updateSettings({ + autoDeleteMergedThreadsDelayMinutes: + defaults.autoDeleteMergedThreadsDelayMinutes, + }) + } + /> + ) : null + } + control={ + + } + /> + ) : null} + + )} + + {activeSection === "authentication" && ( + void refreshProviderStatuses()}> + + Refresh status + + } + > + +
+ {(["codex", "claudeAgent", "openclaw"] as const).map((provider) => ( + status.provider === provider) ?? null} + openclawGatewayUrl={settings.openclawGatewayUrl} + /> + ))} +
+
+ + { + updateSettings({ + claudeBinaryPath: defaults.claudeBinaryPath, + codexBinaryPath: defaults.codexBinaryPath, + codexHomePath: defaults.codexHomePath, + }); + setOpenInstallProviders({ + codex: false, + claudeAgent: false, + openclaw: false, + }); + }} + /> + ) : null + } + > +
+
+ {INSTALL_PROVIDER_SETTINGS.map((providerSettings) => { + const isOpen = openInstallProviders[providerSettings.provider]; + const isDirty = + providerSettings.provider === "codex" + ? settings.codexBinaryPath !== defaults.codexBinaryPath || + settings.codexHomePath !== defaults.codexHomePath + : settings.claudeBinaryPath !== defaults.claudeBinaryPath; + const binaryPathValue = + providerSettings.binaryPathKey === "claudeBinaryPath" + ? claudeBinaryPath + : codexBinaryPath; + + return ( + + setOpenInstallProviders((existing) => ({ + ...existing, + [providerSettings.provider]: open, + })) + } + > +
+ + + +
+
+ + + {providerSettings.homePathKey ? ( + + ) : null} +
+
+
+
+
+ ); + })} +
+
+
+ + 0 + ? `Configured for ${settings.openclawGatewayUrl}` + : "Not configured" + } + resetAction={ + isOpenClawSettingsDirty ? ( + + updateSettings({ + openclawGatewayUrl: defaults.openclawGatewayUrl, + openclawPassword: defaults.openclawPassword, + }) + } + /> + ) : null + } + > +
+ + + +
+ +
+ + {openclawTestResult ? ( +
+
+ {openclawTestResult.success ? ( + + ) : ( + + )} + + {openclawTestResult.success ? "Connection successful" : "Connection failed"} + + + {openclawTestResult.totalDurationMs}ms total + + +
+ + {openclawTestResult.steps.length > 0 ? ( +
+ {openclawTestResult.steps.map((step) => ( +
+ {step.status === "pass" ? ( + + ) : null} + {step.status === "fail" ? ( + + ) : null} + {step.status === "skip" ? ( + + ) : null} +
+
+ {step.name} + + {step.durationMs}ms + +
+ {step.detail ? ( + + {step.detail} + + ) : null} +
+
+ ))} +
+ ) : null} + + {openclawTestResult.error && + !openclawTestResult.steps.some((step) => step.status === "fail") ? ( +
{openclawTestResult.error}
+ ) : null} +
+ ) : null} +
+
+
+ )} + + {activeSection === "hotkeys" && ( + + )} + + {activeSection === "environment" && ( + + + Failed to load saved variables:{" "} + {getErrorMessage(globalEnvironmentVariablesQuery.error)} + + ) : globalEnvironmentVariablesQuery.isFetching ? ( + Loading saved variables... + ) : globalEnvironmentVariablesQuery.data?.entries.length ? ( + + {globalEnvironmentVariablesQuery.data.entries.length} saved variables + + ) : ( + No global variables saved yet. + ) + } + > + + + + + {selectedProject.name} · {selectedProject.cwd} + + ) : ( + Open a project to edit project variables. + ) + } + control={ + projects.length > 0 ? ( + + ) : ( + No projects available. + ) + } + > + + + + )} + + {activeSection === "git" && ( + + + updateSettings({ + rebaseBeforeCommit: defaults.rebaseBeforeCommit, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + rebaseBeforeCommit: Boolean(checked), + }) + } + aria-label="Rebase onto the default branch before committing" + /> + } + /> + + )} + + {activeSection === "models" && ( + + + updateSettings({ + textGenerationModel: defaults.textGenerationModel, + }) + } + /> + ) : null + } + control={ + + } + /> + + 0 ? ( + { + updateSettings({ + customCodexModels: defaults.customCodexModels, + customClaudeModels: defaults.customClaudeModels, + }); + setCustomModelErrorByProvider({}); + setShowAllCustomModels(false); + }} + /> + ) : null + } + > +
+
+ + { + 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} + /> + +
+ + {selectedCustomModelError ? ( +

{selectedCustomModelError}

+ ) : null} + + {totalCustomModels > 0 ? ( +
+
+ {visibleCustomModelRows.map((row) => ( +
+ + {row.providerTitle} + + + {row.slug} + + +
+ ))} +
+ + {savedCustomModelRows.length > 5 ? ( + + ) : null} +
+ ) : null} +
+
+
+ )} + + {activeSection === "mobile" && !isMobileShell && ( + + +
+ +
+
+
+ )} + + {activeSection === "advanced" && ( + + + + {serverConfigQuery.data?.buildInfo ? ( + + ) : null} +
+ } + /> + + )} + +
+ ); +} + +export const Route = createFileRoute("/_chat/settings/")({ + validateSearch: (search: Record): { section?: SettingsSectionId } => { + const section = search.section; + if ( + section === "general" || + section === "authentication" || + section === "hotkeys" || + section === "environment" || + section === "git" || + section === "models" || + section === "mobile" || + section === "advanced" + ) { + return section === "general" ? {} : { section }; + } + return {}; + }, + component: SettingsRouteView, +}); diff --git a/apps/web/src/routes/_chat.settings.style.tsx b/apps/web/src/routes/_chat.settings.style.tsx new file mode 100644 index 000000000..25450ba79 --- /dev/null +++ b/apps/web/src/routes/_chat.settings.style.tsx @@ -0,0 +1,714 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { GlobeIcon, ImportIcon } from "lucide-react"; +import { useState } from "react"; + +import { + DEFAULT_SIDEBAR_FONT_SIZE, + DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT, + DEFAULT_SIDEBAR_SPACING, + DEFAULT_SIDEBAR_THREAD_ROW_HEIGHT, + SIDEBAR_FONT_SIZE_MAX, + SIDEBAR_FONT_SIZE_MIN, + SIDEBAR_PROJECT_ROW_HEIGHT_MAX, + SIDEBAR_PROJECT_ROW_HEIGHT_MIN, + SIDEBAR_SPACING_MAX, + SIDEBAR_SPACING_MIN, + SIDEBAR_THREAD_ROW_HEIGHT_MAX, + SIDEBAR_THREAD_ROW_HEIGHT_MIN, +} from "../appSettings"; +import { CustomThemeDialog } from "../components/CustomThemeDialog"; +import { SettingsShell } from "../components/settings/SettingsShell"; +import { + BackgroundImageSettings, + SettingResetButton, + SettingsRow, + SettingsSection, +} from "../components/settings/SettingsUi"; +import { Button } from "../components/ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; +import { COLOR_THEMES, FONT_FAMILIES, DEFAULT_COLOR_THEME } from "../hooks/useTheme"; +import { useSettingsRouteContext } from "../components/settings/SettingsRouteContext"; +import { + applyCustomTheme, + clearStoredCustomTheme, + getStoredCustomTheme, + removeCustomTheme, + type CustomThemeData, +} from "../lib/customTheme"; +import { openUrlInAppBrowser } from "../lib/openUrlInAppBrowser"; +import { readNativeApi, ensureNativeApi } from "../nativeApi"; +import { useStore } from "../store"; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "../components/ui/select"; +import { Input } from "../components/ui/input"; +import { Switch } from "../components/ui/switch"; + +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; + +export const Route = createFileRoute("/_chat/settings/style")({ + component: SettingsStyleRouteView, +}); + +function SettingsStyleRouteView() { + const { + theme, + setTheme, + colorTheme, + setColorTheme, + fontFamily, + setFontFamily, + settingsState: { settings, defaults, updateSettings }, + radiusOverride, + setRadiusOverride, + fontOverride, + setFontOverride, + fontSizeOverride, + setFontSizeOverride, + changedSettingLabels, + restoreDefaults, + } = useSettingsRouteContext(); + const projects = useStore((state) => state.projects); + const threads = useStore((state) => state.threads); + const [customThemeDialogOpen, setCustomThemeDialogOpen] = useState(false); + const activeProjectId = projects[0]?.id ?? null; + const activeProjectPreviewThreadId = + activeProjectId === null + ? null + : (threads + .filter((thread) => thread.projectId === activeProjectId) + .toSorted((a, b) => + (b.updatedAt ?? b.createdAt).localeCompare(a.updatedAt ?? a.createdAt), + ) + .at(0)?.id ?? null); + + const openTweakcn = async () => { + try { + await openUrlInAppBrowser({ + url: "https://tweakcn.com", + projectId: activeProjectId, + threadId: activeProjectPreviewThreadId, + popOut: true, + nativeApi: readNativeApi(), + }); + } catch { + const nativeApi = ensureNativeApi(); + await nativeApi.shell.openExternal("https://tweakcn.com"); + } + }; + + return ( + +
+ + setTheme("system")} /> + ) : null + } + control={ + + } + /> + + { + setColorTheme(DEFAULT_COLOR_THEME); + clearStoredCustomTheme(); + removeCustomTheme(); + }} + /> + ) : null + } + control={ +
+ + + void openTweakcn()}> + + + } + /> + Open tweakcn in the in-app browser + + + setCustomThemeDialogOpen(true)} + > + + + } + /> + Import from tweakcn.com + +
+ } + /> + + setFontFamily("inter")} /> + ) : null + } + control={ + + } + /> + + setRadiusOverride(null)} /> + ) : null + } + control={ +
+ { + setRadiusOverride(Number.parseFloat(e.target.value)); + }} + className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" + aria-label="Border radius" + /> + + {(radiusOverride ?? 0.625).toFixed(2)}rem + +
+ } + /> + + setFontSizeOverride(null)} + /> + ) : null + } + control={ +
+ { + setFontSizeOverride(Number.parseFloat(e.target.value)); + }} + className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" + aria-label="Code font size" + /> + + {fontSizeOverride ?? 12}px + +
+ } + /> + + setFontOverride("")} + /> + ) : null + } + control={ + setFontOverride(e.target.value)} + placeholder="e.g. Inter, sans-serif" + spellCheck={false} + aria-label="Font family override" + /> + } + /> + + + updateSettings({ + showStitchBorder: defaults.showStitchBorder, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + showStitchBorder: Boolean(checked), + }) + } + aria-label="Show stitch border" + /> + } + /> +
+ + + updateSettings({ sidebarOpacity: defaults.sidebarOpacity })} + /> + ) : null + } + control={ +
+ { + const value = Number(e.target.value) / 100; + updateSettings({ sidebarOpacity: value }); + }} + className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" + aria-label="Sidebar opacity" + /> + + {Math.round(settings.sidebarOpacity * 100)}% + +
+ } + /> + + + updateSettings({ + sidebarProjectRowHeight: DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT, + }) + } + /> + ) : null + } + control={ +
+ { + updateSettings({ + sidebarProjectRowHeight: Number(e.target.value), + }); + }} + className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" + aria-label="Project height" + /> + + {settings.sidebarProjectRowHeight}px + +
+ } + /> + + + updateSettings({ + sidebarThreadRowHeight: DEFAULT_SIDEBAR_THREAD_ROW_HEIGHT, + }) + } + /> + ) : null + } + control={ +
+ { + updateSettings({ + sidebarThreadRowHeight: Number(e.target.value), + }); + }} + className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" + aria-label="Thread height" + /> + + {settings.sidebarThreadRowHeight}px + +
+ } + /> + + updateSettings({ sidebarFontSize: DEFAULT_SIDEBAR_FONT_SIZE })} + /> + ) : null + } + control={ +
+ { + updateSettings({ sidebarFontSize: Number(e.target.value) }); + }} + className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" + aria-label="Sidebar font size" + /> + + {settings.sidebarFontSize}px + +
+ } + /> + + updateSettings({ sidebarSpacing: DEFAULT_SIDEBAR_SPACING })} + /> + ) : null + } + control={ +
+ { + updateSettings({ sidebarSpacing: Number(e.target.value) }); + }} + className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" + aria-label="Sidebar spacing" + /> + + {settings.sidebarSpacing}px + +
+ } + /> + + + + + updateSettings({ + sidebarAccentProjectNames: defaults.sidebarAccentProjectNames, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + sidebarAccentProjectNames: Boolean(checked), + }) + } + aria-label="Accent project names" + /> + } + /> + + + updateSettings({ + sidebarAccentColorOverride: undefined, + }) + } + /> + ) : null + } + control={ +
+ + { + const value = e.target.value.trim(); + updateSettings({ + sidebarAccentColorOverride: value || undefined, + }); + }} + className="h-8 w-28 rounded-md border border-border bg-background px-2 text-xs text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring sm:w-32" + aria-label="Accent color value" + /> +
+ } + /> + + + updateSettings({ + sidebarAccentBgColorOverride: undefined, + }) + } + /> + ) : null + } + control={ +
+ + { + const value = e.target.value.trim(); + updateSettings({ + sidebarAccentBgColorOverride: value || undefined, + }); + }} + className="h-8 w-28 rounded-md border border-border bg-background px-2 text-xs text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring sm:w-32" + aria-label="Accent background color value" + /> +
+ } + /> +
+ + { + applyCustomTheme(themeData); + setColorTheme("custom"); + }} + /> +
+
+ ); +} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index b1f821ac2..5df8d20bb 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,3161 +1,15 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { - CheckCircle2Icon, - ChevronDownIcon, - CpuIcon, - GlobeIcon, - GitBranchIcon, - ImportIcon, - KeyboardIcon, - Loader2Icon, - PaletteIcon, - PlusIcon, - RefreshCwIcon, - RotateCcwIcon, - ShieldCheckIcon, - SkipForwardIcon, - SmartphoneIcon, - Undo2Icon, - VariableIcon, - WrenchIcon, - XCircleIcon, - XIcon, -} from "lucide-react"; -import { type ReactNode, useCallback, useEffect, useState } from "react"; -import type { TestOpenclawGatewayHostKind, TestOpenclawGatewayResult } from "@okcode/contracts"; -import { - type BuildMetadata, - type KeybindingCommand, - type KeybindingRule, - type ProjectId, - type ProviderKind, - type ServerProviderStatus, - DEFAULT_GIT_TEXT_GENERATION_MODEL, -} from "@okcode/contracts"; -import { getModelOptions, normalizeModelSlug } from "@okcode/shared/model"; -import { validateHttpPreviewUrl } from "@okcode/shared/preview"; -import { - DEFAULT_BROWSER_PREVIEW_START_PAGE_URL, - DEFAULT_SIDEBAR_FONT_SIZE, - DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT, - DEFAULT_SIDEBAR_SPACING, - DEFAULT_SIDEBAR_THREAD_ROW_HEIGHT, - DEFAULT_PR_REVIEW_REQUEST_CHANGES_TONE, - getAppModelOptions, - getCustomModelsForProvider, - MAX_CUSTOM_MODEL_LENGTH, - MODEL_PROVIDER_SETTINGS, - patchCustomModels, - PrReviewRequestChangesTone, - resolveBrowserPreviewStartPageUrl, - SIDEBAR_FONT_SIZE_MAX, - SIDEBAR_FONT_SIZE_MIN, - SIDEBAR_PROJECT_ROW_HEIGHT_MAX, - SIDEBAR_PROJECT_ROW_HEIGHT_MIN, - SIDEBAR_SPACING_MAX, - SIDEBAR_SPACING_MIN, - SIDEBAR_THREAD_ROW_HEIGHT_MAX, - SIDEBAR_THREAD_ROW_HEIGHT_MIN, - useAppSettings, -} from "../appSettings"; -import { APP_BUILD_INFO } from "../branding"; -import { Button } from "../components/ui/button"; -import { Collapsible, CollapsibleContent } from "../components/ui/collapsible"; -import { EnvironmentVariablesEditor } from "../components/EnvironmentVariablesEditor"; -import { HotkeysSettingsSection } from "../components/settings/HotkeysSettingsSection"; -import { Input } from "../components/ui/input"; -import { - Select, - SelectItem, - SelectPopup, - SelectTrigger, - SelectValue, -} from "../components/ui/select"; -import { SidebarTrigger } from "../components/ui/sidebar"; -import { Switch } from "../components/ui/switch"; -import { SidebarInset } from "../components/ui/sidebar"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; -import { CustomThemeDialog } from "../components/CustomThemeDialog"; -import { resolveAndPersistPreferredEditor } from "../editorPreferences"; -import { isElectron, isMobileShell } from "../env"; -import { useTheme, COLOR_THEMES, DEFAULT_COLOR_THEME, FONT_FAMILIES } from "../hooks/useTheme"; -import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; -import { - environmentVariablesQueryKeys, - globalEnvironmentVariablesQueryOptions, - projectEnvironmentVariablesQueryOptions, -} from "../lib/environmentVariablesReactQuery"; -import { - applyCustomTheme, - clearFontOverride, - clearFontSizeOverride, - clearRadiusOverride, - clearStoredCustomTheme, - getStoredCustomTheme, - getStoredFontOverride, - getStoredFontSizeOverride, - getStoredRadiusOverride, - removeCustomTheme, - setStoredFontOverride, - setStoredFontSizeOverride, - setStoredRadiusOverride, - type CustomThemeData, -} from "../lib/customTheme"; -import { openUrlInAppBrowser } from "../lib/openUrlInAppBrowser"; -import { - getSelectableThreadProviders, - isProviderReadyForThreadSelection, -} from "../lib/providerAvailability"; -import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; -import { cn } from "../lib/utils"; -import { ensureNativeApi, readNativeApi } from "../nativeApi"; -import { useStore } from "../store"; -import { PairingLink } from "../components/mobile/PairingLink"; -import { - getProviderLabel as getProviderStatusLabelName, - getProviderStatusDescription, - getProviderStatusHeading, -} from "../components/chat/providerStatusPresentation"; +import { Outlet, createFileRoute } from "@tanstack/react-router"; -// --------------------------------------------------------------------------- -// Settings navigation sections -// --------------------------------------------------------------------------- -type SettingsSectionId = - | "general" - | "authentication" - | "hotkeys" - | "environment" - | "git" - | "models" - | "mobile" - | "advanced"; - -interface SettingsNavItem { - id: SettingsSectionId; - label: string; - icon: ReactNode; - hidden?: boolean; -} - -function useSettingsNavItems(): SettingsNavItem[] { - return [ - { id: "general", label: "General", icon: }, - { - id: "authentication", - label: "Authentication", - icon: , - }, - { id: "hotkeys", label: "Hotkeys", icon: }, - { id: "environment", label: "Environment", icon: }, - { id: "git", label: "Git", icon: }, - { id: "models", label: "Models", icon: }, - { - id: "mobile", - label: "Mobile Companion", - icon: , - hidden: isMobileShell, - }, - { id: "advanced", label: "Advanced", icon: }, - ]; -} - -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 TIMESTAMP_FORMAT_LABELS = { - locale: "System default", - "12-hour": "12-hour", - "24-hour": "24-hour", -} as const; - -const PR_REVIEW_REQUEST_CHANGES_TONE_OPTIONS: ReadonlyArray<{ - value: PrReviewRequestChangesTone; - label: string; -}> = [ - { value: "warning", label: "Warning" }, - { value: "neutral", label: "Neutral" }, - { value: "brand", label: "Brand" }, -]; - -function describeOpenclawGatewayHostKind(hostKind: TestOpenclawGatewayHostKind): string { - switch (hostKind) { - case "loopback": - return "Loopback / same machine"; - case "tailscale": - return "Tailscale / tailnet"; - case "private": - return "Private LAN"; - case "public": - return "Public / internet-routable"; - case "unknown": - return "Unknown"; - } -} - -function describeOpenclawGatewayHealthStatus(result: TestOpenclawGatewayResult): string | null { - const diagnostics = result.diagnostics; - if (!diagnostics) return null; - switch (diagnostics.healthStatus) { - case "pass": - return diagnostics.healthDetail ? `Reachable (${diagnostics.healthDetail})` : "Reachable"; - case "fail": - return diagnostics.healthDetail ? `Failed (${diagnostics.healthDetail})` : "Failed"; - case "skip": - return diagnostics.healthDetail ?? "Skipped"; - } -} - -function formatOpenclawGatewayDebugReport(result: TestOpenclawGatewayResult): string { - const lines = [ - `OpenClaw gateway connection test: ${result.success ? "success" : "failed"}`, - `Total duration: ${result.totalDurationMs}ms`, - ]; - - if (result.error) { - lines.push(`Error: ${result.error}`); - } - - lines.push(""); - lines.push("Steps:"); - for (const step of result.steps) { - lines.push( - `- ${step.name}: ${step.status} (${step.durationMs}ms)${ - step.detail ? ` — ${step.detail}` : "" - }`, - ); - } - - if (result.serverInfo) { - lines.push(""); - lines.push("Server info:"); - if (result.serverInfo.version) { - lines.push(`- Version: ${result.serverInfo.version}`); - } - if (result.serverInfo.sessionId) { - lines.push(`- Session: ${result.serverInfo.sessionId}`); - } - } - - if (result.diagnostics) { - const diagnostics = result.diagnostics; - lines.push(""); - lines.push("Diagnostics:"); - if (diagnostics.normalizedUrl) { - lines.push(`- Endpoint: ${diagnostics.normalizedUrl}`); - } - if (diagnostics.hostKind) { - lines.push(`- Host type: ${describeOpenclawGatewayHostKind(diagnostics.hostKind)}`); - } - if (diagnostics.resolvedAddresses.length > 0) { - lines.push(`- Resolved: ${diagnostics.resolvedAddresses.join(", ")}`); - } - const healthStatus = describeOpenclawGatewayHealthStatus(result); - if (healthStatus) { - lines.push( - `- Health probe: ${healthStatus}${ - diagnostics.healthUrl ? ` at ${diagnostics.healthUrl}` : "" - }`, - ); - } - if (diagnostics.socketCloseCode !== undefined) { - lines.push( - `- Socket close: ${diagnostics.socketCloseCode}${ - diagnostics.socketCloseReason ? ` (${diagnostics.socketCloseReason})` : "" - }`, - ); - } - if (diagnostics.socketError) { - lines.push(`- Socket error: ${diagnostics.socketError}`); - } - if (diagnostics.gatewayErrorCode) { - lines.push(`- Gateway error code: ${diagnostics.gatewayErrorCode}`); - } - if (diagnostics.gatewayErrorDetailCode) { - lines.push(`- Gateway detail code: ${diagnostics.gatewayErrorDetailCode}`); - } - if (diagnostics.gatewayErrorDetailReason) { - lines.push(`- Gateway detail reason: ${diagnostics.gatewayErrorDetailReason}`); - } - if (diagnostics.gatewayRecommendedNextStep) { - lines.push(`- Gateway next step: ${diagnostics.gatewayRecommendedNextStep}`); - } - if (diagnostics.gatewayCanRetryWithDeviceToken !== undefined) { - lines.push( - `- Device-token retry available: ${diagnostics.gatewayCanRetryWithDeviceToken ? "yes" : "no"}`, - ); - } - if (diagnostics.observedNotifications.length > 0) { - lines.push(`- Gateway events: ${diagnostics.observedNotifications.join(", ")}`); - } - if (diagnostics.hints.length > 0) { - lines.push(""); - lines.push("Troubleshooting:"); - for (const hint of diagnostics.hints) { - lines.push(`- ${hint}`); - } - } - } - - return lines.join("\n"); -} - -type InstallBinarySettingsKey = "claudeBinaryPath" | "codexBinaryPath"; -type InstallProviderSettings = { - provider: ProviderKind; - title: string; - binaryPathKey: InstallBinarySettingsKey; - binaryPlaceholder: string; - binaryDescription: ReactNode; - homePathKey?: "codexHomePath"; - homePlaceholder?: string; - homeDescription?: ReactNode; -}; - -const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ - { - provider: "codex", - title: "Codex", - binaryPathKey: "codexBinaryPath", - binaryPlaceholder: "Codex binary path", - binaryDescription: ( - <> - Leave blank to use codex from your PATH. Authentication normally uses{" "} - codex login unless your Codex config points at a custom model provider. - - ), - homePathKey: "codexHomePath", - homePlaceholder: "CODEX_HOME", - homeDescription: "Optional custom Codex home and config directory.", - }, - { - provider: "claudeAgent", - title: "Claude Code", - binaryPathKey: "claudeBinaryPath", - binaryPlaceholder: "Claude Code binary path", - binaryDescription: ( - <> - Leave blank to use claude from your PATH. Authentication uses{" "} - claude auth login. - - ), - }, -]; - -const PROVIDER_AUTH_GUIDES: Record< - ProviderKind, - { - installCmd?: string; - authCmd?: string; - verifyCmd?: string; - note: string; - } -> = { - codex: { - installCmd: "npm install -g @openai/codex", - authCmd: "codex login", - verifyCmd: "codex login status", - note: "Codex stays available in thread creation when the CLI is ready and its auth is either confirmed or delegated to a custom model provider.", - }, - claudeAgent: { - installCmd: "npm install -g @anthropic-ai/claude-code", - authCmd: "claude auth login", - verifyCmd: "claude auth status", - note: "Claude Code must be installed and signed in before it appears in the thread picker.", - }, - openclaw: { - verifyCmd: "Test Connection", - note: "OpenClaw uses the gateway URL and password below rather than a local CLI login. A configured gateway unlocks it for new-thread selection.", - }, -}; - -function getAuthenticationBadgeCopy(input: { - status: ServerProviderStatus | null; - provider: ProviderKind; - openclawGatewayUrl: string; -}): { - tone: "success" | "warning" | "error"; - label: string; -} { - if ( - isProviderReadyForThreadSelection({ - provider: input.provider, - statuses: input.status ? [input.status] : [], - openclawGatewayUrl: input.openclawGatewayUrl, - }) - ) { - return { tone: "success", label: "Available in thread picker" }; - } - - if (input.status?.authStatus === "unauthenticated") { - return { tone: "error", label: "Sign-in required" }; - } - - if (input.provider === "openclaw" && input.openclawGatewayUrl.trim().length === 0) { - return { tone: "warning", label: "Gateway not configured" }; - } - - if (input.status?.available === false || input.status?.status === "error") { - return { tone: "error", label: "Unavailable" }; - } - - return { tone: "warning", label: "Needs verification" }; -} - -function AuthenticationStatusCard({ - provider, - status, - openclawGatewayUrl, -}: { - provider: ProviderKind; - status: ServerProviderStatus | null; - openclawGatewayUrl: string; -}) { - const guide = PROVIDER_AUTH_GUIDES[provider]; - const badge = getAuthenticationBadgeCopy({ status, provider, openclawGatewayUrl }); - const badgeClassName = - badge.tone === "success" - ? "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" - : badge.tone === "error" - ? "border-red-500/25 bg-red-500/10 text-red-700 dark:text-red-300" - : "border-amber-500/25 bg-amber-500/10 text-amber-700 dark:text-amber-300"; - const heading = - status !== null - ? getProviderStatusHeading(status) - : provider === "openclaw" && openclawGatewayUrl.trim().length > 0 - ? "OpenClaw gateway is configured locally" - : `${getProviderStatusLabelName(provider)} needs configuration`; - const description = - status !== null - ? getProviderStatusDescription(status) - : provider === "openclaw" && openclawGatewayUrl.trim().length > 0 - ? "OpenClaw is configured in local settings. Use Test Connection below to verify the gateway before starting a thread." - : guide.note; - - return ( -
-
-
-
-

- {getProviderStatusLabelName(provider)} -

- - {badge.label} - -
-

{heading}

-

{description}

-
- {status?.checkedAt ? ( - - Checked {new Date(status.checkedAt).toLocaleString()} - - ) : null} -
- -
-
-
Install
- - {guide.installCmd ?? "Configured in-app"} - -
-
-
Authenticate
- - {guide.authCmd ?? "Use gateway password"} - -
-
-
Verify
- {guide.verifyCmd ?? "N/A"} -
-
- -

{guide.note}

-
- ); -} - -function SettingsSection({ - title, - description, - children, - actions, -}: { - title: string; - description?: string; - children: ReactNode; - actions?: ReactNode; -}) { - return ( -
-
-
-

{title}

- {description ?

{description}

: null} -
- {actions ?
{actions}
: null} -
-
- {children} -
-
- ); -} - -function SettingsNavSidebar({ - items, - activeSection, - onSelect, -}: { - items: SettingsNavItem[]; - activeSection: SettingsSectionId; - onSelect: (id: SettingsSectionId) => void; -}) { - return ( - - ); -} - -function SettingsRow({ - title, - description, - status, - resetAction, - control, - children, - onClick, -}: { - title: string; - description: string; - status?: ReactNode; - resetAction?: ReactNode; - control?: ReactNode; - children?: ReactNode; - onClick?: () => void; -}) { - return ( -
-
-
-
-

{title}

- - {resetAction} - -
-

{description}

- {status ?
{status}
: null} -
- {control ? ( -
- {control} -
- ) : null} -
- {children} -
- ); -} - -function SettingResetButton({ label, onClick }: { label: string; onClick: () => void }) { - return ( - - { - event.stopPropagation(); - onClick(); - }} - > - - - } - /> - Reset to default - - ); -} - -function getErrorMessage(error: unknown): string { - if (error instanceof Error && error.message.trim().length > 0) { - return error.message; - } - if (typeof error === "string" && error.trim().length > 0) { - return error; - } - return "Unknown error"; -} - -function BuildInfoBlock({ label, buildInfo }: { label: string; buildInfo: BuildMetadata }) { - return ( -
-
{label}
-
-
- {buildInfo.version} - - {buildInfo.surface} - - - {buildInfo.platform}/{buildInfo.arch} - -
-
- {buildInfo.channel} - - {buildInfo.commitHash ?? "unknown"} - - {buildInfo.buildTimestamp} -
-
-
- ); -} - -function BackgroundImageSettings({ - backgroundImageUrl, - backgroundImageOpacity, - defaultBackgroundImageUrl, - defaultBackgroundImageOpacity, - updateSettings, -}: { - backgroundImageUrl: string; - backgroundImageOpacity: number; - defaultBackgroundImageUrl: string; - defaultBackgroundImageOpacity: number; - updateSettings: (patch: { backgroundImageOpacity?: number; backgroundImageUrl?: string }) => void; -}) { - const hasBackground = backgroundImageUrl.trim().length > 0; - - const handleUrlChange = useCallback( - (value: string) => { - updateSettings({ - backgroundImageUrl: value, - }); - }, - [updateSettings], - ); - - const handleOpacityChange = useCallback( - (value: number) => { - updateSettings({ backgroundImageOpacity: value }); - }, - [updateSettings], - ); - - const handleReset = useCallback(() => { - updateSettings({ - backgroundImageUrl: defaultBackgroundImageUrl, - backgroundImageOpacity: defaultBackgroundImageOpacity, - }); - }, [defaultBackgroundImageOpacity, defaultBackgroundImageUrl, updateSettings]); - - return ( - <> - - ) : null - } - control={ - handleUrlChange(e.target.value)} - placeholder="https://example.com/image.jpg" - className="w-full sm:w-56" - aria-label="Background image URL" - /> - } - /> - {hasBackground && ( - - { - const value = Number(e.target.value) / 100; - handleOpacityChange(value); - }} - className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" - aria-label="Background opacity" - /> - - {Math.round(backgroundImageOpacity * 100)}% - - - } - /> - )} - - ); -} - -function SettingsRouteView() { - const { theme, setTheme, colorTheme, setColorTheme, fontFamily, setFontFamily } = useTheme(); - const { settings, defaults, updateSettings, resetSettings } = useAppSettings(); - const serverConfigQuery = useQuery(serverConfigQueryOptions()); - const queryClient = useQueryClient(); - const trimmedBrowserPreviewStartPageUrl = settings.browserPreviewStartPageUrl.trim(); - const browserPreviewStartPageValidation = - trimmedBrowserPreviewStartPageUrl.length > 0 - ? validateHttpPreviewUrl(trimmedBrowserPreviewStartPageUrl) - : null; - const effectiveBrowserPreviewStartPageUrl = resolveBrowserPreviewStartPageUrl( - settings.browserPreviewStartPageUrl, - ); - const projects = useStore((state) => state.projects); - const threads = useStore((state) => state.threads); - const [selectedProjectId, setSelectedProjectId] = useState( - () => projects[0]?.id ?? null, - ); - const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); - const [openKeybindingsError, setOpenKeybindingsError] = useState(null); - const [openInstallProviders, setOpenInstallProviders] = useState>({ - codex: Boolean(settings.codexBinaryPath || settings.codexHomePath), - claudeAgent: Boolean(settings.claudeBinaryPath), - openclaw: Boolean(settings.openclawGatewayUrl || settings.openclawPassword), - }); - const [selectedCustomModelProvider, setSelectedCustomModelProvider] = - useState("codex"); - const [customModelInputByProvider, setCustomModelInputByProvider] = useState< - Record - >({ - codex: "", - claudeAgent: "", - openclaw: "", - }); - const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< - Partial> - >({}); - const [showAllCustomModels, setShowAllCustomModels] = useState(false); - const [customThemeDialogOpen, setCustomThemeDialogOpen] = useState(false); - const [radiusOverride, setRadiusOverrideState] = useState(() => - getStoredRadiusOverride(), - ); - const [fontOverride, setFontOverrideState] = useState( - () => getStoredFontOverride() ?? "", - ); - const [fontSizeOverride, setFontSizeOverrideState] = useState(() => - getStoredFontSizeOverride(), - ); - const [openclawTestResult, setOpenclawTestResult] = useState( - null, - ); - const [openclawTestLoading, setOpenclawTestLoading] = useState(false); - const { copyToClipboard: copyOpenclawDebugReport, isCopied: openclawDebugReportCopied } = - useCopyToClipboard(); - - const globalEnvironmentVariablesQuery = useQuery(globalEnvironmentVariablesQueryOptions()); - const activeProjectId = selectedProjectId ?? projects[0]?.id ?? null; - const selectedProject = projects.find((project) => project.id === activeProjectId) ?? null; - const selectedProjectEnvironmentVariablesQuery = useQuery( - projectEnvironmentVariablesQueryOptions(activeProjectId), - ); - const activeProjectPreviewThreadId = - activeProjectId === null - ? null - : (threads - .filter((thread) => thread.projectId === activeProjectId) - .toSorted((a, b) => - (b.updatedAt ?? b.createdAt).localeCompare(a.updatedAt ?? a.createdAt), - ) - .at(0)?.id ?? null); - - useEffect(() => { - if (projects.length === 0) { - if (selectedProjectId !== null) { - setSelectedProjectId(null); - } - return; - } - - if (!selectedProjectId || !projects.some((project) => project.id === selectedProjectId)) { - setSelectedProjectId(projects[0]?.id ?? null); - } - }, [projects, selectedProjectId]); - - const codexBinaryPath = settings.codexBinaryPath; - const codexHomePath = settings.codexHomePath; - const claudeBinaryPath = settings.claudeBinaryPath; - const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; - const availableEditors = serverConfigQuery.data?.availableEditors; - const providerStatuses = serverConfigQuery.data?.providers ?? []; - const selectableProviders = getSelectableThreadProviders({ - statuses: providerStatuses, - openclawGatewayUrl: settings.openclawGatewayUrl, - }); - - const gitTextGenerationModelOptions = getAppModelOptions( - "codex", - settings.customCodexModels, - settings.textGenerationModel, - ); - const currentGitTextGenerationModel = - settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL; - const defaultGitTextGenerationModel = - defaults.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL; - const isGitTextGenerationModelDirty = - currentGitTextGenerationModel !== defaultGitTextGenerationModel; - const selectedGitTextGenerationModelLabel = - gitTextGenerationModelOptions.find((option) => option.slug === currentGitTextGenerationModel) - ?.name ?? currentGitTextGenerationModel; - 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 activeProjectEnvironmentVariables = selectedProjectEnvironmentVariablesQuery.data?.entries; - 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 isInstallSettingsDirty = - settings.claudeBinaryPath !== defaults.claudeBinaryPath || - settings.codexBinaryPath !== defaults.codexBinaryPath || - settings.codexHomePath !== defaults.codexHomePath; - const isOpenClawSettingsDirty = - settings.openclawGatewayUrl !== defaults.openclawGatewayUrl || - settings.openclawPassword !== defaults.openclawPassword; - const changedSettingLabels = [ - ...(theme !== "system" ? ["Theme"] : []), - ...(colorTheme !== DEFAULT_COLOR_THEME ? ["Color theme"] : []), - ...(fontFamily !== "inter" ? ["Font"] : []), - ...(settings.prReviewRequestChangesTone !== DEFAULT_PR_REVIEW_REQUEST_CHANGES_TONE - ? ["PR request changes button"] - : []), - ...(settings.timestampFormat !== defaults.timestampFormat ? ["Time format"] : []), - ...(settings.showStitchBorder !== defaults.showStitchBorder ? ["Stitch border"] : []), - ...(settings.enableAssistantStreaming !== defaults.enableAssistantStreaming - ? ["Assistant output"] - : []), - ...(settings.showReasoningContent !== defaults.showReasoningContent - ? ["Reasoning content"] - : []), - ...(settings.showAuthFailuresAsErrors !== defaults.showAuthFailuresAsErrors - ? ["Auth failure errors"] - : []), - ...(settings.showNotificationDetails !== defaults.showNotificationDetails - ? ["Notification details"] - : []), - ...(settings.includeDiagnosticsTipsInCopy !== defaults.includeDiagnosticsTipsInCopy - ? ["Diagnostics copy tips"] - : []), - ...(settings.openLinksExternally !== defaults.openLinksExternally - ? ["Open links externally"] - : []), - ...(settings.codeViewerAutosave !== defaults.codeViewerAutosave - ? ["Code preview autosave"] - : []), - ...(settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ["New thread mode"] : []), - ...(settings.autoUpdateWorktreeBaseBranch !== defaults.autoUpdateWorktreeBaseBranch - ? ["Worktree base refresh"] - : []), - ...(settings.confirmThreadDelete !== defaults.confirmThreadDelete - ? ["Delete confirmation"] - : []), - ...(settings.autoDeleteMergedThreads !== defaults.autoDeleteMergedThreads - ? ["Auto-delete merged threads"] - : []), - ...(settings.autoDeleteMergedThreadsDelayMinutes !== - defaults.autoDeleteMergedThreadsDelayMinutes - ? ["Auto-delete delay"] - : []), - ...(settings.rebaseBeforeCommit !== defaults.rebaseBeforeCommit - ? ["Rebase before commit"] - : []), - ...(isGitTextGenerationModelDirty ? ["Git writing model"] : []), - ...(settings.customCodexModels.length > 0 || - settings.customClaudeModels.length > 0 || - settings.customOpenClawModels.length > 0 - ? ["Custom models"] - : []), - ...(isInstallSettingsDirty ? ["Provider installs"] : []), - ...(isOpenClawSettingsDirty ? ["OpenClaw gateway"] : []), - ...(settings.backgroundImageUrl !== defaults.backgroundImageUrl ? ["Background image"] : []), - ...(settings.backgroundImageOpacity !== defaults.backgroundImageOpacity - ? ["Background opacity"] - : []), - ...(settings.sidebarOpacity !== defaults.sidebarOpacity ? ["Sidebar opacity"] : []), - ...(settings.sidebarProjectRowHeight !== defaults.sidebarProjectRowHeight - ? ["Project height"] - : []), - ...(settings.sidebarThreadRowHeight !== defaults.sidebarThreadRowHeight - ? ["Thread height"] - : []), - ...(settings.sidebarFontSize !== defaults.sidebarFontSize ? ["Sidebar font size"] : []), - ...(settings.sidebarSpacing !== defaults.sidebarSpacing ? ["Sidebar spacing"] : []), - ...(radiusOverride !== null ? ["Border radius"] : []), - ...(fontOverride ? ["Font family"] : []), - ...(fontSizeOverride !== null ? ["Code font size"] : []), - ]; - - const openTweakcn = useCallback(() => { - void openUrlInAppBrowser({ - url: "https://tweakcn.com", - projectId: activeProjectId, - threadId: activeProjectPreviewThreadId, - popOut: true, - nativeApi: readNativeApi(), - }).catch(() => { - const nativeApi = ensureNativeApi(); - return nativeApi.shell.openExternal("https://tweakcn.com"); - }); - }, [activeProjectId, activeProjectPreviewThreadId]); - - 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 replaceKeybindingRules = useCallback( - async (command: KeybindingCommand, rules: readonly KeybindingRule[]) => { - const api = ensureNativeApi(); - await api.server.replaceKeybindingRules({ command, rules: [...rules] }); - await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); - }, - [queryClient], - ); - - const refreshProviderStatuses = useCallback(async () => { - await queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); - }, [queryClient]); - - const saveGlobalEnvironmentVariables = useCallback( - async (entries: ReadonlyArray<{ key: string; value: string }>) => { - const api = ensureNativeApi(); - const result = await api.server.saveGlobalEnvironmentVariables({ entries }); - queryClient.setQueryData(environmentVariablesQueryKeys.global(), result); - return result.entries; - }, - [queryClient], - ); - - const saveProjectEnvironmentVariables = useCallback( - async (entries: ReadonlyArray<{ key: string; value: string }>) => { - if (!selectedProject) { - throw new Error("Select a project before saving project variables."); - } - const api = ensureNativeApi(); - const result = await api.server.saveProjectEnvironmentVariables({ - projectId: selectedProject.id, - entries, - }); - queryClient.setQueryData(environmentVariablesQueryKeys.project(selectedProject.id), result); - return result.entries; - }, - [queryClient, selectedProject], - ); - - const testOpenclawGateway = useCallback(async () => { - if (openclawTestLoading) return; - setOpenclawTestLoading(true); - setOpenclawTestResult(null); - try { - const api = ensureNativeApi(); - const result = await api.server.testOpenclawGateway({ - gatewayUrl: settings.openclawGatewayUrl, - password: settings.openclawPassword || undefined, - }); - setOpenclawTestResult(result); - } catch (err) { - setOpenclawTestResult({ - success: false, - steps: [], - totalDurationMs: 0, - error: err instanceof Error ? err.message : "Unexpected error during test.", - }); - } finally { - setOpenclawTestLoading(false); - } - }, [openclawTestLoading, settings.openclawGatewayUrl, settings.openclawPassword]); - - const handleCopyOpenclawDebugReport = useCallback(() => { - if (!openclawTestResult) return; - copyOpenclawDebugReport(formatOpenclawGatewayDebugReport(openclawTestResult), undefined); - }, [copyOpenclawDebugReport, openclawTestResult]); - - 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], - ); - - 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"); - setColorTheme(DEFAULT_COLOR_THEME); - setFontFamily("inter"); - resetSettings(); - setOpenInstallProviders({ - codex: false, - claudeAgent: false, - openclaw: false, - }); - setSelectedCustomModelProvider("codex"); - setCustomModelInputByProvider({ - codex: "", - claudeAgent: "", - openclaw: "", - }); - setCustomModelErrorByProvider({}); - - // Reset custom theme + overrides - clearStoredCustomTheme(); - removeCustomTheme(); - clearRadiusOverride(); - setRadiusOverrideState(null); - clearFontOverride(); - setFontOverrideState(""); - clearFontSizeOverride(); - setFontSizeOverrideState(null); - } - - const navItems = useSettingsNavItems(); - const [activeSection, setActiveSection] = useState("general"); - const activeSectionLabel = navItems.find((item) => item.id === activeSection)?.label ?? "General"; +import { SettingsRouteContextProvider } from "../components/settings/SettingsRouteContext"; +function SettingsLayoutRouteView() { return ( - -
- {/* Header */} - {!isElectron && ( -
-
- -
- Settings - / - {activeSectionLabel} -
-
- -
-
-
- )} - - {isElectron && ( -
-
- Settings - / - {activeSectionLabel} -
-
- -
-
- )} - - {/* Body: sidebar + content */} -
- {/* Settings sidebar navigation */} - - - {/* Main content area */} -
- {/* Mobile section selector (visible on small screens) */} -
- -
- -
-
- {activeSection === "general" && ( - - setTheme("system")} /> - ) : null - } - control={ - - } - /> - - { - setColorTheme(DEFAULT_COLOR_THEME); - clearStoredCustomTheme(); - removeCustomTheme(); - }} - /> - ) : null - } - control={ -
- - - - - - } - /> - - Open tweakcn in the in-app browser - - - - setCustomThemeDialogOpen(true)} - aria-label="Import custom theme" - > - - - } - /> - Import from tweakcn.com - -
- } - /> - - { - clearRadiusOverride(); - setRadiusOverrideState(null); - }} - /> - ) : null - } - control={ -
- { - const value = Number.parseFloat(e.target.value); - setRadiusOverrideState(value); - setStoredRadiusOverride(value); - }} - className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" - aria-label="Border radius" - /> - - {(radiusOverride ?? 0.625).toFixed(2)}rem - -
- } - /> - - { - clearFontSizeOverride(); - setFontSizeOverrideState(null); - }} - /> - ) : null - } - control={ -
- { - const value = Number.parseFloat(e.target.value); - setFontSizeOverrideState(value); - setStoredFontSizeOverride(value); - }} - className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" - aria-label="Code font size" - /> - - {fontSizeOverride ?? 12}px - -
- } - /> - - { - clearFontOverride(); - setFontOverrideState(""); - }} - /> - ) : null - } - control={ - { - const value = e.target.value; - setFontOverrideState(value); - if (value.trim()) { - setStoredFontOverride(value); - } else { - clearFontOverride(); - } - }} - placeholder="e.g. Inter, sans-serif" - spellCheck={false} - aria-label="Font family override" - /> - } - /> - - { - applyCustomTheme(theme); - setColorTheme("custom"); - }} - /> - - setFontFamily("inter")} /> - ) : null - } - control={ - - } - /> - - - updateSettings({ sidebarOpacity: defaults.sidebarOpacity }) - } - /> - ) : null - } - control={ -
- { - const value = Number(e.target.value) / 100; - updateSettings({ sidebarOpacity: value }); - }} - className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" - aria-label="Sidebar opacity" - /> - - {Math.round(settings.sidebarOpacity * 100)}% - -
- } - /> - - - updateSettings({ - sidebarProjectRowHeight: DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT, - }) - } - /> - ) : null - } - control={ -
- { - updateSettings({ - sidebarProjectRowHeight: Number(e.target.value), - }); - }} - className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" - aria-label="Project height" - /> - - {settings.sidebarProjectRowHeight}px - -
- } - /> - - - updateSettings({ - sidebarThreadRowHeight: DEFAULT_SIDEBAR_THREAD_ROW_HEIGHT, - }) - } - /> - ) : null - } - control={ -
- { - updateSettings({ - sidebarThreadRowHeight: Number(e.target.value), - }); - }} - className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" - aria-label="Thread height" - /> - - {settings.sidebarThreadRowHeight}px - -
- } - /> - - - updateSettings({ sidebarFontSize: DEFAULT_SIDEBAR_FONT_SIZE }) - } - /> - ) : null - } - control={ -
- { - updateSettings({ sidebarFontSize: Number(e.target.value) }); - }} - className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" - aria-label="Sidebar font size" - /> - - {settings.sidebarFontSize}px - -
- } - /> - - - updateSettings({ sidebarSpacing: DEFAULT_SIDEBAR_SPACING }) - } - /> - ) : null - } - control={ -
- { - updateSettings({ sidebarSpacing: Number(e.target.value) }); - }} - className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" - aria-label="Sidebar spacing" - /> - - {settings.sidebarSpacing}px - -
- } - /> - - - - - updateSettings({ - sidebarAccentProjectNames: defaults.sidebarAccentProjectNames, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - sidebarAccentProjectNames: Boolean(checked), - }) - } - aria-label="Accent project names" - /> - } - /> - - - updateSettings({ - sidebarAccentColorOverride: undefined, - }) - } - /> - ) : null - } - control={ -
- - { - const value = e.target.value.trim(); - updateSettings({ - sidebarAccentColorOverride: value || undefined, - }); - }} - className="h-8 w-28 rounded-md border border-border bg-background px-2 text-xs text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring sm:w-32" - aria-label="Accent color value" - /> -
- } - /> - - - updateSettings({ - sidebarAccentBgColorOverride: undefined, - }) - } - /> - ) : null - } - control={ -
- - { - const value = e.target.value.trim(); - updateSettings({ - sidebarAccentBgColorOverride: value || undefined, - }); - }} - className="h-8 w-28 rounded-md border border-border bg-background px-2 text-xs text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring sm:w-32" - aria-label="Accent background color value" - /> -
- } - /> - - - updateSettings({ - prReviewRequestChangesTone: DEFAULT_PR_REVIEW_REQUEST_CHANGES_TONE, - }) - } - /> - ) : null - } - control={ - - } - /> - - - updateSettings({ - timestampFormat: defaults.timestampFormat, - }) - } - /> - ) : null - } - control={ - - } - /> - - - updateSettings({ - showStitchBorder: defaults.showStitchBorder, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - showStitchBorder: Boolean(checked), - }) - } - aria-label="Show stitch border" - /> - } - /> - - - updateSettings({ - enableAssistantStreaming: defaults.enableAssistantStreaming, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - enableAssistantStreaming: Boolean(checked), - }) - } - aria-label="Stream assistant messages" - /> - } - /> - - - updateSettings({ - showReasoningContent: defaults.showReasoningContent, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - showReasoningContent: Boolean(checked), - }) - } - aria-label="Show reasoning content in work log" - /> - } - /> - - - updateSettings({ - showAuthFailuresAsErrors: defaults.showAuthFailuresAsErrors, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - showAuthFailuresAsErrors: Boolean(checked), - }) - } - aria-label="Show authentication failures as thread errors" - /> - } - /> - - - updateSettings({ - showNotificationDetails: defaults.showNotificationDetails, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - showNotificationDetails: Boolean(checked), - }) - } - aria-label="Show notification details by default" - /> - } - /> - - - updateSettings({ - includeDiagnosticsTipsInCopy: defaults.includeDiagnosticsTipsInCopy, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - includeDiagnosticsTipsInCopy: Boolean(checked), - }) - } - aria-label="Include diagnostics tips in copied text" - /> - } - /> - - - updateSettings({ - openLinksExternally: defaults.openLinksExternally, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - openLinksExternally: Boolean(checked), - }) - } - aria-label="Open links externally" - /> - } - /> - - - Blank uses the default start page:{" "} - {DEFAULT_BROWSER_PREVIEW_START_PAGE_URL} - - ) : browserPreviewStartPageValidation?.ok ? ( - <> - New blank preview tabs will open at{" "} - {browserPreviewStartPageValidation.url}. - - ) : ( - <> - - Invalid URL. Falling back to{" "} - {DEFAULT_BROWSER_PREVIEW_START_PAGE_URL}. - - - Effective start page:{" "} - {effectiveBrowserPreviewStartPageUrl} - - - ) - } - resetAction={ - settings.browserPreviewStartPageUrl !== - defaults.browserPreviewStartPageUrl ? ( - - updateSettings({ - browserPreviewStartPageUrl: defaults.browserPreviewStartPageUrl, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - browserPreviewStartPageUrl: event.target.value, - }) - } - placeholder={DEFAULT_BROWSER_PREVIEW_START_PAGE_URL} - aria-label="Browser preview start page" - autoCapitalize="off" - autoCorrect="off" - spellCheck={false} - className="w-full sm:w-72" - /> - } - /> - - - updateSettings({ - codeViewerAutosave: defaults.codeViewerAutosave, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - codeViewerAutosave: Boolean(checked), - }) - } - aria-label="Enable code preview autosave" - /> - } - /> - - - updateSettings({ - defaultThreadEnvMode: defaults.defaultThreadEnvMode, - }) - } - /> - ) : null - } - control={ - - } - /> - - - updateSettings({ - autoUpdateWorktreeBaseBranch: defaults.autoUpdateWorktreeBaseBranch, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - autoUpdateWorktreeBaseBranch: Boolean(checked), - }) - } - aria-label="Refresh base branch before creating new worktrees" - /> - } - /> - - - updateSettings({ - confirmThreadDelete: defaults.confirmThreadDelete, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - confirmThreadDelete: Boolean(checked), - }) - } - aria-label="Confirm thread deletion" - /> - } - /> - - - updateSettings({ - autoDeleteMergedThreads: defaults.autoDeleteMergedThreads, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - autoDeleteMergedThreads: Boolean(checked), - }) - } - aria-label="Auto-delete merged threads" - /> - } - /> - - {settings.autoDeleteMergedThreads ? ( - - updateSettings({ - autoDeleteMergedThreadsDelayMinutes: - defaults.autoDeleteMergedThreadsDelayMinutes, - }) - } - /> - ) : null - } - control={ - - } - /> - ) : null} -
- )} - - {activeSection === "authentication" && ( - void refreshProviderStatuses()} - > - - Refresh status - - } - > - -
- {(["codex", "claudeAgent", "openclaw"] as const).map((provider) => ( - status.provider === provider) ?? - null - } - openclawGatewayUrl={settings.openclawGatewayUrl} - /> - ))} -
-
- - { - updateSettings({ - claudeBinaryPath: defaults.claudeBinaryPath, - codexBinaryPath: defaults.codexBinaryPath, - codexHomePath: defaults.codexHomePath, - }); - setOpenInstallProviders({ - codex: false, - claudeAgent: false, - openclaw: false, - }); - }} - /> - ) : null - } - > -
-
- {INSTALL_PROVIDER_SETTINGS.map((providerSettings) => { - const isOpen = openInstallProviders[providerSettings.provider]; - const isDirty = - providerSettings.provider === "codex" - ? settings.codexBinaryPath !== defaults.codexBinaryPath || - settings.codexHomePath !== defaults.codexHomePath - : settings.claudeBinaryPath !== defaults.claudeBinaryPath; - const binaryPathValue = - providerSettings.binaryPathKey === "claudeBinaryPath" - ? claudeBinaryPath - : codexBinaryPath; - - return ( - - setOpenInstallProviders((existing) => ({ - ...existing, - [providerSettings.provider]: open, - })) - } - > -
- - - -
-
- - - {providerSettings.homePathKey ? ( - - ) : null} -
-
-
-
-
- ); - })} -
-
-
- - 0 - ? `Configured for ${settings.openclawGatewayUrl}` - : "Not configured" - } - resetAction={ - isOpenClawSettingsDirty ? ( - - updateSettings({ - openclawGatewayUrl: defaults.openclawGatewayUrl, - openclawPassword: defaults.openclawPassword, - }) - } - /> - ) : null - } - > -
- - - -
- -
- - {openclawTestResult ? ( -
-
- {openclawTestResult.success ? ( - - ) : ( - - )} - - {openclawTestResult.success - ? "Connection successful" - : "Connection failed"} - - - {openclawTestResult.totalDurationMs}ms total - - -
- - {openclawTestResult.steps.length > 0 ? ( -
- {openclawTestResult.steps.map((step) => ( -
- {step.status === "pass" ? ( - - ) : null} - {step.status === "fail" ? ( - - ) : null} - {step.status === "skip" ? ( - - ) : null} -
-
- - {step.name} - - - {step.durationMs}ms - -
- {step.detail ? ( - - {step.detail} - - ) : null} -
-
- ))} -
- ) : null} - - {openclawTestResult.error && - !openclawTestResult.steps.some((step) => step.status === "fail") ? ( -
- {openclawTestResult.error} -
- ) : null} -
- ) : null} -
-
-
- )} - - {activeSection === "hotkeys" && ( - - )} - - {activeSection === "environment" && ( - - - Failed to load saved variables:{" "} - {getErrorMessage(globalEnvironmentVariablesQuery.error)} - - ) : globalEnvironmentVariablesQuery.isFetching ? ( - Loading saved variables... - ) : globalEnvironmentVariablesQuery.data?.entries.length ? ( - - {globalEnvironmentVariablesQuery.data.entries.length} saved variables - - ) : ( - No global variables saved yet. - ) - } - > - - - - - {selectedProject.name} · {selectedProject.cwd} - - ) : ( - Open a project to edit project variables. - ) - } - control={ - projects.length > 0 ? ( - - ) : ( - - No projects available. - - ) - } - > - - - - )} - - {activeSection === "git" && ( - - - updateSettings({ - rebaseBeforeCommit: defaults.rebaseBeforeCommit, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - rebaseBeforeCommit: Boolean(checked), - }) - } - aria-label="Rebase onto the default branch before committing" - /> - } - /> - - )} - - {activeSection === "models" && ( - - - updateSettings({ - textGenerationModel: defaults.textGenerationModel, - }) - } - /> - ) : null - } - control={ - - } - /> - - 0 ? ( - { - updateSettings({ - customCodexModels: defaults.customCodexModels, - customClaudeModels: defaults.customClaudeModels, - }); - setCustomModelErrorByProvider({}); - setShowAllCustomModels(false); - }} - /> - ) : null - } - > -
-
- - { - 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} - /> - -
- - {selectedCustomModelError ? ( -

- {selectedCustomModelError} -

- ) : null} - - {totalCustomModels > 0 ? ( -
-
- {visibleCustomModelRows.map((row) => ( -
- - {row.providerTitle} - - - {row.slug} - - -
- ))} -
- - {savedCustomModelRows.length > 5 ? ( - - ) : null} -
- ) : null} -
-
-
- )} - - {activeSection === "mobile" && !isMobileShell && ( - - -
- -
-
-
- )} - - {activeSection === "advanced" && ( - - - - {serverConfigQuery.data?.buildInfo ? ( - - ) : null} -
- } - /> - - )} -
-
-
-
- -
+ + + ); } export const Route = createFileRoute("/_chat/settings")({ - component: SettingsRouteView, + component: SettingsLayoutRouteView, });