From 3179f17c89d7a5728f97e6524249fd0bbbbd4908 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Nov 2025 04:54:29 +0000 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20Settings=20Man?= =?UTF-8?q?ager=20with=20Providers/Models/General=20sections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SettingsModal with sidebar navigation between sections - General section: theme toggle (light/dark) - Providers section: configure API keys and base URLs per provider - Models section: add/remove custom models per provider - Command Palette integration: 'Open Settings', 'Settings: Providers', 'Settings: Models' - New IPC channels: PROVIDERS_GET_CONFIG, PROVIDERS_SET_MODELS - getConfig returns apiKeySet boolean (not the key value) for security _Generated with mux_ --- src/browser/App.stories.tsx | 4 + src/browser/App.tsx | 14 +- src/browser/api.ts | 3 + .../components/Settings/SettingsModal.tsx | 114 ++++++++++ src/browser/components/Settings/index.ts | 2 + .../Settings/sections/GeneralSection.tsx | 38 ++++ .../Settings/sections/ModelsSection.tsx | 155 +++++++++++++ .../Settings/sections/ProvidersSection.tsx | 210 ++++++++++++++++++ src/browser/components/Settings/types.ts | 16 ++ src/browser/contexts/SettingsContext.tsx | 53 +++++ src/browser/utils/commandIds.ts | 4 + src/browser/utils/commands/sources.ts | 34 +++ src/common/constants/ipc-constants.ts | 2 + src/common/types/ipc.ts | 2 + src/desktop/preload.ts | 3 + src/node/services/ipcMain.ts | 45 ++++ 16 files changed, 696 insertions(+), 3 deletions(-) create mode 100644 src/browser/components/Settings/SettingsModal.tsx create mode 100644 src/browser/components/Settings/index.ts create mode 100644 src/browser/components/Settings/sections/GeneralSection.tsx create mode 100644 src/browser/components/Settings/sections/ModelsSection.tsx create mode 100644 src/browser/components/Settings/sections/ProvidersSection.tsx create mode 100644 src/browser/components/Settings/types.ts create mode 100644 src/browser/contexts/SettingsContext.tsx diff --git a/src/browser/App.stories.tsx b/src/browser/App.stories.tsx index b26916b98..37b716869 100644 --- a/src/browser/App.stories.tsx +++ b/src/browser/App.stories.tsx @@ -37,6 +37,8 @@ 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 +557,8 @@ 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..1301a8d59 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -233,6 +233,9 @@ 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/SettingsModal.tsx b/src/browser/components/Settings/SettingsModal.tsx new file mode 100644 index 000000000..33f68ac8b --- /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..6a039996a --- /dev/null +++ b/src/browser/components/Settings/sections/ModelsSection.tsx @@ -0,0 +1,155 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { Plus, Trash2 } from "lucide-react"; +import type { ProvidersConfigMap } from "../types"; +import { SUPPORTED_PROVIDERS } 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: "" }); + } 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); + } 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} + {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..3c3d11473 --- /dev/null +++ b/src/browser/components/Settings/sections/ProvidersSection.tsx @@ -0,0 +1,210 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { ChevronDown, ChevronRight, Check, X } from "lucide-react"; +import type { ProvidersConfigMap } from "../types"; +import { SUPPORTED_PROVIDERS } 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 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"} + + +
+ )} +
+
+ )} +
+ ); + })} +
+ ); +} 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/contexts/SettingsContext.tsx b/src/browser/contexts/SettingsContext.tsx new file mode 100644 index 000000000..4041520c9 --- /dev/null +++ b/src/browser/contexts/SettingsContext.tsx @@ -0,0 +1,53 @@ +import React, { + createContext, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, +} from "react"; + +interface SettingsContextValue { + isOpen: boolean; + activeSection: string; + open: (section?: string) => 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/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/types/ipc.ts b/src/common/types/ipc.ts index 8e907c111..0fd5b26e0 100644 --- a/src/common/types/ipc.ts +++ b/src/common/types/ipc.ts @@ -247,6 +247,8 @@ export interface IPCApi { keyPath: string[], value: string ): Promise>; + setModels(provider: string, models: string[]): Promise>; + getConfig(): Promise>; 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..e2bab6fe0 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -1497,6 +1497,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 +1528,30 @@ 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 = + {}; + 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 { From 2f779f27c67d49b9f605f943c9a03c3a4638e617 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Nov 2025 05:03:08 +0000 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=A4=96=20feat:=20replace=20theme=20to?= =?UTF-8?q?ggle=20with=20settings=20gear=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Generated with mux_ --- src/browser/components/SettingsButton.tsx | 22 ++++++++++++++++++++++ src/browser/components/TitleBar.tsx | 4 ++-- 2 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 src/browser/components/SettingsButton.tsx 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..7f14df236 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"; @@ -253,7 +253,7 @@ export function TitleBar() {
- +
{buildDate}
Built at {extendedTimestamp} From 2cce257f368abd22278c204ef26f6a3f4353cfc6 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Nov 2025 05:10:16 +0000 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=A4=96=20fix:=20Settings=20modal=20he?= =?UTF-8?q?ader=20heights=20and=20custom=20models=20in=20selector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixed header height inconsistency by using explicit h-12 class for both sidebar and content headers - Custom models from Settings now appear in model selector dropdown - Added providers-config-changed event to sync models when settings change - Used useMemo for allModels to avoid unnecessary re-renders --- .../components/Settings/SettingsModal.tsx | 4 +- .../Settings/sections/ModelsSection.tsx | 6 +++ src/browser/hooks/useModelLRU.ts | 48 +++++++++++++++++-- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/browser/components/Settings/SettingsModal.tsx b/src/browser/components/Settings/SettingsModal.tsx index 33f68ac8b..880199ffc 100644 --- a/src/browser/components/Settings/SettingsModal.tsx +++ b/src/browser/components/Settings/SettingsModal.tsx @@ -67,7 +67,7 @@ export function SettingsModal() { > {/* Sidebar */}
-
+

Settings

@@ -93,7 +93,7 @@ export function SettingsModal() { {/* Content */}
-
+

{currentSection.label}

= { + anthropic: "Anthropic", + openai: "OpenAI", + google: "Google", + xai: "xAI", + ollama: "Ollama", + openrouter: "OpenRouter", +}; + /** * Type guard to check if a string is a valid provider name */ From 3556809a393aeab66f76c0b8aea5f7dc68d7f96b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Nov 2025 05:52:02 +0000 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20Clear=20button?= =?UTF-8?q?=20for=20Base=20URL=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Settings/sections/ProvidersSection.tsx | 37 +++++++++++++++---- src/node/services/ipcMain.ts | 8 +++- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/browser/components/Settings/sections/ProvidersSection.tsx b/src/browser/components/Settings/sections/ProvidersSection.tsx index aa4737cea..ad8ec073e 100644 --- a/src/browser/components/Settings/sections/ProvidersSection.tsx +++ b/src/browser/components/Settings/sections/ProvidersSection.tsx @@ -57,6 +57,17 @@ export function ProvidersSection() { } }, [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; }; @@ -192,13 +203,25 @@ export function ProvidersSection() { {providerConfig?.baseUrl ?? "Default"} - +
+ {providerConfig?.baseUrl && ( + + )} + +
)}
diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index e2bab6fe0..13fccc3b1 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 From 4402995047d350b7a9519d4ff56adbfdecdcca6c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Nov 2025 05:56:27 +0000 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=A4=96=20fix:=20align=20TitleBar=20ba?= =?UTF-8?q?ckground=20with=20WorkspaceHeader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed TitleBar from bg-dark to bg-separator to match WorkspaceHeader and create visual continuity across the top of the app. --- src/browser/App.stories.tsx | 10 ++++++++-- src/browser/api.ts | 3 +-- src/browser/components/TitleBar.tsx | 2 +- src/browser/components/WorkspaceHeader.tsx | 2 +- src/common/types/ipc.ts | 4 +++- src/node/services/ipcMain.ts | 6 ++++-- 6 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/browser/App.stories.tsx b/src/browser/App.stories.tsx index 37b716869..ff4c30db0 100644 --- a/src/browser/App.stories.tsx +++ b/src/browser/App.stories.tsx @@ -38,7 +38,10 @@ function setupMockAPI(options: { providers: { setProviderConfig: () => Promise.resolve({ success: true, data: undefined }), setModels: () => Promise.resolve({ success: true, data: undefined }), - getConfig: () => Promise.resolve({} as Record), + getConfig: () => + Promise.resolve( + {} as Record + ), list: () => Promise.resolve([]), }, workspace: { @@ -558,7 +561,10 @@ export const ActiveWorkspaceWithChat: Story = { providers: { setProviderConfig: () => Promise.resolve({ success: true, data: undefined }), setModels: () => Promise.resolve({ success: true, data: undefined }), - getConfig: () => Promise.resolve({} as Record), + getConfig: () => + Promise.resolve( + {} as Record + ), list: () => Promise.resolve(["anthropic", "openai", "xai"]), }, workspace: { diff --git a/src/browser/api.ts b/src/browser/api.ts index 1301a8d59..33b9ad37a 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -233,8 +233,7 @@ 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), + setModels: (provider, models) => invokeIPC(IPC_CHANNELS.PROVIDERS_SET_MODELS, provider, models), getConfig: () => invokeIPC(IPC_CHANNELS.PROVIDERS_GET_CONFIG), list: () => invokeIPC(IPC_CHANNELS.PROVIDERS_LIST), }, diff --git a/src/browser/components/TitleBar.tsx b/src/browser/components/TitleBar.tsx index 7f14df236..44f714f0c 100644 --- a/src/browser/components/TitleBar.tsx +++ b/src/browser/components/TitleBar.tsx @@ -220,7 +220,7 @@ export function TitleBar() { const showUpdateIndicator = true; return ( -
+
{showUpdateIndicator && ( 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 ( -
+
>; setModels(provider: string, models: string[]): Promise>; - getConfig(): Promise>; + getConfig(): Promise< + Record + >; list(): Promise; }; fs?: { diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index 13fccc3b1..e6de922aa 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -1539,8 +1539,10 @@ export class IpcMain { try { const config = this.config.loadProvidersConfig() ?? {}; // Return a sanitized version (only whether API key is set, not the value) - const sanitized: Record = - {}; + 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; From 42801053a1fa569e1f6f0439c95f31de195795cb Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Nov 2025 10:17:56 +0000 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=A4=96=20test:=20add=20Settings=20tes?= =?UTF-8?q?ts=20(Storybook=20+=20Playwright=20E2E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Storybook stories for Settings modal visual states - General, Providers, Models sections - ProvidersExpanded with accordion interaction - ModelsEmpty showing empty state - Interactive tests: overlay close, escape key, X button, sidebar nav - Add Playwright E2E tests for Settings functionality - Open/close modal via gear button, escape, X button, overlay - Section navigation - Provider accordion expansion - Models form visibility - Extend WorkspaceUI helper with settings methods _Generated with `mux`_ --- .../components/Settings/Settings.stories.tsx | 295 ++++++++++++++++++ tests/e2e/scenarios/settings.spec.ts | 118 +++++++ tests/e2e/utils/ui.ts | 49 +++ 3 files changed, 462 insertions(+) create mode 100644 src/browser/components/Settings/Settings.stories.tsx create mode 100644 tests/e2e/scenarios/settings.spec.ts 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/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, }; }