diff --git a/src/browser/App.stories.tsx b/src/browser/App.stories.tsx index b26916b98..ff4c30db0 100644 --- a/src/browser/App.stories.tsx +++ b/src/browser/App.stories.tsx @@ -37,6 +37,11 @@ function setupMockAPI(options: { }, providers: { setProviderConfig: () => Promise.resolve({ success: true, data: undefined }), + setModels: () => Promise.resolve({ success: true, data: undefined }), + getConfig: () => + Promise.resolve( + {} as Record + ), list: () => Promise.resolve([]), }, workspace: { @@ -555,6 +560,11 @@ export const ActiveWorkspaceWithChat: Story = { }, providers: { setProviderConfig: () => Promise.resolve({ success: true, data: undefined }), + setModels: () => Promise.resolve({ success: true, data: undefined }), + getConfig: () => + Promise.resolve( + {} as Record + ), list: () => Promise.resolve(["anthropic", "openai", "xai"]), }, workspace: { diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 0911aaf83..77d9ac718 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -34,6 +34,9 @@ import type { BranchListResult } from "@/common/types/ipc"; import { useTelemetry } from "./hooks/useTelemetry"; import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStartWorkspaceCreation"; +import { SettingsProvider, useSettings } from "./contexts/SettingsContext"; +import { SettingsModal } from "./components/Settings/SettingsModal"; + const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"]; function AppInner() { @@ -50,6 +53,7 @@ function AppInner() { clearPendingWorkspaceCreation, } = useWorkspaceContext(); const { theme, setTheme, toggleTheme } = useTheme(); + const { open: openSettings } = useSettings(); const setThemePreference = useCallback( (nextTheme: ThemeMode) => { setTheme(nextTheme); @@ -412,6 +416,7 @@ function AppInner() { onOpenWorkspaceInTerminal: openWorkspaceInTerminal, onToggleTheme: toggleTheme, onSetTheme: setThemePreference, + onOpenSettings: openSettings, }; useEffect(() => { @@ -634,6 +639,7 @@ function AppInner() { onClose={closeProjectCreateModal} onSuccess={addProject} /> + ); @@ -642,9 +648,11 @@ function AppInner() { function App() { return ( - - - + + + + + ); } diff --git a/src/browser/api.ts b/src/browser/api.ts index 91bb3a8c0..33b9ad37a 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -233,6 +233,8 @@ const webApi: IPCApi = { providers: { setProviderConfig: (provider, keyPath, value) => invokeIPC(IPC_CHANNELS.PROVIDERS_SET_CONFIG, provider, keyPath, value), + setModels: (provider, models) => invokeIPC(IPC_CHANNELS.PROVIDERS_SET_MODELS, provider, models), + getConfig: () => invokeIPC(IPC_CHANNELS.PROVIDERS_GET_CONFIG), list: () => invokeIPC(IPC_CHANNELS.PROVIDERS_LIST), }, projects: { diff --git a/src/browser/components/Settings/Settings.stories.tsx b/src/browser/components/Settings/Settings.stories.tsx new file mode 100644 index 000000000..34bc95b4a --- /dev/null +++ b/src/browser/components/Settings/Settings.stories.tsx @@ -0,0 +1,295 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, waitFor, within } from "storybook/test"; +import React, { useState } from "react"; +import { SettingsProvider, useSettings } from "@/browser/contexts/SettingsContext"; +import { SettingsModal } from "./SettingsModal"; +import type { IPCApi } from "@/common/types/ipc"; + +// Mock providers config for stories +const mockProvidersConfig: Record< + string, + { apiKeySet: boolean; baseUrl?: string; models?: string[] } +> = { + anthropic: { apiKeySet: true }, + openai: { apiKeySet: true, baseUrl: "https://custom.openai.com" }, + google: { apiKeySet: false }, + xai: { apiKeySet: false }, + ollama: { apiKeySet: false, models: ["llama3.2", "codestral"] }, + openrouter: { apiKeySet: true, models: ["mistral/mistral-7b"] }, +}; + +function setupMockAPI(config = mockProvidersConfig) { + const mockProviders: IPCApi["providers"] = { + setProviderConfig: () => Promise.resolve({ success: true, data: undefined }), + setModels: () => Promise.resolve({ success: true, data: undefined }), + getConfig: () => Promise.resolve(config), + list: () => Promise.resolve([]), + }; + + // @ts-expect-error - Assigning mock API to window for Storybook + window.api = { + providers: mockProviders, + }; +} + +// Wrapper component that auto-opens the settings modal +function SettingsStoryWrapper(props: { initialSection?: string }) { + return ( + + + + + ); +} + +function SettingsAutoOpen(props: { initialSection?: string }) { + const { open, isOpen } = useSettings(); + const [hasOpened, setHasOpened] = useState(false); + + React.useEffect(() => { + if (!hasOpened && !isOpen) { + open(props.initialSection); + setHasOpened(true); + } + }, [hasOpened, isOpen, open, props.initialSection]); + + return null; +} + +// Interactive wrapper for testing close behavior +function InteractiveSettingsWrapper(props: { initialSection?: string }) { + const [reopenCount, setReopenCount] = useState(0); + + return ( + +
+ +
+ Click overlay or press Escape to close +
+
+ + +
+ ); +} + +const meta = { + title: "Components/Settings", + component: SettingsModal, + parameters: { + layout: "fullscreen", + }, + tags: ["autodocs"], + decorators: [ + (Story) => { + setupMockAPI(); + return ; + }, + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * Default settings modal showing the General section. + * Contains theme toggle between light/dark modes. + */ +export const General: Story = { + render: () => , +}; + +/** + * Providers section showing API key configuration. + * - Green dot indicates configured providers + * - Accordion expands to show API Key and Base URL fields + * - Shows masked "••••••••" for set keys + */ +export const Providers: Story = { + render: () => , +}; + +/** + * Providers section with expanded Anthropic accordion. + */ +export const ProvidersExpanded: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for modal to render + await waitFor(async () => { + const modal = canvas.getByRole("dialog"); + await expect(modal).toBeInTheDocument(); + }); + + // Click Anthropic to expand + const anthropicButton = canvas.getByRole("button", { name: /Anthropic/i }); + await userEvent.click(anthropicButton); + + // Verify the accordion expanded (API Key label should be visible) + await waitFor(async () => { + const apiKeyLabel = canvas.getByText("API Key"); + await expect(apiKeyLabel).toBeVisible(); + }); + }, +}; + +/** + * Models section showing custom model management. + * - Form to add new models with provider dropdown + * - List of existing custom models with delete buttons + */ +export const Models: Story = { + render: () => , +}; + +/** + * Models section with no custom models configured. + */ +export const ModelsEmpty: Story = { + decorators: [ + (Story) => { + setupMockAPI({ + anthropic: { apiKeySet: true }, + openai: { apiKeySet: true }, + google: { apiKeySet: false }, + xai: { apiKeySet: false }, + ollama: { apiKeySet: false }, + openrouter: { apiKeySet: false }, + }); + return ; + }, + ], + render: () => , +}; + +/** + * Test that clicking overlay closes the modal. + */ +export const OverlayClickCloses: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for modal + await waitFor(async () => { + const modal = canvas.getByRole("dialog"); + await expect(modal).toBeInTheDocument(); + }); + + // Wait for event listeners to attach + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Click overlay + const overlay = document.querySelector('[role="presentation"]'); + await expect(overlay).toBeInTheDocument(); + await userEvent.click(overlay!); + + // Modal should close + await waitFor(async () => { + const closedModal = canvas.queryByRole("dialog"); + await expect(closedModal).not.toBeInTheDocument(); + }); + }, +}; + +/** + * Test that pressing Escape closes the modal. + */ +export const EscapeKeyCloses: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for modal + await waitFor(async () => { + const modal = canvas.getByRole("dialog"); + await expect(modal).toBeInTheDocument(); + }); + + // Wait for event listeners + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Press Escape + await userEvent.keyboard("{Escape}"); + + // Modal should close + await waitFor(async () => { + const closedModal = canvas.queryByRole("dialog"); + await expect(closedModal).not.toBeInTheDocument(); + }); + }, +}; + +/** + * Test sidebar navigation between sections. + */ +export const SidebarNavigation: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for modal + await waitFor(async () => { + const modal = canvas.getByRole("dialog"); + await expect(modal).toBeInTheDocument(); + }); + + // Should start on General - verify by checking theme toggle presence + await expect(canvas.getByText("Theme")).toBeVisible(); + + // Click Providers in sidebar + const providersNav = canvas.getByRole("button", { name: /Providers/i }); + await userEvent.click(providersNav); + + // Content should update to show Providers section text + await waitFor(async () => { + const providersText = canvas.getByText(/Configure API keys/i); + await expect(providersText).toBeVisible(); + }); + + // Click Models in sidebar + const modelsNav = canvas.getByRole("button", { name: /Models/i }); + await userEvent.click(modelsNav); + + // Content should update to show Models section text + await waitFor(async () => { + const modelsText = canvas.getByText(/Add custom models/i); + await expect(modelsText).toBeVisible(); + }); + }, +}; + +/** + * Test X button closes the modal. + */ +export const CloseButtonCloses: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for modal + await waitFor(async () => { + const modal = canvas.getByRole("dialog"); + await expect(modal).toBeInTheDocument(); + }); + + // Click close button + const closeButton = canvas.getByRole("button", { name: /Close settings/i }); + await userEvent.click(closeButton); + + // Modal should close + await waitFor(async () => { + const closedModal = canvas.queryByRole("dialog"); + await expect(closedModal).not.toBeInTheDocument(); + }); + }, +}; diff --git a/src/browser/components/Settings/SettingsModal.tsx b/src/browser/components/Settings/SettingsModal.tsx new file mode 100644 index 000000000..fc91fa828 --- /dev/null +++ b/src/browser/components/Settings/SettingsModal.tsx @@ -0,0 +1,114 @@ +import React, { useEffect, useCallback } from "react"; +import { Settings, Key, Cpu, X } from "lucide-react"; +import { useSettings } from "@/browser/contexts/SettingsContext"; +import { ModalOverlay } from "@/browser/components/Modal"; +import { matchesKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; +import { GeneralSection } from "./sections/GeneralSection"; +import { ProvidersSection } from "./sections/ProvidersSection"; +import { ModelsSection } from "./sections/ModelsSection"; +import type { SettingsSection } from "./types"; + +const SECTIONS: SettingsSection[] = [ + { + id: "general", + label: "General", + icon: , + component: GeneralSection, + }, + { + id: "providers", + label: "Providers", + icon: , + component: ProvidersSection, + }, + { + id: "models", + label: "Models", + icon: , + component: ModelsSection, + }, +]; + +export function SettingsModal() { + const { isOpen, close, activeSection, setActiveSection } = useSettings(); + + const handleClose = useCallback(() => { + close(); + }, [close]); + + // Handle escape key + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (matchesKeybind(e, KEYBINDS.CANCEL)) { + e.preventDefault(); + handleClose(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, handleClose]); + + if (!isOpen) return null; + + const currentSection = SECTIONS.find((s) => s.id === activeSection) ?? SECTIONS[0]; + const SectionComponent = currentSection.component; + + return ( + +
e.stopPropagation()} + className="bg-dark border-border flex h-[70vh] max-h-[600px] w-[90%] max-w-[800px] overflow-hidden rounded-lg border shadow-lg" + > + {/* Sidebar */} +
+
+ + Settings + +
+ +
+ + {/* Content */} +
+
+ {currentSection.label} + +
+
+ +
+
+
+
+ ); +} diff --git a/src/browser/components/Settings/index.ts b/src/browser/components/Settings/index.ts new file mode 100644 index 000000000..5aaed3b82 --- /dev/null +++ b/src/browser/components/Settings/index.ts @@ -0,0 +1,2 @@ +export { SettingsModal } from "./SettingsModal"; +export { SettingsProvider, useSettings } from "@/browser/contexts/SettingsContext"; diff --git a/src/browser/components/Settings/sections/GeneralSection.tsx b/src/browser/components/Settings/sections/GeneralSection.tsx new file mode 100644 index 000000000..ecd8da9d5 --- /dev/null +++ b/src/browser/components/Settings/sections/GeneralSection.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { MoonStar, SunMedium } from "lucide-react"; +import { useTheme } from "@/browser/contexts/ThemeContext"; + +export function GeneralSection() { + const { theme, toggleTheme } = useTheme(); + + return ( +
+
+

Appearance

+
+
+
Theme
+
Choose light or dark appearance
+
+ +
+
+
+ ); +} diff --git a/src/browser/components/Settings/sections/ModelsSection.tsx b/src/browser/components/Settings/sections/ModelsSection.tsx new file mode 100644 index 000000000..c2056142c --- /dev/null +++ b/src/browser/components/Settings/sections/ModelsSection.tsx @@ -0,0 +1,164 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { Plus, Trash2 } from "lucide-react"; +import type { ProvidersConfigMap } from "../types"; +import { SUPPORTED_PROVIDERS, PROVIDER_DISPLAY_NAMES } from "@/common/constants/providers"; + +interface NewModelForm { + provider: string; + modelId: string; +} + +export function ModelsSection() { + const [config, setConfig] = useState({}); + const [newModel, setNewModel] = useState({ provider: "", modelId: "" }); + const [saving, setSaving] = useState(false); + + // Load config on mount + useEffect(() => { + void (async () => { + const cfg = await window.api.providers.getConfig(); + setConfig(cfg); + })(); + }, []); + + // Get all custom models across providers + const getAllModels = (): Array<{ provider: string; modelId: string }> => { + const models: Array<{ provider: string; modelId: string }> = []; + for (const [provider, providerConfig] of Object.entries(config)) { + if (providerConfig.models) { + for (const modelId of providerConfig.models) { + models.push({ provider, modelId }); + } + } + } + return models; + }; + + const handleAddModel = useCallback(async () => { + if (!newModel.provider || !newModel.modelId.trim()) return; + + setSaving(true); + try { + const currentModels = config[newModel.provider]?.models ?? []; + const updatedModels = [...currentModels, newModel.modelId.trim()]; + + await window.api.providers.setModels(newModel.provider, updatedModels); + + // Refresh config + const cfg = await window.api.providers.getConfig(); + setConfig(cfg); + setNewModel({ provider: "", modelId: "" }); + + // Notify other components about the change + window.dispatchEvent(new Event("providers-config-changed")); + } finally { + setSaving(false); + } + }, [newModel, config]); + + const handleRemoveModel = useCallback( + async (provider: string, modelId: string) => { + setSaving(true); + try { + const currentModels = config[provider]?.models ?? []; + const updatedModels = currentModels.filter((m) => m !== modelId); + + await window.api.providers.setModels(provider, updatedModels); + + // Refresh config + const cfg = await window.api.providers.getConfig(); + setConfig(cfg); + + // Notify other components about the change + window.dispatchEvent(new Event("providers-config-changed")); + } finally { + setSaving(false); + } + }, + [config] + ); + + const allModels = getAllModels(); + + return ( +
+

+ Add custom models to use with your providers. These will appear in the model selector. +

+ + {/* Add new model form */} +
+
Add Custom Model
+
+ + setNewModel((prev) => ({ ...prev, modelId: e.target.value }))} + placeholder="model-id (e.g., gpt-4-turbo)" + className="bg-modal-bg border-border-medium focus:border-accent flex-1 rounded border px-2 py-1.5 font-mono text-xs focus:outline-none" + onKeyDown={(e) => { + if (e.key === "Enter") void handleAddModel(); + }} + /> + +
+
+ + {/* List of custom models */} + {allModels.length > 0 ? ( +
+
+ Custom Models +
+ {allModels.map(({ provider, modelId }) => ( +
+
+ + {PROVIDER_DISPLAY_NAMES[provider as keyof typeof PROVIDER_DISPLAY_NAMES] ?? + provider} + + {modelId} +
+ +
+ ))} +
+ ) : ( +
+ No custom models configured. Add one above to get started. +
+ )} +
+ ); +} diff --git a/src/browser/components/Settings/sections/ProvidersSection.tsx b/src/browser/components/Settings/sections/ProvidersSection.tsx new file mode 100644 index 000000000..ad8ec073e --- /dev/null +++ b/src/browser/components/Settings/sections/ProvidersSection.tsx @@ -0,0 +1,235 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { ChevronDown, ChevronRight, Check, X } from "lucide-react"; +import type { ProvidersConfigMap } from "../types"; +import { SUPPORTED_PROVIDERS, PROVIDER_DISPLAY_NAMES } from "@/common/constants/providers"; + +export function ProvidersSection() { + const [config, setConfig] = useState({}); + const [expandedProvider, setExpandedProvider] = useState(null); + const [editingField, setEditingField] = useState<{ + provider: string; + field: "apiKey" | "baseUrl"; + } | null>(null); + const [editValue, setEditValue] = useState(""); + const [saving, setSaving] = useState(false); + + // Load config on mount + useEffect(() => { + void (async () => { + const cfg = await window.api.providers.getConfig(); + setConfig(cfg); + })(); + }, []); + + const handleToggleProvider = (provider: string) => { + setExpandedProvider((prev) => (prev === provider ? null : provider)); + setEditingField(null); + }; + + const handleStartEdit = (provider: string, field: "apiKey" | "baseUrl") => { + setEditingField({ provider, field }); + // For API key, start empty since we only show masked value + // For baseUrl, show current value + setEditValue(field === "baseUrl" ? (config[provider]?.baseUrl ?? "") : ""); + }; + + const handleCancelEdit = () => { + setEditingField(null); + setEditValue(""); + }; + + const handleSaveEdit = useCallback(async () => { + if (!editingField) return; + + setSaving(true); + try { + const { provider, field } = editingField; + const keyPath = field === "apiKey" ? ["apiKey"] : ["baseUrl"]; + await window.api.providers.setProviderConfig(provider, keyPath, editValue); + + // Refresh config + const cfg = await window.api.providers.getConfig(); + setConfig(cfg); + setEditingField(null); + setEditValue(""); + } finally { + setSaving(false); + } + }, [editingField, editValue]); + + const handleClearBaseUrl = useCallback(async (provider: string) => { + setSaving(true); + try { + await window.api.providers.setProviderConfig(provider, ["baseUrl"], ""); + const cfg = await window.api.providers.getConfig(); + setConfig(cfg); + } finally { + setSaving(false); + } + }, []); + + const isConfigured = (provider: string) => { + return config[provider]?.apiKeySet ?? false; + }; + + return ( +
+

+ Configure API keys and endpoints for AI providers. Keys are stored in{" "} + ~/.mux/providers.jsonc +

+ + {SUPPORTED_PROVIDERS.map((provider) => { + const isExpanded = expandedProvider === provider; + const providerConfig = config[provider]; + const configured = isConfigured(provider); + + return ( +
+ {/* Provider header */} + + + {/* Provider settings */} + {isExpanded && ( +
+ {/* API Key */} +
+ + {editingField?.provider === provider && editingField?.field === "apiKey" ? ( +
+ setEditValue(e.target.value)} + placeholder="Enter API key" + className="bg-modal-bg border-border-medium focus:border-accent flex-1 rounded border px-2 py-1.5 font-mono text-xs focus:outline-none" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") void handleSaveEdit(); + if (e.key === "Escape") handleCancelEdit(); + }} + /> + + +
+ ) : ( +
+ + {providerConfig?.apiKeySet ? "••••••••" : "Not set"} + + +
+ )} +
+ + {/* Base URL (optional) */} +
+ + {editingField?.provider === provider && editingField?.field === "baseUrl" ? ( +
+ setEditValue(e.target.value)} + placeholder="https://api.example.com" + className="bg-modal-bg border-border-medium focus:border-accent flex-1 rounded border px-2 py-1.5 font-mono text-xs focus:outline-none" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") void handleSaveEdit(); + if (e.key === "Escape") handleCancelEdit(); + }} + /> + + +
+ ) : ( +
+ + {providerConfig?.baseUrl ?? "Default"} + +
+ {providerConfig?.baseUrl && ( + + )} + +
+
+ )} +
+
+ )} +
+ ); + })} +
+ ); +} diff --git a/src/browser/components/Settings/types.ts b/src/browser/components/Settings/types.ts new file mode 100644 index 000000000..21b642872 --- /dev/null +++ b/src/browser/components/Settings/types.ts @@ -0,0 +1,16 @@ +import type { ReactNode } from "react"; + +export interface SettingsSection { + id: string; + label: string; + icon: ReactNode; + component: React.ComponentType; +} + +export interface ProviderConfigDisplay { + apiKeySet: boolean; + baseUrl?: string; + models?: string[]; +} + +export type ProvidersConfigMap = Record; diff --git a/src/browser/components/SettingsButton.tsx b/src/browser/components/SettingsButton.tsx new file mode 100644 index 000000000..71b92bbb9 --- /dev/null +++ b/src/browser/components/SettingsButton.tsx @@ -0,0 +1,22 @@ +import { Settings } from "lucide-react"; +import { useSettings } from "@/browser/contexts/SettingsContext"; +import { TooltipWrapper, Tooltip } from "./Tooltip"; + +export function SettingsButton() { + const { open } = useSettings(); + + return ( + + + Settings + + ); +} diff --git a/src/browser/components/TitleBar.tsx b/src/browser/components/TitleBar.tsx index 6b1166336..44f714f0c 100644 --- a/src/browser/components/TitleBar.tsx +++ b/src/browser/components/TitleBar.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef } from "react"; import { cn } from "@/common/lib/utils"; import { VERSION } from "@/version"; -import { ThemeToggleButton } from "./ThemeToggleButton"; +import { SettingsButton } from "./SettingsButton"; import { TooltipWrapper, Tooltip } from "./Tooltip"; import type { UpdateStatus } from "@/common/types/ipc"; import { isTelemetryEnabled } from "@/common/telemetry"; @@ -220,7 +220,7 @@ export function TitleBar() { const showUpdateIndicator = true; return ( -
+
{showUpdateIndicator && ( @@ -253,7 +253,7 @@ export function TitleBar() {
- +
{buildDate}
Built at {extendedTimestamp} diff --git a/src/browser/components/WorkspaceHeader.tsx b/src/browser/components/WorkspaceHeader.tsx index d74f201db..3fe55ac84 100644 --- a/src/browser/components/WorkspaceHeader.tsx +++ b/src/browser/components/WorkspaceHeader.tsx @@ -28,7 +28,7 @@ export const WorkspaceHeader: React.FC = ({ }, [workspaceId]); return ( -
+
void; + close: () => void; + setActiveSection: (section: string) => void; +} + +const SettingsContext = createContext(null); + +export function useSettings(): SettingsContextValue { + const ctx = useContext(SettingsContext); + if (!ctx) throw new Error("useSettings must be used within SettingsProvider"); + return ctx; +} + +const DEFAULT_SECTION = "general"; + +export function SettingsProvider(props: { children: ReactNode }) { + const [isOpen, setIsOpen] = useState(false); + const [activeSection, setActiveSection] = useState(DEFAULT_SECTION); + + const open = useCallback((section?: string) => { + if (section) setActiveSection(section); + setIsOpen(true); + }, []); + + const close = useCallback(() => { + setIsOpen(false); + }, []); + + const value = useMemo( + () => ({ + isOpen, + activeSection, + open, + close, + setActiveSection, + }), + [isOpen, activeSection, open, close] + ); + + return {props.children}; +} diff --git a/src/browser/hooks/useModelLRU.ts b/src/browser/hooks/useModelLRU.ts index 565698ead..8d2c352a4 100644 --- a/src/browser/hooks/useModelLRU.ts +++ b/src/browser/hooks/useModelLRU.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { usePersistedState, readPersistedState, updatePersistedState } from "./usePersistedState"; import { MODEL_ABBREVIATIONS } from "@/browser/utils/slashCommands/registry"; import { defaultModel } from "@/common/utils/ai/models"; @@ -42,6 +42,7 @@ export function getDefaultModel(): string { * Hook to manage a Least Recently Used (LRU) cache of AI models. * Stores up to 8 recently used models in localStorage. * Initializes with default abbreviated models if empty. + * Also includes custom models configured in Settings. */ export function useModelLRU() { const [recentModels, setRecentModels] = usePersistedState( @@ -49,6 +50,7 @@ export function useModelLRU() { DEFAULT_MODELS.slice(0, MAX_LRU_SIZE), { listener: true } ); + const [customModels, setCustomModels] = useState([]); const [defaultModel, setDefaultModel] = usePersistedState( DEFAULT_MODEL_KEY, @@ -70,6 +72,44 @@ export function useModelLRU() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Only run once on mount + // Fetch custom models from providers config + useEffect(() => { + const fetchCustomModels = async () => { + try { + const config = await window.api.providers.getConfig(); + const models: string[] = []; + for (const [provider, providerConfig] of Object.entries(config)) { + if (providerConfig.models) { + for (const modelId of providerConfig.models) { + // Format as provider:modelId for consistency + models.push(`${provider}:${modelId}`); + } + } + } + setCustomModels(models); + } catch { + // Ignore errors fetching custom models + } + }; + void fetchCustomModels(); + + // Listen for settings changes via custom event + const handleSettingsChange = () => void fetchCustomModels(); + window.addEventListener("providers-config-changed", handleSettingsChange); + return () => window.removeEventListener("providers-config-changed", handleSettingsChange); + }, []); + + // Combine LRU models with custom models (custom models appended, deduplicated) + const allModels = useMemo(() => { + const combined = [...recentModels]; + for (const model of customModels) { + if (!combined.includes(model)) { + combined.push(model); + } + } + return combined; + }, [recentModels, customModels]); + /** * Add a model to the LRU cache. If it already exists, move it to the front. * If the cache is full, remove the least recently used model. @@ -94,8 +134,8 @@ export function useModelLRU() { * Get the list of recently used models, most recent first. */ const getRecentModels = useCallback(() => { - return recentModels; - }, [recentModels]); + return allModels; + }, [allModels]); const evictModel = useCallback((modelString: string) => { if (!modelString.trim()) { @@ -108,7 +148,7 @@ export function useModelLRU() { addModel, evictModel, getRecentModels, - recentModels, + recentModels: allModels, defaultModel, setDefaultModel, }; diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index cce0e5ad2..8976082fc 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -54,6 +54,10 @@ export const CommandIds = { themeToggle: () => "appearance:theme:toggle" as const, themeSet: (theme: "light" | "dark") => `appearance:theme:set:${theme}` as const, + // Settings commands + settingsOpen: () => "settings:open" as const, + settingsOpenSection: (section: string) => `settings:open:${section}` as const, + // Help commands helpKeybinds: () => "help:keybinds" as const, } as const; diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index bf64d38b9..09029e5f4 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -45,6 +45,7 @@ export interface BuildSourcesParams { onOpenWorkspaceInTerminal: (workspaceId: string) => void; onToggleTheme: () => void; onSetTheme: (theme: ThemeMode) => void; + onOpenSettings?: (section?: string) => void; } const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"]; @@ -61,6 +62,7 @@ export const COMMAND_SECTIONS = { HELP: "Help", PROJECTS: "Projects", APPEARANCE: "Appearance", + SETTINGS: "Settings", } as const; const section = { @@ -71,6 +73,7 @@ const section = { mode: COMMAND_SECTIONS.MODE, help: COMMAND_SECTIONS.HELP, projects: COMMAND_SECTIONS.PROJECTS, + settings: COMMAND_SECTIONS.SETTINGS, }; export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandAction[]> { @@ -532,5 +535,36 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi return list; }); + // Settings + if (p.onOpenSettings) { + const openSettings = p.onOpenSettings; + actions.push(() => [ + { + id: CommandIds.settingsOpen(), + title: "Open Settings", + section: section.settings, + keywords: ["preferences", "config", "configuration"], + shortcutHint: "⌘,", + run: () => openSettings(), + }, + { + id: CommandIds.settingsOpenSection("providers"), + title: "Settings: Providers", + subtitle: "Configure API keys and endpoints", + section: section.settings, + keywords: ["api", "key", "anthropic", "openai", "google"], + run: () => openSettings("providers"), + }, + { + id: CommandIds.settingsOpenSection("models"), + title: "Settings: Models", + subtitle: "Manage custom models", + section: section.settings, + keywords: ["model", "custom", "add"], + run: () => openSettings("models"), + }, + ]); + } + return actions; } diff --git a/src/common/constants/ipc-constants.ts b/src/common/constants/ipc-constants.ts index 7834f7aae..828797a31 100644 --- a/src/common/constants/ipc-constants.ts +++ b/src/common/constants/ipc-constants.ts @@ -6,6 +6,8 @@ export const IPC_CHANNELS = { // Provider channels PROVIDERS_SET_CONFIG: "providers:setConfig", + PROVIDERS_SET_MODELS: "providers:setModels", + PROVIDERS_GET_CONFIG: "providers:getConfig", PROVIDERS_LIST: "providers:list", // Project channels diff --git a/src/common/constants/providers.ts b/src/common/constants/providers.ts index dc48ac2f7..2b2a5f754 100644 --- a/src/common/constants/providers.ts +++ b/src/common/constants/providers.ts @@ -80,6 +80,18 @@ export type ProviderName = keyof typeof PROVIDER_REGISTRY; */ export const SUPPORTED_PROVIDERS = Object.keys(PROVIDER_REGISTRY) as ProviderName[]; +/** + * Display names for providers (proper casing for UI) + */ +export const PROVIDER_DISPLAY_NAMES: Record = { + anthropic: "Anthropic", + openai: "OpenAI", + google: "Google", + xai: "xAI", + ollama: "Ollama", + openrouter: "OpenRouter", +}; + /** * Type guard to check if a string is a valid provider name */ diff --git a/src/common/types/ipc.ts b/src/common/types/ipc.ts index 8e907c111..a6ea39192 100644 --- a/src/common/types/ipc.ts +++ b/src/common/types/ipc.ts @@ -247,6 +247,10 @@ export interface IPCApi { keyPath: string[], value: string ): Promise>; + setModels(provider: string, models: string[]): Promise>; + getConfig(): Promise< + Record + >; list(): Promise; }; fs?: { diff --git a/src/desktop/preload.ts b/src/desktop/preload.ts index 62d702cb0..8b5cd86e3 100644 --- a/src/desktop/preload.ts +++ b/src/desktop/preload.ts @@ -40,6 +40,9 @@ const api: IPCApi = { providers: { setProviderConfig: (provider, keyPath, value) => ipcRenderer.invoke(IPC_CHANNELS.PROVIDERS_SET_CONFIG, provider, keyPath, value), + setModels: (provider, models) => + ipcRenderer.invoke(IPC_CHANNELS.PROVIDERS_SET_MODELS, provider, models), + getConfig: () => ipcRenderer.invoke(IPC_CHANNELS.PROVIDERS_GET_CONFIG), list: () => ipcRenderer.invoke(IPC_CHANNELS.PROVIDERS_LIST), }, fs: { diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index 0700c49f2..e6de922aa 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -1483,7 +1483,13 @@ export class IpcMain { } if (keyPath.length > 0) { - current[keyPath[keyPath.length - 1]] = value; + const lastKey = keyPath[keyPath.length - 1]; + // Delete key if value is empty string, otherwise set it + if (value === "") { + delete current[lastKey]; + } else { + current[lastKey] = value; + } } // Save updated config @@ -1497,6 +1503,27 @@ export class IpcMain { } ); + ipcMain.handle( + IPC_CHANNELS.PROVIDERS_SET_MODELS, + (_event, provider: string, models: string[]) => { + try { + const providersConfig = this.config.loadProvidersConfig() ?? {}; + + if (!providersConfig[provider]) { + providersConfig[provider] = {}; + } + + providersConfig[provider].models = models; + this.config.saveProvidersConfig(providersConfig); + + return { success: true, data: undefined }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: `Failed to set models: ${message}` }; + } + } + ); + ipcMain.handle(IPC_CHANNELS.PROVIDERS_LIST, () => { try { // Return all supported providers from centralized registry @@ -1507,6 +1534,32 @@ export class IpcMain { return []; } }); + + ipcMain.handle(IPC_CHANNELS.PROVIDERS_GET_CONFIG, () => { + try { + const config = this.config.loadProvidersConfig() ?? {}; + // Return a sanitized version (only whether API key is set, not the value) + const sanitized: Record< + string, + { apiKeySet: boolean; baseUrl?: string; models?: string[] } + > = {}; + for (const [provider, providerConfig] of Object.entries(config)) { + const baseUrl = providerConfig.baseUrl ?? providerConfig.baseURL; + const models = providerConfig.models; + sanitized[provider] = { + apiKeySet: !!providerConfig.apiKey, + baseUrl: typeof baseUrl === "string" ? baseUrl : undefined, + models: Array.isArray(models) + ? models.filter((m): m is string => typeof m === "string") + : undefined, + }; + } + return sanitized; + } catch (error) { + log.error("Failed to get providers config:", error); + return {}; + } + }); } private registerProjectHandlers(ipcMain: ElectronIpcMain): void { diff --git a/tests/e2e/scenarios/settings.spec.ts b/tests/e2e/scenarios/settings.spec.ts new file mode 100644 index 000000000..e2cb9bd1d --- /dev/null +++ b/tests/e2e/scenarios/settings.spec.ts @@ -0,0 +1,118 @@ +import { electronTest as test, electronExpect as expect } from "../electronTest"; + +test.skip( + ({ browserName }) => browserName !== "chromium", + "Electron scenario runs on chromium only" +); + +test.describe("Settings Modal", () => { + test("opens settings modal via gear button", async ({ ui, page }) => { + await ui.projects.openFirstWorkspace(); + + // Open settings + await ui.settings.open(); + + // Verify modal is open with correct structure + const dialog = page.getByRole("dialog", { name: "Settings" }); + await expect(dialog).toBeVisible(); + + // Verify sidebar sections are present + await expect(page.getByRole("button", { name: "General", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Providers", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Models", exact: true })).toBeVisible(); + + // Verify default section is General (theme toggle visible) + await expect(page.getByText("Theme")).toBeVisible(); + }); + + test("navigates between settings sections", async ({ ui, page }) => { + await ui.projects.openFirstWorkspace(); + await ui.settings.open(); + + // Navigate to Providers section + await ui.settings.selectSection("Providers"); + await expect(page.getByText(/Configure API keys and endpoints/i)).toBeVisible(); + + // Navigate to Models section + await ui.settings.selectSection("Models"); + await expect(page.getByPlaceholder(/model-id/i)).toBeVisible(); + + // Navigate back to General + await ui.settings.selectSection("General"); + await expect(page.getByText("Theme")).toBeVisible(); + }); + + test("closes settings with Escape key", async ({ ui }) => { + await ui.projects.openFirstWorkspace(); + await ui.settings.open(); + + // Close via Escape + await ui.settings.close(); + + // Verify closed + await ui.settings.expectClosed(); + }); + + test("closes settings with X button", async ({ ui, page }) => { + await ui.projects.openFirstWorkspace(); + await ui.settings.open(); + + // Click close button + const closeButton = page.getByRole("button", { name: /close settings/i }); + await closeButton.click(); + + // Verify closed + await ui.settings.expectClosed(); + }); + + test("closes settings by clicking overlay", async ({ ui, page }) => { + await ui.projects.openFirstWorkspace(); + await ui.settings.open(); + + // Click overlay (outside modal content) + const overlay = page.locator('[role="presentation"]'); + await overlay.click({ position: { x: 10, y: 10 } }); + + // Verify closed + await ui.settings.expectClosed(); + }); + + test("expands provider accordion in Providers section", async ({ ui, page }) => { + await ui.projects.openFirstWorkspace(); + await ui.settings.open(); + await ui.settings.selectSection("Providers"); + + // Expand Anthropic provider + await ui.settings.expandProvider("Anthropic"); + + // Verify accordion content is visible - use exact matches to avoid ambiguity + await expect(page.getByText("API Key", { exact: true })).toBeVisible(); + await expect(page.getByText(/Base URL/, { exact: false })).toBeVisible(); + }); + + test("shows all provider names correctly", async ({ ui, page }) => { + await ui.projects.openFirstWorkspace(); + await ui.settings.open(); + await ui.settings.selectSection("Providers"); + + // Verify all providers are listed with correct display names + await expect(page.getByRole("button", { name: /Anthropic/i })).toBeVisible(); + await expect(page.getByRole("button", { name: /OpenAI/i })).toBeVisible(); + await expect(page.getByRole("button", { name: /Google/i })).toBeVisible(); + await expect(page.getByRole("button", { name: /xAI/i })).toBeVisible(); + await expect(page.getByRole("button", { name: /Ollama/i })).toBeVisible(); + await expect(page.getByRole("button", { name: /OpenRouter/i })).toBeVisible(); + }); + + test("Models section shows add form", async ({ ui, page }) => { + await ui.projects.openFirstWorkspace(); + await ui.settings.open(); + await ui.settings.selectSection("Models"); + + // Verify add model form elements - use exact match for title + await expect(page.getByText("Add Custom Model", { exact: true })).toBeVisible(); + await expect(page.getByRole("combobox")).toBeVisible(); // Provider dropdown + await expect(page.getByPlaceholder(/model-id/i)).toBeVisible(); + await expect(page.getByRole("button", { name: /^Add$/i })).toBeVisible(); + }); +}); diff --git a/tests/e2e/utils/ui.ts b/tests/e2e/utils/ui.ts index 9bce83ea1..eae4451c8 100644 --- a/tests/e2e/utils/ui.ts +++ b/tests/e2e/utils/ui.ts @@ -41,6 +41,14 @@ export interface WorkspaceUI { expectVisible(): Promise; selectTab(label: string): Promise; }; + readonly settings: { + open(): Promise; + close(): Promise; + expectOpen(): Promise; + expectClosed(): Promise; + selectSection(section: "General" | "Providers" | "Models"): Promise; + expandProvider(providerName: string): Promise; + }; readonly context: DemoProjectConfig; } @@ -333,10 +341,51 @@ export function createWorkspaceUI(page: Page, context: DemoProjectConfig): Works }, }; + const settings = { + async open(): Promise { + // Click the settings gear button in the title bar + const settingsButton = page.getByRole("button", { name: /settings/i }); + await expect(settingsButton).toBeVisible(); + await settingsButton.click(); + await settings.expectOpen(); + }, + + async close(): Promise { + // Press Escape to close + await page.keyboard.press("Escape"); + await settings.expectClosed(); + }, + + async expectOpen(): Promise { + const dialog = page.getByRole("dialog", { name: "Settings" }); + await expect(dialog).toBeVisible({ timeout: 5000 }); + }, + + async expectClosed(): Promise { + const dialog = page.getByRole("dialog", { name: "Settings" }); + await expect(dialog).not.toBeVisible({ timeout: 5000 }); + }, + + async selectSection(section: "General" | "Providers" | "Models"): Promise { + const sectionButton = page.getByRole("button", { name: section, exact: true }); + await expect(sectionButton).toBeVisible(); + await sectionButton.click(); + }, + + async expandProvider(providerName: string): Promise { + const providerButton = page.getByRole("button", { name: new RegExp(providerName, "i") }); + await expect(providerButton).toBeVisible(); + await providerButton.click(); + // Wait for expansion - look for the "Base URL" label which is more unique + await expect(page.getByText(/Base URL/)).toBeVisible({ timeout: 5000 }); + }, + }; + return { projects, chat, metaSidebar, + settings, context, }; }