Compare commits

..
Author SHA1 Message Date
chengyongru e0e8330ecc refactor(webui): split settings frontend by domain 2026-08-11 11:25:10 +08:00
35 changed files with 16931 additions and 15386 deletions
@@ -0,0 +1,654 @@
import { ChevronLeft, Loader2 } from "lucide-react";
import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings";
import { ImageGenerationSettings } from "@/components/settings/capabilities/ImageGenerationSettings";
import { AdvancedSettings } from "@/components/settings/capabilities/SecuritySettings";
import { TranscriptionSettings } from "@/components/settings/capabilities/TranscriptionSettings";
import { WebSettings } from "@/components/settings/capabilities/WebSettings";
import {
ModelPresetDeleteDialog,
ModelsSettings,
} from "@/components/settings/models/ModelsSettings";
import {
ProviderOAuthLoginDialog,
ProvidersSettings,
providerFormFromRow,
} from "@/components/settings/models/ProviderSettings";
import { AppearanceSettings, OverviewSettings } from "@/components/settings/overview/OverviewSettings";
import { SettingsSidebar, standaloneSectionTitle } from "@/components/settings/SettingsSidebar";
import {
NanobotFeatureInstallDialog,
SettingsGroup,
SettingsRow,
} from "@/components/settings/shared/SettingsControls";
import { AppsCatalogSettings } from "@/components/settings/system/AppsSettings";
import {
AutomationDeleteDialog,
AutomationEditDialog,
AutomationsSettings,
} from "@/components/settings/system/AutomationsSettings";
import { ChannelsSettings } from "@/components/settings/system/ChannelsSettings";
import { RuntimeSettings } from "@/components/settings/system/RuntimeSettings";
import type { SettingsController } from "@/components/settings/useSettingsController";
import type { SkillSummary } from "@/lib/types";
import { cn } from "@/lib/utils";
interface SettingsPageProps {
controller: SettingsController;
theme: "light" | "dark";
showSidebar: boolean;
onToggleTheme: () => void;
onBackToChat: () => void;
skills: SkillSummary[];
onLogout?: () => void;
isRestarting: boolean;
hostChromeInset: boolean;
}
export function SettingsPage({
controller,
theme,
showSidebar,
onToggleTheme,
onBackToChat,
skills,
onLogout,
isRestarting,
hostChromeInset,
}: SettingsPageProps) {
const {
activeSection,
apiService,
apiServiceAction,
apiServiceError,
apiServiceLoading,
appsKindFilter,
appsQuery,
automationAction,
automationPendingDelete,
automationPendingEdit,
automations,
automationsError,
automationsFilter,
automationsLoading,
automationsQuery,
automationsSort,
beginModelPresetCreation,
cancelModelPresetCreation,
changeModelCallOrder,
channelsQuery,
cliApps,
cliAppsAction,
cliAppsError,
cliAppsFocusName,
cliAppsLoading,
cliAppsMessage,
closeProviderOAuthFlow,
completeProviderOAuthResponse,
createCustomProvider,
customMcpForm,
editingProviderKeys,
error,
expandedProvider,
featureCatalog,
form,
handleApiServiceAction,
handleAutomationAction,
handleAutomationEdit,
handleCliAppAction,
handleDeleteModelConfiguration,
handleImportMcpConfig,
handleMcpOAuthCancel,
handleMcpOAuthComplete,
handleMcpOAuthConnect,
handleMcpOAuthOpen,
handleMcpPresetAction,
handleMcpToolsChange,
handleMigrateModelConfigurations,
handleNanobotFeatureAction,
handleSaveCustomMcp,
handleToggleProvider,
handleWebSearchProviderChange,
hasPendingRestart,
hostEngineApplying,
imageGenerationDirty,
imageGenerationForm,
imageGenerationSaving,
installCapabilities,
loading,
localPrefs,
mcpConfigImport,
mcpError,
mcpFieldValues,
mcpMessage,
mcpOAuthCallbackError,
mcpOAuthCallbackUrl,
mcpOAuthCompleting,
mcpOAuthFlow,
mcpOAuthPopupBlocked,
mcpPresetAction,
mcpPresets,
mcpPresetsLoading,
modelCallOrder,
modelCallOrderSaving,
modelConfigurationSaving,
modelDirty,
modelMigrationSaving,
modelPresetBeforeCreateRef,
modelPresetCreating,
modelPresetPendingDelete,
nanobotFeatureAction,
nanobotFeatureConfirm,
nanobotFeatures,
nanobotFeaturesError,
nanobotFeaturesLoading,
networkSafetyDirty,
networkSafetyForm,
networkSafetySaving,
pendingRestartSections,
providerForms,
providerOAuthCompleting,
providerOAuthDialogError,
providerOAuthFlow,
providerOAuthResponse,
providerSaving,
remoteBrowserAccess,
resetWebSearchDraft,
restartViaSettingsSurface,
runProviderOAuth,
saveImageGenerationSettings,
saveModelSettings,
saveNetworkSafetySettings,
saveProvider,
saveTranscriptionSettings,
saveWebSearch,
saving,
selectSection,
setAppsKindFilter,
setAppsQuery,
setAutomationPendingDelete,
setAutomationPendingEdit,
setAutomationsFilter,
setAutomationsQuery,
setAutomationsSort,
setChannelsQuery,
setCliAppsError,
setCliAppsMessage,
setCustomMcpForm,
setForm,
setImageGenerationForm,
setLocalPrefs,
setMcpConfigImport,
setMcpError,
setMcpFieldValues,
setMcpMessage,
setMcpOAuthCallbackError,
setMcpOAuthCallbackUrl,
setModelPresetCreating,
setModelPresetPendingDelete,
setNanobotFeatureConfirm,
setNanobotFeatures,
setNanobotFeaturesError,
setNetworkSafetyForm,
setProviderForms,
setProviderOAuthDialogError,
setProviderOAuthResponse,
setTranscriptionForm,
setWebSearchForm,
setWebSearchKeyEditing,
setWebSearchKeyVisible,
settings,
t,
toggleProviderKeyEditing,
toggleProviderKeyVisibility,
token,
transcriptionDirty,
transcriptionForm,
transcriptionSaving,
visibleProviderKeys,
webSearchForm,
webSearchKeyEditing,
webSearchKeyVisible,
webSearchSaving,
} = controller;
const renderSection = () => {
if (!settings) return null;
switch (activeSection) {
case "overview":
return (
<OverviewSettings
settings={settings}
requiresRestart={hasPendingRestart}
showBrandLogos={localPrefs.brandLogos}
onSelectSection={selectSection}
/>
);
case "appearance":
return (
<AppearanceSettings
theme={theme}
onToggleTheme={onToggleTheme}
localPrefs={localPrefs}
onChangeLocalPrefs={setLocalPrefs}
/>
);
case "models":
return (
<div className="space-y-8">
<ModelsSettings
token={token}
form={form}
setForm={setForm}
settings={settings}
dirty={modelDirty}
creating={modelPresetCreating}
creatingSaving={modelConfigurationSaving}
callOrder={modelCallOrder}
saving={saving}
orderSaving={modelCallOrderSaving || modelConfigurationSaving}
migrationSaving={modelMigrationSaving}
showBrandLogos={localPrefs.brandLogos}
providerSaving={providerSaving}
onChangeCallOrder={changeModelCallOrder}
onProviderOAuthLogin={(provider) => runProviderOAuth(provider, "login")}
onSave={saveModelSettings}
onMigrate={handleMigrateModelConfigurations}
onBeginCreate={beginModelPresetCreation}
onCancelCreate={cancelModelPresetCreation}
onSelectConfiguration={() => {
setModelPresetCreating(false);
modelPresetBeforeCreateRef.current = null;
}}
onDeleteConfiguration={setModelPresetPendingDelete}
/>
<ProvidersSettings
settings={settings}
nanobotFeatures={nanobotFeatures}
featureAction={nanobotFeatureAction}
capabilityError={nanobotFeaturesError}
expandedProvider={expandedProvider}
providerForms={providerForms}
visibleProviderKeys={visibleProviderKeys}
editingProviderKeys={editingProviderKeys}
providerSaving={providerSaving}
showBrandLogos={localPrefs.brandLogos}
remoteBrowserAccess={remoteBrowserAccess}
onToggleProvider={handleToggleProvider}
onToggleProviderKey={toggleProviderKeyVisibility}
onToggleProviderKeyEditing={toggleProviderKeyEditing}
onChangeProviderForm={(provider, value) =>
setProviderForms((prev) => ({
...prev,
[provider]: {
...(prev[provider] ?? providerFormFromRow(
settings.providers.find((row) => row.name === provider) ?? {
name: provider,
label: provider,
configured: false,
},
)),
...value,
},
}))
}
onSaveProvider={saveProvider}
onCreateCustomProvider={createCustomProvider}
onProviderOAuthLogin={(provider) => runProviderOAuth(provider, "login")}
onProviderOAuthLogout={(provider) => runProviderOAuth(provider, "logout")}
imageProviderRestartPending={pendingRestartSections.image || pendingRestartSections.runtime}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
/>
</div>
);
case "image":
return (
<ImageGenerationSettings
token={token}
settings={settings}
form={imageGenerationForm}
dirty={imageGenerationDirty}
saving={imageGenerationSaving}
onChangeForm={setImageGenerationForm}
onSave={saveImageGenerationSettings}
onOpenProviders={() => selectSection("models")}
showBrandLogos={localPrefs.brandLogos}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
requiresRestartPending={pendingRestartSections.image}
/>
);
case "voice":
return (
<TranscriptionSettings
settings={settings}
form={transcriptionForm}
dirty={transcriptionDirty}
saving={transcriptionSaving}
onChangeForm={setTranscriptionForm}
onSave={saveTranscriptionSettings}
onOpenProviders={() => selectSection("models")}
showBrandLogos={localPrefs.brandLogos}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
requiresRestartPending={pendingRestartSections.browser}
/>
);
case "browser":
return (
<WebSettings
settings={settings}
form={webSearchForm}
keyVisible={webSearchKeyVisible}
keyEditing={webSearchKeyEditing}
saving={webSearchSaving}
onChangeForm={setWebSearchForm}
onChangeProvider={handleWebSearchProviderChange}
onToggleKey={() => setWebSearchKeyVisible((visible) => !visible)}
onToggleKeyEditing={() => {
setWebSearchKeyEditing((editing) => !editing);
setWebSearchKeyVisible(false);
setWebSearchForm((prev) => ({ ...prev, apiKey: "" }));
}}
onReset={resetWebSearchDraft}
onSave={saveWebSearch}
showBrandLogos={localPrefs.brandLogos}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
requiresRestartPending={pendingRestartSections.browser}
olostepFeature={featureCatalog.find((feature) => feature.name === "olostep")}
olostepInstalling={nanobotFeatureAction === "enable:olostep"}
capabilityError={nanobotFeaturesError}
/>
);
case "channels":
return (
<ChannelsSettings
token={token}
nanobotFeatures={nanobotFeatures}
loading={nanobotFeaturesLoading}
query={channelsQuery}
actionKey={nanobotFeatureAction}
chatAppsDocsUrl={settings.docs?.chat_apps_url}
showBrandLogos={localPrefs.brandLogos}
error={nanobotFeaturesError}
requiresRestartPending={pendingRestartSections.runtime}
onQueryChange={setChannelsQuery}
onAction={handleNanobotFeatureAction}
onFeaturesUpdate={setNanobotFeatures}
onDismissStatus={() => {
setNanobotFeaturesError(null);
}}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
/>
);
case "apps":
return (
<AppsCatalogSettings
cliApps={cliApps}
mcpPresets={mcpPresets}
cliAppsLoading={cliAppsLoading}
mcpPresetsLoading={mcpPresetsLoading}
query={appsQuery}
filter={appsKindFilter}
cliActionKey={cliAppsAction}
mcpActionKey={mcpPresetAction}
mcpOAuthFlow={mcpOAuthFlow}
mcpOAuthPopupBlocked={mcpOAuthPopupBlocked}
mcpOAuthCallbackUrl={mcpOAuthCallbackUrl}
mcpOAuthCompleting={mcpOAuthCompleting}
mcpOAuthCallbackError={mcpOAuthCallbackError}
cliMessage={cliAppsMessage}
cliError={cliAppsError}
cliFocusName={cliAppsFocusName}
mcpMessage={mcpMessage}
mcpError={mcpError}
mcpFieldValues={mcpFieldValues}
customMcpForm={customMcpForm}
mcpConfigImport={mcpConfigImport}
showBrandLogos={localPrefs.brandLogos}
requiresRestartPending={pendingRestartSections.runtime}
onQueryChange={setAppsQuery}
onFilterChange={setAppsKindFilter}
onCliAction={handleCliAppAction}
onMcpAction={handleMcpPresetAction}
onMcpOAuthConnect={handleMcpOAuthConnect}
onMcpOAuthCancel={() => void handleMcpOAuthCancel()}
onMcpOAuthOpen={handleMcpOAuthOpen}
onMcpOAuthCallbackUrlChange={(value) => {
setMcpOAuthCallbackUrl(value);
setMcpOAuthCallbackError(null);
}}
onMcpOAuthComplete={() => void handleMcpOAuthComplete()}
onDismissStatus={() => {
setCliAppsMessage(null);
setCliAppsError(null);
setMcpMessage(null);
setMcpError(null);
}}
onBackToChat={onBackToChat}
onMcpFieldChange={(presetName, fieldName, value) => {
setMcpFieldValues((prev) => ({
...prev,
[presetName]: {
...(prev[presetName] ?? {}),
[fieldName]: value,
},
}));
}}
onCustomMcpFormChange={setCustomMcpForm}
onMcpConfigImportChange={setMcpConfigImport}
onSaveCustomMcp={handleSaveCustomMcp}
onImportMcpConfig={handleImportMcpConfig}
onMcpToolsChange={handleMcpToolsChange}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
/>
);
case "automations":
return (
<AutomationsSettings
payload={automations}
loading={automationsLoading}
query={automationsQuery}
filter={automationsFilter}
sort={automationsSort}
actionKey={automationAction}
error={automationsError}
onQueryChange={setAutomationsQuery}
onFilterChange={setAutomationsFilter}
onSortChange={setAutomationsSort}
onAction={handleAutomationAction}
onRequestEdit={setAutomationPendingEdit}
onRequestDelete={setAutomationPendingDelete}
onBackToChat={onBackToChat}
/>
);
case "skills":
return <SkillsCatalogSettings skills={skills} />;
case "runtime":
return (
<RuntimeSettings
form={form}
settings={settings}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
requiresRestartPending={pendingRestartSections.runtime}
apiService={apiService}
apiServiceLoading={apiServiceLoading}
apiServiceAction={apiServiceAction}
apiServiceError={apiServiceError}
langfuseFeature={featureCatalog.find((feature) => feature.name === "langfuse")}
capabilitiesLoading={nanobotFeaturesLoading}
capabilityAction={nanobotFeatureAction}
capabilityError={nanobotFeaturesError}
onApiServiceAction={handleApiServiceAction}
onInstallCapability={(name) => void installCapabilities([name])}
/>
);
case "advanced":
return (
<AdvancedSettings
form={networkSafetyForm}
dirty={networkSafetyDirty}
saving={networkSafetySaving}
isNativeHostSurface={(settings.surface ?? settings.runtime_surface) === "native"}
onChangeForm={setNetworkSafetyForm}
onSave={saveNetworkSafetySettings}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
requiresRestartPending={pendingRestartSections.runtime}
/>
);
default:
return null;
}
};
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-settings-canvas lg:flex-row">
{showSidebar ? (
<SettingsSidebar
activeSection={activeSection}
onSelectSection={selectSection}
onBackToChat={onBackToChat}
onLogout={onLogout}
hostChromeInset={hostChromeInset}
/>
) : null}
<ModelPresetDeleteDialog
preset={modelPresetPendingDelete}
deleting={saving}
onOpenChange={(open) => {
if (!open) setModelPresetPendingDelete(null);
}}
onConfirm={handleDeleteModelConfiguration}
/>
<ProviderOAuthLoginDialog
flow={providerOAuthFlow}
providerLabel={
providerOAuthFlow
? settings?.providers.find((provider) => provider.name === providerOAuthFlow.provider)
?.label ?? providerOAuthFlow.provider
: ""
}
authorizationResponse={providerOAuthResponse}
completing={providerOAuthCompleting}
error={providerOAuthDialogError}
remoteBrowserAccess={remoteBrowserAccess}
onAuthorizationResponseChange={(value) => {
setProviderOAuthResponse(value);
setProviderOAuthDialogError(null);
}}
onOpenAuthorization={() => {
if (!providerOAuthFlow) return;
const opened = window.open(
providerOAuthFlow.authorization_url,
"_blank",
"noopener,noreferrer",
);
if (opened) opened.opener = null;
}}
onComplete={() => void completeProviderOAuthResponse()}
onClose={closeProviderOAuthFlow}
/>
<NanobotFeatureInstallDialog
feature={nanobotFeatureConfirm}
installing={nanobotFeatureAction === `enable:${nanobotFeatureConfirm?.name ?? ""}`}
onOpenChange={(open) => {
if (!open) setNanobotFeatureConfirm(null);
}}
onConfirm={(feature) => handleNanobotFeatureAction("enable", feature.name, true)}
/>
<AutomationDeleteDialog
job={automationPendingDelete}
deleting={automationAction === `delete:${automationPendingDelete?.id ?? ""}`}
onOpenChange={(open) => {
if (!open) setAutomationPendingDelete(null);
}}
onConfirm={(job) => handleAutomationAction("delete", job)}
/>
<AutomationEditDialog
job={automationPendingEdit}
saving={automationAction === `update:${automationPendingEdit?.id ?? ""}`}
onOpenChange={(open) => {
if (!open) setAutomationPendingEdit(null);
}}
onSave={handleAutomationEdit}
/>
<div
className={cn(
"min-w-0 flex-1 bg-settings-canvas [scrollbar-gutter:stable]",
activeSection === "channels" ? "overflow-y-auto xl:overflow-hidden" : "overflow-y-auto",
)}
>
<div
key={activeSection}
data-testid="settings-section-transition"
data-settings-section={activeSection}
className={cn(
"mx-auto w-full animate-in fade-in-0 slide-in-from-bottom-1 px-4 py-6 duration-200 ease-out",
"motion-reduce:animate-none sm:px-8 sm:py-8 lg:py-12",
activeSection === "channels" ? "max-w-[1240px] xl:px-10" : "max-w-[920px]",
activeSection === "channels" && "flex min-h-full flex-col xl:h-full xl:min-h-0",
hostChromeInset && "pt-[4.25rem] sm:pt-[4.25rem] lg:pt-[4.75rem]",
)}
>
{!showSidebar ? (
<div className="mb-7">
<button
type="button"
onClick={onBackToChat}
className="touch-target mb-4 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:hidden"
>
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
{t("settings.backToChat")}
</button>
<h1 className="text-[24px] font-normal leading-tight tracking-normal text-foreground sm:text-[28px]">
{t(`settings.nav.${activeSection}`, {
defaultValue: standaloneSectionTitle(activeSection),
})}
</h1>
</div>
) : null}
{loading ? (
<div className="flex h-48 items-center justify-center rounded-[22px] bg-settings-surface text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("settings.status.loading")}
</div>
) : error && !settings ? (
<SettingsGroup>
<SettingsRow title={t("settings.status.loadError")}>
<span className="max-w-[520px] text-sm text-muted-foreground">{error}</span>
</SettingsRow>
</SettingsGroup>
) : settings ? (
<div
className={cn(
"space-y-5",
activeSection === "channels" &&
"flex min-h-0 flex-1 flex-col xl:overflow-hidden",
)}
>
{error ? (
<div className="rounded-[18px] border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
{error}
</div>
) : null}
{renderSection()}
</div>
) : null}
</div>
</div>
</div>
);
}
@@ -0,0 +1,188 @@
import { useRef } from "react";
import {
Activity,
Check,
ChevronDown,
ChevronLeft,
Globe2,
ImageIcon,
LogOut,
MessageCircle,
Mic,
Palette,
Server,
ShieldCheck,
SlidersHorizontal,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import {
SIDEBAR_SELECTION_ITEM_CLASS,
SidebarSelectionHighlight,
} from "@/components/SidebarSelectionHighlight";
import type { SettingsSectionKey } from "@/components/settings/contracts";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
const SETTINGS_NAV_ITEMS: Array<{ key: SettingsSectionKey; icon: LucideIcon; fallback: string }> = [
{ key: "overview", icon: Activity, fallback: "Overview" },
{ key: "appearance", icon: Palette, fallback: "Appearance" },
{ key: "models", icon: SlidersHorizontal, fallback: "Models" },
{ key: "image", icon: ImageIcon, fallback: "Image" },
{ key: "voice", icon: Mic, fallback: "Voice" },
{ key: "browser", icon: Globe2, fallback: "Web" },
{ key: "channels", icon: MessageCircle, fallback: "Channels" },
{ key: "runtime", icon: Server, fallback: "System" },
{ key: "advanced", icon: ShieldCheck, fallback: "Security" },
];
export function standaloneSectionTitle(section: SettingsSectionKey): string {
if (section === "apps") return "Apps";
if (section === "automations") return "Automations";
if (section === "skills") return "Skills";
return SETTINGS_NAV_ITEMS.find((item) => item.key === section)?.fallback ?? "Settings";
}
export function SettingsSidebar({
activeSection,
onSelectSection,
onBackToChat,
onLogout,
hostChromeInset,
}: {
activeSection: SettingsSectionKey;
onSelectSection: (section: SettingsSectionKey) => void;
onBackToChat: () => void;
onLogout?: () => void;
hostChromeInset?: boolean;
}) {
const { t } = useTranslation();
const activeNavItemRef = useRef<HTMLButtonElement>(null);
const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection)
?? SETTINGS_NAV_ITEMS[0];
const ActiveIcon = activeItem.icon;
const activeLabel = t(`settings.nav.${activeItem.key}`, {
defaultValue: activeItem.fallback,
});
return (
<aside
className={cn(
"flex w-full shrink-0 flex-col bg-settings-surface px-3 pb-2 lg:w-[17rem] lg:px-3 lg:pb-4",
hostChromeInset ? "pt-[4.25rem] lg:pt-[4.25rem]" : "pt-4 lg:pt-4",
)}
>
<button
type="button"
onClick={onBackToChat}
className="touch-target mb-2 inline-flex w-fit items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:mb-3"
>
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
{t("settings.backToChat")}
</button>
<div className="mb-3 px-1 lg:mb-4 lg:px-2">
<h1 className="text-[18px] font-normal tracking-normal text-foreground">
{t("settings.sidebar.title")}
</h1>
</div>
<nav
aria-label={t("settings.sidebar.ariaLabel")}
className="w-full"
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={`${t("settings.sidebar.title")}: ${activeLabel}`}
className="touch-target flex h-11 w-full items-center gap-2.5 rounded-[14px] bg-sidebar-accent px-3 text-left text-[13px] font-medium text-foreground transition-colors hover:bg-sidebar-accent/80 lg:hidden"
>
<ActiveIcon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
<span className="min-w-0 flex-1 truncate">{activeLabel}</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={6}
className="w-[var(--radix-dropdown-menu-trigger-width)] max-w-[calc(100vw-1.5rem)]"
>
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
const active = key === activeSection;
return (
<DropdownMenuItem
key={key}
aria-current={active ? "page" : undefined}
onSelect={() => onSelectSection(key)}
className={cn(
"flex h-10 cursor-default items-center gap-2.5 px-2.5 text-[13px] font-medium",
active && "bg-sidebar-accent text-foreground focus:bg-sidebar-accent",
)}
>
<Icon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
<span className="min-w-0 flex-1 truncate">
{t(`settings.nav.${key}`, { defaultValue: fallback })}
</span>
{active ? <Check className="h-4 w-4 shrink-0" aria-hidden /> : null}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<SidebarSelectionHighlight
targetRef={activeNavItemRef}
activeId={activeSection}
scope="settings"
className="relative hidden space-y-1 lg:block"
>
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
const active = key === activeSection;
return (
<button
ref={active ? activeNavItemRef : undefined}
key={key}
type="button"
aria-current={active ? "page" : undefined}
onClick={() => onSelectSection(key)}
className={cn(
"touch-target flex h-9 w-full items-center gap-2 rounded-xl px-2.5 text-left text-[13px] font-medium",
SIDEBAR_SELECTION_ITEM_CLASS,
active
? "text-sidebar-accent-foreground"
: "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground",
)}
>
<Icon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
<span className="truncate">
{t(`settings.nav.${key}`, { defaultValue: fallback })}
</span>
</button>
);
})}
</SidebarSelectionHighlight>
</nav>
<div className="hidden lg:mt-auto lg:block lg:pt-4">
{onLogout && !hostChromeInset ? (
<Button
type="button"
variant="ghost"
onClick={onLogout}
className="h-9 w-full justify-start gap-2 rounded-[10px] px-2.5 text-[13px] font-medium text-muted-foreground hover:bg-destructive/8 hover:text-destructive"
>
<LogOut className="h-4 w-4" aria-hidden />
{t("app.account.logout")}
</Button>
) : null}
</div>
</aside>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,213 @@
import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { ModelIdPicker, ProviderPicker, optionRowsWithCurrent } from "@/components/settings/shared/ModelControls";
import {
NumberInput,
ReadOnlyRow,
RestartSettingsFooter,
SettingsGroup,
SettingsRow,
SettingsSectionTitle,
StatusPill,
} from "@/components/settings/shared/SettingsControls";
import { ToggleButton } from "@/components/settings/ToggleButton";
import { Button } from "@/components/ui/button";
import type { ImageGenerationSettingsUpdate, SettingsPayload } from "@/lib/types";
const IMAGE_ASPECT_RATIO_OPTIONS = ["1:1", "3:4", "9:16", "4:3", "16:9", "3:2", "2:3", "21:9"];
const IMAGE_SIZE_OPTIONS = ["1K", "2K", "4K", "1024x1024", "1536x1024", "1024x1536"];
export const DEFAULT_IMAGE_GENERATION_FORM: ImageGenerationSettingsUpdate = {
enabled: false,
provider: "openrouter",
model: "openai/gpt-5.4-image-2",
defaultAspectRatio: "1:1",
defaultImageSize: "1K",
maxImagesPerTurn: 4,
};
export function imageGenerationFormFromPayload(payload: SettingsPayload): ImageGenerationSettingsUpdate {
return {
enabled: payload.image_generation.enabled,
provider: payload.image_generation.provider,
model: payload.image_generation.model,
defaultAspectRatio: payload.image_generation.default_aspect_ratio,
defaultImageSize: payload.image_generation.default_image_size,
maxImagesPerTurn: payload.image_generation.max_images_per_turn,
};
}
export function ImageGenerationSettings({
token,
settings,
form,
dirty,
saving,
onChangeForm,
onSave,
onOpenProviders,
showBrandLogos,
onRestart,
isRestarting,
requiresRestartPending,
}: {
token: string;
settings: SettingsPayload;
form: ImageGenerationSettingsUpdate;
dirty: boolean;
saving: boolean;
onChangeForm: Dispatch<SetStateAction<ImageGenerationSettingsUpdate>>;
onSave: () => void;
onOpenProviders: () => void;
showBrandLogos: boolean;
onRestart?: () => void;
isRestarting?: boolean;
requiresRestartPending: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const selectedProvider =
settings.image_generation.providers.find((provider) => provider.name === form.provider) ??
settings.image_generation.providers[0];
const providerConfigured = !!selectedProvider?.configured;
const missingCredential = form.enabled && !providerConfigured;
const aspectOptions = optionRowsWithCurrent(
IMAGE_ASPECT_RATIO_OPTIONS.map((value) => ({ name: value, label: value })),
form.defaultAspectRatio,
);
const sizeOptions = optionRowsWithCurrent(
IMAGE_SIZE_OPTIONS.map((value) => ({ name: value, label: value })),
form.defaultImageSize,
);
const selectProvider = (provider: string) => {
const nextProvider = settings.image_generation.providers.find((row) => row.name === provider);
onChangeForm((prev) => ({
...prev,
provider,
model: nextProvider?.default_model || nextProvider?.models?.[0] || prev.model,
}));
};
return (
<div className="space-y-7">
<section>
<SettingsSectionTitle>{tx("settings.sections.imageGeneration", "Image generation")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow title={tx("settings.rows.imageGeneration", "Image generation")}>
<ToggleButton
checked={form.enabled}
onChange={(enabled) => onChangeForm((prev) => ({ ...prev, enabled }))}
ariaLabel={tx("settings.rows.imageGeneration", "Image generation")}
label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.imageProvider", "Image provider")}>
<ProviderPicker
providers={settings.image_generation.providers}
value={form.provider}
emptyLabel={tx("settings.image.selectProvider", "Select provider")}
showProviderLogos={showBrandLogos}
onChange={selectProvider}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.imageProviderStatus", "Provider status")}
description={tx("settings.help.imageProviderStatus", "Image generation reuses provider credentials from Providers.")}
>
<div className="flex flex-wrap items-center justify-end gap-2">
<StatusPill tone={providerConfigured ? "success" : "neutral"}>
{providerConfigured
? tx("settings.values.configured", "Configured")
: tx("settings.values.notConfigured", "Not configured")}
</StatusPill>
{!providerConfigured ? (
<Button size="sm" variant="outline" onClick={onOpenProviders} className="rounded-full">
{tx("settings.image.configureProvider", "Configure provider")}
</Button>
) : null}
</div>
</SettingsRow>
<SettingsRow title={tx("settings.rows.imageProviderBase", "Provider base")}>
<span className="max-w-[320px] truncate text-right text-[13px] text-muted-foreground">
{selectedProvider?.api_base || selectedProvider?.default_api_base || selectedProvider?.name || tx("settings.values.notAvailable", "Not available")}
</span>
</SettingsRow>
</SettingsGroup>
</section>
<section>
<SettingsSectionTitle>{tx("settings.sections.imageDefaults", "Defaults")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow title={tx("settings.rows.imageModel", "Image model")}>
<ModelIdPicker
token={token}
settings={settings}
provider={form.provider}
models={selectedProvider?.models ?? []}
value={form.model}
showProviderLogos={showBrandLogos}
emptyLabel={tx("settings.image.selectModel", "Select image model")}
searchPlaceholder={tx(
"settings.image.searchOrTypeModel",
"Search or type model ID",
)}
emptyMessage={tx(
"settings.image.typeModelId",
"Type the model ID supported by this provider.",
)}
onChange={(model) => onChangeForm((prev) => ({ ...prev, model }))}
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.defaultAspectRatio", "Default aspect")}>
<ProviderPicker
providers={aspectOptions}
value={form.defaultAspectRatio}
emptyLabel={tx("settings.image.selectAspect", "Select aspect")}
onChange={(defaultAspectRatio) =>
onChangeForm((prev) => ({ ...prev, defaultAspectRatio }))
}
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.defaultImageSize", "Default size")}>
<ProviderPicker
providers={sizeOptions}
value={form.defaultImageSize}
emptyLabel={tx("settings.image.selectSize", "Select size")}
onChange={(defaultImageSize) =>
onChangeForm((prev) => ({ ...prev, defaultImageSize }))
}
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.maxImagesPerTurn", "Max images per turn")}>
<NumberInput
value={form.maxImagesPerTurn}
min={1}
max={8}
onChange={(maxImagesPerTurn) =>
onChangeForm((prev) => ({ ...prev, maxImagesPerTurn }))
}
/>
</SettingsRow>
<ReadOnlyRow title={tx("settings.rows.imageSaveDir", "Save directory")} value={settings.image_generation.save_dir} />
<RestartSettingsFooter
dirty={dirty}
saving={saving}
pendingRestart={requiresRestartPending}
disabled={missingCredential}
message={
missingCredential
? tx("settings.image.missingCredential", "Configure this provider before enabling image generation.")
: undefined
}
dirtyMessage={tx("settings.status.restartAfterSaving", "Save changes, then restart when ready.")}
pendingMessage={tx("settings.status.savedRestartApply", "Saved. Restart when ready.")}
onSave={onSave}
onRestart={onRestart}
isRestarting={isRestarting}
/>
</SettingsGroup>
</section>
</div>
);
}
@@ -0,0 +1,131 @@
import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import {
RestartSettingsFooter,
SettingsGroup,
SettingsRow,
SettingsSectionTitle,
} from "@/components/settings/shared/SettingsControls";
import { ToggleButton } from "@/components/settings/ToggleButton";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type {
NetworkSafetySettingsUpdate,
SettingsPayload,
WebuiDefaultAccessMode,
} from "@/lib/types";
export const DEFAULT_NETWORK_SAFETY_FORM: NetworkSafetySettingsUpdate = {
webuiAllowLocalServiceAccess: true,
webuiDefaultAccessMode: "default",
};
export function networkSafetyFormFromPayload(payload: SettingsPayload): NetworkSafetySettingsUpdate {
return {
webuiAllowLocalServiceAccess:
payload.advanced.webui_allow_local_service_access ??
payload.advanced.allow_local_preview_access ??
true,
webuiDefaultAccessMode: visibleWebuiDefaultAccessMode(
payload.advanced.webui_default_access_mode,
),
};
}
export function visibleWebuiDefaultAccessMode(mode: string | null | undefined): WebuiDefaultAccessMode {
return mode === "full" ? "full" : "default";
}
export function AdvancedSettings({
form,
dirty,
saving,
requiresRestartPending,
isNativeHostSurface,
onChangeForm,
onSave,
onRestart,
isRestarting,
}: {
form: NetworkSafetySettingsUpdate;
dirty: boolean;
saving: boolean;
requiresRestartPending: boolean;
isNativeHostSurface: boolean;
onChangeForm: Dispatch<SetStateAction<NetworkSafetySettingsUpdate>>;
onSave: () => void;
onRestart?: () => void;
isRestarting?: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return (
<div className="space-y-7">
<section>
<SettingsSectionTitle>
{isNativeHostSurface
? tx("settings.sections.hostSafety", "App safety")
: tx("settings.sections.webuiSafety", "Web safety")}
</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.localServiceAccess", "Local Service Access")}
description={tx(
isNativeHostSurface ? "settings.help.localServiceAccessNative" : "settings.help.localServiceAccess",
isNativeHostSurface
? "Allow Full Access shell commands to reach services on this Mac."
: "Allow Full Access shell commands to reach localhost services.",
)}
>
<ToggleButton
checked={form.webuiAllowLocalServiceAccess}
onChange={(webuiAllowLocalServiceAccess) =>
onChangeForm((prev) => ({ ...prev, webuiAllowLocalServiceAccess }))
}
ariaLabel={tx("settings.rows.localServiceAccess", "Local Service Access")}
label={form.webuiAllowLocalServiceAccess ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.webuiDefaultAccess", "Default access")}
description={tx(
isNativeHostSurface ? "settings.help.webuiDefaultAccessNative" : "settings.help.webuiDefaultAccess",
isNativeHostSurface
? "Used by native chats without a project-specific permission."
: "Used by web chats without a project-specific permission.",
)}
>
<SegmentedControl
value={form.webuiDefaultAccessMode}
options={[
{ value: "default", label: tx("settings.values.defaultPermission", "Default Permission") },
{ value: "full", label: tx("settings.values.fullAccess", "Full Access") },
]}
onChange={(webuiDefaultAccessMode) =>
onChangeForm((prev) => ({
...prev,
webuiDefaultAccessMode: webuiDefaultAccessMode as WebuiDefaultAccessMode,
}))
}
/>
</SettingsRow>
<RestartSettingsFooter
dirty={dirty}
saving={saving}
pendingRestart={requiresRestartPending}
onSave={onSave}
onRestart={onRestart}
isRestarting={isRestarting}
/>
</SettingsGroup>
</section>
<p className="max-w-3xl px-1 text-sm leading-6 text-muted-foreground">
{tx(
"settings.help.securityManagedControls",
"Web fetches always protect local, private, and metadata services. Core channel safety stays in config.json.",
)}
</p>
</div>
);
}
@@ -0,0 +1,176 @@
import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { ProviderPicker } from "@/components/settings/shared/ModelControls";
import {
NumberInput,
RestartSettingsFooter,
SettingsGroup,
SettingsRow,
SettingsSectionTitle,
StatusPill,
} from "@/components/settings/shared/SettingsControls";
import { ToggleButton } from "@/components/settings/ToggleButton";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { SettingsPayload, TranscriptionSettingsUpdate } from "@/lib/types";
export const DEFAULT_TRANSCRIPTION_FORM: TranscriptionSettingsUpdate = {
enabled: true,
provider: "groq",
model: "",
language: "",
maxDurationSec: 120,
maxUploadMb: 25,
};
export const DEFAULT_TRANSCRIPTION_SETTINGS: NonNullable<SettingsPayload["transcription"]> = {
enabled: true,
provider: "groq",
provider_configured: false,
model: "whisper-large-v3",
language: null,
max_duration_sec: 120,
max_upload_mb: 25,
providers: [],
};
export function transcriptionFormFromPayload(payload: SettingsPayload): TranscriptionSettingsUpdate {
const transcription = payload.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
return {
enabled: transcription.enabled,
provider: transcription.provider,
model: transcription.model,
language: transcription.language ?? "",
maxDurationSec: transcription.max_duration_sec,
maxUploadMb: transcription.max_upload_mb,
};
}
export function TranscriptionSettings({
settings,
form,
dirty,
saving,
onChangeForm,
onSave,
onOpenProviders,
showBrandLogos,
onRestart,
isRestarting,
requiresRestartPending,
}: {
settings: SettingsPayload;
form: TranscriptionSettingsUpdate;
dirty: boolean;
saving: boolean;
onChangeForm: Dispatch<SetStateAction<TranscriptionSettingsUpdate>>;
onSave: () => void;
onOpenProviders: () => void;
showBrandLogos: boolean;
onRestart?: () => void;
isRestarting?: boolean;
requiresRestartPending: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const transcription = settings.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
const selectedProvider =
transcription.providers.find((provider) => provider.name === form.provider) ??
transcription.providers[0];
const providerConfigured = !!selectedProvider?.configured;
return (
<section>
<SettingsSectionTitle>{tx("settings.sections.voiceInput", "Voice input")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.transcription", "Transcription")}
description={tx("settings.help.transcription", "Transcribe microphone input before sending it. Chat channel voice messages use the same settings.")}
>
<ToggleButton
checked={form.enabled}
onChange={(enabled) => onChangeForm((prev) => ({ ...prev, enabled }))}
ariaLabel={tx("settings.rows.transcription", "Transcription")}
label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.transcriptionProvider", "Provider")}>
<ProviderPicker
providers={transcription.providers}
value={form.provider}
emptyLabel={tx("settings.voice.selectProvider", "Select provider")}
showProviderLogos={showBrandLogos}
onChange={(provider) => onChangeForm((prev) => ({ ...prev, provider }))}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.transcriptionProviderStatus", "Provider status")}
description={tx("settings.help.transcriptionProviderStatus", "API keys stay under providers, not in transcription settings.")}
>
<div className="flex flex-wrap items-center justify-end gap-2">
<StatusPill tone={providerConfigured ? "success" : "neutral"}>
{providerConfigured
? tx("settings.values.configured", "Configured")
: tx("settings.values.notConfigured", "Not configured")}
</StatusPill>
{!providerConfigured ? (
<Button size="sm" variant="outline" onClick={onOpenProviders} className="rounded-full">
{tx("settings.voice.configureProvider", "Configure provider")}
</Button>
) : null}
</div>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.transcriptionModel", "Model")}
description={tx("settings.help.transcriptionModel", "Leave as the resolved default unless your provider needs a custom model id.")}
>
<Input
value={form.model}
onChange={(event) => onChangeForm((prev) => ({ ...prev, model: event.target.value }))}
className="h-8 w-[min(300px,70vw)] rounded-full text-[13px]"
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.transcriptionLanguage", "Language")}
description={tx("settings.help.transcriptionLanguage", "Optional ISO-639 hint such as en, zh, ja, or ko.")}
>
<Input
value={form.language}
onChange={(event) => onChangeForm((prev) => ({ ...prev, language: event.target.value }))}
placeholder={tx("settings.voice.languageAuto", "Auto")}
className="h-8 w-[min(180px,60vw)] rounded-full text-[13px]"
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.voiceLimits", "Limits")}>
<div className="flex flex-wrap justify-end gap-2">
<NumberInput
value={form.maxDurationSec}
min={1}
max={600}
suffix="s"
onChange={(maxDurationSec) => onChangeForm((prev) => ({ ...prev, maxDurationSec }))}
/>
<NumberInput
value={form.maxUploadMb}
min={1}
max={100}
suffix="MB"
onChange={(maxUploadMb) => onChangeForm((prev) => ({ ...prev, maxUploadMb }))}
/>
</div>
</SettingsRow>
<RestartSettingsFooter
dirty={dirty}
saving={saving}
pendingRestart={requiresRestartPending}
dirtyMessage={tx("settings.status.restartAfterSaving", "Save changes, then restart when ready.")}
pendingMessage={tx("settings.status.savedRestartApply", "Saved. Restart when ready.")}
onSave={onSave}
onRestart={onRestart}
isRestarting={isRestarting}
/>
</SettingsGroup>
</section>
);
}
@@ -0,0 +1,293 @@
import type { Dispatch, SetStateAction } from "react";
import { Eye, EyeOff, Pencil } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ProviderPicker } from "@/components/settings/shared/ModelControls";
import {
CapabilityInstallNotice,
NumberInput,
RestartSettingsFooter,
SettingsGroup,
SettingsRow,
SettingsSectionTitle,
StatusPill,
} from "@/components/settings/shared/SettingsControls";
import { ToggleButton } from "@/components/settings/ToggleButton";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type {
NanobotFeatureInfo,
SettingsPayload,
WebSearchSettingsUpdate,
} from "@/lib/types";
export const DEFAULT_WEB_SEARCH_FORM: WebSearchSettingsUpdate = {
provider: "duckduckgo",
apiKey: "",
baseUrl: "",
maxResults: 5,
timeout: 30,
useJinaReader: true,
};
export function webSearchFormFromPayload(
payload: SettingsPayload,
previous?: WebSearchSettingsUpdate,
): WebSearchSettingsUpdate {
return {
provider: payload.web_search.provider,
apiKey: previous?.provider === payload.web_search.provider ? previous.apiKey ?? "" : "",
baseUrl: payload.web_search.base_url ?? "",
maxResults: payload.web_search.max_results,
timeout: payload.web_search.timeout,
useJinaReader: payload.web.fetch.use_jina_reader,
};
}
type WebSearchProviderOption = SettingsPayload["web_search"]["providers"][number];
export function webSearchProviderAcceptsApiKey(provider?: WebSearchProviderOption): boolean {
return provider?.credential === "api_key" || provider?.credential === "optional_api_key";
}
export function webSearchProviderRequiresApiKey(provider?: WebSearchProviderOption): boolean {
return provider?.credential === "api_key";
}
export function WebSettings({
settings,
form,
keyVisible,
keyEditing,
saving,
onChangeForm,
onChangeProvider,
onToggleKey,
onToggleKeyEditing,
onReset,
onSave,
showBrandLogos,
onRestart,
isRestarting,
requiresRestartPending,
olostepFeature,
olostepInstalling,
capabilityError,
}: {
settings: SettingsPayload;
form: WebSearchSettingsUpdate;
keyVisible: boolean;
keyEditing: boolean;
saving: boolean;
onChangeForm: Dispatch<SetStateAction<WebSearchSettingsUpdate>>;
onChangeProvider: (provider: string) => void;
onToggleKey: () => void;
onToggleKeyEditing: () => void;
onReset: () => void;
onSave: () => void;
showBrandLogos: boolean;
onRestart?: () => void;
isRestarting?: boolean;
requiresRestartPending: boolean;
olostepFeature?: NanobotFeatureInfo;
olostepInstalling: boolean;
capabilityError: string | null;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const selectedProvider =
settings.web_search.providers.find((provider) => provider.name === form.provider) ??
settings.web_search.providers[0];
const hasExistingSecret =
webSearchProviderAcceptsApiKey(selectedProvider) &&
form.provider === settings.web_search.provider &&
!!settings.web_search.api_key_hint;
const showKeyInput = webSearchProviderAcceptsApiKey(selectedProvider) && (!hasExistingSecret || keyEditing);
const apiKey = form.apiKey?.trim() ?? "";
const baseUrl = form.baseUrl?.trim() ?? "";
const effectiveJinaReader = form.useJinaReader ?? settings.web.fetch.use_jina_reader;
const dirty =
form.provider !== settings.web_search.provider ||
apiKey.length > 0 ||
baseUrl !== (settings.web_search.base_url ?? "") ||
form.maxResults !== settings.web_search.max_results ||
form.timeout !== settings.web_search.timeout ||
effectiveJinaReader !== settings.web.fetch.use_jina_reader;
const jinaReaderDirty = effectiveJinaReader !== settings.web.fetch.use_jina_reader;
const missingCredential =
webSearchProviderRequiresApiKey(selectedProvider)
? !apiKey && !hasExistingSecret
: selectedProvider?.credential === "base_url"
? !baseUrl
: false;
return (
<div className="space-y-7">
<section>
<SettingsSectionTitle>{tx("settings.sections.webSearch", "Web search")}</SettingsSectionTitle>
{form.provider === "olostep" && olostepFeature && !olostepFeature.installed ? (
<div className="mb-3">
<CapabilityInstallNotice
title={tx("settings.capabilities.searchSupport", "Search provider support")}
description={tx(
"settings.capabilities.searchInstallOnSave",
"Olostep support will be installed automatically when you save.",
)}
installing={olostepInstalling}
/>
</div>
) : null}
{capabilityError ? (
<p className="mb-3 text-[12px] text-destructive">{capabilityError}</p>
) : null}
<SettingsGroup>
<SettingsRow title={t("settings.byok.webSearch.provider")}>
<ProviderPicker
providers={settings.web_search.providers}
value={form.provider}
emptyLabel={t("settings.byok.webSearch.selectProvider")}
showProviderLogos={showBrandLogos}
onChange={onChangeProvider}
/>
</SettingsRow>
{selectedProvider?.credential === "none" ? (
<SettingsRow title={t("settings.byok.webSearch.credentials")}>
<StatusPill tone="success">{t("settings.byok.webSearch.noCredentialRequired")}</StatusPill>
</SettingsRow>
) : null}
{webSearchProviderAcceptsApiKey(selectedProvider) ? (
<SettingsRow
title={t("settings.byok.apiKey")}
description={t("settings.byok.webSearch.apiKeyHelp")}
>
<div className="relative w-[280px] max-w-full">
{showKeyInput ? (
<>
<Input
type={keyVisible ? "text" : "password"}
value={form.apiKey ?? ""}
onChange={(event) =>
onChangeForm((prev) => ({ ...prev, apiKey: event.target.value }))
}
placeholder={
hasExistingSecret
? t("settings.byok.apiKeyConfiguredPlaceholder")
: t("settings.byok.apiKeyPlaceholder")
}
className="h-9 rounded-full pr-11 text-[13px]"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onToggleKey}
aria-label={
keyVisible ? t("settings.byok.hideApiKey") : t("settings.byok.showApiKey")
}
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
>
{keyVisible ? (
<EyeOff className="h-3.5 w-3.5" aria-hidden />
) : (
<Eye className="h-3.5 w-3.5" aria-hidden />
)}
</Button>
</>
) : (
<>
<div className="flex h-9 items-center rounded-full border border-input bg-background px-3 pr-11 text-[13px] text-muted-foreground">
{settings.web_search.api_key_hint ?? t("settings.byok.configuredKeyHint")}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onToggleKeyEditing}
aria-label={t("settings.actions.edit")}
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
>
<Pencil className="h-3.5 w-3.5" aria-hidden />
</Button>
</>
)}
</div>
</SettingsRow>
) : null}
{selectedProvider?.credential === "base_url" ? (
<SettingsRow
title={t("settings.byok.webSearch.baseUrl")}
description={t("settings.byok.webSearch.baseUrlHelp")}
>
<Input
value={form.baseUrl ?? ""}
onChange={(event) =>
onChangeForm((prev) => ({ ...prev, baseUrl: event.target.value }))
}
placeholder={t("settings.byok.webSearch.baseUrlPlaceholder")}
className="h-9 w-[280px] rounded-full text-[13px]"
/>
</SettingsRow>
) : null}
</SettingsGroup>
</section>
<section>
<SettingsSectionTitle>{tx("settings.sections.webBehavior", "Behavior")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow title={tx("settings.rows.maxResults", "Max results")}>
<NumberInput
value={form.maxResults ?? settings.web_search.max_results}
min={1}
max={10}
onChange={(maxResults) => onChangeForm((prev) => ({ ...prev, maxResults }))}
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.timeout", "Timeout")}>
<NumberInput
value={form.timeout ?? settings.web_search.timeout}
min={1}
max={120}
onChange={(timeout) => onChangeForm((prev) => ({ ...prev, timeout }))}
suffix="s"
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.jinaReader", "Jina reader")}
description={tx("settings.help.jinaReader", "Use Jina Reader for web_fetch when available.")}
>
<ToggleButton
checked={effectiveJinaReader}
onChange={(useJinaReader) => onChangeForm((prev) => ({ ...prev, useJinaReader }))}
ariaLabel={tx("settings.rows.jinaReader", "Jina reader")}
label={effectiveJinaReader ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<RestartSettingsFooter
dirty={dirty}
saving={saving}
pendingRestart={requiresRestartPending}
disabled={missingCredential}
message={
missingCredential
? t("settings.byok.webSearch.missingCredential")
: requiresRestartPending && !dirty
? tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
: jinaReaderDirty
? tx("settings.status.restartAfterSaving", "Save changes, then restart when ready.")
: dirty
? t("settings.byok.webSearch.saveHint")
: undefined
}
onSave={onSave}
onRestart={onRestart}
onReset={onReset}
isRestarting={isRestarting}
/>
</SettingsGroup>
</section>
</div>
);
}
@@ -0,0 +1,224 @@
import { useCallback, type Dispatch, type SetStateAction } from "react";
import type { TFunction } from "i18next";
import {
webSearchProviderAcceptsApiKey,
webSearchProviderRequiresApiKey,
} from "@/components/settings/capabilities/WebSettings";
import type { CapabilitySettingsState } from "@/components/settings/capabilities/useCapabilitySettingsState";
import type {
ApplySettingsPayload,
MaybeRestartHostEngine,
PendingRestartSections,
} from "@/components/settings/contracts";
import {
updateImageGenerationSettings,
updateNetworkSafetySettings,
updateTranscriptionSettings,
updateWebSearchSettings,
} from "@/lib/api";
import type { NanobotClient } from "@/lib/nanobot-client";
import type { SettingsPayload, WebSearchSettingsUpdate } from "@/lib/types";
interface CapabilitySettingsActionsOptions {
state: CapabilitySettingsState;
settings: SettingsPayload | null;
client: NanobotClient;
t: TFunction;
applyPayload: ApplySettingsPayload;
maybeRestartHostEngine: MaybeRestartHostEngine;
setPendingRestartSections: Dispatch<SetStateAction<PendingRestartSections>>;
setError: Dispatch<SetStateAction<string | null>>;
installCapabilities: (names: string[]) => Promise<boolean>;
imageGenerationDirty: boolean;
transcriptionDirty: boolean;
networkSafetyDirty: boolean;
}
export function useCapabilitySettingsActions({
state,
settings,
client,
t,
applyPayload,
maybeRestartHostEngine,
setPendingRestartSections,
setError,
installCapabilities,
imageGenerationDirty,
transcriptionDirty,
networkSafetyDirty,
}: CapabilitySettingsActionsOptions) {
const {
imageGenerationForm,
imageGenerationSaving,
networkSafetyForm,
networkSafetySaving,
setImageGenerationSaving,
setNetworkSafetySaving,
setTranscriptionSaving,
setWebSearchForm,
setWebSearchKeyEditing,
setWebSearchKeyVisible,
setWebSearchSaving,
transcriptionForm,
transcriptionSaving,
webSearchForm,
webSearchKeyEditing,
webSearchSaving,
} = state;
const saveImageGenerationSettings = async () => {
if (!settings || !imageGenerationDirty || imageGenerationSaving) return;
setImageGenerationSaving(true);
try {
const payload = await updateImageGenerationSettings(client, imageGenerationForm);
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, image: true }));
}
await maybeRestartHostEngine(payload);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setImageGenerationSaving(false);
}
};
const saveTranscriptionSettings = async () => {
if (!settings || !transcriptionDirty || transcriptionSaving) return;
setTranscriptionSaving(true);
try {
const payload = await updateTranscriptionSettings(client, transcriptionForm);
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
}
await maybeRestartHostEngine(payload);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setTranscriptionSaving(false);
}
};
const saveNetworkSafetySettings = async () => {
if (!settings || !networkSafetyDirty || networkSafetySaving) return;
setNetworkSafetySaving(true);
try {
const payload = await updateNetworkSafetySettings(client, networkSafetyForm);
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
await maybeRestartHostEngine(payload);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setNetworkSafetySaving(false);
}
};
const saveWebSearch = async () => {
if (!settings || webSearchSaving) return;
const provider = settings.web_search.providers.find((item) => item.name === webSearchForm.provider);
if (!provider) return;
const apiKey = webSearchForm.apiKey?.trim() ?? "";
const baseUrl = webSearchForm.baseUrl?.trim() ?? "";
const hasExistingSecret =
webSearchProviderAcceptsApiKey(provider) &&
webSearchForm.provider === settings.web_search.provider &&
!!settings.web_search.api_key_hint;
if (webSearchProviderRequiresApiKey(provider) && !apiKey && !hasExistingSecret) {
setError(t("settings.byok.webSearch.apiKeyRequired"));
return;
}
if (provider.credential === "base_url" && !baseUrl) {
setError(t("settings.byok.webSearch.baseUrlRequired"));
return;
}
setWebSearchSaving(true);
try {
if (provider.name === "olostep" && !(await installCapabilities(["olostep"]))) return;
const webFetchRestartRequired =
(webSearchForm.useJinaReader ?? settings.web.fetch.use_jina_reader) !==
settings.web.fetch.use_jina_reader;
const update: WebSearchSettingsUpdate = {
provider: webSearchForm.provider,
maxResults: webSearchForm.maxResults,
timeout: webSearchForm.timeout,
useJinaReader: webSearchForm.useJinaReader,
};
if (
webSearchProviderAcceptsApiKey(provider) &&
(apiKey || (provider.credential === "optional_api_key" && webSearchKeyEditing))
) {
update.apiKey = apiKey;
}
if (provider.credential === "base_url") update.baseUrl = baseUrl;
const payload = await updateWebSearchSettings(client, update);
applyPayload(payload);
if (payload.requires_restart || webFetchRestartRequired) {
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
}
await maybeRestartHostEngine(payload);
setWebSearchForm((prev) => ({
provider: payload.web_search.provider,
apiKey: "",
baseUrl: payload.web_search.base_url ?? prev.baseUrl ?? "",
maxResults: payload.web_search.max_results,
timeout: payload.web_search.timeout,
useJinaReader: payload.web.fetch.use_jina_reader,
}));
setWebSearchKeyVisible(false);
setWebSearchKeyEditing(false);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setWebSearchSaving(false);
}
};
const resetWebSearchDraft = useCallback(() => {
if (!settings) return;
setWebSearchForm({
provider: settings.web_search.provider,
apiKey: "",
baseUrl: settings.web_search.base_url ?? "",
maxResults: settings.web_search.max_results,
timeout: settings.web_search.timeout,
useJinaReader: settings.web.fetch.use_jina_reader,
});
setWebSearchKeyVisible(false);
setWebSearchKeyEditing(false);
}, [settings]);
const handleWebSearchProviderChange = useCallback((provider: string) => {
if (!settings) return;
setWebSearchForm((prev) => ({
provider,
apiKey: "",
baseUrl: provider === settings.web_search.provider ? settings.web_search.base_url ?? "" : "",
maxResults: prev.maxResults ?? settings.web_search.max_results,
timeout: prev.timeout ?? settings.web_search.timeout,
useJinaReader: prev.useJinaReader ?? settings.web.fetch.use_jina_reader,
}));
setWebSearchKeyVisible(false);
setWebSearchKeyEditing(false);
}, [settings]);
return {
handleWebSearchProviderChange,
resetWebSearchDraft,
saveImageGenerationSettings,
saveNetworkSafetySettings,
saveTranscriptionSettings,
saveWebSearch,
};
}
@@ -0,0 +1,73 @@
import { useState } from "react";
import {
DEFAULT_IMAGE_GENERATION_FORM,
imageGenerationFormFromPayload,
} from "@/components/settings/capabilities/ImageGenerationSettings";
import {
DEFAULT_NETWORK_SAFETY_FORM,
networkSafetyFormFromPayload,
} from "@/components/settings/capabilities/SecuritySettings";
import {
DEFAULT_TRANSCRIPTION_FORM,
transcriptionFormFromPayload,
} from "@/components/settings/capabilities/TranscriptionSettings";
import {
DEFAULT_WEB_SEARCH_FORM,
webSearchFormFromPayload,
} from "@/components/settings/capabilities/WebSettings";
import type {
ImageGenerationSettingsUpdate,
NetworkSafetySettingsUpdate,
SettingsPayload,
TranscriptionSettingsUpdate,
WebSearchSettingsUpdate,
} from "@/lib/types";
export function useCapabilitySettingsState(initialSettings: SettingsPayload | null) {
const [webSearchSaving, setWebSearchSaving] = useState(false);
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
const [transcriptionSaving, setTranscriptionSaving] = useState(false);
const [networkSafetySaving, setNetworkSafetySaving] = useState(false);
const [webSearchForm, setWebSearchForm] = useState<WebSearchSettingsUpdate>(() =>
initialSettings ? webSearchFormFromPayload(initialSettings) : DEFAULT_WEB_SEARCH_FORM,
);
const [imageGenerationForm, setImageGenerationForm] = useState<ImageGenerationSettingsUpdate>(
() => initialSettings
? imageGenerationFormFromPayload(initialSettings)
: DEFAULT_IMAGE_GENERATION_FORM,
);
const [transcriptionForm, setTranscriptionForm] = useState<TranscriptionSettingsUpdate>(
() => initialSettings ? transcriptionFormFromPayload(initialSettings) : DEFAULT_TRANSCRIPTION_FORM,
);
const [networkSafetyForm, setNetworkSafetyForm] = useState<NetworkSafetySettingsUpdate>(() =>
initialSettings ? networkSafetyFormFromPayload(initialSettings) : DEFAULT_NETWORK_SAFETY_FORM,
);
const [webSearchKeyVisible, setWebSearchKeyVisible] = useState(false);
const [webSearchKeyEditing, setWebSearchKeyEditing] = useState(false);
return {
imageGenerationForm,
imageGenerationSaving,
networkSafetyForm,
networkSafetySaving,
setImageGenerationForm,
setImageGenerationSaving,
setNetworkSafetyForm,
setNetworkSafetySaving,
setTranscriptionForm,
setTranscriptionSaving,
setWebSearchForm,
setWebSearchKeyEditing,
setWebSearchKeyVisible,
setWebSearchSaving,
transcriptionForm,
transcriptionSaving,
webSearchForm,
webSearchKeyEditing,
webSearchKeyVisible,
webSearchSaving,
};
}
export type CapabilitySettingsState = ReturnType<typeof useCapabilitySettingsState>;
@@ -0,0 +1,32 @@
import type { SettingsPayload } from "@/lib/types";
export type SettingsSectionKey =
| "overview"
| "appearance"
| "models"
| "image"
| "voice"
| "browser"
| "channels"
| "apps"
| "automations"
| "skills"
| "runtime"
| "advanced";
export type PendingRestartSection = "runtime" | "browser" | "image";
export type PendingRestartSections = Record<PendingRestartSection, boolean>;
export type RestartAwarePayload = {
requires_restart?: boolean;
surface?: SettingsPayload["surface"];
runtime_surface?: SettingsPayload["runtime_surface"];
runtime_capabilities?: SettingsPayload["runtime_capabilities"];
};
export type ApplySettingsPayload = (
payload: SettingsPayload,
options?: { preserveAgentForm?: boolean },
) => void;
export type MaybeRestartHostEngine = (payload: RestartAwarePayload) => Promise<void>;
@@ -0,0 +1,923 @@
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import {
ChevronDown,
ChevronRight,
GripVertical,
ListOrdered,
Loader2,
Plus,
Trash2,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import {
ModelIdPicker,
ProviderPicker,
ProviderPickerIcon,
formatContextWindow,
formatModelContextWindow,
normalizeContextWindowTokens,
settingsProviderConfigured,
} from "@/components/settings/shared/ModelControls";
import {
SettingsGroup,
SettingsRow,
SettingsSectionTitle,
SettingsStatusMessage,
StatusPill,
} from "@/components/settings/shared/SettingsControls";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { cn } from "@/lib/utils";
import type { SettingsPayload } from "@/lib/types";
export interface AgentSettingsDraft {
model: string;
provider: string;
modelPreset: string;
presetLabel: string;
maxTokens: number;
contextWindowTokens: number;
temperature: number;
reasoningEffort: string;
timezone: string;
toolHintMaxLength: number;
}
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 200_000, 262_144, 500_000, 1_048_576] as const;
function modelPresetValue(payload: SettingsPayload): string {
return (
payload.model_call_order?.[0] ??
payload.model_presets.find((preset) => !preset.is_default)?.name ??
""
);
}
export const DEFAULT_AGENT_SETTINGS_DRAFT: AgentSettingsDraft = {
model: "",
provider: "",
modelPreset: "",
presetLabel: "",
maxTokens: 8192,
contextWindowTokens: 200_000,
temperature: 0.1,
reasoningEffort: "",
timezone: "UTC",
toolHintMaxLength: 40,
};
export function agentDraftFromPayload(
payload: SettingsPayload,
preferredPresetName?: string,
): AgentSettingsDraft {
const activePresetName = preferredPresetName ?? modelPresetValue(payload);
const activePreset =
payload.model_presets.find(
(preset) => !preset.is_default && preset.name === activePresetName,
) ?? null;
return {
model: activePreset?.model ?? payload.agent.model,
provider: activePreset?.provider ?? payload.agent.provider ?? payload.agent.resolved_provider ?? "",
modelPreset: activePresetName,
presetLabel: activePreset?.label ?? activePresetName,
maxTokens: activePreset?.max_tokens ?? payload.agent.max_tokens,
contextWindowTokens: normalizeContextWindowTokens(
activePreset?.context_window_tokens ?? payload.agent.context_window_tokens,
),
temperature: activePreset?.temperature ?? payload.agent.temperature,
reasoningEffort: activePreset?.reasoning_effort ?? "",
timezone: payload.agent.timezone,
toolHintMaxLength: payload.agent.tool_hint_max_length,
};
}
export function ModelPresetDeleteDialog({
preset,
deleting,
onOpenChange,
onConfirm,
}: {
preset: SettingsPayload["model_presets"][number] | null;
deleting: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(values ?? {}) });
return (
<Dialog open={preset !== null} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[440px] rounded-[24px]">
<DialogHeader className="text-left">
<DialogTitle>
{tx("settings.models.deletePresetTitle", "Delete model preset?")}
</DialogTitle>
<DialogDescription className="leading-5">
{tx(
"settings.models.deletePresetHelp",
"This removes the preset “{{name}}”. Provider credentials are not affected.",
{ name: preset?.label ?? "" },
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:space-x-0">
<Button
type="button"
variant="ghost"
className="rounded-full"
disabled={deleting}
onClick={() => onOpenChange(false)}
>
{tx("settings.actions.cancel", "Cancel")}
</Button>
<Button
type="button"
variant="destructive"
className="rounded-full"
disabled={deleting}
onClick={onConfirm}
>
{deleting ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : null}
{deleting
? tx("settings.actions.deleting", "Deleting...")
: tx("settings.actions.delete", "Delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function ModelsSettings({
token,
form,
setForm,
settings,
dirty,
creating,
creatingSaving,
callOrder,
saving,
orderSaving,
migrationSaving,
showBrandLogos,
providerSaving,
onChangeCallOrder,
onProviderOAuthLogin,
onSave,
onMigrate,
onBeginCreate,
onCancelCreate,
onSelectConfiguration,
onDeleteConfiguration,
}: {
token: string;
form: AgentSettingsDraft;
setForm: Dispatch<SetStateAction<AgentSettingsDraft>>;
settings: SettingsPayload;
dirty: boolean;
creating: boolean;
creatingSaving: boolean;
callOrder: string[];
saving: boolean;
orderSaving: boolean;
migrationSaving: boolean;
showBrandLogos: boolean;
providerSaving: string | null;
onChangeCallOrder: (order: string[]) => void;
onProviderOAuthLogin: (provider: string) => void;
onSave: () => void;
onMigrate: () => void;
onBeginCreate: () => void;
onCancelCreate: () => void;
onSelectConfiguration: () => void;
onDeleteConfiguration: (preset: SettingsPayload["model_presets"][number]) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(values ?? {}) });
const [editorOpen, setEditorOpen] = useState(false);
const [editorRowKey, setEditorRowKey] = useState<string | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [draggedCallOrderIndex, setDraggedCallOrderIndex] = useState<number | null>(null);
const [dragOverCallOrderIndex, setDragOverCallOrderIndex] = useState<number | null>(null);
const namedPresets = settings.model_presets.filter((preset) => !preset.is_default);
const namedPresetsByName = new Map(namedPresets.map((preset) => [preset.name, preset]));
const unorderedPresets = namedPresets.filter((preset) => !callOrder.includes(preset.name));
const callOrderOccurrences = new Map<string, number>();
const presetRows = [
...callOrder.map((name, orderIndex) => {
const occurrence = callOrderOccurrences.get(name) ?? 0;
callOrderOccurrences.set(name, occurrence + 1);
return {
key: `ordered:${name}:${occurrence}`,
name,
orderIndex,
preset: namedPresetsByName.get(name),
};
}),
...unorderedPresets.map((preset) => ({
key: `disabled:${preset.name}`,
name: preset.name,
orderIndex: -1,
preset,
})),
];
const selectedPreset = namedPresetsByName.get(form.modelPreset) ?? null;
const activeEditorRowKey =
editorRowKey ??
presetRows.find((row) => row.name === selectedPreset?.name)?.key ??
null;
useEffect(() => {
setAdvancedOpen(false);
}, [editorOpen, selectedPreset?.name]);
const configuredProviders = settings.providers.filter((provider) => provider.configured);
const selectedProvider = settings.providers.find((provider) => provider.name === form.provider);
const selectableProviders = uniqueProviders([
...configuredProviders,
...(selectedProvider ? [selectedProvider] : []),
]);
const showAutoProvider = selectedPreset?.provider === "auto" || form.provider === "auto";
const providerOptions = showAutoProvider
? [{ name: "auto", label: tx("settings.values.auto", "Auto") }, ...selectableProviders]
: selectableProviders;
const providerValue = providerOptions.some((provider) => provider.name === form.provider)
? form.provider
: "";
const selectedProviderNeedsSignIn =
selectedProvider?.auth_type === "oauth" && !selectedProvider.configured;
const selectedProviderSigningIn = providerSaving === selectedProvider?.name;
const selectedProviderConfigured = settingsProviderConfigured(
settings,
form.provider,
selectedPreset?.resolved_provider,
);
const modelFieldsMissing =
!form.model.trim() ||
!form.provider.trim() ||
!form.presetLabel.trim() ||
form.maxTokens <= 0 ||
form.temperature < 0 ||
form.temperature > 2;
const selectedPresetReferenced = Boolean(
selectedPreset && callOrder.includes(selectedPreset.name),
);
const callOrderBusy = orderSaving || saving;
const selectPreset = (
preset: SettingsPayload["model_presets"][number],
rowKey: string,
) => {
const toggleCurrentPreset =
!creating && selectedPreset?.name === preset.name && activeEditorRowKey === rowKey;
onSelectConfiguration();
if (toggleCurrentPreset) {
setEditorOpen((open) => !open);
return;
}
setForm((prev) => ({
...prev,
modelPreset: preset.name,
model: preset.model,
provider: preset.provider,
presetLabel: preset.label,
maxTokens: preset.max_tokens,
contextWindowTokens: normalizeContextWindowTokens(preset.context_window_tokens),
temperature: preset.temperature,
reasoningEffort: preset.reasoning_effort ?? "",
}));
setEditorRowKey(rowKey);
setEditorOpen(true);
};
const moveCallOrderItem = (index: number, offset: -1 | 1) => {
if (callOrderBusy) return;
const nextIndex = index + offset;
if (nextIndex < 0 || nextIndex >= callOrder.length) return;
const next = [...callOrder];
[next[index], next[nextIndex]] = [next[nextIndex], next[index]];
onChangeCallOrder(next);
};
const removeCallOrderItem = (index: number) => {
if (callOrderBusy || callOrder.length <= 1) return;
onChangeCallOrder(callOrder.filter((_, itemIndex) => itemIndex !== index));
};
const dropCallOrderItem = (targetIndex: number) => {
if (
callOrderBusy ||
draggedCallOrderIndex === null ||
draggedCallOrderIndex === targetIndex
) {
setDraggedCallOrderIndex(null);
setDragOverCallOrderIndex(null);
return;
}
const next = [...callOrder];
const moved = next.splice(draggedCallOrderIndex, 1)[0];
if (!moved) {
setDraggedCallOrderIndex(null);
setDragOverCallOrderIndex(null);
return;
}
next.splice(targetIndex, 0, moved);
setDraggedCallOrderIndex(null);
setDragOverCallOrderIndex(null);
onChangeCallOrder(next);
};
const renderPresetEditor = () => (
<div
id="model-preset-editor"
data-testid="model-preset-editor"
className="mx-3 mb-3 divide-y divide-border/45 overflow-hidden rounded-[18px] border border-border/45 bg-background/80 shadow-sm motion-reduce:animate-none animate-in fade-in-0 slide-in-from-top-1 duration-200 sm:mx-5 lg:mx-auto lg:w-[calc(100%-2.5rem)] lg:max-w-6xl"
>
{creating ? (
<div className="flex min-h-[52px] items-center px-4 py-3 sm:px-5">
<span className="text-[13px] font-semibold text-foreground/85">
{tx("settings.models.newPreset", "New model preset")}
</span>
</div>
) : null}
<SettingsRow title={tx("settings.models.presetName", "Preset name")}>
<Input
autoFocus={creating}
value={form.presetLabel}
placeholder={tx("settings.models.presetNamePlaceholder", "Fast writing")}
onChange={(event) =>
setForm((prev) => ({ ...prev, presetLabel: event.target.value }))
}
className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
/>
</SettingsRow>
<SettingsRow title={t("settings.rows.provider")}>
<ProviderPicker
providers={providerOptions}
value={providerValue}
emptyLabel={t("settings.byok.noConfiguredProviders")}
showProviderLogos={showBrandLogos}
onChange={(provider) =>
setForm((prev) => ({
...prev,
provider,
model: provider === prev.provider ? prev.model : "",
}))
}
/>
</SettingsRow>
{selectedProviderNeedsSignIn ? (
<SettingsRow
title={tx("settings.oauth.signInRequired", "Sign in required")}
description={tx(
"settings.oauth.signInBeforeSaving",
"Sign in before saving this provider in the preset.",
)}
>
<Button
size="sm"
variant="outline"
onClick={() => selectedProvider && onProviderOAuthLogin(selectedProvider.name)}
disabled={!selectedProvider?.oauth_login_supported || selectedProviderSigningIn}
className="rounded-full"
>
{selectedProviderSigningIn ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : null}
{selectedProviderSigningIn
? tx("settings.oauth.signingIn", "Signing in...")
: tx("settings.oauth.signIn", "Sign in")}
</Button>
</SettingsRow>
) : null}
<SettingsRow title={t("settings.rows.model")}>
<ModelIdPicker
token={token}
settings={settings}
provider={form.provider}
value={form.model}
showProviderLogos={showBrandLogos}
onChange={(model) => setForm((prev) => ({ ...prev, model }))}
/>
</SettingsRow>
<button
type="button"
aria-expanded={advancedOpen}
onClick={() => setAdvancedOpen((value) => !value)}
className="flex min-h-[62px] w-full items-center justify-between gap-4 px-4 py-3.5 text-left transition-colors hover:bg-muted/30 sm:px-5"
>
<span>
<span className="block text-[14px] font-medium text-foreground">
{tx("settings.models.advancedOptions", "Advanced options")}
</span>
<span className="mt-0.5 block text-[12px] text-muted-foreground">
{tx(
"settings.models.advancedSummary",
"Context {{context}} · Max {{max}} tokens",
{
context: formatModelContextWindow(form.contextWindowTokens),
max: formatContextWindow(form.maxTokens),
},
)}
</span>
</span>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
advancedOpen && "rotate-180",
)}
aria-hidden
/>
</button>
{advancedOpen ? (
<div className="bg-muted/12 px-4 py-4 sm:px-5">
<ModelAdvancedFields
maxTokens={form.maxTokens}
contextWindowTokens={form.contextWindowTokens}
temperature={form.temperature}
reasoningEffort={form.reasoningEffort}
onChange={(value) => setForm((prev) => ({ ...prev, ...value }))}
/>
</div>
) : null}
<div className="flex min-h-[58px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
{creating ? (
<Button
size="sm"
variant="ghost"
className="self-start rounded-full text-muted-foreground"
disabled={creatingSaving}
onClick={() => {
setEditorOpen(false);
onCancelCreate();
}}
>
{tx("settings.actions.cancel", "Cancel")}
</Button>
) : selectedPreset ? (
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<Button
size="sm"
variant="ghost"
className="rounded-full text-muted-foreground hover:text-destructive"
disabled={selectedPresetReferenced || saving || orderSaving}
aria-describedby={
selectedPresetReferenced ? "model-preset-delete-hint" : undefined
}
onClick={() => onDeleteConfiguration(selectedPreset)}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{tx("settings.actions.delete", "Delete")}
</Button>
{selectedPresetReferenced ? (
<span
id="model-preset-delete-hint"
className="text-[11px] leading-4 text-muted-foreground"
>
{tx(
"settings.models.removeBeforeDelete",
"Remove this preset from the call order before deleting it.",
)}
</span>
) : null}
</div>
) : null}
<div className="flex items-center justify-end gap-3">
<Button
size="sm"
variant="outline"
className="rounded-full"
disabled={
(!creating && !dirty) ||
!selectedProviderConfigured ||
modelFieldsMissing ||
saving ||
orderSaving
}
onClick={onSave}
>
{saving || creatingSaving
? tx("settings.actions.saving", "Saving...")
: tx("settings.actions.savePreset", "Save preset")}
</Button>
</div>
</div>
</div>
);
return (
<div className="space-y-7">
<section>
<SettingsSectionTitle>
{tx("settings.models.presets", "Model presets")}
</SettingsSectionTitle>
<SettingsGroup>
{!settings.model_call_order_editable ? (
<div className="flex flex-col gap-4 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="flex min-w-0 items-start gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-[12px] bg-muted text-muted-foreground">
<ListOrdered className="h-4 w-4" aria-hidden />
</span>
<div className="min-w-0">
<p className="text-[14px] font-medium text-foreground">
{tx("settings.models.convertTitle", "Convert the current model setup")}
</p>
<p className="mt-0.5 max-w-[34rem] text-[12px] leading-5 text-muted-foreground">
{tx(
"settings.models.convertHelp",
"Turn the existing primary and fallback models into presets so their order can be managed here.",
)}
</p>
</div>
</div>
<Button
size="sm"
variant="outline"
className="shrink-0 rounded-full"
disabled={migrationSaving}
onClick={onMigrate}
>
{migrationSaving ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : null}
{migrationSaving
? tx("settings.models.converting", "Converting...")
: tx("settings.models.convertAction", "Convert to presets")}
</Button>
</div>
) : (
<>
<div role="list" className="divide-y divide-border/45">
{presetRows.map(({ key, name, orderIndex, preset }) => {
const ordered = orderIndex >= 0;
const provider = preset
? modelPresetProviderKey(preset, settings)
: settings.agent.resolved_provider ?? settings.agent.provider;
const presetConfigured = preset
? settingsProviderConfigured(
settings,
preset.provider,
preset.resolved_provider,
)
: true;
const isDropTarget =
ordered &&
dragOverCallOrderIndex === orderIndex &&
draggedCallOrderIndex !== orderIndex;
const dropAfterTarget =
isDropTarget &&
draggedCallOrderIndex !== null &&
draggedCallOrderIndex < orderIndex;
const isSelected =
editorOpen &&
!creating &&
activeEditorRowKey === key &&
selectedPreset?.name === name;
const presetRow = (
<div
tabIndex={ordered ? 0 : -1}
draggable={ordered && !callOrderBusy}
aria-label={
ordered
? `${preset?.label ?? name}. ${tx(
"settings.models.dragToReorder",
"Drag to reorder",
)}`
: preset?.label ?? name
}
data-testid={`model-call-order-row-${name}`}
onDragStart={(event) => {
if (!ordered || callOrderBusy) {
event.preventDefault();
return;
}
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", name);
setDraggedCallOrderIndex(orderIndex);
setDragOverCallOrderIndex(orderIndex);
}}
onDragEnd={() => {
setDraggedCallOrderIndex(null);
setDragOverCallOrderIndex(null);
}}
onDragEnter={(event) => {
if (ordered && draggedCallOrderIndex !== null) {
event.preventDefault();
setDragOverCallOrderIndex(orderIndex);
}
}}
onDragOver={(event) => {
if (!ordered || draggedCallOrderIndex === null) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}}
onDrop={(event) => {
if (!ordered) return;
event.preventDefault();
dropCallOrderItem(orderIndex);
}}
onKeyDown={(event) => {
if (event.currentTarget !== event.target) return;
if (ordered && event.key === "ArrowUp") {
event.preventDefault();
moveCallOrderItem(orderIndex, -1);
} else if (ordered && event.key === "ArrowDown") {
event.preventDefault();
moveCallOrderItem(orderIndex, 1);
} else if ((event.key === "Enter" || event.key === " ") && preset) {
event.preventDefault();
selectPreset(preset, key);
}
}}
className={cn(
"group relative flex min-h-[76px] select-none items-center gap-3 px-4 py-3 outline-none transition-[background-color,opacity] duration-150 sm:px-5",
ordered &&
(callOrderBusy
? "cursor-wait"
: "cursor-grab active:cursor-grabbing"),
"hover:bg-muted/25",
isDropTarget &&
!dropAfterTarget &&
"before:absolute before:inset-x-4 before:top-0 before:z-10 before:h-0.5 before:rounded-full before:bg-foreground sm:before:inset-x-5",
isDropTarget &&
dropAfterTarget &&
"after:absolute after:inset-x-4 after:bottom-0 after:z-10 after:h-0.5 after:rounded-full after:bg-foreground sm:after:inset-x-5",
ordered && draggedCallOrderIndex === orderIndex && "opacity-35",
isSelected && "bg-muted/45 hover:bg-muted/45",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
)}
>
{ordered ? (
<GripVertical
className="pointer-events-none h-4 w-4 shrink-0 text-muted-foreground/40 transition-colors group-hover:text-muted-foreground"
aria-hidden
/>
) : (
<span className="h-4 w-4 shrink-0" aria-hidden />
)}
<button
type="button"
aria-pressed={selectedPreset?.name === name}
aria-expanded={isSelected}
aria-controls={isSelected ? "model-preset-editor" : undefined}
disabled={!preset}
onClick={() => preset && selectPreset(preset, key)}
className="flex min-w-0 flex-1 items-center gap-3 rounded-[12px] text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{ordered ? (
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-muted font-mono text-[11px] font-semibold tabular-nums text-muted-foreground">
{orderIndex + 1}
</span>
) : (
<span className="h-7 w-7 shrink-0" aria-hidden />
)}
<ProviderPickerIcon
provider={provider}
showBrandLogos={showBrandLogos}
unconfigured={!presetConfigured}
/>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 flex-wrap items-center gap-2">
<span className="truncate text-[14px] font-medium text-foreground">
{preset?.label ?? name}
</span>
{orderIndex === 0 ? (
<StatusPill tone="success">
{tx("settings.models.primary", "Primary")}
</StatusPill>
) : !ordered ? (
<StatusPill tone="neutral">
{tx("settings.models.disabled", "Disabled")}
</StatusPill>
) : null}
{!presetConfigured ? (
<span className="text-[11px] font-medium text-amber-700 dark:text-amber-300">
{tx(
"settings.models.providerSetupRequired",
"Provider setup required",
)}
</span>
) : null}
</span>
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
{preset?.model ?? name}
</span>
</span>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
isSelected && "rotate-90",
)}
aria-hidden
/>
</button>
<button
type="button"
role="switch"
aria-checked={ordered}
aria-label={
ordered
? tx("settings.models.removeFromOrder", "Disable preset")
: tx("settings.models.addToOrder", "Enable preset")
}
disabled={callOrderBusy || (ordered && callOrder.length <= 1)}
onClick={() => {
if (ordered) {
removeCallOrderItem(orderIndex);
} else if (preset) {
onChangeCallOrder([...callOrder, preset.name]);
}
}}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40",
ordered ? "bg-foreground" : "bg-muted-foreground/25",
)}
>
<span
className={cn(
"h-4 w-4 rounded-full bg-background shadow-sm transition-transform",
ordered ? "translate-x-[18px]" : "translate-x-0.5",
)}
aria-hidden
/>
</button>
</div>
);
return (
<div key={key} role="listitem">
{presetRow}
{isSelected ? renderPresetEditor() : null}
</div>
);
})}
</div>
<div className="flex min-h-[58px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
{!creating ? (
<Button
size="sm"
variant="ghost"
className="rounded-full"
disabled={callOrderBusy}
onClick={() => {
setEditorRowKey(null);
setEditorOpen(true);
onBeginCreate();
}}
>
<Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{tx("settings.models.newPreset", "New model preset")}
</Button>
) : (
<span />
)}
{orderSaving ? (
<SettingsStatusMessage>
<span className="inline-flex items-center gap-1.5">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
{tx("settings.actions.saving", "Saving...")}
</span>
</SettingsStatusMessage>
) : null}
</div>
{creating && editorOpen ? renderPresetEditor() : null}
</>
)}
</SettingsGroup>
</section>
</div>
);
}
function ModelAdvancedFields({
maxTokens,
contextWindowTokens,
temperature,
reasoningEffort,
onChange,
}: {
maxTokens: number;
contextWindowTokens: number;
temperature: number;
reasoningEffort: string;
onChange: (
value: Partial<
Pick<
AgentSettingsDraft,
"maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort"
>
>,
) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const contextWindowOptions = Array.from(
new Set([...CONTEXT_WINDOW_TOKEN_OPTIONS, contextWindowTokens]),
).sort((left, right) => left - right);
return (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<label className="block">
<span className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{tx("settings.models.maxTokens", "Max output tokens")}
</span>
<Input
type="number"
min={1}
step={1}
value={maxTokens}
onChange={(event) => {
const value = Number(event.target.value);
if (Number.isFinite(value)) onChange({ maxTokens: value });
}}
className="h-9 rounded-[12px] text-[13px]"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{tx("settings.models.temperature", "Temperature")}
</span>
<Input
type="number"
min={0}
max={2}
step={0.1}
value={temperature}
onChange={(event) => {
const value = Number(event.target.value);
if (Number.isFinite(value)) onChange({ temperature: value });
}}
className="h-9 rounded-[12px] text-[13px]"
/>
</label>
</div>
<div>
<span className="mb-2 block text-[12px] font-medium text-muted-foreground">
{tx("settings.rows.contextWindow", "Context window")}
</span>
<SegmentedControl
value={String(contextWindowTokens)}
options={contextWindowOptions.map((tokens) => ({
value: String(tokens),
label: formatModelContextWindow(tokens),
}))}
onChange={(value) =>
onChange({ contextWindowTokens: normalizeContextWindowTokens(Number(value)) })
}
/>
</div>
<label className="block">
<span className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{tx("settings.models.reasoningEffort", "Reasoning effort")}
</span>
<Input
value={reasoningEffort}
onChange={(event) => onChange({ reasoningEffort: event.target.value })}
placeholder={tx("settings.values.default", "Default")}
autoCapitalize="none"
spellCheck={false}
className="h-9 rounded-[12px] text-[13px]"
/>
</label>
</div>
);
}
function uniqueProviders(
providers: SettingsPayload["providers"],
): SettingsPayload["providers"] {
const seen = new Set<string>();
return providers.filter((provider) => {
if (seen.has(provider.name)) return false;
seen.add(provider.name);
return true;
});
}
function modelPresetProviderKey(
preset: SettingsPayload["model_presets"][number],
settings: SettingsPayload,
options: { draftProvider?: string } = {},
): string {
const provider = options.draftProvider ?? preset.provider;
if (provider === "auto") {
return (
preset.resolved_provider ||
settings.agent.resolved_provider ||
settings.agent.provider ||
preset.provider
);
}
return provider;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,562 @@
import { useCallback, type Dispatch, type SetStateAction } from "react";
import type { TFunction } from "i18next";
import type {
ApplySettingsPayload,
MaybeRestartHostEngine,
PendingRestartSections,
} from "@/components/settings/contracts";
import { agentDraftFromPayload } from "@/components/settings/models/ModelsSettings";
import {
CUSTOM_PROVIDER_CREATION_KEY,
providerFormFromRow,
type CustomProviderDraft,
} from "@/components/settings/models/ProviderSettings";
import type { ModelSettingsState } from "@/components/settings/models/useModelSettingsState";
import { normalizeContextWindowTokens } from "@/components/settings/shared/ModelControls";
import {
completeProviderOAuth,
createModelConfiguration,
createProviderSettings,
deleteModelConfiguration,
loginProviderOAuth,
logoutProviderOAuth,
migrateModelConfigurations,
updateModelCallOrder,
updateModelConfiguration,
updateProviderSettings,
} from "@/lib/api";
import type { NanobotClient } from "@/lib/nanobot-client";
import type {
ProviderOAuthAuthorizationRequired,
ProviderOAuthCompletionResult,
ProviderOAuthLoginResult,
ProviderOAuthPending,
ProviderSettingsUpdate,
SettingsPayload,
} from "@/lib/types";
function isProviderOAuthAuthorizationRequired(
payload: ProviderOAuthLoginResult,
): payload is ProviderOAuthAuthorizationRequired {
return (payload as ProviderOAuthAuthorizationRequired).status === "authorization_required";
}
function isProviderOAuthPending(
payload: ProviderOAuthCompletionResult,
): payload is ProviderOAuthPending {
return (payload as ProviderOAuthPending).status === "pending";
}
interface ModelSettingsActionsOptions {
state: ModelSettingsState;
settings: SettingsPayload | null;
client: NanobotClient;
t: TFunction;
applyPayload: ApplySettingsPayload;
maybeRestartHostEngine: MaybeRestartHostEngine;
setPendingRestartSections: Dispatch<SetStateAction<PendingRestartSections>>;
setError: Dispatch<SetStateAction<string | null>>;
onModelNameChange: (modelName: string | null) => void;
remoteBrowserAccess: boolean;
closeProviderOAuthFlow: () => void;
installCapabilities: (names: string[]) => Promise<boolean>;
modelDirty: boolean;
configuredModelProviderOptions: Array<{ name: string; label: string }>;
}
export function useModelSettingsActions({
state,
settings,
client,
t,
applyPayload,
maybeRestartHostEngine,
setPendingRestartSections,
setError,
onModelNameChange,
remoteBrowserAccess,
closeProviderOAuthFlow,
installCapabilities,
modelDirty,
configuredModelProviderOptions,
}: ModelSettingsActionsOptions) {
const {
expandedProvider,
form,
modelCallOrder,
modelCallOrderSaving,
modelConfigurationSaving,
modelMigrationSaving,
modelPresetBeforeCreateRef,
modelPresetCreating,
modelPresetPendingDelete,
providerForms,
providerOAuthCompleting,
providerOAuthFlowRef,
providerOAuthResponse,
providerSaving,
saving,
setEditingProviderKeys,
setExpandedProvider,
setForm,
setModelCallOrder,
setModelCallOrderSaving,
setModelConfigurationSaving,
setModelMigrationSaving,
setModelPresetCreating,
setModelPresetPendingDelete,
setProviderForms,
setProviderOAuthCompleting,
setProviderOAuthDialogError,
setProviderOAuthFlow,
setProviderOAuthResponse,
setProviderSaving,
setSaving,
setVisibleProviderKeys,
visibleProviderKeys,
} = state;
const saveModelSettings = async () => {
if (
!settings ||
saving ||
modelCallOrderSaving ||
modelConfigurationSaving
) {
return;
}
if (modelPresetCreating) {
const label = form.presetLabel.trim();
const provider = form.provider.trim();
const model = form.model.trim();
if (
!label ||
!provider ||
!model ||
form.maxTokens <= 0 ||
form.contextWindowTokens <= 0 ||
form.temperature < 0 ||
form.temperature > 2
) {
return;
}
setModelConfigurationSaving(true);
try {
const payload = await createModelConfiguration(client, {
label,
provider,
model,
maxTokens: form.maxTokens,
contextWindowTokens: form.contextWindowTokens,
temperature: form.temperature,
reasoningEffort: form.reasoningEffort || null,
});
const createdPreset = payload.created_model_preset;
const nextOrder = createdPreset ? [...modelCallOrder, createdPreset] : null;
applyPayload(payload);
if (createdPreset) {
setForm(agentDraftFromPayload(payload, createdPreset));
}
let finalPayload = payload;
if (nextOrder) {
const orderedPayload = await updateModelCallOrder(client, nextOrder);
applyPayload(orderedPayload);
finalPayload = orderedPayload;
}
if (createdPreset) {
setForm(agentDraftFromPayload(finalPayload, createdPreset));
}
modelPresetBeforeCreateRef.current = null;
onModelNameChange(finalPayload.agent.model || null);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setModelConfigurationSaving(false);
}
return;
}
if (!modelDirty) return;
const selectedPreset = settings.model_presets.find(
(preset) => !preset.is_default && preset.name === form.modelPreset,
);
if (!selectedPreset) return;
const reasoningEffort = form.reasoningEffort || null;
setSaving(true);
try {
const payload = await updateModelConfiguration(client, {
name: selectedPreset.name,
label:
form.presetLabel.trim() !== selectedPreset.label
? form.presetLabel.trim()
: undefined,
model: form.model !== selectedPreset.model ? form.model : undefined,
provider: form.provider !== selectedPreset.provider ? form.provider : undefined,
maxTokens:
form.maxTokens !== selectedPreset.max_tokens ? form.maxTokens : undefined,
contextWindowTokens:
form.contextWindowTokens !==
normalizeContextWindowTokens(selectedPreset.context_window_tokens)
? form.contextWindowTokens
: undefined,
temperature:
form.temperature !== selectedPreset.temperature ? form.temperature : undefined,
reasoningEffort:
reasoningEffort !== selectedPreset.reasoning_effort ? reasoningEffort : undefined,
});
applyPayload(payload);
setForm(agentDraftFromPayload(payload, selectedPreset.name));
onModelNameChange(payload.agent.model || null);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setSaving(false);
}
};
const beginModelPresetCreation = () => {
if (!settings || saving || modelCallOrderSaving || modelConfigurationSaving) return;
const primaryPreset = settings.model_presets.find(
(preset) => !preset.is_default && preset.name === settings.model_call_order?.[0],
);
const currentProvider = primaryPreset?.provider === "auto"
? primaryPreset.resolved_provider ?? settings.agent.resolved_provider
: primaryPreset?.provider ?? settings.agent.provider;
const provider =
configuredModelProviderOptions.find((option) => option.name === currentProvider)?.name ??
configuredModelProviderOptions[0]?.name ??
"";
modelPresetBeforeCreateRef.current = form.modelPreset;
setForm((prev) => ({
...prev,
modelPreset: "",
presetLabel: "",
provider,
model: "",
maxTokens: primaryPreset?.max_tokens ?? settings.agent.max_tokens,
contextWindowTokens: normalizeContextWindowTokens(
primaryPreset?.context_window_tokens ?? settings.agent.context_window_tokens,
),
temperature: primaryPreset?.temperature ?? settings.agent.temperature,
reasoningEffort: primaryPreset?.reasoning_effort ?? settings.agent.reasoning_effort ?? "",
}));
setModelPresetCreating(true);
};
const cancelModelPresetCreation = () => {
if (!settings || modelConfigurationSaving) return;
const previousPreset = modelPresetBeforeCreateRef.current;
setModelPresetCreating(false);
setForm(agentDraftFromPayload(settings, previousPreset ?? undefined));
modelPresetBeforeCreateRef.current = null;
};
const changeModelCallOrder = async (nextOrder: string[]) => {
const unchanged =
nextOrder.length === modelCallOrder.length &&
nextOrder.every((name, index) => name === modelCallOrder[index]);
if (
!settings ||
saving ||
modelCallOrderSaving ||
modelConfigurationSaving ||
nextOrder.length === 0 ||
unchanged
) {
return;
}
const previousOrder = [...modelCallOrder];
setModelCallOrder(nextOrder);
setModelCallOrderSaving(true);
try {
const payload = await updateModelCallOrder(client, nextOrder);
applyPayload(payload, { preserveAgentForm: true });
onModelNameChange(payload.agent.model || null);
setError(null);
} catch (err) {
setModelCallOrder(previousOrder);
setError((err as Error).message);
} finally {
setModelCallOrderSaving(false);
}
};
const handleMigrateModelConfigurations = async () => {
if (modelMigrationSaving) return;
setModelMigrationSaving(true);
try {
const payload = await migrateModelConfigurations(client);
applyPayload(payload);
onModelNameChange(payload.agent.model || null);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setModelMigrationSaving(false);
}
};
const handleDeleteModelConfiguration = async () => {
if (
!modelPresetPendingDelete ||
saving ||
modelCallOrderSaving ||
modelConfigurationSaving
) {
return;
}
setSaving(true);
try {
const payload = await deleteModelConfiguration(client, modelPresetPendingDelete.name);
applyPayload(payload);
setModelPresetPendingDelete(null);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setSaving(false);
}
};
const saveProvider = async (providerName: string) => {
if (providerSaving) return;
const provider = settings?.providers.find((item) => item.name === providerName);
if (!provider) return;
const isOauthProvider = provider.auth_type === "oauth";
const providerForm = providerForms[providerName] ?? providerFormFromRow(provider);
const apiKey = providerForm.apiKey.trim();
const apiKeyRequired = provider.api_key_required ?? true;
if (!isOauthProvider && !provider.configured && apiKeyRequired && !apiKey) {
setError(t("settings.byok.apiKeyRequired"));
return;
}
setProviderSaving(providerName);
try {
const supportName = providerName === "bedrock"
? "bedrock"
: providerName === "azure_openai"
? "azure"
: null;
if (supportName && !(await installCapabilities([supportName]))) return;
const update: ProviderSettingsUpdate = { provider: providerName };
if (!isOauthProvider) {
update.apiKey = apiKey || undefined;
update.apiBase = providerForm.apiBase.trim();
if (provider.is_custom) update.displayName = providerForm.displayName.trim();
}
for (const field of provider.advanced_fields ?? []) {
if (field === "api_type") update.apiType = providerForm.apiType;
if (field === "proxy") update.proxy = providerForm.proxy.trim();
if (field === "extra_headers") {
update.extraHeaders = providerForm.extraHeaders.trim();
}
if (field === "extra_body") update.extraBody = providerForm.extraBody.trim();
if (field === "extra_query") update.extraQuery = providerForm.extraQuery.trim();
if (field === "thinking_style") {
update.thinkingStyle = providerForm.thinkingStyle.trim();
}
if (field === "region") update.region = providerForm.region.trim();
if (field === "profile") update.profile = providerForm.profile.trim();
}
const payload = await updateProviderSettings(client, update);
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, image: true }));
}
await maybeRestartHostEngine(payload);
setProviderForms((prev) => ({
...prev,
[providerName]: {
...providerForm,
displayName: providerForm.displayName.trim(),
apiKey: "",
apiBase: providerForm.apiBase.trim(),
proxy: providerForm.proxy.trim(),
thinkingStyle: providerForm.thinkingStyle.trim(),
region: providerForm.region.trim(),
profile: providerForm.profile.trim(),
},
}));
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false }));
setEditingProviderKeys((prev) => ({ ...prev, [providerName]: false }));
if (!isOauthProvider) setExpandedProvider(null);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setProviderSaving(null);
}
};
const createCustomProvider = async (draft: CustomProviderDraft): Promise<boolean> => {
if (providerSaving) return false;
setProviderSaving(CUSTOM_PROVIDER_CREATION_KEY);
try {
const payload = await createProviderSettings(client, {
name: draft.name.trim(),
apiKey: draft.apiKey.trim() || undefined,
apiBase: draft.apiBase.trim(),
proxy: draft.proxy.trim(),
extraHeaders: draft.extraHeaders.trim(),
extraBody: draft.extraBody.trim(),
extraQuery: draft.extraQuery.trim(),
thinkingStyle: draft.thinkingStyle.trim(),
});
applyPayload(payload);
setExpandedProvider(null);
setError(null);
return true;
} catch (err) {
setError((err as Error).message);
return false;
} finally {
setProviderSaving(null);
}
};
const runProviderOAuth = async (providerName: string, action: "login" | "logout") => {
if (providerSaving) return;
let popup: Window | null = null;
if (
action === "login"
&& providerName === "xai_grok"
&& !remoteBrowserAccess
) {
try {
popup = window.open("about:blank", "_blank");
if (popup) popup.opener = null;
} catch {
popup = null;
}
}
setProviderSaving(providerName);
try {
const payload =
action === "login"
? await loginProviderOAuth(
client,
providerName,
providerName === "openai_codex" && remoteBrowserAccess,
)
: await logoutProviderOAuth(client, providerName);
if (isProviderOAuthAuthorizationRequired(payload)) {
try {
if (popup && !popup.closed) popup.location.href = payload.authorization_url;
} catch {
// The dialog keeps the authorization link available when the popup was closed.
}
providerOAuthFlowRef.current = payload;
setProviderOAuthFlow(payload);
setProviderOAuthResponse("");
setProviderOAuthDialogError(null);
setExpandedProvider(providerName);
setError(null);
return;
}
popup?.close();
closeProviderOAuthFlow();
applyPayload(payload);
setExpandedProvider(providerName);
setError(null);
} catch (err) {
popup?.close();
setError((err as Error).message);
} finally {
setProviderSaving(null);
}
};
const completeProviderOAuthResponse = async () => {
const flow = providerOAuthFlowRef.current;
const authorizationResponse = providerOAuthResponse.trim();
if (!flow || !authorizationResponse || providerOAuthCompleting) return;
setProviderOAuthCompleting(true);
setProviderOAuthDialogError(null);
try {
const payload = await completeProviderOAuth(
client,
flow.provider,
flow.flow_id,
authorizationResponse,
);
if (providerOAuthFlowRef.current?.flow_id !== flow.flow_id) return;
if (isProviderOAuthPending(payload)) return;
applyPayload(payload);
setExpandedProvider(flow.provider);
setError(null);
closeProviderOAuthFlow();
} catch (err) {
if (providerOAuthFlowRef.current?.flow_id === flow.flow_id) {
setProviderOAuthDialogError((err as Error).message);
}
} finally {
setProviderOAuthCompleting(false);
}
};
const resetProviderDraft = useCallback((providerName: string) => {
const provider = settings?.providers.find((item) => item.name === providerName);
if (!provider) return;
setProviderForms((prev) => ({
...prev,
[providerName]: providerFormFromRow(provider),
}));
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false }));
setEditingProviderKeys((prev) => ({ ...prev, [providerName]: false }));
}, [settings]);
const handleToggleProvider = useCallback((providerName: string) => {
if (expandedProvider) resetProviderDraft(expandedProvider);
setExpandedProvider(expandedProvider === providerName ? null : providerName);
}, [expandedProvider, resetProviderDraft]);
const toggleProviderKeyVisibility = (providerName: string) => {
const isVisible = visibleProviderKeys[providerName];
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: !isVisible }));
};
const toggleProviderKeyEditing = (providerName: string) => {
setEditingProviderKeys((prev) => {
const nextEditing = !prev[providerName];
if (!nextEditing) {
setProviderForms((forms) => ({
...forms,
[providerName]: {
...(forms[providerName] ?? providerFormFromRow(
settings?.providers.find((provider) => provider.name === providerName) ?? {
name: providerName,
label: providerName,
configured: false,
},
)),
apiKey: "",
},
}));
setVisibleProviderKeys((visible) => ({ ...visible, [providerName]: false }));
}
return { ...prev, [providerName]: nextEditing };
});
};
return {
beginModelPresetCreation,
cancelModelPresetCreation,
changeModelCallOrder,
completeProviderOAuthResponse,
createCustomProvider,
handleDeleteModelConfiguration,
handleMigrateModelConfigurations,
handleToggleProvider,
resetProviderDraft,
runProviderOAuth,
saveModelSettings,
saveProvider,
toggleProviderKeyEditing,
toggleProviderKeyVisibility,
};
}
@@ -0,0 +1,97 @@
import { useEffect, type Dispatch, type SetStateAction } from "react";
import type { ApplySettingsPayload } from "@/components/settings/contracts";
import { providerFormFromRow } from "@/components/settings/models/ProviderSettings";
import type { ModelSettingsState } from "@/components/settings/models/useModelSettingsState";
import { completeProviderOAuth } from "@/lib/api";
import type { NanobotClient } from "@/lib/nanobot-client";
import type {
ProviderOAuthCompletionResult,
ProviderOAuthPending,
SettingsPayload,
} from "@/lib/types";
function isProviderOAuthPending(
payload: ProviderOAuthCompletionResult,
): payload is ProviderOAuthPending {
return (payload as ProviderOAuthPending).status === "pending";
}
interface ProviderOAuthPollingOptions {
state: ModelSettingsState;
client: NanobotClient;
applyPayload: ApplySettingsPayload;
setError: Dispatch<SetStateAction<string | null>>;
closeProviderOAuthFlow: () => void;
}
export function useProviderOAuthPolling({
state,
client,
applyPayload,
setError,
closeProviderOAuthFlow,
}: ProviderOAuthPollingOptions) {
const {
providerOAuthFlow,
providerOAuthFlowRef,
setExpandedProvider,
} = state;
useEffect(() => {
if (!providerOAuthFlow) return;
let cancelled = false;
let timer: number | null = null;
const poll = async () => {
try {
const payload = await completeProviderOAuth(
client,
providerOAuthFlow.provider,
providerOAuthFlow.flow_id,
);
if (
cancelled
|| providerOAuthFlowRef.current?.flow_id !== providerOAuthFlow.flow_id
) return;
if (isProviderOAuthPending(payload)) {
timer = window.setTimeout(() => void poll(), 1000);
return;
}
applyPayload(payload);
setExpandedProvider(providerOAuthFlow.provider);
setError(null);
closeProviderOAuthFlow();
} catch (err) {
if (
cancelled
|| providerOAuthFlowRef.current?.flow_id !== providerOAuthFlow.flow_id
) return;
setError((err as Error).message);
closeProviderOAuthFlow();
}
};
timer = window.setTimeout(() => void poll(), 1000);
return () => {
cancelled = true;
if (timer !== null) window.clearTimeout(timer);
};
}, [applyPayload, client, closeProviderOAuthFlow, providerOAuthFlow]);
}
export function useProviderFormsSync(
state: ModelSettingsState,
settings: SettingsPayload | null,
) {
const { setProviderForms } = state;
useEffect(() => {
if (!settings) return;
setProviderForms((prev) => {
const next = { ...prev };
for (const provider of settings.providers) {
next[provider.name] = next[provider.name] ?? providerFormFromRow(provider);
}
return next;
});
}, [settings]);
}
@@ -0,0 +1,78 @@
import { useRef, useState } from "react";
import {
DEFAULT_AGENT_SETTINGS_DRAFT,
agentDraftFromPayload,
type AgentSettingsDraft,
} from "@/components/settings/models/ModelsSettings";
import type { ProviderForm } from "@/components/settings/models/ProviderSettings";
import type { ProviderOAuthAuthorizationRequired, SettingsPayload } from "@/lib/types";
export function useModelSettingsState(initialSettings: SettingsPayload | null) {
const [saving, setSaving] = useState(false);
const [modelPresetCreating, setModelPresetCreating] = useState(false);
const [modelConfigurationSaving, setModelConfigurationSaving] = useState(false);
const [modelCallOrderSaving, setModelCallOrderSaving] = useState(false);
const [modelMigrationSaving, setModelMigrationSaving] = useState(false);
const [modelPresetPendingDelete, setModelPresetPendingDelete] =
useState<SettingsPayload["model_presets"][number] | null>(null);
const modelPresetBeforeCreateRef = useRef<string | null>(null);
const [providerSaving, setProviderSaving] = useState<string | null>(null);
const [providerOAuthFlow, setProviderOAuthFlow] =
useState<ProviderOAuthAuthorizationRequired | null>(null);
const providerOAuthFlowRef = useRef<ProviderOAuthAuthorizationRequired | null>(null);
const [providerOAuthResponse, setProviderOAuthResponse] = useState("");
const [providerOAuthCompleting, setProviderOAuthCompleting] = useState(false);
const [providerOAuthDialogError, setProviderOAuthDialogError] = useState<string | null>(null);
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
const [providerForms, setProviderForms] = useState<Record<string, ProviderForm>>({});
const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({});
const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({});
const [form, setForm] = useState<AgentSettingsDraft>(() =>
initialSettings ? agentDraftFromPayload(initialSettings) : DEFAULT_AGENT_SETTINGS_DRAFT,
);
const [modelCallOrder, setModelCallOrder] = useState<string[]>(
() => initialSettings?.model_call_order ?? [],
);
return {
editingProviderKeys,
expandedProvider,
form,
modelCallOrder,
modelCallOrderSaving,
modelConfigurationSaving,
modelMigrationSaving,
modelPresetBeforeCreateRef,
modelPresetCreating,
modelPresetPendingDelete,
providerForms,
providerOAuthCompleting,
providerOAuthDialogError,
providerOAuthFlow,
providerOAuthFlowRef,
providerOAuthResponse,
providerSaving,
saving,
setEditingProviderKeys,
setExpandedProvider,
setForm,
setModelCallOrder,
setModelCallOrderSaving,
setModelConfigurationSaving,
setModelMigrationSaving,
setModelPresetCreating,
setModelPresetPendingDelete,
setProviderForms,
setProviderOAuthCompleting,
setProviderOAuthDialogError,
setProviderOAuthFlow,
setProviderOAuthResponse,
setProviderSaving,
setSaving,
setVisibleProviderKeys,
visibleProviderKeys,
};
}
export type ModelSettingsState = ReturnType<typeof useModelSettingsState>;
@@ -0,0 +1,526 @@
import { useState, type Dispatch, type SetStateAction } from "react";
import {
ArrowUpCircle,
Bot,
Check,
ChevronRight,
ExternalLink,
Globe2,
HardDrive,
ImageIcon,
Loader2,
Mic,
Server,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { DEFAULT_TRANSCRIPTION_SETTINGS } from "@/components/settings/capabilities/TranscriptionSettings";
import type { SettingsSectionKey } from "@/components/settings/contracts";
import { settingsProviderConfigured } from "@/components/settings/shared/ModelControls";
import {
SettingsGroup,
SettingsRow,
SettingsSectionTitle,
} from "@/components/settings/shared/SettingsControls";
import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap";
import { ToggleButton } from "@/components/settings/ToggleButton";
import { Button } from "@/components/ui/button";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { checkVersion } from "@/lib/api";
import type {
FileEditDisplayMode,
LocalActivityMode,
LocalDensity,
LocalPreferences,
} from "@/lib/local-preferences";
import { providerBrand, providerDisplayLabel } from "@/lib/provider-brand";
import type { SettingsPayload } from "@/lib/types";
import { cn } from "@/lib/utils";
import { shortWorkspacePath } from "@/lib/workspace";
import { useClient } from "@/providers/ClientProvider";
export function OverviewSettings({
settings,
requiresRestart,
onSelectSection,
showBrandLogos,
}: {
settings: SettingsPayload;
requiresRestart: boolean;
onSelectSection: (section: SettingsSectionKey) => void;
showBrandLogos: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const activePresetName = settings.agent.model_preset;
const activePreset =
activePresetName && activePresetName !== "default"
? settings.model_presets.find((preset) => preset.name === activePresetName)?.label ??
activePresetName
: null;
const activeProvider = settings.agent.resolved_provider ?? settings.agent.provider;
const activeProviderConfigured = settingsProviderConfigured(settings, activeProvider);
const activeProviderLabel = providerDisplayLabel(settings.providers, activeProvider);
const activeModelValue = activeProviderConfigured
? settings.agent.model
: tx("settings.values.notConfigured", "Not configured");
const activeModelCaption = activeProviderConfigured
? [activeProvider, activePreset].filter(Boolean).join(" · ")
: activeProviderLabel || settings.agent.model
? [activeProviderLabel, settings.agent.model].filter(Boolean).join(" · ")
: tx("settings.byok.noConfiguredProviders", "No configured providers");
const webStatus = settings.web.enable
? tx("settings.values.enabled", "Enabled")
: tx("settings.values.disabled", "Disabled");
const webSearchProvider =
settings.web_search.providers.find((provider) => provider.name === settings.web_search.provider) ??
settings.web_search.providers[0];
const webSearchProviderLabel = providerDisplayLabel(
settings.web_search.providers,
settings.web_search.provider,
);
const webSearchCredentialStatus =
webSearchProvider?.credential === "none"
? tx("settings.byok.webSearch.noCredentialRequired", "No key required")
: webSearchProvider?.credential === "optional_api_key"
? settings.web_search.api_key_hint
? tx("settings.values.configured", "Configured")
: tx("settings.byok.webSearch.noCredentialRequired", "No key required")
: webSearchProvider?.credential === "base_url"
? settings.web_search.base_url
? tx("settings.values.configured", "Configured")
: tx("settings.values.notConfigured", "Not configured")
: settings.web_search.api_key_hint
? tx("settings.values.configured", "Configured")
: tx("settings.values.notConfigured", "Not configured");
const webCaption = `${webSearchProviderLabel} · ${webSearchCredentialStatus}`;
const imageStatus = settings.image_generation.enabled
? tx("settings.values.enabled", "Enabled")
: tx("settings.values.disabled", "Disabled");
const imageCaption = `${providerDisplayLabel(settings.image_generation.providers, settings.image_generation.provider)} · ${
settings.image_generation.provider_configured
? tx("settings.values.configured", "Configured")
: tx("settings.values.notConfigured", "Not configured")
}`;
const transcription = settings.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
const voiceStatus = transcription.enabled
? tx("settings.values.enabled", "Enabled")
: tx("settings.values.disabled", "Disabled");
const voiceCaption = `${providerDisplayLabel(transcription.providers, transcription.provider)} · ${
transcription.provider_configured
? tx("settings.values.configured", "Configured")
: tx("settings.values.notConfigured", "Not configured")
}`;
const isNativeHost = (settings.surface ?? settings.runtime_surface) === "native";
const workspaceCaption = shortWorkspacePath(settings.runtime.workspace_path);
const runtimeTitle = isNativeHost
? tx("settings.rows.engine", "Engine")
: tx("settings.rows.gateway", "Gateway");
const runtimeValue = isNativeHost
? tx("settings.values.privateEngine", "Private engine")
: `${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`;
const runtimeCaption = isNativeHost
? tx("settings.values.unixSocket", "Unix socket")
: requiresRestart
? tx("settings.values.restartPending", "Restart pending")
: tx("settings.values.ready", "Ready");
return (
<div className="space-y-7">
<section className="rounded-[22px] bg-settings-surface px-4 py-4 sm:px-5">
<TokenUsageHeatmap usage={settings.usage} timeZone={settings.agent.timezone} />
</section>
<section>
<SettingsSectionTitle>{tx("settings.sections.ai", "AI")}</SettingsSectionTitle>
<SettingsGroup>
<OverviewListRow
icon={Bot}
valueLogoProvider={activeProvider}
title={tx("settings.overview.model", "Current model")}
value={activeModelValue}
caption={activeModelCaption}
showBrandLogos={showBrandLogos}
onClick={() => onSelectSection("models")}
/>
</SettingsGroup>
</section>
<section>
<SettingsSectionTitle>{tx("settings.sections.capabilities", "Capabilities")}</SettingsSectionTitle>
<SettingsGroup>
<OverviewListRow
icon={Globe2}
valueLogoProvider={settings.web_search.provider}
title={tx("settings.overview.webSearch", "Web search")}
value={webStatus}
caption={webCaption}
showBrandLogos={showBrandLogos}
onClick={() => onSelectSection("browser")}
/>
<OverviewListRow
icon={ImageIcon}
valueLogoProvider={settings.image_generation.provider}
title={tx("settings.overview.imageGeneration", "Image generation")}
value={imageStatus}
caption={imageCaption}
showBrandLogos={showBrandLogos}
onClick={() => onSelectSection("image")}
/>
<OverviewListRow
icon={Mic}
valueLogoProvider={transcription.provider}
title={tx("settings.overview.voiceInput", "Voice input")}
value={voiceStatus}
caption={voiceCaption}
showBrandLogos={showBrandLogos}
onClick={() => onSelectSection("voice")}
/>
</SettingsGroup>
</section>
<section>
<SettingsSectionTitle>{tx("settings.sections.system", "System")}</SettingsSectionTitle>
<SettingsGroup>
<OverviewListRow
icon={Server}
title={runtimeTitle}
value={runtimeValue}
caption={runtimeCaption}
onClick={() => onSelectSection("runtime")}
/>
<OverviewListRow
icon={HardDrive}
title={tx("settings.overview.workspace", "Workspace")}
value={tx("settings.values.defaultWorkspace", "Default workspace")}
caption={workspaceCaption}
onClick={() => onSelectSection("runtime")}
/>
</SettingsGroup>
</section>
<section>
<SettingsSectionTitle>{tx("settings.sections.about", "About")}</SettingsSectionTitle>
<SettingsGroup>
<VersionCheckRow currentVersion={settings.version?.current} />
</SettingsGroup>
</section>
</div>
);
}
function VersionCheckRow({ currentVersion }: { currentVersion?: string }) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const { token } = useClient();
const [checking, setChecking] = useState(false);
const [result, setResult] = useState<
| { type: "up-to-date" }
| { type: "update"; latestVersion: string; pypiUrl?: string }
| { type: "error"; message: string }
| null
>(null);
const handleCheck = async () => {
setChecking(true);
setResult(null);
try {
const res = await checkVersion(token);
if (res.updateAvailable) {
setResult({
type: "update",
latestVersion: res.updateAvailable.latestVersion,
pypiUrl: res.updateAvailable.pypiUrl,
});
} else {
setResult({ type: "up-to-date" });
}
} catch (err) {
setResult({ type: "error", message: (err as Error).message });
} finally {
setChecking(false);
}
};
return (
<div className="flex min-h-[62px] flex-col gap-3 px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="min-w-0">
<div className="text-[14px] font-medium leading-5 text-foreground">
{tx("settings.about.version", "Version")}
</div>
<div className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
{currentVersion ? `v${currentVersion}` : "nanobot"}
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => void handleCheck()}
disabled={checking}
className="rounded-full"
>
{checking ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<ArrowUpCircle className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{checking
? tx("settings.about.checking", "Checking...")
: tx("settings.about.checkForUpdates", "Check for updates")}
</Button>
{result?.type === "up-to-date" ? (
<span className="inline-flex items-center gap-1.5 text-[12px] text-emerald-600 dark:text-emerald-300">
<Check className="h-3 w-3" aria-hidden />
{tx("settings.about.upToDate", "You're up to date")}
</span>
) : null}
{result?.type === "update" ? (
<span className="inline-flex items-center gap-1.5 text-[12px] text-blue-600 dark:text-blue-300">
<ArrowUpCircle className="h-3 w-3" aria-hidden />
{t("settings.about.updateAvailable", {
defaultValue: "Update available v{{version}}",
version: result.latestVersion,
})}
{result.pypiUrl ? (
<a
href={result.pypiUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 underline-offset-2 hover:underline"
>
PyPI
<ExternalLink className="h-2.5 w-2.5" aria-hidden />
</a>
) : null}
</span>
) : null}
{result?.type === "error" ? (
<span className="text-[12px] text-destructive">{result.message}</span>
) : null}
</div>
</div>
);
}
export function AppearanceSettings({
theme,
onToggleTheme,
localPrefs,
onChangeLocalPrefs,
}: {
theme: "light" | "dark";
onToggleTheme: () => void;
localPrefs: LocalPreferences;
onChangeLocalPrefs: Dispatch<SetStateAction<LocalPreferences>>;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return (
<div className="space-y-7">
<section>
<SettingsSectionTitle>{t("settings.sections.interface")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow title={t("settings.rows.theme")}>
<button
type="button"
onClick={onToggleTheme}
className="inline-flex h-8 items-center rounded-full bg-muted p-0.5 text-[12px] font-medium text-muted-foreground"
>
<span
className={cn(
"rounded-full px-3 py-1 transition-colors",
theme === "light" &&
"bg-background text-foreground ring-1 ring-inset ring-border/45",
)}
>
{t("settings.values.light")}
</span>
<span
className={cn(
"rounded-full px-3 py-1 transition-colors",
theme === "dark" &&
"bg-background text-foreground ring-1 ring-inset ring-border/45",
)}
>
{t("settings.values.dark")}
</span>
</button>
</SettingsRow>
<SettingsRow title={t("settings.rows.language")}>
<LanguageSwitcher />
</SettingsRow>
</SettingsGroup>
</section>
<section>
<SettingsSectionTitle>{tx("settings.sections.localPreferences", "Local preferences")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow title={tx("settings.rows.density", "Density")}>
<SegmentedControl
value={localPrefs.density}
options={[
{ value: "comfortable", label: tx("settings.values.comfortable", "Comfortable") },
{ value: "compact", label: tx("settings.values.compact", "Compact") },
]}
onChange={(density) =>
onChangeLocalPrefs((prev) => ({ ...prev, density: density as LocalDensity }))
}
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.activityMode", "Activity detail")}>
<SegmentedControl
value={localPrefs.activityMode}
options={[
{ value: "auto", label: tx("settings.values.auto", "Auto") },
{ value: "expanded", label: tx("settings.values.expanded", "Expanded") },
]}
onChange={(activityMode) =>
onChangeLocalPrefs((prev) => ({ ...prev, activityMode: activityMode as LocalActivityMode }))
}
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.fileEditDisplay", "File edit display")}>
<SegmentedControl
value={localPrefs.fileEditDisplayMode}
options={[
{ value: "summary", label: tx("settings.values.summary", "Summary") },
{ value: "diff", label: tx("settings.values.diff", "Diff") },
{ value: "collapsed_diff", label: tx("settings.values.collapsedDiff", "Collapsed diff") },
]}
onChange={(fileEditDisplayMode) =>
onChangeLocalPrefs((prev) => ({
...prev,
fileEditDisplayMode: fileEditDisplayMode as FileEditDisplayMode,
}))
}
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.codeWrap", "Code wrapping")}>
<ToggleButton
checked={localPrefs.codeWrap}
onChange={(codeWrap) => onChangeLocalPrefs((prev) => ({ ...prev, codeWrap }))}
ariaLabel={tx("settings.rows.codeWrap", "Code wrapping")}
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.brandLogos", "Brand logos")}
description={tx(
"settings.legal.thirdPartyBrands",
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.",
)}
>
<ToggleButton
checked={localPrefs.brandLogos}
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
ariaLabel={tx("settings.rows.brandLogos", "Brand logos")}
label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
</SettingsGroup>
</section>
</div>
);
}
function OverviewRowIcon({
icon: Icon,
}: {
icon: LucideIcon;
}) {
return (
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-[12px] bg-muted text-foreground/82 transition-colors group-hover:bg-muted/80 dark:bg-muted/70">
<Icon className="h-4 w-4" aria-hidden />
</span>
);
}
function OverviewValueLogo({
provider,
showBrandLogos,
}: {
provider: string | null | undefined;
showBrandLogos: boolean;
}) {
const brand = provider ? providerBrand(provider) : null;
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
if (!provider || !showBrandLogos || !brand) return null;
if (logoUrl) {
return (
<span
data-testid={`overview-logo-${provider}`}
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-md border border-border/35 bg-background"
aria-hidden
>
<img
src={logoUrl}
alt=""
decoding="async"
loading="lazy"
className="h-3.5 w-3.5 object-contain"
onLoad={onLogoLoad}
onError={onLogoError}
/>
</span>
);
}
return (
<span
data-testid={`overview-logo-fallback-${provider}`}
className="grid h-5 w-5 shrink-0 place-items-center rounded-md text-[7.5px] font-semibold text-white"
style={{ backgroundColor: brand.color }}
aria-hidden
>
{brand.initials}
</span>
);
}
function OverviewListRow({
icon: Icon,
valueLogoProvider,
title,
value,
caption,
showBrandLogos = false,
onClick,
}: {
icon: LucideIcon;
valueLogoProvider?: string | null;
title: string;
value: string;
caption: string;
showBrandLogos?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="group flex min-h-[68px] w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-muted/30 sm:px-5"
>
<OverviewRowIcon icon={Icon} />
<span className="min-w-0 flex-1">
<span className="block text-[14px] font-medium leading-5 text-foreground">{title}</span>
<span className="mt-0.5 block truncate text-[12px] leading-5 text-muted-foreground">{caption}</span>
</span>
<span className="ml-auto flex min-w-0 max-w-[48%] items-center gap-2">
<OverviewValueLogo provider={valueLogoProvider} showBrandLogos={showBrandLogos} />
<span className="truncate text-right text-[13px] leading-5 text-muted-foreground">
{value}
</span>
<ChevronRight
className="h-4 w-4 shrink-0 text-muted-foreground/60 transition-transform group-hover:translate-x-0.5"
aria-hidden
/>
</span>
</button>
);
}
@@ -0,0 +1,619 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Bot,
Brain,
Check,
ChevronDown,
CircleAlert,
Cloud,
Cpu,
Database,
Gem,
Grid3X3,
Hexagon,
Layers,
Loader2,
Moon,
Orbit,
Pencil,
Search,
Sparkles,
Triangle,
Waves,
Zap,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { ComboboxOption, useComboboxNavigation } from "@/components/ui/combobox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { fetchProviderModels } from "@/lib/api";
import { providerBrand } from "@/lib/provider-brand";
import type { ProviderModelsPayload, SettingsPayload } from "@/lib/types";
import { cn } from "@/lib/utils";
const DEFERRED_MODEL_LIST_PROVIDERS = new Set([
"aihubmix",
"atomic_chat",
"byteplus",
"byteplus_coding_plan",
"huggingface",
"lm_studio",
"modelscope",
"novita",
"ollama",
"openrouter",
"ovms",
"siliconflow",
"vllm",
"volcengine",
"volcengine_coding_plan",
]);
const DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH = 2;
export function normalizeContextWindowTokens(value: number | null | undefined): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 200_000;
}
function settingsProviderRow(
payload: SettingsPayload,
provider: string | null | undefined,
): SettingsPayload["providers"][number] | null {
if (!provider) return null;
return payload.providers.find((row) => row.name === provider) ?? null;
}
export function settingsProviderConfigured(
payload: SettingsPayload,
provider: string | null | undefined,
resolvedProvider?: string | null,
): boolean {
const row = settingsProviderRow(payload, provider);
if (row) return row.configured;
if (provider === "auto") {
const resolvedRow = settingsProviderRow(
payload,
resolvedProvider ?? payload.agent.resolved_provider ?? payload.agent.provider,
);
if (resolvedRow) return resolvedRow.configured;
}
return payload.agent.has_api_key;
}
export function ProviderPicker({
providers,
value,
emptyLabel,
showProviderLogos = false,
onChange,
}: {
providers: Array<{ name: string; label: string }>;
value: string;
emptyLabel: string;
showProviderLogos?: boolean;
onChange: (provider: string) => void;
}) {
const selectedProvider = providers.find((provider) => provider.name === value) ?? null;
const disabled = providers.length === 0;
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild disabled={disabled}>
<Button
type="button"
variant="outline"
disabled={disabled}
className={cn(
"h-8 w-[210px] justify-between rounded-full border-input bg-background px-3 text-[13px] font-normal shadow-none",
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
disabled && "text-muted-foreground",
)}
>
<span className="flex min-w-0 items-center gap-2">
{selectedProvider && showProviderLogos ? (
<ProviderPickerIcon
provider={selectedProvider.name}
showBrandLogos={showProviderLogos}
/>
) : null}
<span className="truncate">{selectedProvider?.label ?? emptyLabel}</span>
</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-h-[18rem] w-[240px] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
>
{providers.map((provider) => {
const selected = provider.name === value;
return (
<DropdownMenuItem
key={provider.name}
onSelect={() => onChange(provider.name)}
className={cn(
"flex cursor-default items-center justify-between gap-2 text-[13px]",
selected && "bg-muted/80 text-foreground focus:bg-muted",
)}
>
<span className="flex min-w-0 items-center gap-2">
{showProviderLogos ? (
<ProviderPickerIcon
provider={provider.name}
showBrandLogos={showProviderLogos}
/>
) : null}
<span className="truncate">{provider.label}</span>
</span>
{selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
export function ModelIdPicker({
token,
settings,
provider,
models,
value,
showProviderLogos,
emptyLabel,
searchPlaceholder,
emptyMessage,
onChange,
}: {
token: string;
settings: SettingsPayload;
provider: string;
models?: string[];
value: string;
showProviderLogos: boolean;
emptyLabel?: string;
searchPlaceholder?: string;
emptyMessage?: string;
onChange: (model: string) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const tokenRef = useRef(token);
tokenRef.current = token;
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [payload, setPayload] = useState<ProviderModelsPayload | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const effectiveProvider =
provider === "auto" ? settings.agent.resolved_provider ?? provider : provider;
const hasConcreteProvider = Boolean(effectiveProvider && effectiveProvider !== "auto");
const hasStaticModels = models !== undefined;
const providerRow = settingsProviderRow(settings, effectiveProvider);
const providerConfigured = settingsProviderConfigured(settings, effectiveProvider);
const providerRequiresConfiguration =
!hasStaticModels && hasConcreteProvider && !providerConfigured;
const providerHasBuiltinModels = providerRow?.model_catalog === "builtin";
const providerUsesManualModelIds =
!hasStaticModels &&
hasConcreteProvider &&
providerConfigured &&
providerRow?.auth_type === "oauth" &&
!providerHasBuiltinModels;
const canFetchModels =
!hasStaticModels &&
hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
const normalizedQuery = query.trim().toLowerCase();
const providerModels: ProviderModelsPayload["models"] = useMemo(
() => hasStaticModels
? (models?.map((id) => ({ id })) ?? [])
: (payload?.models ?? []),
[hasStaticModels, models, payload?.models],
);
const visibleModels = useMemo(
() => providerModels
.filter((model) => {
if (!normalizedQuery) return true;
return [model.id, model.label ?? "", model.description ?? "", model.owned_by ?? ""]
.some((field) => field.toLowerCase().includes(normalizedQuery));
})
.slice(0, 80),
[normalizedQuery, providerModels],
);
const isCatalog = payload?.catalog_kind === "catalog";
const defersModelList = DEFERRED_MODEL_LIST_PROVIDERS.has(effectiveProvider);
const hasDeferredSearchQuery =
normalizedQuery.length >= DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH;
const shouldFetchModels =
canFetchModels && (!defersModelList || hasDeferredSearchQuery);
const waitingForModelSearch =
open && canFetchModels && defersModelList && !hasDeferredSearchQuery;
const hasModelList = hasStaticModels || payload?.status === "available";
const showModels = Boolean(
hasModelList && (hasStaticModels || (payload && (!isCatalog || normalizedQuery))),
);
const customCandidate = query.trim();
const allowCustomModel = !providerRequiresConfiguration;
const exactQueryMatch = providerModels.some((model) => model.id === customCandidate);
const showCustomModel = Boolean(
allowCustomModel && customCandidate && !exactQueryMatch && customCandidate !== value,
);
const providerModelCount = payload?.model_count ?? providerModels.length;
const modelUnconfigured = !value.trim() || !providerConfigured;
useEffect(() => {
if (!open) return;
setQuery(providerUsesManualModelIds || !hasConcreteProvider ? value : "");
}, [open, effectiveProvider, hasConcreteProvider, providerUsesManualModelIds, value]);
useEffect(() => {
if (!open || !shouldFetchModels) {
setPayload(null);
setError(null);
setLoading(false);
return;
}
let cancelled = false;
setPayload(null);
setError(null);
setLoading(true);
fetchProviderModels(tokenRef.current, effectiveProvider)
.then((nextPayload) => {
if (!cancelled) setPayload(nextPayload);
})
.catch((err) => {
if (!cancelled) setError((err as Error).message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [effectiveProvider, open, shouldFetchModels]);
const selectModel = (model: string) => {
onChange(model);
setOpen(false);
};
const navigationValues = useMemo(
() => [
...(showModels ? visibleModels.map((model) => model.id) : []),
...(showCustomModel ? [customCandidate] : []),
],
[customCandidate, showCustomModel, showModels, visibleModels],
);
const navigation = useComboboxNavigation({
open,
values: navigationValues,
selectedValue: value,
onSelect: selectModel,
onClose: () => setOpen(false),
});
const renderModelRow = (
model: ProviderModelsPayload["models"][number],
options: { selected?: boolean } = {},
) => (
<ComboboxOption
key={model.id}
{...navigation.getOptionProps(model.id)}
className={cn(
"flex cursor-default items-center justify-between gap-2 rounded-[12px] px-2 py-1.5 text-[12px]",
options.selected && "text-foreground",
)}
>
<span className="flex min-w-0 items-center gap-2">
<ProviderPickerIcon
provider={effectiveProvider}
showBrandLogos={showProviderLogos}
unconfigured={!providerConfigured}
/>
<span className="min-w-0">
<span className="block truncate font-medium text-foreground">
{model.label ?? model.id}
</span>
{model.description || (model.label && model.label !== model.id) ? (
<span className="mt-0.5 block truncate text-[10.5px] text-muted-foreground">
{[model.label && model.label !== model.id ? model.id : null, model.description]
.filter(Boolean)
.join(" · ")}
</span>
) : null}
</span>
</span>
<span className="ml-2 flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
{model.context_window ? <span>{formatContextWindow(model.context_window)}</span> : null}
{options.selected ? <Check className="h-3.5 w-3.5 text-foreground" aria-hidden /> : null}
</span>
</ComboboxOption>
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
"h-9 w-[min(360px,70vw)] justify-between rounded-full border-input bg-background px-3 text-[12px] font-normal shadow-none",
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<span className="flex min-w-0 items-center gap-2">
<ProviderPickerIcon
provider={effectiveProvider}
showBrandLogos={showProviderLogos}
unconfigured={modelUnconfigured}
/>
<span
className={cn(
"min-w-0 truncate font-medium",
value ? "text-foreground" : "text-muted-foreground",
)}
>
{value || emptyLabel || tx("settings.models.selectModel", "Select model")}
</span>
</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-[360px] max-w-[calc(100vw-2rem)] p-1.5"
>
<div className="p-1 pb-1.5">
<div className="relative">
<Search
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
{...navigation.inputProps}
placeholder={
searchPlaceholder || tx("settings.models.searchModels", "Search or type model ID")
}
aria-label={
searchPlaceholder || tx("settings.models.searchModels", "Search or type model ID")
}
className="h-8 rounded-full pl-8 pr-3 text-[12px]"
/>
</div>
</div>
{providerRequiresConfiguration ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
</div>
) : hasStaticModels && !providerModels.length ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{emptyMessage || tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
</div>
) : providerUsesManualModelIds ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
</div>
) : !canFetchModels ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.autoProviderCustomOnly", "Auto provider mode uses custom model IDs.")}
</div>
) : waitingForModelSearch ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
</div>
) : loading ? (
<div className="flex items-center gap-2 px-2 py-1.5 text-[11px] text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
{tx("settings.models.loadingModels", "Loading models...")}
</div>
) : error || payload?.status === "error" ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{payload?.message || error || tx("settings.models.loadFailed", "Model list unavailable.")}
</div>
) : payload?.status === "not_configured" ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
</div>
) : payload?.status === "unsupported" || payload?.status === "missing_api_base" ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{payload.message || tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
</div>
) : isCatalog && !normalizedQuery ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
{providerModelCount ? ` ${providerModelCount} ${tx("settings.models.modelsAvailable", "available")}.` : ""}
</div>
) : null}
{navigationValues.length ? (
<div
{...navigation.listProps}
aria-label={searchPlaceholder || tx("settings.models.selectModel", "Select model")}
className="max-h-[16rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent"
>
{showModels
? visibleModels.map((model) =>
renderModelRow(model, { selected: model.id === value }),
)
: null}
{showCustomModel ? (
<>
{showModels && visibleModels.length ? (
<div role="separator" className="-mx-1.5 my-1.5 h-px bg-border/50" />
) : null}
<ComboboxOption
{...navigation.getOptionProps(customCandidate)}
className="flex cursor-default items-center gap-2 rounded-[12px] px-2 py-1.5 text-[12px]"
>
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted/80 text-muted-foreground">
<Pencil className="h-3 w-3" aria-hidden />
</span>
<span className="min-w-0 truncate">
{tx("settings.models.useCustomModel", "Use")}{" "}
<span className="font-medium text-foreground">{customCandidate}</span>
</span>
</ComboboxOption>
</>
) : null}
</div>
) : showModels ? (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">
{tx("settings.models.noModelResults", "No matching models.")}
</div>
) : null}
</PopoverContent>
</Popover>
);
}
export function formatContextWindow(tokens: number): string {
if (tokens >= 1_000_000) {
const value = tokens / 1_000_000;
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}M`;
}
if (tokens >= 1_000) {
const value = tokens / 1_000;
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}K`;
}
return String(tokens);
}
export function formatModelContextWindow(tokens: number): string {
if (tokens === 65_536) return "64K";
if (tokens === 262_144) return "256K";
if (tokens === 1_048_576) return "1M";
return formatContextWindow(tokens);
}
export function ProviderPickerIcon({
provider,
showBrandLogos,
unconfigured = false,
}: {
provider: string;
showBrandLogos: boolean;
unconfigured?: boolean;
}) {
const brand = providerBrand(provider);
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
if (unconfigured) {
return (
<span
data-testid="provider-picker-unconfigured-icon"
className="grid h-5 w-5 shrink-0 place-items-center text-amber-700 dark:text-amber-200"
aria-hidden
>
<CircleAlert className="h-4 w-4" strokeWidth={1.8} />
</span>
);
}
if (showBrandLogos && logoUrl) {
return (
<span
data-testid={`provider-picker-logo-${provider}`}
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-md border border-border/35 bg-background"
aria-hidden
>
<img
src={logoUrl}
alt=""
decoding="async"
loading="lazy"
className="h-3.5 w-3.5 object-contain"
onLoad={onLogoLoad}
onError={onLogoError}
/>
</span>
);
}
if (showBrandLogos && brand) {
return (
<span
data-testid={`provider-picker-logo-fallback-${provider}`}
className="grid h-5 w-5 shrink-0 place-items-center rounded-md text-[7.5px] font-semibold text-white"
style={{ backgroundColor: brand.color }}
aria-hidden
>
{brand.initials}
</span>
);
}
return (
<span
className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground"
aria-hidden
>
<Icon className="h-3 w-3" strokeWidth={2} />
</span>
);
}
export function optionRowsWithCurrent(
options: Array<{ name: string; label: string }>,
value: string,
): Array<{ name: string; label: string }> {
if (!value || options.some((option) => option.name === value)) return options;
return [{ name: value, label: value }, ...options];
}
export const PROVIDER_ICONS: Record<string, LucideIcon> = {
custom: Hexagon,
openrouter: Sparkles,
skywork: Sparkles,
aihubmix: Triangle,
anthropic: Brain,
openai: Bot,
deepseek: Waves,
zhipu: Grid3X3,
dashscope: Cloud,
modelscope: Layers,
moonshot: Moon,
minimax: Zap,
minimax_anthropic: Brain,
groq: Cpu,
huggingface: Layers,
gemini: Gem,
mistral: Orbit,
siliconflow: Layers,
volcengine: Cloud,
volcengine_coding_plan: Cloud,
byteplus: Cloud,
byteplus_coding_plan: Cloud,
qianfan: Database,
ant_ling: Sparkles,
azure_openai: Cloud,
bedrock: Database,
bocha: Search,
brave: Search,
duckduckgo: Search,
exa: Search,
jina: Search,
kagi: Search,
olostep: Search,
searxng: Search,
tavily: Search,
vllm: Cpu,
ollama: Cpu,
lm_studio: Cpu,
atomic_chat: Cpu,
ovms: Cpu,
nvidia: Zap,
};
@@ -0,0 +1,410 @@
import type { ReactNode } from "react";
import { CircleAlert, Loader2, RotateCcw, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { isNativeRuntime } from "@/lib/runtime";
import type { NanobotFeatureInfo } from "@/lib/types";
import { cn } from "@/lib/utils";
export const SETTINGS_SEARCH_INPUT_CLASS = cn(
"border-border/45 bg-settings-surface transition-colors hover:border-border/70",
"focus-visible:border-border/70 focus-visible:bg-background",
"focus-visible:ring-0 focus-visible:ring-offset-0",
);
export function CapabilityInstallNotice({
title,
description,
installing = false,
}: {
title: string;
description: string;
installing?: boolean;
}) {
return (
<div className="flex items-start gap-3 rounded-[14px] border border-border/55 bg-muted/22 px-3.5 py-3">
{installing ? (
<Loader2 className="mt-0.5 h-4 w-4 shrink-0 animate-spin text-muted-foreground" aria-hidden />
) : (
<CircleAlert className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
)}
<div className="min-w-0">
<p className="text-[12.5px] font-medium text-foreground">{title}</p>
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">{description}</p>
</div>
</div>
);
}
export function NanobotFeatureInstallDialog({
feature,
installing,
onOpenChange,
onConfirm,
}: {
feature: NanobotFeatureInfo | null;
installing: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (feature: NanobotFeatureInfo) => void | Promise<void>;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(values ?? {}) });
const name = feature?.display_name || feature?.name || "";
return (
<Dialog open={Boolean(feature)} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="w-[min(calc(100vw-2rem),24rem)] gap-0 p-5 text-center"
>
<DialogHeader className="items-center space-y-0 text-center">
<DialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
{tx("settings.nanobotFeatures.installConfirmTitle", "Install support for {{name}}?", { name })}
</DialogTitle>
<DialogDescription className="mt-3 max-w-[20rem] text-center text-[14px] leading-6 text-muted-foreground">
{tx(
"settings.nanobotFeatures.installConfirmDescription",
"nanobot will add what {{name}} needs, then turn it on. Continue?",
{ name },
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-7 !grid grid-cols-1 gap-3 space-x-0 sm:grid-cols-2 sm:space-x-0">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={installing}
className="h-11 w-full min-w-0 rounded-full bg-muted/70 px-5 text-[15px] font-semibold text-foreground shadow-none hover:bg-muted"
>
{tx("settings.automations.cancel", "Cancel")}
</Button>
<Button
type="button"
onClick={() => feature && void onConfirm(feature)}
disabled={!feature || installing}
className="h-11 w-full min-w-0 !whitespace-normal rounded-full px-5 text-center text-[15px] font-semibold"
>
{installing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
{tx("settings.nanobotFeatures.installConfirmAction", "Install and enable")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function DismissibleStatusMessage({
message,
isError,
onDismiss,
}: {
message: string;
isError: boolean;
onDismiss: () => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return (
<div
className={cn(
"flex items-center justify-between gap-3 rounded-[12px] border py-2.5 pl-4 pr-2 text-[13px]",
isError
? "border-destructive/20 bg-destructive/5 text-destructive"
: "border-border/55 bg-muted/35 text-muted-foreground",
)}
>
<span className="min-w-0">{message}</span>
<button
type="button"
aria-label={tx("settings.actions.dismiss", "Dismiss")}
title={tx("settings.actions.dismiss", "Dismiss")}
onClick={onDismiss}
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors",
isError
? "text-destructive/70 hover:bg-destructive/10 hover:text-destructive"
: "text-muted-foreground/70 hover:bg-muted hover:text-foreground",
)}
>
<X className="h-3.5 w-3.5" aria-hidden />
</button>
</div>
);
}
export function RestartRequiredNotice({
message,
onRestart,
isRestarting,
}: {
message: string;
onRestart?: () => void;
isRestarting?: boolean;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3 rounded-[12px] border border-amber-500/20 bg-amber-500/8 px-4 py-3 text-[12.5px] text-amber-800 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between">
<span>{message}</span>
{onRestart ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={onRestart}
disabled={isRestarting}
className="h-8 rounded-full bg-background/80 px-3 text-[12px] font-semibold"
>
{isRestarting ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{isRestarting ? t("app.system.restarting") : t("app.system.restart")}
</Button>
) : null}
</div>
);
}
export function SettingsSectionTitle({ children }: { children: ReactNode }) {
return (
<h2 className="mb-2 px-1 text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
{children}
</h2>
);
}
export function SettingsGroup({ children }: { children: ReactNode }) {
return (
<div className="overflow-hidden rounded-[22px] bg-settings-surface">
<div className="divide-y divide-border/45">{children}</div>
</div>
);
}
export function SettingsRow({
title,
description,
children,
}: {
title: string;
description?: string;
children?: ReactNode;
}) {
return (
<div className="flex min-h-[62px] flex-col gap-3 px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="min-w-0">
<div className="text-[14px] font-medium leading-5 text-foreground">{title}</div>
{description ? (
<div className="mt-0.5 max-w-[28rem] text-[12px] leading-5 text-muted-foreground">
{description}
</div>
) : null}
</div>
{children ? <div className="min-w-0 sm:ml-6 sm:shrink-0">{children}</div> : null}
</div>
);
}
export function ReadOnlyRow({
title,
value,
description,
}: {
title: string;
value: string;
description?: string;
}) {
return (
<SettingsRow title={title} description={description}>
<span className="block max-w-full truncate text-left text-[13px] text-muted-foreground sm:max-w-[320px] sm:text-right">
{value}
</span>
</SettingsRow>
);
}
export function RestartSettingsFooter({
dirty,
saving,
pendingRestart,
disabled = false,
message,
dirtyMessage,
pendingMessage,
onSave,
onRestart,
onReset,
isRestarting,
}: {
dirty: boolean;
saving: boolean;
pendingRestart: boolean;
disabled?: boolean;
message?: string;
dirtyMessage?: string;
pendingMessage?: string;
onSave: () => void;
onRestart?: () => void;
onReset?: () => void;
isRestarting?: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const isNativeHost = isNativeRuntime();
const restartLabel = isNativeHost
? tx("app.system.restartEngine", "Restart engine")
: t("app.system.restart");
const restartingLabel = isNativeHost
? tx("app.system.restartingEngine", "Restarting engine...")
: t("app.system.restarting");
const statusMessage =
message ??
(pendingRestart && !dirty
? pendingMessage ?? tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
: dirty
? dirtyMessage ?? t("settings.status.unsaved")
: undefined);
const statusTone = disabled ? "danger" : dirty || pendingRestart ? "accent" : undefined;
return (
<div className="flex min-h-[58px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="min-w-0 text-[13px] leading-5 text-muted-foreground">
<SettingsStatusMessage tone={statusTone}>{statusMessage}</SettingsStatusMessage>
</div>
<div className="flex w-full shrink-0 flex-wrap justify-end gap-2 sm:w-auto">
{pendingRestart && !dirty && onRestart ? (
<Button
size="sm"
variant="ghost"
onClick={onRestart}
disabled={isRestarting}
className="rounded-full"
>
{isRestarting ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{isRestarting ? restartingLabel : restartLabel}
</Button>
) : null}
{onReset ? (
<Button
size="sm"
variant="ghost"
onClick={onReset}
disabled={!dirty || saving}
className="rounded-full"
>
{t("settings.actions.cancel")}
</Button>
) : null}
<Button
size="sm"
variant="outline"
onClick={onSave}
disabled={!dirty || disabled || saving}
className="rounded-full"
>
{saving ? t("settings.actions.saving") : t("settings.actions.save")}
</Button>
</div>
</div>
);
}
export function SettingsStatusMessage({
children,
tone,
}: {
children?: ReactNode;
tone?: "accent" | "danger";
}) {
if (!children) return null;
return (
<span
className={cn(
"inline-flex items-center gap-2",
tone === "accent" && "font-medium text-blue-600 dark:text-blue-300",
tone === "danger" && "font-medium text-destructive",
)}
>
{tone ? (
<span
className={cn(
"h-1.5 w-1.5 shrink-0 rounded-full",
tone === "accent" &&
"bg-blue-500 dark:bg-blue-400",
tone === "danger" && "bg-destructive/70",
)}
aria-hidden
/>
) : null}
<span>{children}</span>
</span>
);
}
export function StatusPill({
children,
tone = "neutral",
}: {
children: ReactNode;
tone?: "neutral" | "success" | "warning";
}) {
return (
<span
className={cn(
"inline-flex max-w-[260px] items-center rounded-full px-2.5 py-1 text-[12px] font-medium",
tone === "success" && "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
tone === "warning" && "bg-amber-500/10 text-amber-700 dark:text-amber-300",
tone === "neutral" && "bg-muted text-muted-foreground",
)}
>
<span className="truncate">{children}</span>
</span>
);
}
export function NumberInput({
value,
min,
max,
onChange,
suffix,
}: {
value: number;
min: number;
max: number;
onChange: (value: number) => void;
suffix?: string;
}) {
return (
<div className="flex items-center gap-2">
<Input
type="number"
min={min}
max={max}
value={value}
onChange={(event) => {
const parsed = Number(event.target.value);
if (Number.isFinite(parsed)) onChange(parsed);
}}
className="h-8 w-24 max-w-full rounded-full text-[13px]"
/>
{suffix ? <span className="text-[12px] text-muted-foreground">{suffix}</span> : null}
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,257 @@
import { useEffect, useRef, useState } from "react";
import { ChevronLeft, Loader2, Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
channelIsRunning,
channelMatchesFilter,
channelSearchText,
localizedChannelDisplayName,
type ChannelFilter,
} from "@/components/settings/channels/ChannelIdentity";
import { ChannelCatalogRow, ChannelSetupPanel } from "@/components/settings/channels/ChannelSetupPanel";
import {
DismissibleStatusMessage,
RestartRequiredNotice,
SETTINGS_SEARCH_INPUT_CLASS,
} from "@/components/settings/shared/SettingsControls";
import { Input } from "@/components/ui/input";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import type { NanobotFeaturesPayload } from "@/lib/types";
import { cn } from "@/lib/utils";
export function ChannelsSettings({
token,
nanobotFeatures,
loading,
query,
actionKey,
chatAppsDocsUrl,
showBrandLogos,
error,
requiresRestartPending,
onQueryChange,
onAction,
onFeaturesUpdate,
onDismissStatus,
onRestart,
isRestarting,
}: {
token: string;
nanobotFeatures: NanobotFeaturesPayload | null;
loading: boolean;
query: string;
actionKey: string | null;
chatAppsDocsUrl?: string;
showBrandLogos: boolean;
error: string | null;
requiresRestartPending: boolean;
onQueryChange: (value: string) => void;
onAction: (action: "enable" | "disable", name: string) => void;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
onDismissStatus: () => void;
onRestart?: () => void;
isRestarting?: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const normalizedQuery = query.trim().toLowerCase();
const [filter, setFilter] = useState<ChannelFilter>("all");
const splitLayout = useMediaQuery("(min-width: 1280px)");
const containerRef = useRef<HTMLDivElement>(null);
const compactDetailTopRef = useRef<HTMLButtonElement>(null);
const [compactDetailOpen, setCompactDetailOpen] = useState(false);
const allChannels = (nanobotFeatures?.features ?? [])
.filter((feature) => feature.type === "channel")
.filter((feature) => feature.settings_visible !== false)
.filter((feature) => !normalizedQuery || channelSearchText(feature, t).includes(normalizedQuery))
.sort((left, right) => {
const rank = Number(!left.ready) - Number(!right.ready);
return rank || localizedChannelDisplayName(left, t).localeCompare(
localizedChannelDisplayName(right, t),
);
});
const channels = allChannels.filter((feature) => channelMatchesFilter(feature, filter));
const [selectedChannelName, setSelectedChannelName] = useState<string | null>(null);
const selectedChannel =
channels.find((feature) => feature.name === selectedChannelName) ?? channels[0] ?? null;
const enabledCount = allChannels.filter(channelIsRunning).length;
const offCount = Math.max(0, allChannels.length - enabledCount);
const filterOptions: Array<{ value: ChannelFilter; label: string; count: number }> = [
{ value: "all", label: tx("settings.channels.filterAll", "All"), count: allChannels.length },
{ value: "on", label: tx("settings.channels.filterOn", "On"), count: enabledCount },
{ value: "off", label: tx("settings.channels.filterOff", "Off"), count: offCount },
];
const statusMessage = error;
const statusIsError = true;
useEffect(() => {
if (!channels.length) {
if (selectedChannelName !== null) setSelectedChannelName(null);
setCompactDetailOpen(false);
return;
}
if (!selectedChannelName || !channels.some((feature) => feature.name === selectedChannelName)) {
setSelectedChannelName(channels[0].name);
setCompactDetailOpen(false);
}
}, [channels, selectedChannelName]);
useEffect(() => {
if (splitLayout) return;
const resetScroll = () => {
let node = containerRef.current?.parentElement ?? null;
while (node) {
node.scrollTop = 0;
node = node.parentElement;
}
if (compactDetailOpen) {
compactDetailTopRef.current?.scrollIntoView?.({ block: "start" });
}
};
resetScroll();
const frame = window.requestAnimationFrame(resetScroll);
return () => window.cancelAnimationFrame(frame);
}, [compactDetailOpen, selectedChannelName, splitLayout]);
const openChannel = (name: string) => {
setSelectedChannelName(name);
if (!splitLayout) setCompactDetailOpen(true);
};
const setupPanel = selectedChannel ? (
<ChannelSetupPanel
token={token}
feature={selectedChannel}
actionKey={actionKey}
chatAppsDocsUrl={chatAppsDocsUrl}
showBrandLogos={showBrandLogos}
onAction={onAction}
onFeaturesUpdate={onFeaturesUpdate}
/>
) : null;
const showingCompactDetail = !splitLayout && compactDetailOpen && selectedChannel !== null;
return (
<div
ref={containerRef}
className="flex min-h-full flex-1 flex-col xl:min-h-0 xl:overflow-hidden"
>
{!showingCompactDetail ? (
<section className="shrink-0 space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<Input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx("settings.channels.searchPlaceholder", "Search channels")}
className={cn(
"h-12 rounded-[14px] pl-11 text-[15px]",
SETTINGS_SEARCH_INPUT_CLASS,
)}
/>
</div>
<div className="flex shrink-0 flex-wrap gap-1.5 rounded-[14px] bg-muted/55 p-1">
{filterOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setFilter(option.value)}
className={cn(
"rounded-[11px] px-3 py-1.5 text-[12px] font-medium transition-colors",
filter === option.value
? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
<span className="ml-1 text-[11px] text-muted-foreground">{option.count}</span>
</button>
))}
</div>
</div>
</section>
) : null}
{statusMessage ? (
<div className="mt-3 shrink-0">
<DismissibleStatusMessage
message={statusMessage}
isError={statusIsError}
onDismiss={onDismissStatus}
/>
</div>
) : null}
{requiresRestartPending ? (
<div className="mt-3 shrink-0">
<RestartRequiredNotice
message={tx("settings.channels.restartRequired", "Restart nanobot to apply updated channel support.")}
onRestart={onRestart}
isRestarting={isRestarting}
/>
</div>
) : null}
<section
className={cn(
"flex flex-1 flex-col",
showingCompactDetail ? "mt-1" : "mt-5",
splitLayout && "min-h-0 overflow-hidden",
)}
>
{loading && !nanobotFeatures ? (
<div className="flex h-36 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
{tx("settings.channels.loading", "Loading Channels...")}
</div>
) : channels.length ? splitLayout ? (
<div className="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_minmax(400px,460px)] gap-6 overflow-hidden">
<div className="min-h-0 space-y-1 overflow-y-auto overscroll-contain pr-1">
{channels.map((feature) => (
<ChannelCatalogRow
key={feature.name}
feature={feature}
selected={selectedChannel?.name === feature.name}
showBrandLogos={showBrandLogos}
onSelect={() => openChannel(feature.name)}
/>
))}
</div>
<div className="min-h-0 overflow-y-auto overscroll-contain pr-1">{setupPanel}</div>
</div>
) : showingCompactDetail ? (
<div className="pb-6">
<button
ref={compactDetailTopRef}
type="button"
onClick={() => setCompactDetailOpen(false)}
className="mb-4 inline-flex h-9 items-center gap-1.5 rounded-full px-2.5 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground"
>
<ChevronLeft className="h-4 w-4" aria-hidden />
{tx("settings.channels.backToChannels", "All channels")}
</button>
{setupPanel}
</div>
) : (
<div className="space-y-1 pb-6">
{channels.map((feature) => (
<ChannelCatalogRow
key={feature.name}
feature={feature}
selected={false}
showBrandLogos={showBrandLogos}
onSelect={() => openChannel(feature.name)}
/>
))}
</div>
) : (
<div className="min-h-0 flex-1 px-3 py-12 text-center text-sm text-muted-foreground">
{tx("settings.channels.empty", "No channels match this filter.")}
</div>
)}
</section>
</div>
);
}
@@ -0,0 +1,406 @@
import { useEffect, useState } from "react";
import { Eye, EyeOff, Loader2, PauseCircle, PlayCircle, RotateCcw } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { AgentSettingsDraft } from "@/components/settings/models/ModelsSettings";
import {
NumberInput,
ReadOnlyRow,
SettingsGroup,
SettingsRow,
SettingsSectionTitle,
StatusPill,
} from "@/components/settings/shared/SettingsControls";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { isLoopbackHost } from "@/lib/network";
import { getRuntimeHost, isNativeRuntime } from "@/lib/runtime";
import type { ApiServicePayload, NanobotFeatureInfo, SettingsPayload } from "@/lib/types";
export function RuntimeSettings({
form,
settings,
onRestart,
isRestarting,
requiresRestartPending,
apiService,
apiServiceLoading,
apiServiceAction,
apiServiceError,
langfuseFeature,
capabilitiesLoading,
capabilityAction,
capabilityError,
onApiServiceAction,
onInstallCapability,
}: {
form: AgentSettingsDraft;
settings: SettingsPayload;
onRestart?: () => void;
isRestarting?: boolean;
requiresRestartPending: boolean;
apiService: ApiServicePayload | null;
apiServiceLoading: boolean;
apiServiceAction: "start" | "stop" | null;
apiServiceError: string | null;
langfuseFeature?: NanobotFeatureInfo;
capabilitiesLoading: boolean;
capabilityAction: string | null;
capabilityError: string | null;
onApiServiceAction: (
action: "start" | "stop",
values?: { host: string; port: number; timeout: number; apiKey?: string },
) => void;
onInstallCapability: (name: string) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const runtimeSurface = settings.surface ?? settings.runtime_surface;
const runtimeHost = getRuntimeHost(runtimeSurface, settings.runtime_capabilities);
const openLogs = runtimeHost.openLogs;
const exportDiagnostics = runtimeHost.exportDiagnostics;
const isNativeHost = isNativeRuntime(runtimeSurface);
const restartActionLabel = isNativeHost
? tx("app.system.restartEngine", "Restart engine")
: t("app.system.restart");
const restartingActionLabel = isNativeHost
? tx("app.system.restartingEngine", "Restarting engine...")
: t("app.system.restarting");
const [diagnosticsPath, setDiagnosticsPath] = useState<string | null>(null);
const [hostActionMessage, setHostActionMessage] = useState<{
target: "logs" | "diagnostics";
message: string;
} | null>(null);
const [hostActionBusy, setHostActionBusy] =
useState<"logs" | "diagnostics" | null>(null);
const apiDefaults = apiService ?? {
installed: false,
running: false,
managed: false,
host: settings.api?.host ?? "127.0.0.1",
port: settings.api?.port ?? 8900,
timeout: settings.api?.timeout ?? 120,
api_key_hint: settings.api?.api_key_hint,
endpoint: `http://127.0.0.1:${settings.api?.port ?? 8900}/v1`,
command: "nanobot serve",
};
const [apiHost, setApiHost] = useState(apiDefaults.host);
const [apiPort, setApiPort] = useState(apiDefaults.port);
const [apiKey, setApiKey] = useState("");
const [apiKeyVisible, setApiKeyVisible] = useState(false);
useEffect(() => {
if (!apiService) return;
setApiHost(apiService.host);
setApiPort(apiService.port);
setApiKey("");
setApiKeyVisible(false);
}, [apiService]);
const apiNetworkAccess = !isLoopbackHost(apiHost);
const apiMissingNetworkKey = apiNetworkAccess && !apiKey.trim() && !apiDefaults.api_key_hint;
const engineState = isRestarting
? tx("settings.values.restartingEngine", "Restarting")
: settings.apply_state?.status === "pending"
? tx("settings.values.pending", "Pending")
: tx("settings.values.ready", "Ready");
const runHostAction = async (
target: "logs" | "diagnostics",
action: (() => Promise<string | void>) | undefined,
successMessage: (result: string | void) => string,
failureMessage: string,
) => {
if (!action) {
setHostActionMessage({
target,
message: tx(
"settings.status.hostApiUnavailable",
"Host actions are only available inside the native app.",
),
});
return;
}
setHostActionBusy(target);
setHostActionMessage(null);
try {
const result = await action();
setHostActionMessage({ target, message: successMessage(result) });
} catch {
setHostActionMessage({ target, message: failureMessage });
} finally {
setHostActionBusy(null);
}
};
return (
<div className="space-y-7">
{isNativeHost ? (
<section>
<SettingsSectionTitle>{tx("settings.sections.nativeHost", "Native host")}</SettingsSectionTitle>
<SettingsGroup>
<ReadOnlyRow title={tx("settings.rows.engine", "Engine")} value={engineState} />
{settings.runtime_capabilities?.can_open_logs ? (
<SettingsRow
title={tx("settings.rows.logs", "Logs")}
description={
hostActionMessage?.target === "logs" ? hostActionMessage.message : undefined
}
>
<Button
size="sm"
variant="outline"
onClick={() =>
void runHostAction(
"logs",
openLogs,
() => tx("settings.status.logsOpened", "Opened logs folder."),
tx("settings.status.logsOpenFailed", "Could not open logs folder."),
)
}
disabled={hostActionBusy !== null}
className="rounded-full"
>
{hostActionBusy === "logs"
? tx("settings.actions.opening", "Opening...")
: tx("settings.actions.open", "Open")}
</Button>
</SettingsRow>
) : null}
{settings.runtime_capabilities?.can_export_diagnostics ? (
<SettingsRow
title={tx("settings.rows.diagnostics", "Diagnostics")}
description={
hostActionMessage?.target === "diagnostics"
? hostActionMessage.message
: diagnosticsPath || undefined
}
>
<Button
size="sm"
variant="outline"
onClick={() =>
void runHostAction(
"diagnostics",
exportDiagnostics ? async () => {
const path = await exportDiagnostics();
setDiagnosticsPath(path);
return path;
} : undefined,
(path) =>
t("settings.status.diagnosticsExported", {
path: String(path ?? ""),
defaultValue: "Diagnostics exported to {{path}}.",
}),
tx("settings.status.diagnosticsExportFailed", "Could not export diagnostics."),
)
}
disabled={hostActionBusy !== null}
className="rounded-full"
>
{hostActionBusy === "diagnostics"
? tx("settings.actions.exporting", "Exporting...")
: tx("settings.actions.export", "Export")}
</Button>
</SettingsRow>
) : null}
</SettingsGroup>
</section>
) : null}
<section>
<SettingsSectionTitle>{tx("settings.api.title", "API server")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.api.openaiCompatible", "OpenAI-compatible API")}
description={
apiServiceError
? apiServiceError
: apiDefaults.running
? apiDefaults.endpoint
: undefined
}
>
<div className="flex items-center justify-end gap-2">
<StatusPill tone={apiDefaults.running ? "success" : "neutral"}>
{apiServiceLoading
? tx("settings.values.checking", "Checking")
: apiDefaults.running
? tx("settings.values.running", "Running")
: tx("settings.values.off", "Off")}
</StatusPill>
<Button
size="sm"
variant="outline"
disabled={apiServiceLoading || apiServiceAction !== null || apiMissingNetworkKey}
onClick={() =>
onApiServiceAction(
apiDefaults.running ? "stop" : "start",
apiDefaults.running
? undefined
: {
host: apiHost,
port: apiPort,
timeout: apiDefaults.timeout,
apiKey: apiKey.trim() || undefined,
},
)
}
className="rounded-full"
>
{apiServiceAction ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : apiDefaults.running ? (
<PauseCircle className="mr-1.5 h-3.5 w-3.5" aria-hidden />
) : (
<PlayCircle className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{apiServiceAction === "start"
? tx("settings.api.starting", "Starting...")
: apiServiceAction === "stop"
? tx("settings.api.stopping", "Stopping...")
: apiDefaults.running
? tx("settings.api.stop", "Stop")
: tx("settings.api.start", "Start API server")}
</Button>
</div>
</SettingsRow>
{!apiDefaults.running ? (
<>
<SettingsRow
title={tx("settings.api.access", "Access")}
description={
apiNetworkAccess
? tx("settings.api.networkHelp", "Other devices can connect; an API key is required.")
: tx("settings.api.localHelp", "Only this device can connect.")
}
>
<SegmentedControl
value={apiNetworkAccess ? "network" : "local"}
options={[
{ value: "local", label: tx("settings.api.thisDevice", "This device") },
{ value: "network", label: tx("settings.api.localNetwork", "Local network") },
]}
onChange={(value) => setApiHost(value === "network" ? "0.0.0.0" : "127.0.0.1")}
/>
</SettingsRow>
<SettingsRow title={tx("settings.api.port", "Port")}>
<NumberInput value={apiPort} min={1} max={65535} onChange={setApiPort} />
</SettingsRow>
{apiNetworkAccess ? (
<SettingsRow
title={tx("settings.api.apiKey", "API key")}
description={
apiMissingNetworkKey
? tx("settings.api.apiKeyRequired", "Required before exposing the API to your network.")
: tx("settings.api.apiKeyHelp", "Clients send this as a Bearer token.")
}
>
<div className="relative w-[280px] max-w-full">
<Input
type={apiKeyVisible ? "text" : "password"}
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder={apiDefaults.api_key_hint ?? tx("settings.api.apiKeyPlaceholder", "Enter an API key")}
className="h-9 rounded-full pr-10 text-[13px]"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setApiKeyVisible((visible) => !visible)}
aria-label={apiKeyVisible ? tx("settings.byok.hideApiKey", "Hide API key") : tx("settings.byok.showApiKey", "Show API key")}
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full"
>
{apiKeyVisible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</Button>
</div>
</SettingsRow>
) : null}
</>
) : null}
</SettingsGroup>
</section>
<section>
<SettingsSectionTitle>{tx("settings.observability.title", "Observability")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title="Langfuse"
description={
settings.observability?.configured
? undefined
: tx(
"settings.observability.environment",
"Set LANGFUSE_SECRET_KEY and LANGFUSE_PUBLIC_KEY, then restart nanobot.",
)
}
>
{capabilitiesLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" aria-hidden />
) : langfuseFeature?.installed ? (
<StatusPill tone={settings.observability?.configured ? "success" : "neutral"}>
{settings.observability?.configured
? tx("settings.values.ready", "Ready")
: tx("settings.values.needsSetup", "Needs setup")}
</StatusPill>
) : (
<Button
size="sm"
variant="outline"
disabled={capabilityAction === "enable:langfuse"}
onClick={() => onInstallCapability("langfuse")}
className="rounded-full"
>
{capabilityAction === "enable:langfuse" ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : null}
{capabilityAction === "enable:langfuse"
? tx("settings.capabilities.installing", "Installing support...")
: tx("settings.observability.enable", "Enable tracing support")}
</Button>
)}
</SettingsRow>
</SettingsGroup>
{capabilityError ? <p className="mt-2 text-[12px] text-destructive">{capabilityError}</p> : null}
</section>
<section>
<SettingsSectionTitle>{t("settings.sections.system")}</SettingsSectionTitle>
<SettingsGroup>
{!isNativeHost ? (
<ReadOnlyRow
title={tx("settings.rows.gateway", "Gateway")}
value={`${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`}
/>
) : null}
<ReadOnlyRow title={t("settings.rows.configPath")} value={settings.runtime.config_path} />
<ReadOnlyRow title={tx("settings.rows.workspacePath", "Default workspace")} value={settings.runtime.workspace_path} />
<ReadOnlyRow title={tx("settings.rows.timezone", "Timezone")} value={form.timezone} />
{onRestart ? (
<SettingsRow
title={t("settings.rows.restart")}
description={
requiresRestartPending
? tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
: undefined
}
>
<Button
size="sm"
variant="outline"
onClick={onRestart}
disabled={isRestarting}
className="rounded-full"
>
{isRestarting ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{isRestarting ? restartingActionLabel : restartActionLabel}
</Button>
</SettingsRow>
) : null}
</SettingsGroup>
</section>
</div>
);
}
@@ -0,0 +1,656 @@
import type { Dispatch, SetStateAction } from "react";
import type { TFunction } from "i18next";
import type {
ApplySettingsPayload,
MaybeRestartHostEngine,
PendingRestartSections,
} from "@/components/settings/contracts";
import type { AutomationAction } from "@/components/settings/system/AutomationsSettings";
import { DEFAULT_CUSTOM_MCP_FORM } from "@/components/settings/system/AppsSettings";
import type { SystemSettingsState } from "@/components/settings/system/useSystemSettingsState";
import {
cancelMcpOAuth,
completeMcpOAuth,
disableNanobotFeature,
enableNanobotFeature,
fetchNanobotFeatures,
fetchSettings,
fetchMcpOAuthStatus,
fetchMcpPresets,
importMcpConfig,
runAutomationAction,
runCliAppAction,
runMcpPresetAction,
saveCustomMcpServer,
startMcpOAuth,
startApiService,
stopApiService,
updateAutomation,
updateMcpServerTools,
} from "@/lib/api";
import { notifyCliAppsChanged } from "@/lib/cli-app-events";
import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events";
import type { NanobotClient } from "@/lib/nanobot-client";
import type {
AutomationUpdatePayload,
McpOAuthFlowPayload,
McpPresetsPayload,
NanobotFeatureInfo,
SessionAutomationJob,
} from "@/lib/types";
function isExpectedMcpOAuthPendingReloadFailure(
payload: McpPresetsPayload,
expectedName?: string,
): boolean {
if (
!expectedName
|| payload.last_action?.ok === false
|| payload.hot_reload?.ok !== false
) return false;
const normalizedName = expectedName.trim().toLowerCase();
const failed = payload.hot_reload.failed ?? [];
if (
!normalizedName
|| failed.length !== 1
|| failed[0].trim().toLowerCase() !== normalizedName
) return false;
return payload.presets.some((preset) => (
preset.name.trim().toLowerCase() === normalizedName
&& preset.auth === "oauth"
&& preset.status === "authorization_required"
));
}
interface SystemSettingsActionsOptions {
state: SystemSettingsState;
featureCatalog: NanobotFeatureInfo[];
client: NanobotClient;
token: string;
getToken: () => string;
t: TFunction;
applyPayload: ApplySettingsPayload;
maybeRestartHostEngine: MaybeRestartHostEngine;
setPendingRestartSections: Dispatch<SetStateAction<PendingRestartSections>>;
refreshAutomations: (showLoading?: boolean) => Promise<void>;
}
export function createSystemSettingsActions({
state,
featureCatalog,
client,
token,
getToken,
t,
applyPayload,
maybeRestartHostEngine,
setPendingRestartSections,
refreshAutomations,
}: SystemSettingsActionsOptions) {
const {
apiServiceAction,
customMcpForm,
mcpConfigImport,
mcpOAuthCallbackUrl,
mcpOAuthFlowRef,
mcpOAuthNavigatedUrlRef,
mcpOAuthPopupRef,
nanobotFeatures,
setApiService,
setApiServiceAction,
setApiServiceError,
setAutomationAction,
setAutomationPendingDelete,
setAutomationPendingEdit,
setAutomations,
setAutomationsError,
setCliApps,
setCliAppsAction,
setCliAppsError,
setCliAppsFocusName,
setCliAppsMessage,
setCustomMcpForm,
setMcpConfigImport,
setMcpError,
setMcpFieldValues,
setMcpMessage,
setMcpOAuthCallbackError,
setMcpOAuthCallbackUrl,
setMcpOAuthCompleting,
setMcpOAuthFlow,
setMcpOAuthPopupBlocked,
setMcpPresetAction,
setMcpPresets,
setNanobotFeatureAction,
setNanobotFeatureConfirm,
setNanobotFeatures,
setNanobotFeaturesError,
} = state;
const installCapabilities = async (names: string[]): Promise<boolean> => {
const missing = names.filter(
(name) => !featureCatalog.find((feature) => feature.name === name)?.installed,
);
if (!missing.length) return true;
setNanobotFeatureAction(`enable:${names.join("+")}`);
setNanobotFeaturesError(null);
try {
let latest = nanobotFeatures;
for (const name of missing) {
latest = await enableNanobotFeature(client, name);
if (latest.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
}
if (latest) setNanobotFeatures(latest);
return true;
} catch (err) {
setNanobotFeaturesError((err as Error).message);
return false;
} finally {
setNanobotFeatureAction(null);
}
};
const handleApiServiceAction = async (
action: "start" | "stop",
values?: { host: string; port: number; timeout: number; apiKey?: string },
) => {
if (apiServiceAction) return;
setApiServiceAction(action);
setApiServiceError(null);
try {
const payload = action === "start"
? await startApiService(client, values!)
: await stopApiService(client);
setApiService(payload);
const refreshed = await fetchNanobotFeatures(token);
setNanobotFeatures(refreshed);
const nextSettings = await fetchSettings(token);
applyPayload(nextSettings);
} catch (err) {
setApiServiceError((err as Error).message);
} finally {
setApiServiceAction(null);
}
};
const handleCliAppAction = async (
action: "install" | "update" | "uninstall" | "test",
name: string,
) => {
const key = `${action}:${name}`;
setCliAppsAction(key);
setCliAppsMessage(null);
setCliAppsError(null);
try {
const payload = await runCliAppAction(client, action, name);
setCliApps(payload);
if (action !== "test") {
notifyCliAppsChanged(payload);
}
setCliAppsMessage(payload.last_action?.message ?? null);
setCliAppsFocusName(action === "uninstall" ? null : name);
} catch (err) {
setCliAppsError((err as Error).message);
} finally {
setCliAppsAction(null);
}
};
const handleNanobotFeatureAction = async (
action: "enable" | "disable",
name: string,
confirmed = false,
) => {
const feature = featureCatalog.find((item) => item.name === name);
if (action === "enable" && !confirmed && feature && !feature.installed && feature.install_supported) {
setNanobotFeaturesError(null);
setNanobotFeatureConfirm(feature);
return;
}
const key = `${action}:${name}`;
setNanobotFeatureAction(key);
setNanobotFeatureConfirm(null);
setNanobotFeaturesError(null);
try {
const payload = action === "enable"
? await enableNanobotFeature(client, name)
: await disableNanobotFeature(client, name);
setNanobotFeatures(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
} catch (err) {
setNanobotFeaturesError((err as Error).message);
} finally {
setNanobotFeatureAction(null);
}
};
const handleAutomationAction = async (
action: AutomationAction,
job: SessionAutomationJob,
) => {
const key = `${action}:${job.id}`;
setAutomationAction(key);
setAutomationsError(null);
try {
const payload = await runAutomationAction(client, action, job.id);
setAutomations(payload);
if (action === "delete") setAutomationPendingDelete(null);
if (action === "run") {
window.setTimeout(() => void refreshAutomations(false), 1200);
window.setTimeout(() => void refreshAutomations(false), 4000);
}
} catch (err) {
setAutomationsError((err as Error).message);
} finally {
setAutomationAction(null);
}
};
const handleAutomationEdit = async (
job: SessionAutomationJob,
values: AutomationUpdatePayload,
) => {
const key = `update:${job.id}`;
setAutomationAction(key);
setAutomationsError(null);
try {
const payload = await updateAutomation(client, job.id, values);
setAutomations(payload);
setAutomationPendingEdit(null);
} catch (err) {
setAutomationsError((err as Error).message);
} finally {
setAutomationAction(null);
}
};
const closeMcpOAuthPopup = () => {
const popup = mcpOAuthPopupRef.current;
mcpOAuthPopupRef.current = null;
mcpOAuthNavigatedUrlRef.current = null;
if (!popup) return;
try {
if (!popup.closed) popup.close();
} catch {
// The authorization page may have navigated cross-origin before it closed itself.
}
};
const openMcpOAuthPopup = (authorizationUrl?: string): Window | null => {
let popup: Window | null = null;
try {
popup = window.open(
authorizationUrl ?? "about:blank",
"nanobot-mcp-oauth",
"popup,width=560,height=720,resizable=yes,scrollbars=yes",
);
if (popup) {
mcpOAuthPopupRef.current = popup;
mcpOAuthNavigatedUrlRef.current = authorizationUrl ?? null;
if (!authorizationUrl) {
try {
popup.document.title = t("settings.oauth.signingIn", { defaultValue: "Preparing sign-in…" });
popup.document.body.textContent = t("settings.mcp.preparingSignIn", {
defaultValue: "Preparing secure sign-in…",
});
} catch {
// about:blank can become unavailable if the window is reused mid-navigation.
}
}
try {
popup.opener = null;
popup.focus();
} catch {
// A cross-origin authorization page can restrict window access.
}
}
} catch {
// Browsers can reject popup creation before returning a window handle.
}
setMcpOAuthPopupBlocked(!popup);
return popup;
};
const navigateMcpOAuthPopup = (flow: McpOAuthFlowPayload) => {
const authorizationUrl = flow.authorization_url;
if (!authorizationUrl) return;
const popup = mcpOAuthPopupRef.current;
// OAuth pages can use Cross-Origin-Opener-Policy, which severs the
// WindowProxy and makes an open tab appear closed. Once navigation was
// requested, do not mistake that browser isolation for a blocked popup.
if (popup && mcpOAuthNavigatedUrlRef.current === authorizationUrl) return;
try {
if (popup && !popup.closed) {
popup.location.replace(authorizationUrl);
mcpOAuthNavigatedUrlRef.current = authorizationUrl;
popup.focus();
setMcpOAuthPopupBlocked(false);
return;
}
if (popup) return;
} catch {
// Fall through to the explicit Continue in browser action.
}
setMcpOAuthPopupBlocked(true);
};
const finishMcpOAuthFlow = async (flow: McpOAuthFlowPayload) => {
if (mcpOAuthFlowRef.current?.flow_id !== flow.flow_id) return;
closeMcpOAuthPopup();
mcpOAuthFlowRef.current = null;
setMcpOAuthFlow(null);
setMcpPresetAction(null);
setMcpOAuthCallbackUrl("");
setMcpOAuthCompleting(false);
setMcpOAuthCallbackError(null);
if (flow.status === "connected") {
try {
const payload = await fetchMcpPresets(getToken());
setMcpPresets(payload);
notifyMcpPresetsChanged(payload);
setMcpMessage(null);
setMcpError(null);
} catch (err) {
setMcpError((err as Error).message);
}
return;
}
if (flow.status === "authorized" && flow.hot_reload) {
if (flow.hot_reload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
setMcpError(
flow.hot_reload.message
|| t("settings.mcp.reloadFailed", {
defaultValue: "Signed in, but nanobot could not connect the tools. Try restarting nanobot.",
}),
);
return;
}
if (flow.status === "failed") {
setMcpError(
flow.error
|| t("settings.mcp.oauthFailed", {
defaultValue: "Unable to connect. Try signing in again.",
}),
);
}
};
const monitorMcpOAuthFlow = async (initial: McpOAuthFlowPayload) => {
let current = initial;
while (mcpOAuthFlowRef.current?.flow_id === current.flow_id) {
navigateMcpOAuthPopup(current);
const terminal =
current.status === "connected"
|| current.status === "failed"
|| current.status === "cancelled"
|| (current.status === "authorized" && Boolean(current.hot_reload));
if (terminal) {
await finishMcpOAuthFlow(current);
return;
}
await new Promise((resolve) => window.setTimeout(resolve, 800));
if (mcpOAuthFlowRef.current?.flow_id !== current.flow_id) return;
try {
current = await fetchMcpOAuthStatus(getToken(), current.flow_id);
if (mcpOAuthFlowRef.current?.flow_id !== current.flow_id) return;
mcpOAuthFlowRef.current = current;
setMcpOAuthFlow(current);
} catch (err) {
if (mcpOAuthFlowRef.current?.flow_id !== current.flow_id) return;
closeMcpOAuthPopup();
mcpOAuthFlowRef.current = null;
setMcpOAuthFlow(null);
setMcpPresetAction(null);
setMcpOAuthCallbackUrl("");
setMcpOAuthCompleting(false);
setMcpOAuthCallbackError(null);
setMcpError((err as Error).message);
return;
}
}
};
const handleMcpOAuthConnect = async (name: string) => {
openMcpOAuthPopup();
const key = `oauth:${name}`;
setMcpPresetAction(key);
setMcpMessage(null);
setMcpError(null);
setMcpOAuthCallbackUrl("");
setMcpOAuthCompleting(false);
setMcpOAuthCallbackError(null);
try {
const flow = await startMcpOAuth(client, name);
mcpOAuthFlowRef.current = flow;
setMcpOAuthFlow(flow);
navigateMcpOAuthPopup(flow);
void monitorMcpOAuthFlow(flow);
} catch (err) {
closeMcpOAuthPopup();
mcpOAuthFlowRef.current = null;
setMcpOAuthFlow(null);
setMcpPresetAction(null);
setMcpOAuthCallbackUrl("");
setMcpOAuthCompleting(false);
setMcpOAuthCallbackError(null);
setMcpError((err as Error).message);
}
};
const handleMcpOAuthCancel = async () => {
const flow = mcpOAuthFlowRef.current;
if (!flow) return;
mcpOAuthFlowRef.current = null;
setMcpOAuthFlow(null);
setMcpPresetAction(null);
setMcpOAuthCallbackUrl("");
setMcpOAuthCompleting(false);
setMcpOAuthCallbackError(null);
closeMcpOAuthPopup();
try {
await cancelMcpOAuth(client, flow.flow_id);
} catch (err) {
setMcpError((err as Error).message);
}
};
const handleMcpOAuthOpen = () => {
const authorizationUrl = mcpOAuthFlowRef.current?.authorization_url;
if (!authorizationUrl) return;
openMcpOAuthPopup(authorizationUrl);
};
const handleMcpOAuthComplete = async () => {
const flow = mcpOAuthFlowRef.current;
const callbackUrl = mcpOAuthCallbackUrl.trim();
if (!flow || flow.completion_input !== "callback_url") return;
if (!callbackUrl) {
setMcpOAuthCallbackError(t("settings.oauth.pasteCallbackToContinue"));
return;
}
setMcpOAuthCompleting(true);
setMcpOAuthCallbackError(null);
try {
const next = await completeMcpOAuth(client, flow.flow_id, callbackUrl);
if (mcpOAuthFlowRef.current?.flow_id !== flow.flow_id) return;
mcpOAuthFlowRef.current = next;
setMcpOAuthFlow(next);
} catch (err) {
if (mcpOAuthFlowRef.current?.flow_id !== flow.flow_id) return;
setMcpOAuthCallbackError((err as Error).message);
} finally {
if (mcpOAuthFlowRef.current?.flow_id === flow.flow_id) {
setMcpOAuthCompleting(false);
}
}
};
const applyMcpActionFeedback = (
payload: McpPresetsPayload,
announceSuccess = false,
expectedOAuthPendingName?: string,
) => {
const expectedOAuthPending = isExpectedMcpOAuthPendingReloadFailure(
payload,
expectedOAuthPendingName,
);
const actionError = payload.last_action?.ok === false
? payload.last_action.error || payload.last_action.message
: payload.hot_reload?.ok === false && !expectedOAuthPending
? payload.hot_reload.message
: null;
setMcpError(actionError || null);
setMcpMessage(
actionError || !announceSuccess
? null
: payload.last_action?.message ?? null,
);
};
const handleMcpPresetAction = async (
action: "enable" | "remove" | "test",
name: string,
values: Record<string, string> = {},
) => {
const key = `${action}:${name}`;
setMcpPresetAction(key);
setMcpMessage(null);
setMcpError(null);
try {
const payload = await runMcpPresetAction(client, action, name, values);
setMcpPresets(payload);
applyMcpActionFeedback(payload, action === "test");
if (action !== "test") {
notifyMcpPresetsChanged(payload);
}
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
await maybeRestartHostEngine(payload);
if (action === "enable") {
setMcpFieldValues((prev) => ({ ...prev, [name]: {} }));
}
} catch (err) {
setMcpError((err as Error).message);
} finally {
setMcpPresetAction(null);
}
};
const handleSaveCustomMcp = async () => {
const name = customMcpForm.name.trim();
const expectsOAuthAuthorization = (
customMcpForm.transport !== "stdio" && customMcpForm.auth === "oauth"
);
const key = `custom:${name || "new"}`;
setMcpPresetAction(key);
setMcpMessage(null);
setMcpError(null);
try {
const payload = await saveCustomMcpServer(client, {
name,
transport: customMcpForm.transport,
auth:
customMcpForm.transport !== "stdio" && customMcpForm.auth === "oauth"
? "oauth"
: "",
command: customMcpForm.command,
args: customMcpForm.args,
url: customMcpForm.url,
env: customMcpForm.env,
headers:
customMcpForm.transport !== "stdio" && customMcpForm.auth === "headers"
? customMcpForm.headers
: "",
tool_timeout: customMcpForm.toolTimeout,
});
setMcpPresets(payload);
applyMcpActionFeedback(
payload,
false,
expectsOAuthAuthorization ? name : undefined,
);
notifyMcpPresetsChanged(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
await maybeRestartHostEngine(payload);
setCustomMcpForm((prev) => ({ ...DEFAULT_CUSTOM_MCP_FORM, transport: prev.transport }));
} catch (err) {
setMcpError((err as Error).message);
} finally {
setMcpPresetAction(null);
}
};
const handleImportMcpConfig = async () => {
setMcpPresetAction("import");
setMcpMessage(null);
setMcpError(null);
try {
const payload = await importMcpConfig(client, mcpConfigImport);
setMcpPresets(payload);
applyMcpActionFeedback(payload);
notifyMcpPresetsChanged(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
await maybeRestartHostEngine(payload);
setMcpConfigImport("");
} catch (err) {
setMcpError((err as Error).message);
} finally {
setMcpPresetAction(null);
}
};
const handleMcpToolsChange = async (name: string, enabledTools: string[]) => {
setMcpPresetAction(`tools:${name}`);
setMcpMessage(null);
setMcpError(null);
try {
const payload = await updateMcpServerTools(client, name, enabledTools);
setMcpPresets(payload);
applyMcpActionFeedback(payload);
notifyMcpPresetsChanged(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
await maybeRestartHostEngine(payload);
} catch (err) {
setMcpError((err as Error).message);
} finally {
setMcpPresetAction(null);
}
};
return {
handleApiServiceAction,
handleAutomationAction,
handleAutomationEdit,
handleCliAppAction,
handleImportMcpConfig,
handleMcpOAuthCancel,
handleMcpOAuthComplete,
handleMcpOAuthConnect,
handleMcpOAuthOpen,
handleMcpPresetAction,
handleMcpToolsChange,
handleNanobotFeatureAction,
handleSaveCustomMcp,
installCapabilities,
};
}
@@ -0,0 +1,221 @@
import { useCallback, useEffect } from "react";
import type { SettingsSectionKey } from "@/components/settings/contracts";
import {
CLI_APPS_REFRESH_MAX_RETRIES,
CLI_APPS_REFRESH_RETRY_MS,
} from "@/components/settings/system/AppsSettings";
import type { SystemSettingsState } from "@/components/settings/system/useSystemSettingsState";
import {
fetchApiService,
fetchAutomations,
fetchCliApps,
fetchMcpPresets,
fetchNanobotFeatures,
} from "@/lib/api";
interface SystemSettingsEffectsOptions {
state: SystemSettingsState;
activeSection: SettingsSectionKey;
getToken: () => string;
pageVisible: boolean;
}
export function useSystemSettingsEffects({
state,
activeSection,
getToken,
pageVisible,
}: SystemSettingsEffectsOptions) {
const {
setApiService,
setApiServiceError,
setApiServiceLoading,
setAutomations,
setAutomationsError,
setAutomationsLoading,
setCliApps,
setCliAppsError,
setCliAppsLoading,
setMcpError,
setMcpPresets,
setMcpPresetsLoading,
setNanobotFeatures,
setNanobotFeaturesError,
setNanobotFeaturesLoading,
} = state;
useEffect(() => {
if (activeSection !== "apps") return;
let cancelled = false;
let retry: number | null = null;
let retryCount = 0;
const loadCliApps = (showLoading: boolean) => {
if (showLoading) setCliAppsLoading(true);
fetchCliApps(getToken())
.then((payload) => {
if (cancelled) return;
if (payload.catalog_refresh_pending && retryCount < CLI_APPS_REFRESH_MAX_RETRIES) {
retryCount += 1;
retry = window.setTimeout(() => {
retry = null;
loadCliApps(false);
}, CLI_APPS_REFRESH_RETRY_MS);
}
setCliApps(payload);
setCliAppsError(null);
setCliAppsLoading(false);
})
.catch((err) => {
if (!cancelled) {
setCliAppsError((err as Error).message);
setCliAppsLoading(false);
}
});
};
loadCliApps(true);
return () => {
cancelled = true;
if (retry !== null) window.clearTimeout(retry);
};
}, [activeSection, getToken]);
useEffect(() => {
if (
!pageVisible
|| !["channels", "models", "browser", "runtime"].includes(activeSection)
) {
return;
}
let cancelled = false;
let refreshing = false;
const refresh = async (showLoading = false): Promise<void> => {
if (refreshing) return;
refreshing = true;
if (showLoading) setNanobotFeaturesLoading(true);
try {
const payload = await fetchNanobotFeatures(getToken());
if (!cancelled) {
setNanobotFeatures(payload);
setNanobotFeaturesError(null);
}
} catch (err) {
const message = (err as Error).message;
if (!cancelled && message !== "HTTP 404") setNanobotFeaturesError(message);
} finally {
refreshing = false;
if (!cancelled && showLoading) setNanobotFeaturesLoading(false);
}
};
void refresh(true);
const interval = activeSection === "channels"
? window.setInterval(() => void refresh(false), 5000)
: null;
const refreshOnFocus = () => {
if (activeSection === "channels" && document.visibilityState !== "hidden") {
void refresh(false);
}
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
return () => {
cancelled = true;
if (interval !== null) window.clearInterval(interval);
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
};
}, [activeSection, getToken, pageVisible]);
useEffect(() => {
if (activeSection !== "runtime") return;
let cancelled = false;
setApiServiceLoading(true);
fetchApiService(getToken())
.then((payload) => {
if (!cancelled) {
setApiService(payload);
setApiServiceError(null);
}
})
.catch((err) => {
if (!cancelled) setApiServiceError((err as Error).message);
})
.finally(() => {
if (!cancelled) setApiServiceLoading(false);
});
return () => {
cancelled = true;
};
}, [activeSection, getToken]);
useEffect(() => {
if (activeSection !== "apps") return;
let cancelled = false;
setMcpPresetsLoading(true);
fetchMcpPresets(getToken())
.then((payload) => {
if (!cancelled) {
setMcpPresets(payload);
setMcpError(null);
}
})
.catch((err) => {
if (!cancelled) setMcpError((err as Error).message);
})
.finally(() => {
if (!cancelled) setMcpPresetsLoading(false);
});
return () => {
cancelled = true;
};
}, [activeSection, getToken]);
const refreshAutomations = useCallback(
async (showLoading = false) => {
if (showLoading) setAutomationsLoading(true);
try {
const payload = await fetchAutomations(getToken());
setAutomations(payload);
setAutomationsError(null);
} catch (err) {
setAutomationsError((err as Error).message);
} finally {
if (showLoading) setAutomationsLoading(false);
}
},
[getToken],
);
useEffect(() => {
if (activeSection !== "automations" || !pageVisible) return;
let cancelled = false;
let refreshing = false;
const refresh = async (showLoading = false) => {
if (cancelled || refreshing) return;
refreshing = true;
if (showLoading) setAutomationsLoading(true);
try {
const payload = await fetchAutomations(getToken());
if (cancelled) return;
setAutomations(payload);
setAutomationsError(null);
} catch (err) {
if (!cancelled) setAutomationsError((err as Error).message);
} finally {
refreshing = false;
if (!cancelled && showLoading) setAutomationsLoading(false);
}
};
void refresh(true);
const interval = window.setInterval(() => void refresh(false), 5000);
const refreshOnFocus = () => void refresh(false);
window.addEventListener("focus", refreshOnFocus);
return () => {
cancelled = true;
window.clearInterval(interval);
window.removeEventListener("focus", refreshOnFocus);
};
}, [activeSection, getToken, pageVisible]);
return { refreshAutomations };
}
@@ -0,0 +1,157 @@
import { useRef, useState } from "react";
import type {
AutomationFilter,
AutomationSort,
} from "@/components/settings/system/AutomationsSettings";
import {
DEFAULT_CUSTOM_MCP_FORM,
type AppsKindFilter,
type CustomMcpForm,
} from "@/components/settings/system/AppsSettings";
import type {
ApiServicePayload,
AutomationsPayload,
CliAppsPayload,
McpOAuthFlowPayload,
McpPresetsPayload,
NanobotFeatureInfo,
NanobotFeaturesPayload,
SessionAutomationJob,
} from "@/lib/types";
export function useSystemSettingsState() {
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
const [nanobotFeatures, setNanobotFeatures] = useState<NanobotFeaturesPayload | null>(null);
const [mcpPresets, setMcpPresets] = useState<McpPresetsPayload | null>(null);
const [automations, setAutomations] = useState<AutomationsPayload | null>(null);
const [cliAppsLoading, setCliAppsLoading] = useState(true);
const [nanobotFeaturesLoading, setNanobotFeaturesLoading] = useState(true);
const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true);
const [automationsLoading, setAutomationsLoading] = useState(false);
const [cliAppsAction, setCliAppsAction] = useState<string | null>(null);
const [nanobotFeatureAction, setNanobotFeatureAction] = useState<string | null>(null);
const [nanobotFeatureConfirm, setNanobotFeatureConfirm] = useState<NanobotFeatureInfo | null>(null);
const [mcpPresetAction, setMcpPresetAction] = useState<string | null>(null);
const [mcpOAuthFlow, setMcpOAuthFlow] = useState<McpOAuthFlowPayload | null>(null);
const mcpOAuthFlowRef = useRef<McpOAuthFlowPayload | null>(null);
const mcpOAuthPopupRef = useRef<Window | null>(null);
const mcpOAuthNavigatedUrlRef = useRef<string | null>(null);
const [mcpOAuthPopupBlocked, setMcpOAuthPopupBlocked] = useState(false);
const [mcpOAuthCallbackUrl, setMcpOAuthCallbackUrl] = useState("");
const [mcpOAuthCompleting, setMcpOAuthCompleting] = useState(false);
const [mcpOAuthCallbackError, setMcpOAuthCallbackError] = useState<string | null>(null);
const [apiService, setApiService] = useState<ApiServicePayload | null>(null);
const [apiServiceLoading, setApiServiceLoading] = useState(false);
const [apiServiceAction, setApiServiceAction] = useState<"start" | "stop" | null>(null);
const [apiServiceError, setApiServiceError] = useState<string | null>(null);
const [appsQuery, setAppsQuery] = useState("");
const [channelsQuery, setChannelsQuery] = useState("");
const [automationsQuery, setAutomationsQuery] = useState("");
const [automationsFilter, setAutomationsFilter] = useState<AutomationFilter>("all");
const [automationsSort, setAutomationsSort] = useState<AutomationSort>("next");
const [cliAppsMessage, setCliAppsMessage] = useState<string | null>(null);
const [cliAppsError, setCliAppsError] = useState<string | null>(null);
const [nanobotFeaturesError, setNanobotFeaturesError] = useState<string | null>(null);
const [cliAppsFocusName, setCliAppsFocusName] = useState<string | null>(null);
const [appsKindFilter, setAppsKindFilter] = useState<AppsKindFilter>("cli");
const [mcpMessage, setMcpMessage] = useState<string | null>(null);
const [mcpError, setMcpError] = useState<string | null>(null);
const [automationsError, setAutomationsError] = useState<string | null>(null);
const [automationAction, setAutomationAction] = useState<string | null>(null);
const [automationPendingDelete, setAutomationPendingDelete] =
useState<SessionAutomationJob | null>(null);
const [automationPendingEdit, setAutomationPendingEdit] =
useState<SessionAutomationJob | null>(null);
const [mcpFieldValues, setMcpFieldValues] = useState<Record<string, Record<string, string>>>({});
const [customMcpForm, setCustomMcpForm] = useState<CustomMcpForm>(DEFAULT_CUSTOM_MCP_FORM);
const [mcpConfigImport, setMcpConfigImport] = useState("");
return {
apiService,
apiServiceAction,
apiServiceError,
apiServiceLoading,
appsKindFilter,
appsQuery,
automationAction,
automationPendingDelete,
automationPendingEdit,
automations,
automationsError,
automationsFilter,
automationsLoading,
automationsQuery,
automationsSort,
channelsQuery,
cliApps,
cliAppsAction,
cliAppsError,
cliAppsFocusName,
cliAppsLoading,
cliAppsMessage,
customMcpForm,
mcpConfigImport,
mcpError,
mcpFieldValues,
mcpMessage,
mcpOAuthCallbackError,
mcpOAuthCallbackUrl,
mcpOAuthCompleting,
mcpOAuthFlow,
mcpOAuthFlowRef,
mcpOAuthNavigatedUrlRef,
mcpOAuthPopupBlocked,
mcpOAuthPopupRef,
mcpPresetAction,
mcpPresets,
mcpPresetsLoading,
nanobotFeatureAction,
nanobotFeatureConfirm,
nanobotFeatures,
nanobotFeaturesError,
nanobotFeaturesLoading,
setApiService,
setApiServiceAction,
setApiServiceError,
setApiServiceLoading,
setAppsKindFilter,
setAppsQuery,
setAutomationAction,
setAutomationPendingDelete,
setAutomationPendingEdit,
setAutomations,
setAutomationsError,
setAutomationsFilter,
setAutomationsLoading,
setAutomationsQuery,
setAutomationsSort,
setChannelsQuery,
setCliApps,
setCliAppsAction,
setCliAppsError,
setCliAppsFocusName,
setCliAppsLoading,
setCliAppsMessage,
setCustomMcpForm,
setMcpConfigImport,
setMcpError,
setMcpFieldValues,
setMcpMessage,
setMcpOAuthCallbackError,
setMcpOAuthCallbackUrl,
setMcpOAuthCompleting,
setMcpOAuthFlow,
setMcpOAuthPopupBlocked,
setMcpPresetAction,
setMcpPresets,
setMcpPresetsLoading,
setNanobotFeatureAction,
setNanobotFeatureConfirm,
setNanobotFeatures,
setNanobotFeaturesError,
setNanobotFeaturesLoading,
};
}
export type SystemSettingsState = ReturnType<typeof useSystemSettingsState>;
@@ -0,0 +1,613 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { imageGenerationFormFromPayload } from "@/components/settings/capabilities/ImageGenerationSettings";
import {
networkSafetyFormFromPayload,
visibleWebuiDefaultAccessMode,
} from "@/components/settings/capabilities/SecuritySettings";
import {
DEFAULT_TRANSCRIPTION_SETTINGS,
transcriptionFormFromPayload,
} from "@/components/settings/capabilities/TranscriptionSettings";
import { useCapabilitySettingsActions } from "@/components/settings/capabilities/useCapabilitySettingsActions";
import { useCapabilitySettingsState } from "@/components/settings/capabilities/useCapabilitySettingsState";
import { webSearchFormFromPayload } from "@/components/settings/capabilities/WebSettings";
import type {
ApplySettingsPayload,
PendingRestartSections,
RestartAwarePayload,
SettingsSectionKey,
} from "@/components/settings/contracts";
import { agentDraftFromPayload } from "@/components/settings/models/ModelsSettings";
import { useModelSettingsActions } from "@/components/settings/models/useModelSettingsActions";
import {
useProviderFormsSync,
useProviderOAuthPolling,
} from "@/components/settings/models/useModelSettingsEffects";
import { useModelSettingsState } from "@/components/settings/models/useModelSettingsState";
import { normalizeContextWindowTokens } from "@/components/settings/shared/ModelControls";
import { createSystemSettingsActions } from "@/components/settings/system/createSystemSettingsActions";
import { useSystemSettingsEffects } from "@/components/settings/system/useSystemSettingsEffects";
import { useSystemSettingsState } from "@/components/settings/system/useSystemSettingsState";
import { usePageVisibility } from "@/hooks/usePageVisibility";
import { fetchSettings, fetchSettingsUsage } from "@/lib/api";
import {
readLocalPreferences,
writeLocalPreferences,
type LocalPreferences,
} from "@/lib/local-preferences";
import { isLoopbackHost } from "@/lib/network";
import type { SettingsPayload } from "@/lib/types";
import { useClient } from "@/providers/ClientProvider";
interface SettingsControllerOptions {
initialSection: SettingsSectionKey;
initialSettings: SettingsPayload | null;
onModelNameChange: (modelName: string | null) => void;
onSettingsChange?: (payload: SettingsPayload) => void;
onSectionChange?: (section: SettingsSectionKey) => void;
onRestart?: () => void;
onNativeEngineRestart?: () => Promise<string>;
}
const EMPTY_PENDING_RESTART_SECTIONS: PendingRestartSections = {
runtime: false,
browser: false,
image: false,
};
function pendingRestartSectionsFromPayload(payload: SettingsPayload): PendingRestartSections {
const sections = payload.restart_required_sections ?? [];
return {
runtime: sections.includes("runtime"),
browser: sections.includes("browser"),
image: sections.includes("image"),
};
}
export function useSettingsController({
initialSection,
initialSettings,
onModelNameChange,
onSettingsChange,
onSectionChange,
onRestart,
onNativeEngineRestart,
}: SettingsControllerOptions) {
const { t } = useTranslation();
const { client, getToken, token } = useClient();
const pageVisible = usePageVisibility();
const remoteBrowserAccess =
typeof window !== "undefined" && !isLoopbackHost(window.location.hostname);
const [settings, setSettings] = useState<SettingsPayload | null>(() => initialSettings);
const [loading, setLoading] = useState(() => initialSettings === null);
const [hostEngineApplying, setHostEngineApplying] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeSection, setActiveSection] = useState<SettingsSectionKey>(initialSection);
const [pendingRestartSections, setPendingRestartSections] = useState<PendingRestartSections>(
EMPTY_PENDING_RESTART_SECTIONS,
);
const [localPrefs, setLocalPrefs] = useState<LocalPreferences>(() => readLocalPreferences());
const modelState = useModelSettingsState(initialSettings);
const {
editingProviderKeys, expandedProvider, form, modelCallOrder, modelCallOrderSaving,
modelConfigurationSaving, modelMigrationSaving, modelPresetBeforeCreateRef,
modelPresetCreating, modelPresetPendingDelete, providerForms, providerOAuthCompleting,
providerOAuthDialogError, providerOAuthFlow, providerOAuthFlowRef, providerOAuthResponse,
providerSaving, saving, setForm,
setModelCallOrder, setModelPresetCreating, setModelPresetPendingDelete,
setProviderForms, setProviderOAuthCompleting, setProviderOAuthDialogError,
setProviderOAuthFlow, setProviderOAuthResponse, visibleProviderKeys,
} = modelState;
const capabilityState = useCapabilitySettingsState(initialSettings);
const {
imageGenerationForm, imageGenerationSaving, networkSafetyForm, networkSafetySaving,
setImageGenerationForm, setNetworkSafetyForm, setTranscriptionForm, setWebSearchForm,
setWebSearchKeyEditing, setWebSearchKeyVisible, transcriptionForm,
transcriptionSaving, webSearchForm, webSearchKeyEditing, webSearchKeyVisible,
webSearchSaving,
} = capabilityState;
const systemState = useSystemSettingsState();
const {
apiService, apiServiceAction, apiServiceError, apiServiceLoading, appsKindFilter, appsQuery,
automationAction, automationPendingDelete, automationPendingEdit, automations,
automationsError, automationsFilter, automationsLoading, automationsQuery, automationsSort,
channelsQuery, cliApps, cliAppsAction, cliAppsError, cliAppsFocusName, cliAppsLoading,
cliAppsMessage, customMcpForm, mcpConfigImport, mcpError, mcpFieldValues, mcpMessage,
mcpOAuthCallbackError, mcpOAuthCallbackUrl, mcpOAuthCompleting, mcpOAuthFlow,
mcpOAuthPopupBlocked, mcpPresetAction, mcpPresets, mcpPresetsLoading, nanobotFeatureAction,
nanobotFeatureConfirm, nanobotFeatures, nanobotFeaturesError, nanobotFeaturesLoading,
setAppsKindFilter, setAppsQuery, setAutomationPendingDelete,
setAutomationPendingEdit, setAutomationsFilter,
setAutomationsQuery, setAutomationsSort, setChannelsQuery,
setCliAppsError,
setCliAppsMessage, setCustomMcpForm, setMcpConfigImport, setMcpError, setMcpFieldValues,
setMcpMessage, setMcpOAuthCallbackError, setMcpOAuthCallbackUrl,
setNanobotFeatureConfirm, setNanobotFeatures,
setNanobotFeaturesError,
} = systemState;
const featureCatalog = nanobotFeatures?.features ?? [];
useEffect(() => {
setActiveSection(initialSection);
}, [initialSection]);
const selectSection = useCallback(
(section: SettingsSectionKey) => {
setActiveSection(section);
onSectionChange?.(section);
},
[onSectionChange],
);
const applyPayload: ApplySettingsPayload = useCallback(
(
payload: SettingsPayload,
options: { preserveAgentForm?: boolean } = {},
) => {
setSettings(payload);
if (!options.preserveAgentForm) {
setForm(agentDraftFromPayload(payload));
setModelPresetCreating(false);
}
setModelCallOrder(payload.model_call_order ?? []);
setWebSearchForm((prev) => webSearchFormFromPayload(payload, prev));
setImageGenerationForm(imageGenerationFormFromPayload(payload));
setTranscriptionForm(transcriptionFormFromPayload(payload));
setNetworkSafetyForm(networkSafetyFormFromPayload(payload));
if (payload.restart_required_sections) {
setPendingRestartSections(pendingRestartSectionsFromPayload(payload));
}
onSettingsChange?.(payload);
},
[onSettingsChange],
);
const closeProviderOAuthFlow = useCallback(() => {
providerOAuthFlowRef.current = null;
setProviderOAuthFlow(null);
setProviderOAuthResponse("");
setProviderOAuthCompleting(false);
setProviderOAuthDialogError(null);
}, []);
useProviderOAuthPolling({
state: modelState,
client,
applyPayload,
setError,
closeProviderOAuthFlow,
});
useEffect(() => {
if (!initialSettings || settings !== null) return;
applyPayload(initialSettings);
setLoading(false);
}, [applyPayload, initialSettings, settings]);
useEffect(() => {
let cancelled = false;
const showLoading = settings === null;
if (showLoading) setLoading(true);
fetchSettings(getToken())
.then((payload) => {
if (!cancelled) {
applyPayload(payload);
setError(null);
}
})
.catch((err) => {
if (!cancelled && showLoading) setError((err as Error).message);
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [applyPayload, getToken]);
const hasSettings = settings !== null;
useEffect(() => {
if (activeSection !== "overview" || !hasSettings || !pageVisible) return;
let cancelled = false;
let refreshing = false;
const refresh = async () => {
if (refreshing) return;
refreshing = true;
try {
const usage = await fetchSettingsUsage(getToken());
if (!cancelled) {
setSettings((current) => (current ? { ...current, usage } : current));
}
} catch {
// Usage is best-effort telemetry; the settings snapshot remains usable.
} finally {
refreshing = false;
}
};
void refresh();
const interval = window.setInterval(() => void refresh(), 5000);
const onFocus = () => void refresh();
window.addEventListener("focus", onFocus);
return () => {
cancelled = true;
window.clearInterval(interval);
window.removeEventListener("focus", onFocus);
};
}, [activeSection, getToken, hasSettings, pageVisible]);
const { refreshAutomations } = useSystemSettingsEffects({
state: systemState,
activeSection,
getToken,
pageVisible,
});
useEffect(() => {
writeLocalPreferences(localPrefs);
}, [localPrefs]);
useProviderFormsSync(modelState, settings);
const modelDirty = useMemo(() => {
if (!settings) return false;
const selectedPreset = settings.model_presets.find(
(preset) => !preset.is_default && preset.name === form.modelPreset,
);
if (!selectedPreset) return false;
return (
form.model !== selectedPreset.model ||
form.provider !== selectedPreset.provider ||
form.maxTokens !== selectedPreset.max_tokens ||
form.contextWindowTokens !== normalizeContextWindowTokens(selectedPreset.context_window_tokens) ||
form.temperature !== selectedPreset.temperature ||
form.reasoningEffort !== (selectedPreset.reasoning_effort ?? "") ||
form.presetLabel.trim() !== selectedPreset.label
);
}, [form, settings]);
const imageGenerationDirty = useMemo(() => {
if (!settings) return false;
return (
imageGenerationForm.enabled !== settings.image_generation.enabled ||
imageGenerationForm.provider !== settings.image_generation.provider ||
imageGenerationForm.model !== settings.image_generation.model ||
imageGenerationForm.defaultAspectRatio !== settings.image_generation.default_aspect_ratio ||
imageGenerationForm.defaultImageSize !== settings.image_generation.default_image_size ||
imageGenerationForm.maxImagesPerTurn !== settings.image_generation.max_images_per_turn
);
}, [imageGenerationForm, settings]);
const transcriptionDirty = useMemo(() => {
if (!settings) return false;
const transcription = settings.transcription ?? DEFAULT_TRANSCRIPTION_SETTINGS;
return (
transcriptionForm.enabled !== transcription.enabled ||
transcriptionForm.provider !== transcription.provider ||
transcriptionForm.model !== transcription.model ||
transcriptionForm.language !== (transcription.language ?? "") ||
transcriptionForm.maxDurationSec !== transcription.max_duration_sec ||
transcriptionForm.maxUploadMb !== transcription.max_upload_mb
);
}, [settings, transcriptionForm]);
const networkSafetyDirty = useMemo(() => {
if (!settings) return false;
const currentLocalServiceAccess =
settings.advanced.webui_allow_local_service_access ?? settings.advanced.allow_local_preview_access ?? true;
const currentDefaultAccess = visibleWebuiDefaultAccessMode(settings.advanced.webui_default_access_mode);
return (
networkSafetyForm.webuiAllowLocalServiceAccess !== currentLocalServiceAccess ||
networkSafetyForm.webuiDefaultAccessMode !== currentDefaultAccess
);
}, [networkSafetyForm, settings]);
const configuredModelProviderOptions = useMemo(
() =>
settings?.providers
.filter((provider) => provider.configured && provider.model_selectable !== false)
.map((provider) => ({ name: provider.name, label: provider.label })) ?? [],
[settings],
);
const hasPendingRestart = useMemo(
() =>
!!settings?.requires_restart ||
pendingRestartSections.runtime ||
pendingRestartSections.browser ||
pendingRestartSections.image,
[pendingRestartSections, settings?.requires_restart],
);
const restartViaSettingsSurface = useCallback(async () => {
const isNativeHost = (settings?.surface ?? settings?.runtime_surface) === "native";
if (
isNativeHost &&
settings?.runtime_capabilities?.can_restart_engine &&
onNativeEngineRestart
) {
setHostEngineApplying(true);
try {
const nextToken = await onNativeEngineRestart();
const payload = await fetchSettings(nextToken);
applyPayload(payload);
setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setHostEngineApplying(false);
}
return;
}
onRestart?.();
}, [applyPayload, onNativeEngineRestart, onRestart, settings]);
const maybeRestartHostEngine = useCallback(
async (payload: RestartAwarePayload) => {
const surface = payload.surface ?? payload.runtime_surface ?? settings?.surface ?? settings?.runtime_surface;
const capabilities = payload.runtime_capabilities ?? settings?.runtime_capabilities;
const isNativeHost = surface === "native";
if (
!payload.requires_restart ||
!isNativeHost ||
!capabilities?.can_restart_engine ||
!onNativeEngineRestart
) {
return;
}
setHostEngineApplying(true);
try {
const nextToken = await onNativeEngineRestart();
const refreshed = await fetchSettings(nextToken);
applyPayload(refreshed);
setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setHostEngineApplying(false);
}
},
[applyPayload, onNativeEngineRestart, settings],
);
const systemActions = createSystemSettingsActions({
state: systemState,
featureCatalog,
client,
token,
getToken,
t,
applyPayload,
maybeRestartHostEngine,
setPendingRestartSections,
refreshAutomations,
});
const { installCapabilities } = systemActions;
const modelActions = useModelSettingsActions({
state: modelState,
settings,
client,
t,
applyPayload,
maybeRestartHostEngine,
setPendingRestartSections,
setError,
onModelNameChange,
remoteBrowserAccess,
closeProviderOAuthFlow,
installCapabilities,
modelDirty,
configuredModelProviderOptions,
});
const capabilityActions = useCapabilitySettingsActions({
state: capabilityState,
settings,
client,
t,
applyPayload,
maybeRestartHostEngine,
setPendingRestartSections,
setError,
installCapabilities,
imageGenerationDirty,
transcriptionDirty,
networkSafetyDirty,
});
const {
beginModelPresetCreation,
cancelModelPresetCreation,
changeModelCallOrder,
completeProviderOAuthResponse,
createCustomProvider,
handleDeleteModelConfiguration,
handleMigrateModelConfigurations,
handleToggleProvider,
runProviderOAuth,
saveModelSettings,
saveProvider,
toggleProviderKeyEditing,
toggleProviderKeyVisibility,
} = modelActions;
const {
handleWebSearchProviderChange,
resetWebSearchDraft,
saveImageGenerationSettings,
saveNetworkSafetySettings,
saveTranscriptionSettings,
saveWebSearch,
} = capabilityActions;
const {
handleApiServiceAction,
handleAutomationAction,
handleAutomationEdit,
handleCliAppAction,
handleImportMcpConfig,
handleMcpOAuthCancel,
handleMcpOAuthComplete,
handleMcpOAuthConnect,
handleMcpOAuthOpen,
handleMcpPresetAction,
handleMcpToolsChange,
handleNanobotFeatureAction,
handleSaveCustomMcp,
} = systemActions;
return {
activeSection,
apiService,
apiServiceAction,
apiServiceError,
apiServiceLoading,
appsKindFilter,
appsQuery,
automationAction,
automationPendingDelete,
automationPendingEdit,
automations,
automationsError,
automationsFilter,
automationsLoading,
automationsQuery,
automationsSort,
beginModelPresetCreation,
cancelModelPresetCreation,
changeModelCallOrder,
channelsQuery,
cliApps,
cliAppsAction,
cliAppsError,
cliAppsFocusName,
cliAppsLoading,
cliAppsMessage,
closeProviderOAuthFlow,
completeProviderOAuthResponse,
createCustomProvider,
customMcpForm,
editingProviderKeys,
error,
expandedProvider,
featureCatalog,
form,
handleApiServiceAction,
handleAutomationAction,
handleAutomationEdit,
handleCliAppAction,
handleDeleteModelConfiguration,
handleImportMcpConfig,
handleMcpOAuthCancel,
handleMcpOAuthComplete,
handleMcpOAuthConnect,
handleMcpOAuthOpen,
handleMcpPresetAction,
handleMcpToolsChange,
handleMigrateModelConfigurations,
handleNanobotFeatureAction,
handleSaveCustomMcp,
handleToggleProvider,
handleWebSearchProviderChange,
hasPendingRestart,
hostEngineApplying,
imageGenerationDirty,
imageGenerationForm,
imageGenerationSaving,
installCapabilities,
loading,
localPrefs,
mcpConfigImport,
mcpError,
mcpFieldValues,
mcpMessage,
mcpOAuthCallbackError,
mcpOAuthCallbackUrl,
mcpOAuthCompleting,
mcpOAuthFlow,
mcpOAuthPopupBlocked,
mcpPresetAction,
mcpPresets,
mcpPresetsLoading,
modelCallOrder,
modelCallOrderSaving,
modelConfigurationSaving,
modelDirty,
modelMigrationSaving,
modelPresetBeforeCreateRef,
modelPresetCreating,
modelPresetPendingDelete,
nanobotFeatureAction,
nanobotFeatureConfirm,
nanobotFeatures,
nanobotFeaturesError,
nanobotFeaturesLoading,
networkSafetyDirty,
networkSafetyForm,
networkSafetySaving,
pendingRestartSections,
providerForms,
providerOAuthCompleting,
providerOAuthDialogError,
providerOAuthFlow,
providerOAuthResponse,
providerSaving,
remoteBrowserAccess,
resetWebSearchDraft,
restartViaSettingsSurface,
runProviderOAuth,
saveImageGenerationSettings,
saveModelSettings,
saveNetworkSafetySettings,
saveProvider,
saveTranscriptionSettings,
saveWebSearch,
saving,
selectSection,
setAppsKindFilter,
setAppsQuery,
setAutomationPendingDelete,
setAutomationPendingEdit,
setAutomationsFilter,
setAutomationsQuery,
setAutomationsSort,
setChannelsQuery,
setCliAppsError,
setCliAppsMessage,
setCustomMcpForm,
setForm,
setImageGenerationForm,
setLocalPrefs,
setMcpConfigImport,
setMcpError,
setMcpFieldValues,
setMcpMessage,
setMcpOAuthCallbackError,
setMcpOAuthCallbackUrl,
setModelPresetCreating,
setModelPresetPendingDelete,
setNanobotFeatureConfirm,
setNanobotFeatures,
setNanobotFeaturesError,
setNetworkSafetyForm,
setProviderForms,
setProviderOAuthDialogError,
setProviderOAuthResponse,
setTranscriptionForm,
setWebSearchForm,
setWebSearchKeyEditing,
setWebSearchKeyVisible,
settings,
t,
toggleProviderKeyEditing,
toggleProviderKeyVisibility,
token,
transcriptionDirty,
transcriptionForm,
transcriptionSaving,
visibleProviderKeys,
webSearchForm,
webSearchKeyEditing,
webSearchKeyVisible,
webSearchSaving,
};
}
export type SettingsController = ReturnType<typeof useSettingsController>;
@@ -0,0 +1,566 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
installSettingsViewTestHooks,
jsonResponse,
renderSettingsView,
requestMutationMock,
settingsPayload,
} from "@/tests/settings-test-utils";
const xmindMcpPreset = {
name: "xmind",
display_name: "Xmind",
category: "productivity",
description: "Create, read, and edit cloud mind maps through Xmind.",
docs_url: "https://xmind.com/user-guide/xmind-mcp",
transport: "streamableHttp",
auth: "oauth" as const,
requires: "Xmind account",
note: "Connects securely in your browser with Xmind OAuth.",
install_supported: true,
installed: false,
configured: false,
available: false,
status: "not_installed",
logo_url: null,
brand_color: "#F4B41A",
required_fields: [],
connection_summary: "",
enabled_tools: ["*"],
source: "preset",
};
describe("SettingsView Apps catalog", () => {
installSettingsViewTestHooks();
it("connects an OAuth MCP from the Apps catalog without manual callback input", async () => {
let connected = false;
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({
presets: [connected
? {
...xmindMcpPreset,
installed: true,
configured: true,
available: true,
status: "configured",
connection_summary: "https://app.xmind.com/api/mcp",
}
: xmindMcpPreset],
installed_count: connected ? 1 : 0,
});
}
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-123") {
connected = true;
return jsonResponse({
flow_id: "flow-123",
name: "xmind",
status: "connected",
expires_in: 295,
hot_reload: {
ok: false,
requires_restart: false,
connected: ["xmind"],
failed: ["notion"],
message: "MCP config reloaded, but some servers did not connect: notion",
},
});
}
return { ok: false, status: 404, text: async () => "Not found" } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (action: string) => {
if (action === "settings.mcp.oauth_start") {
return {
flow_id: "flow-123",
name: "xmind",
status: "authorization_required",
expires_in: 300,
authorization_url: "https://accounts.xmind.test/authorize?state=state-123",
};
}
return settingsPayload();
});
const replace = vi.fn();
const popup = {
opener: window,
closed: false,
location: { replace },
document: { title: "", body: { textContent: "" } },
focus: vi.fn(),
close: vi.fn(),
};
const open = vi.fn(() => popup);
vi.stubGlobal("open", open);
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
expect(screen.getByText("MCP tools")).toBeInTheDocument();
const connectButton = await screen.findByRole("button", { name: "Connect Xmind" });
expect(connectButton).toHaveTextContent("Connect");
fireEvent.click(connectButton);
expect(open).toHaveBeenCalledWith(
"about:blank",
"nanobot-mcp-oauth",
"popup,width=560,height=720,resizable=yes,scrollbars=yes",
);
await waitFor(() => expect(replace).toHaveBeenCalledWith(
"https://accounts.xmind.test/authorize?state=state-123",
));
expect(popup.opener).toBeNull();
expect(screen.queryByRole("textbox", { name: /authorization/i })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent(
"Finish signing in in the browser window.",
);
expect(screen.getByRole("button", { name: "Connecting Xmind" })).toHaveTextContent(
"Connecting…",
);
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
expect(await screen.findByRole("button", { name: "Xmind: Configured" }, { timeout: 2500 }))
.toHaveTextContent("Configured");
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
expect(screen.queryByText("Xmind connected.")).not.toBeInTheDocument();
expect(screen.queryByText(/some servers did not connect: notion/i)).not.toBeInTheDocument();
expect(popup.close).toHaveBeenCalledTimes(1);
expect(replace).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/mcp-oauth/status?flow_id=flow-123",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
);
});
it("configures OAuth for a custom remote MCP without importing JSON", async () => {
const customPreset = {
...xmindMcpPreset,
name: "team-mcp",
display_name: "team-mcp",
source: "custom",
installed: true,
status: "authorization_required",
connection_summary: "https://mcp.example.com/mcp",
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, text: async () => "Not found" } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (action: string) => {
if (action === "settings.mcp.custom") {
return {
presets: [customPreset],
installed_count: 1,
hot_reload: {
ok: false,
message: "MCP config reloaded, but some servers did not connect: team-mcp",
failed: ["team-mcp"],
},
last_action: { ok: true, message: "Saved custom MCP server team-mcp." },
};
}
return settingsPayload();
});
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
fireEvent.click(await screen.findByRole("button", { name: "Custom" }));
expect(screen.queryByText("Authentication")).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Server name"), {
target: { value: "team-mcp" },
});
fireEvent.click(screen.getByRole("button", { name: "HTTP" }));
fireEvent.change(screen.getByLabelText("URL"), {
target: { value: "https://mcp.example.com/mcp" },
});
const authentication = screen.getByRole("group", { name: "Authentication" });
const oauth = within(authentication).getByRole("button", { name: "OAuth" });
expect(oauth).toHaveAttribute("aria-pressed", "false");
fireEvent.click(within(authentication).getByRole("button", { name: "Headers" }));
fireEvent.change(screen.getByLabelText("Headers JSON"), {
target: { value: '{"Authorization":"Bearer stale"}' },
});
expect(screen.getByText("Add the request headers used by this server.")).toBeInTheDocument();
fireEvent.click(oauth);
expect(oauth).toHaveAttribute("aria-pressed", "true");
expect(screen.queryByLabelText("Headers JSON")).not.toBeInTheDocument();
expect(
screen.getByText("Save the server, then select Connect to sign in."),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Save MCP" }));
await waitFor(() => {
const saveCall = requestMutationMock.mock.calls.find(
([action]) => action === "settings.mcp.custom",
);
expect(saveCall).toBeDefined();
const values = saveCall?.[1] as Record<string, string>;
expect(values).toMatchObject({
name: "team-mcp",
transport: "streamableHttp",
url: "https://mcp.example.com/mcp",
auth: "oauth",
});
expect(values).not.toHaveProperty("headers");
expect(saveCall?.[2]).toBe(20_000);
});
expect(await screen.findByRole("button", { name: "Connect team-mcp" }))
.toBeInTheDocument();
expect(
screen.queryByText("MCP config reloaded, but some servers did not connect: team-mcp"),
).not.toBeInTheDocument();
});
it("offers a pasted callback flow when the remote WebUI uses HTTP", async () => {
let completed = false;
const callbackUrl =
"http://127.0.0.1:8765/auth/mcp/callback?code=oauth-code&state=manual-state";
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({
presets: [completed
? {
...xmindMcpPreset,
installed: true,
configured: true,
available: true,
status: "configured",
connection_summary: "https://app.xmind.com/api/mcp",
}
: xmindMcpPreset],
installed_count: completed ? 1 : 0,
});
}
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-manual") {
return jsonResponse({
flow_id: "flow-manual",
name: "xmind",
status: completed ? "connected" : "authorization_required",
expires_in: 298,
completion_input: "callback_url",
authorization_url: completed
? undefined
: "https://accounts.xmind.test/authorize?state=manual-state",
hot_reload: completed ? { ok: true, requires_restart: false } : undefined,
});
}
return { ok: false, status: 404, text: async () => "Not found" } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (action: string) => {
if (action === "settings.mcp.oauth_start") {
return {
flow_id: "flow-manual",
name: "xmind",
status: "authorization_required",
expires_in: 300,
completion_input: "callback_url",
authorization_url: "https://accounts.xmind.test/authorize?state=manual-state",
};
}
if (action === "settings.mcp.oauth_complete") {
completed = true;
return {
flow_id: "flow-manual",
name: "xmind",
status: "connecting",
expires_in: 299,
completion_input: "callback_url",
};
}
return settingsPayload();
});
const popup = {
opener: window,
closed: false,
location: { replace: vi.fn() },
document: { title: "", body: { textContent: "" } },
focus: vi.fn(),
close: vi.fn(),
};
vi.stubGlobal("open", vi.fn(() => popup));
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
const callbackInput = await screen.findByRole("textbox", { name: "Full callback URL" });
expect(screen.getByText(/localhost page will not load/i)).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent(
"Finish signing in, then paste the callback URL into nanobot.",
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
fireEvent.change(callbackInput, { target: { value: callbackUrl } });
fireEvent.click(screen.getByRole("button", { name: "Finish sign-in" }));
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
"settings.mcp.oauth_complete",
{ flow_id: "flow-manual", callback_url: callbackUrl },
20_000,
));
expect(await screen.findByRole("button", { name: "Xmind: Configured" }, { timeout: 2500 }))
.toHaveTextContent("Configured");
expect(screen.queryByRole("textbox", { name: "Full callback URL" })).not.toBeInTheDocument();
expect(popup.close).toHaveBeenCalledTimes(1);
});
it("lets the user cancel an active OAuth connection after closing the popup", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [xmindMcpPreset], installed_count: 0 });
}
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-cancel") {
return new Promise<Response>(() => {});
}
return { ok: false, status: 404, text: async () => "Not found" } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (action: string) => {
if (action === "settings.mcp.oauth_start") {
return {
flow_id: "flow-cancel",
name: "xmind",
status: "authorization_required",
expires_in: 300,
authorization_url: "https://accounts.xmind.test/authorize?state=cancel",
};
}
if (action === "settings.mcp.oauth_cancel") {
return {
flow_id: "flow-cancel",
name: "xmind",
status: "cancelled",
expires_in: 299,
};
}
return settingsPayload();
});
const popup = {
opener: window,
closed: false,
location: { replace: vi.fn() },
document: { title: "", body: { textContent: "" } },
focus: vi.fn(),
close: vi.fn(),
};
vi.stubGlobal("open", vi.fn(() => popup));
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
const cancelButton = await screen.findByRole("button", { name: "Cancel" });
expect(screen.getByRole("button", { name: "Connecting Xmind" })).toBeInTheDocument();
popup.closed = true;
fireEvent.click(cancelButton);
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
"settings.mcp.oauth_cancel",
{ flow_id: "flow-cancel" },
20_000,
));
expect(await screen.findByRole("button", { name: "Connect Xmind" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Connecting Xmind" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
expect(popup.close).not.toHaveBeenCalled();
});
it("silently removes an MCP when the card already shows the result", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({
presets: [{
...xmindMcpPreset,
installed: true,
configured: true,
available: true,
status: "configured",
connection_summary: "https://app.xmind.com/api/mcp",
}],
installed_count: 1,
});
}
return { ok: false, status: 404, text: async () => "Not found" } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce({
presets: [xmindMcpPreset],
installed_count: 0,
requires_restart: false,
hot_reload: {
ok: true,
message: "MCP config reloaded without restarting nanobot.",
},
last_action: {
ok: true,
message: "Removed MCP preset for Xmind. MCP config reloaded without restarting nanobot.",
removed: true,
},
});
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
fireEvent.click(await screen.findByRole("button", { name: "Remove" }));
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
"settings.mcp.remove",
{ name: "xmind" },
20_000,
));
expect(await screen.findByRole("button", { name: "Connect Xmind" })).toBeInTheDocument();
expect(screen.queryByText(/Removed MCP preset|reloaded without restarting/)).not.toBeInTheDocument();
});
it("offers a one-click recovery when the OAuth popup is blocked", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [xmindMcpPreset], installed_count: 0 });
}
return new Promise<Response>(() => {});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (action: string) => {
if (action === "settings.mcp.oauth_start") {
return {
flow_id: "flow-blocked",
name: "xmind",
status: "authorization_required",
expires_in: 300,
authorization_url: "https://accounts.xmind.test/authorize?state=blocked",
};
}
return settingsPayload();
});
const popup = {
opener: window,
closed: false,
location: { replace: vi.fn() },
focus: vi.fn(),
close: vi.fn(),
};
const open = vi.fn()
.mockReturnValueOnce(null)
.mockReturnValueOnce(popup);
vi.stubGlobal("open", open);
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
const continueButton = await screen.findByRole("button", { name: "Continue sign-in" });
expect(screen.getByRole("status")).toHaveTextContent("Open the sign-in page to continue.");
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
fireEvent.click(continueButton);
expect(open).toHaveBeenLastCalledWith(
"https://accounts.xmind.test/authorize?state=blocked",
"nanobot-mcp-oauth",
"popup,width=560,height=720,resizable=yes,scrollbars=yes",
);
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
});
it("does not mistake a COOP-isolated OAuth tab for a blocked popup", async () => {
let popupIsolated = false;
let statusCalls = 0;
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [xmindMcpPreset], installed_count: 0 });
}
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-coop") {
statusCalls += 1;
return jsonResponse({
flow_id: "flow-coop",
name: "xmind",
status: statusCalls === 1 ? "authorization_required" : "failed",
expires_in: 299,
error: statusCalls === 1 ? undefined : "Cancelled for test cleanup.",
authorization_url: statusCalls === 1
? "https://accounts.xmind.test/authorize?state=coop"
: undefined,
});
}
return { ok: false, status: 404, text: async () => "Not found" } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (action: string) => {
if (action === "settings.mcp.oauth_start") {
return {
flow_id: "flow-coop",
name: "xmind",
status: "authorization_required",
expires_in: 300,
authorization_url: "https://accounts.xmind.test/authorize?state=coop",
};
}
return settingsPayload();
});
const popup = {
opener: window,
get closed() {
return popupIsolated;
},
location: {
replace: vi.fn(() => {
popupIsolated = true;
}),
},
document: { title: "", body: { textContent: "" } },
focus: vi.fn(),
close: vi.fn(),
};
vi.stubGlobal("open", vi.fn(() => popup));
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
await waitFor(() => expect(statusCalls).toBe(1), { timeout: 2000 });
expect(screen.getByRole("status")).toHaveTextContent(
"Finish signing in in the browser window.",
);
expect(screen.queryByRole("button", { name: "Continue sign-in" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
});
});
@@ -0,0 +1,255 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import type { SettingsPayload } from "@/lib/types";
import { requestMutationMock, jsonResponse, settingsPayload, renderSettingsView, openPopover, installSettingsViewTestHooks } from "@/tests/settings-test-utils";
describe("Settings capabilities", () => {
installSettingsViewTestHooks();
it("selects image models from provider-specific options", async () => {
const base = settingsPayload();
const payload: SettingsPayload = {
...base,
image_generation: {
...base.image_generation,
providers: [
{
name: "openrouter",
label: "OpenRouter",
configured: true,
models: ["openai/gpt-5.4-image-2"],
default_model: "openai/gpt-5.4-image-2",
},
{
name: "gemini",
label: "Gemini",
configured: true,
models: ["gemini-2.5-flash-image", "imagen-4.0-generate-001"],
default_model: "gemini-2.5-flash-image",
},
{
name: "custom",
label: "Custom",
configured: true,
models: [],
default_model: null,
},
],
},
};
renderSettingsView({ initialSection: "image", initialSettings: payload });
expect(screen.queryByDisplayValue("openai/gpt-5.4-image-2")).not.toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "OpenRouter" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Gemini" }));
expect(await screen.findByRole("button", { name: "gemini-2.5-flash-image" })).toBeInTheDocument();
await openPopover(screen.getByRole("button", { name: "gemini-2.5-flash-image" }));
fireEvent.click(await screen.findByRole("option", { name: "imagen-4.0-generate-001" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "imagen-4.0-generate-001" })).toBeInTheDocument(),
);
await openPopover(screen.getByRole("button", { name: "imagen-4.0-generate-001" }));
const modelInput = await screen.findByRole("combobox", { name: "Search or type model ID" });
fireEvent.change(modelInput, { target: { value: "imagen-5-preview" } });
fireEvent.click(await screen.findByRole("option", { name: "Use “imagen-5-preview”" }));
expect(await screen.findByRole("button", { name: "imagen-5-preview" })).toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "Gemini" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Custom" }));
expect(screen.getByRole("button", { name: "imagen-5-preview" })).toBeInTheDocument();
await openPopover(screen.getByRole("button", { name: "imagen-5-preview" }));
const customProviderInput = await screen.findByRole("combobox", {
name: "Search or type model ID",
});
fireEvent.change(customProviderInput, { target: { value: "private/image-v2" } });
fireEvent.keyDown(customProviderInput, { key: "Enter" });
expect(await screen.findByRole("button", { name: "private/image-v2" })).toBeInTheDocument();
});
it("saves network safety without exposing technical SSRF copy", async () => {
const payload = settingsPayload();
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce({
...payload,
advanced: { ...payload.advanced, webui_allow_local_service_access: false },
requires_restart: true,
restart_required_sections: ["runtime"],
});
renderSettingsView({ initialSection: "advanced" });
expect(await screen.findByText("Web safety")).toBeInTheDocument();
expect(screen.queryByText(/SSRF/i)).not.toBeInTheDocument();
expect(screen.queryByText("Private Service Protection")).not.toBeInTheDocument();
expect(screen.getByText("Default access")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Restricted" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Default Permission" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Full Access" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("switch", { name: "Local services" }));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.network_safety.update",
{
webui_allow_local_service_access: false,
webui_default_access_mode: "default",
},
20_000,
),
);
});
it("saves optional-key web search providers without an API key", async () => {
const payload = {
...settingsPayload(),
web_search: {
...settingsPayload().web_search,
provider: "duckduckgo",
providers: [
{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" as const },
{ name: "keenable", label: "Keenable", credential: "optional_api_key" as const },
],
},
};
const updatedPayload = {
...payload,
web_search: {
...payload.web_search,
provider: "keenable",
},
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce(updatedPayload);
renderSettingsView({ initialSection: "browser" });
fireEvent.pointerDown(await screen.findByRole("button", { name: /DuckDuckGo/ }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Keenable" }));
const saveButton = screen
.getAllByRole("button", { name: "Save" })
.find((button) => !(button as HTMLButtonElement).disabled);
if (!saveButton) throw new Error("enabled Save button was not found");
fireEvent.click(saveButton);
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.web_search.update",
{
provider: "keenable",
max_results: 5,
timeout: 30,
use_jina_reader: true,
},
20_000,
),
);
});
it("uses native host safety copy on the native surface", async () => {
const payload = {
...settingsPayload(),
surface: "native" as const,
runtime_surface: "native" as const,
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "advanced" });
expect(await screen.findByText("App safety")).toBeInTheDocument();
expect(screen.queryByText("Web safety")).not.toBeInTheDocument();
expect(screen.getByText("Allow Full Access shell commands to reach services on this Mac.")).toBeInTheDocument();
});
it("refreshes settings with a fresh token after native engine restart", async () => {
const payload = {
...settingsPayload(),
surface: "native" as const,
runtime_surface: "native" as const,
runtime_capabilities: {
can_restart_engine: true,
can_pick_folder: true,
can_open_logs: true,
can_export_diagnostics: true,
},
};
const restartedPayload = {
...payload,
advanced: { ...payload.advanced, webui_allow_local_service_access: false },
requires_restart: true,
restart_required_sections: ["runtime"],
};
const refreshedPayload = {
...restartedPayload,
requires_restart: false,
restart_required_sections: [],
};
const restartEngine = vi.fn(async () => "fresh-token");
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const auth = (init?.headers as Record<string, string> | undefined)?.Authorization;
if (url === "/api/settings" && auth === "Bearer fresh-token") {
return jsonResponse(refreshedPayload);
}
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce(restartedPayload);
renderSettingsView({
initialSection: "advanced",
onNativeEngineRestart: restartEngine,
});
expect(await screen.findByText("App safety")).toBeInTheDocument();
fireEvent.click(screen.getByRole("switch", { name: "Local services" }));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(restartEngine).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings",
expect.objectContaining({
headers: { Authorization: "Bearer fresh-token" },
}),
),
);
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+241
View File
@@ -0,0 +1,241 @@
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import type { SettingsPayload } from "@/lib/types";
import { jsonResponse, settingsPayload, renderSettingsView, installSettingsViewTestHooks } from "@/tests/settings-test-utils";
const thirdPartyBrandNotice =
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.";
describe("Settings overview and appearance", () => {
installSettingsViewTestHooks();
it("persists the file edit display local preference", async () => {
renderSettingsView({
initialSection: "appearance",
initialSettings: settingsPayload(),
showSidebar: true,
});
expect(screen.getByText("File edit display")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Diff" }));
await waitFor(() => {
const saved = JSON.parse(localStorage.getItem("nanobot-webui.settings-preferences") || "{}");
expect(saved.fileEditDisplayMode).toBe("diff");
});
});
it("shows the third-party brand notice only with the brand logo preference", () => {
renderSettingsView({
initialSection: "appearance",
initialSettings: settingsPayload(),
showSidebar: true,
});
const brandLogosTitle = screen.getByText("Brand logos");
const brandLogosRow = brandLogosTitle.parentElement?.parentElement;
expect(brandLogosRow).not.toBeNull();
expect(
within(brandLogosRow as HTMLElement).getByText(thirdPartyBrandNotice),
).toBeInTheDocument();
expect(screen.getAllByText(thirdPartyBrandNotice)).toHaveLength(1);
});
it.each(["apps", "channels"] as const)(
"does not repeat the third-party brand notice in %s",
(initialSection) => {
renderSettingsView({ initialSection, initialSettings: settingsPayload() });
expect(screen.queryByText(thirdPartyBrandNotice)).not.toBeInTheDocument();
},
);
it("publishes the latest settings payload to the shell", async () => {
const payload = settingsPayload();
const onSettingsChange = vi.fn();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ onSettingsChange });
await waitFor(() => expect(onSettingsChange).toHaveBeenCalledWith(payload));
});
it("does not keep Apps loading while an empty CLI catalog refresh is pending", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({
apps: [],
installed_count: 0,
catalog_updated_at: null,
catalog_refresh_pending: true,
});
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView();
expect(await screen.findByText("No apps available.")).toBeInTheDocument();
expect(screen.queryByText("Loading Apps...")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Browse MCP tools" }));
expect(await screen.findByText("Add MCP server")).toBeInTheDocument();
});
it("shows token activity on the overview", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
usage: {
days: [
{
date: "2026-06-03",
prompt_tokens: 1200,
completion_tokens: 300,
cached_tokens: 500,
total_tokens: 1500,
requests: 2,
},
],
total_tokens: 1500,
total_tokens_30d: 1500,
total_tokens_365d: 1500,
peak_day_tokens: 1500,
current_streak_days: 1,
longest_streak_days: 1,
active_days_30d: 1,
requests_30d: 2,
updated_at: "2026-06-03T00:00:00Z",
},
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "overview" });
expect(await screen.findByLabelText("Token activity")).toBeInTheDocument();
expect(screen.getByText("Token Usage")).toBeInTheDocument();
expect(screen.queryByText("Token activity")).not.toBeInTheDocument();
expect(screen.queryByText("Total tokens")).not.toBeInTheDocument();
expect(screen.queryByText("Peak tokens")).not.toBeInTheDocument();
});
it("coalesces focus refreshes while usage is already loading", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
usage: {
days: [],
total_tokens: 0,
total_tokens_30d: 0,
total_tokens_365d: 0,
peak_day_tokens: 0,
current_streak_days: 0,
longest_streak_days: 0,
active_days_30d: 0,
requests_30d: 0,
updated_at: null,
},
};
let resolveUsage!: (response: Response) => void;
const pendingUsage = new Promise<Response>((resolve) => {
resolveUsage = resolve;
});
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/usage") return pendingUsage;
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "overview", initialSettings: payload });
await waitFor(() => {
expect(fetchMock.mock.calls.filter(([input]) => (
String(input) === "/api/settings/usage"
))).toHaveLength(1);
});
window.dispatchEvent(new Event("focus"));
window.dispatchEvent(new Event("focus"));
expect(fetchMock.mock.calls.filter(([input]) => (
String(input) === "/api/settings/usage"
))).toHaveLength(1);
await act(async () => {
resolveUsage(jsonResponse(payload.usage));
await pendingUsage;
});
});
it("aligns token activity days with the configured timezone", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-02T18:00:00Z"));
const basePayload = settingsPayload();
const payload: SettingsPayload = {
...basePayload,
agent: {
...basePayload.agent,
timezone: "Asia/Shanghai",
},
usage: {
days: [
{
date: "2026-06-03",
prompt_tokens: 1200,
completion_tokens: 300,
cached_tokens: 500,
total_tokens: 1500,
requests: 2,
},
],
total_tokens: 1500,
total_tokens_30d: 1500,
total_tokens_365d: 1500,
peak_day_tokens: 1500,
current_streak_days: 1,
longest_streak_days: 1,
active_days_30d: 1,
requests_30d: 2,
updated_at: "2026-06-03T00:00:00Z",
},
};
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
renderSettingsView({ initialSection: "overview", initialSettings: payload });
expect(screen.getByLabelText("2026-06-03: 1.5K tokens, 2 requests")).toBeInTheDocument();
});
});
+849
View File
@@ -0,0 +1,849 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import type { SettingsPayload } from "@/lib/types";
import { requestMutationMock, jsonResponse, settingsPayload, renderSettingsView, installSettingsViewTestHooks } from "@/tests/settings-test-utils";
async function chooseProviderToConfigure(label: string) {
fireEvent.pointerDown(
await screen.findByRole("button", { name: "Add your own model provider" }),
);
fireEvent.click(await screen.findByRole("menuitem", { name: label }));
}
describe("Settings providers", () => {
installSettingsViewTestHooks();
it("signs in to the xAI Grok provider", async () => {
const base = settingsPayload();
const xaiProvider = {
name: "xai_grok",
label: "xAI Grok",
configured: false,
auth_type: "oauth" as const,
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://cli-chat-proxy.grok.com/v1",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
};
const payload: SettingsPayload = { ...base, providers: [xaiProvider] };
const signedIn: SettingsPayload = {
...payload,
providers: [{ ...xaiProvider, configured: true, oauth_account: "user@example.com" }],
};
const authorization = {
status: "authorization_required",
provider: "xai_grok",
flow_id: "flow-123",
authorization_url: "https://auth.x.ai/oauth2/authorize?state=test",
expires_in: 600,
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock
.mockResolvedValueOnce(authorization)
.mockResolvedValueOnce(signedIn);
const popup = {
opener: window,
location: { href: "about:blank" },
close: vi.fn(),
};
vi.stubGlobal("open", vi.fn(() => popup));
renderSettingsView({ initialSection: "models", initialSettings: payload });
await chooseProviderToConfigure("xAI Grok");
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.oauth_login",
{ provider: "xai_grok" },
20_000,
),
);
expect(popup.opener).toBeNull();
expect(popup.location.href).toBe(authorization.authorization_url);
expect(
screen.getByText(
"Complete sign-in in your browser. Nanobot usually finishes automatically; if it does not, paste the authorization code below.",
),
).toBeInTheDocument();
const callbackInput = await screen.findByRole("textbox", {
name: "Authorization code",
});
fireEvent.change(callbackInput, {
target: { value: "secret" },
});
fireEvent.click(screen.getByRole("button", { name: "Finish sign-in" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.oauth_complete",
{
provider: "xai_grok",
flow_id: "flow-123",
authorization_response: "secret",
},
20_000,
),
);
expect(await screen.findByText("Signed in as user@example.com")).toBeInTheDocument();
});
it("recognizes remote access before starting xAI Grok sign-in", async () => {
const happyWindow = window as typeof window & {
happyDOM: { setURL: (url: string) => void };
};
const originalUrl = window.location.href;
happyWindow.happyDOM.setURL("http://203.0.113.10:18887/#/settings?section=models");
try {
const base = settingsPayload();
const xaiProvider = {
name: "xai_grok",
label: "xAI Grok",
configured: false,
auth_type: "oauth" as const,
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://cli-chat-proxy.grok.com/v1",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
};
const payload: SettingsPayload = { ...base, providers: [xaiProvider] };
const authorization = {
status: "authorization_required",
provider: "xai_grok",
flow_id: "flow-remote",
authorization_url: "https://auth.x.ai/oauth2/authorize?state=remote",
expires_in: 600,
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce(authorization);
const popup = {
opener: window,
location: { href: "about:blank" },
close: vi.fn(),
};
const openMock = vi.fn(() => popup);
vi.stubGlobal("open", openMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
await chooseProviderToConfigure("xAI Grok");
expect(
screen.getByText(
"Select Sign in to open xAI on your computer, then paste the authorization code shown after login.",
),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
const dialog = await screen.findByRole("dialog");
expect(openMock).not.toHaveBeenCalled();
expect(
within(dialog).getByText(
"Select Sign in to open xAI on your computer. After signing in, paste the authorization code shown by xAI below.",
),
).toBeInTheDocument();
expect(
within(dialog).queryByRole("textbox", { name: "xAI sign-in URL" }),
).not.toBeInTheDocument();
expect(
within(dialog).queryByRole("button", { name: "Copy" }),
).not.toBeInTheDocument();
expect(
within(dialog).getByRole("textbox", { name: "Authorization code" }),
).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: "Sign in" }));
expect(openMock).toHaveBeenCalledWith(
authorization.authorization_url,
"_blank",
"noopener,noreferrer",
);
expect(popup.opener).toBeNull();
} finally {
happyWindow.happyDOM.setURL(originalUrl);
}
});
it("polls local OpenAI Codex sign-in until the loopback callback completes", async () => {
const base = settingsPayload();
const codexProvider = {
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth" as const,
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://chatgpt.com/backend-api",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
};
const payload: SettingsPayload = { ...base, providers: [codexProvider] };
const signedIn: SettingsPayload = {
...payload,
providers: [{ ...codexProvider, configured: true, oauth_account: "acct-codex" }],
};
const authorization = {
status: "authorization_required",
provider: "openai_codex",
flow_id: "flow-codex-local",
authorization_url: "https://auth.openai.com/oauth/authorize?state=local",
expires_in: 600,
completion_input: "callback_url",
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock
.mockResolvedValueOnce(authorization)
.mockResolvedValueOnce(signedIn);
const openMock = vi.fn();
vi.stubGlobal("open", openMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
await chooseProviderToConfigure("OpenAI Codex");
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
const dialog = await screen.findByRole("dialog");
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.oauth_login",
{ provider: "openai_codex" },
20_000,
);
expect(openMock).not.toHaveBeenCalled();
expect(
within(dialog).getByText(
"Complete sign-in in your browser. Nanobot usually finishes automatically; if it does not, copy the full localhost callback URL from the address bar and paste it below.",
),
).toBeInTheDocument();
expect(within(dialog).getByText("Waiting for the browser callback…")).toBeInTheDocument();
expect(
within(dialog).queryByText("Paste the callback URL to continue."),
).not.toBeInTheDocument();
expect(
await screen.findByText("Signed in as acct-codex", {}, { timeout: 2500 }),
).toBeInTheDocument();
});
it("completes remote OpenAI Codex sign-in with the full callback URL", async () => {
const happyWindow = window as typeof window & {
happyDOM: { setURL: (url: string) => void };
};
const originalUrl = window.location.href;
happyWindow.happyDOM.setURL("http://203.0.113.10:18887/#/settings?section=models");
try {
const base = settingsPayload();
const codexProvider = {
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth" as const,
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://chatgpt.com/backend-api",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
};
const payload: SettingsPayload = { ...base, providers: [codexProvider] };
const signedIn: SettingsPayload = {
...payload,
providers: [{ ...codexProvider, configured: true, oauth_account: "acct-codex" }],
};
const authorization = {
status: "authorization_required",
provider: "openai_codex",
flow_id: "flow-codex",
authorization_url: "https://auth.openai.com/oauth/authorize?state=test",
expires_in: 600,
completion_input: "callback_url",
};
const callbackUrl =
"http://localhost:1455/auth/callback?code=secret&state=test";
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (
action: string,
mutationPayload: Record<string, unknown>,
) => {
if (action === "settings.provider.oauth_login") return authorization;
if (mutationPayload.authorization_response === callbackUrl) return signedIn;
return {
status: "pending",
provider: "openai_codex",
flow_id: "flow-codex",
};
});
const popup = {
opener: window,
location: { href: "about:blank" },
close: vi.fn(),
};
const openMock = vi.fn(() => popup);
vi.stubGlobal("open", openMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
await chooseProviderToConfigure("OpenAI Codex");
expect(
screen.getByText(
"Sign in through this browser, then paste the full localhost callback URL back into nanobot.",
),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
const dialog = await screen.findByRole("dialog");
expect(openMock).not.toHaveBeenCalled();
expect(
within(dialog).getByText(
"Open ChatGPT in this browser and finish signing in. When the localhost page fails to load, copy the full URL from the address bar and paste it below.",
),
).toBeInTheDocument();
expect(within(dialog).getByText("Paste the callback URL to continue.")).toBeInTheDocument();
const callbackInput = within(dialog).getByRole("textbox", {
name: "Full callback URL",
});
expect(callbackInput).toHaveAttribute(
"placeholder",
"http://localhost:1455/auth/callback?code=…&state=…",
);
fireEvent.click(within(dialog).getByRole("button", { name: "Open ChatGPT" }));
expect(openMock).toHaveBeenCalledWith(
authorization.authorization_url,
"_blank",
"noopener,noreferrer",
);
expect(popup.opener).toBeNull();
fireEvent.change(callbackInput, { target: { value: callbackUrl } });
fireEvent.click(within(dialog).getByRole("button", { name: "Finish sign-in" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.oauth_complete",
{
provider: "openai_codex",
flow_id: "flow-codex",
authorization_response: callbackUrl,
},
20_000,
),
);
expect(await screen.findByText("Signed in as acct-codex")).toBeInTheDocument();
} finally {
happyWindow.happyDOM.setURL(originalUrl);
}
});
it("saves scoped proxies for xAI and OpenAI Codex OAuth providers", async () => {
const base = settingsPayload();
const providers: SettingsPayload["providers"] = [
{
name: "xai_grok",
label: "xAI Grok",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://cli-chat-proxy.grok.com/v1",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
proxy: "http://127.0.0.1:7000",
advanced_fields: ["extra_body", "proxy"],
extra_body: null,
},
{
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://chatgpt.com/backend-api",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
proxy: null,
advanced_fields: ["extra_body", "proxy"],
extra_body: null,
},
];
let payload: SettingsPayload = { ...base, providers };
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (
_action: string,
values: { provider?: string; proxy?: string; extraBody?: string },
) => {
payload = {
...payload,
providers: payload.providers.map((provider) =>
provider.name === values.provider
? {
...provider,
proxy: values.proxy || null,
extra_body: values.extraBody ? JSON.parse(values.extraBody) : null,
}
: provider,
),
};
return payload;
});
renderSettingsView({ initialSection: "models", initialSettings: payload });
await chooseProviderToConfigure("xAI Grok");
fireEvent.click(screen.getByRole("button", { name: "Advanced options" }));
const xaiProxy = screen.getByLabelText("Network proxy");
expect(xaiProxy).toHaveValue("http://127.0.0.1:7000");
fireEvent.change(xaiProxy, { target: { value: "http://127.0.0.1:7890" } });
expect(screen.getByRole("button", { name: "Sign in" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Sign in" })).toHaveAttribute(
"title",
"Save advanced changes before signing in.",
);
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.update",
{
provider: "xai_grok",
extraBody: "",
proxy: "http://127.0.0.1:7890",
},
20_000,
),
);
await waitFor(() => expect(screen.getByRole("button", { name: "Sign in" })).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "xAI Grok" }));
await chooseProviderToConfigure("OpenAI Codex");
fireEvent.click(screen.getByRole("button", { name: "Advanced options" }));
const codexProxy = screen.getByLabelText("Network proxy");
expect(codexProxy).toHaveValue("");
fireEvent.change(codexProxy, { target: { value: "http://proxy.example:8080" } });
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.update",
{
provider: "openai_codex",
extraBody: "",
proxy: "http://proxy.example:8080",
},
20_000,
),
);
});
it("maps provider request switches to raw extraBody fields", async () => {
const base = settingsPayload();
const providers: SettingsPayload["providers"] = [
{
name: "xai_grok",
label: "xAI Grok",
configured: true,
auth_type: "oauth",
api_key_required: false,
oauth_account: "grok@example.com",
oauth_login_supported: true,
advanced_fields: ["extra_body", "proxy"],
extra_body: null,
},
{
name: "openai_codex",
label: "OpenAI Codex",
configured: true,
auth_type: "oauth",
api_key_required: false,
oauth_account: "codex@example.com",
oauth_login_supported: true,
advanced_fields: ["extra_body", "proxy"],
extra_body: null,
},
{
name: "deepseek",
label: "DeepSeek",
configured: true,
api_key_required: true,
api_key_hint: "deep••••test",
api_base: "https://api.deepseek.com",
advanced_fields: ["extra_body"],
extra_body: null,
},
{
name: "openai",
label: "OpenAI",
configured: true,
api_key_required: true,
api_key_hint: "sk-••••test",
api_base: "https://api.openai.com/v1",
api_type: "auto",
advanced_fields: ["api_type", "extra_body"],
extra_body: null,
},
];
const payload: SettingsPayload = { ...base, providers };
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValue(payload);
renderSettingsView({ initialSection: "models", initialSettings: payload });
fireEvent.click(screen.getByRole("button", { name: "xAI Grok" }));
const xSearch = screen.getByRole("switch", { name: "X Search" });
expect(xSearch).toHaveAttribute("aria-checked", "true");
fireEvent.click(xSearch);
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.update",
expect.objectContaining({ provider: "xai_grok" }),
20_000,
));
await waitFor(() => expect(
screen.getByRole("button", { name: "Save provider" }),
).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "xAI Grok" }));
fireEvent.click(screen.getByRole("button", { name: "OpenAI Codex" }));
fireEvent.click(screen.getByRole("switch", { name: "Fast mode" }));
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
"settings.provider.update",
expect.objectContaining({ provider: "openai_codex" }),
20_000,
));
await waitFor(() => expect(
screen.getByRole("button", { name: "Save provider" }),
).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "OpenAI Codex" }));
fireEvent.click(screen.getByRole("button", { name: /^DeepSeek/ }));
expect(screen.getByText(/DeepSeek V4 Flash/)).toBeInTheDocument();
const deepSeekSearch = screen.getByRole("switch", { name: "DeepSeek web search" });
expect(deepSeekSearch).toHaveAttribute("aria-checked", "true");
fireEvent.click(deepSeekSearch);
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => expect(
screen.queryByRole("switch", { name: "DeepSeek web search" }),
).not.toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: /^OpenAI https:/ }));
fireEvent.click(screen.getByRole("switch", { name: "OpenAI web search" }));
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => expect(
screen.queryByRole("switch", { name: "OpenAI web search" }),
).not.toBeInTheDocument());
await waitFor(() => {
const requestUpdates = requestMutationMock.mock.calls
.filter(([action]) => action === "settings.provider.update")
.map(([, values]) => {
const update = values as {
provider: string;
apiType?: string;
extraBody?: string;
};
return [update.provider, {
...(update.apiType ? { apiType: update.apiType } : {}),
extraBody: JSON.parse(update.extraBody ?? "{}"),
}] as const;
});
expect(requestUpdates).toEqual([
["xai_grok", { extraBody: { tools: [] } }],
["openai_codex", { extraBody: { service_tier: "priority" } }],
["deepseek", { extraBody: { tools: [] } }],
["openai", {
apiType: "responses",
extraBody: { tools: [{ type: "web_search" }] },
}],
]);
});
});
it("recognizes and removes versioned web search tools without losing raw settings", async () => {
const base = settingsPayload();
const payload: SettingsPayload = {
...base,
providers: [{
name: "openai",
label: "OpenAI",
configured: true,
api_key_required: true,
api_key_hint: "sk-••••test",
api_base: "https://api.openai.com/v1",
api_type: "auto",
advanced_fields: ["api_type", "extra_body"],
extra_body: {
metadata: { owner: "legacy-config" },
tools: [
{ type: "web_search_preview", search_context_size: "medium" },
{ type: "file_search", vector_store_ids: ["vs_legacy"] },
],
},
}],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce(payload);
renderSettingsView({ initialSection: "models", initialSettings: payload });
fireEvent.click(await screen.findByRole("button", { name: /^OpenAI https:/ }));
const searchSwitch = screen.getByRole("switch", { name: "OpenAI web search" });
expect(searchSwitch).toHaveAttribute("aria-checked", "true");
fireEvent.click(searchSwitch);
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => {
const updateCall = requestMutationMock.mock.calls.find(
([action]) => action === "settings.provider.update",
);
expect(updateCall).toBeTruthy();
const values = updateCall?.[1] as { extraBody: string };
expect(JSON.parse(values.extraBody)).toEqual({
metadata: { owner: "legacy-config" },
tools: [{ type: "file_search", vector_store_ids: ["vs_legacy"] }],
});
});
});
it("creates a custom provider with folded advanced request settings", async () => {
const base = settingsPayload();
let payload: SettingsPayload = {
...base,
providers: [
{
name: "deepseek",
label: "DeepSeek",
configured: true,
api_key_required: true,
api_key_hint: "deep••••test",
api_base: "https://api.deepseek.com",
},
{
name: "openrouter",
label: "OpenRouter",
configured: false,
api_key_required: true,
api_key_hint: null,
api_base: null,
default_api_base: "https://openrouter.ai/api/v1",
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementationOnce(async (
_action: string,
values: Record<string, string>,
) => {
payload = {
...payload,
created_provider: "custom-company-gateway",
providers: [
...payload.providers,
{
name: "custom-company-gateway",
label: values.name,
is_custom: true,
configured: true,
api_key_required: false,
api_key_hint: "sk-c••••pany",
api_base: values.apiBase,
default_api_base: null,
advanced_fields: [
"extra_headers",
"extra_body",
"extra_query",
"proxy",
"thinking_style",
],
extra_headers: JSON.parse(values.extraHeaders),
extra_body: JSON.parse(values.extraBody),
extra_query: JSON.parse(values.extraQuery),
proxy: values.proxy,
thinking_style: values.thinkingStyle,
},
],
};
return payload;
});
renderSettingsView({ initialSection: "models", initialSettings: payload });
fireEvent.pointerDown(
screen.getByRole("button", { name: "Add your own model provider" }),
);
const customOption = await screen.findByRole("menuitem", { name: "Custom provider" });
const openRouterOption = screen.getByRole("menuitem", { name: "OpenRouter" });
expect(customOption.querySelector("svg, img")).not.toBeNull();
expect(openRouterOption.querySelector("svg, img")).not.toBeNull();
fireEvent.click(customOption);
expect(
screen.queryByRole("button", { name: "Add your own model provider" }),
).not.toBeInTheDocument();
expect(screen.queryByLabelText("Extra headers")).not.toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("My model provider"), {
target: { value: "Company Gateway" },
});
fireEvent.change(screen.getByPlaceholderText("https://api.example.com/v1"), {
target: { value: "https://gateway.example/v1" },
});
fireEvent.change(screen.getByPlaceholderText("Enter API key"), {
target: { value: "sk-company" },
});
fireEvent.click(screen.getByRole("button", { name: "Advanced options" }));
fireEvent.change(screen.getByLabelText("Extra headers"), {
target: { value: '{"X-Tenant":"engineering"}' },
});
fireEvent.change(screen.getByLabelText("Extra body"), {
target: { value: '{"service_tier":"priority"}' },
});
fireEvent.change(screen.getByLabelText("Extra query"), {
target: { value: '{"api-version":"2026-01-01"}' },
});
fireEvent.change(screen.getByLabelText("Network proxy"), {
target: { value: "http://127.0.0.1:7890" },
});
fireEvent.pointerDown(screen.getByRole("button", { name: "Thinking style" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "enable_thinking" }));
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => {
const createCall = requestMutationMock.mock.calls.find(
([action]) => action === "settings.provider.create",
);
expect(createCall).toBeTruthy();
expect(createCall?.[1]).toEqual({
name: "Company Gateway",
apiKey: "sk-company",
apiBase: "https://gateway.example/v1",
proxy: "http://127.0.0.1:7890",
extraHeaders: '{"X-Tenant":"engineering"}',
extraBody: '{"service_tier":"priority"}',
extraQuery: '{"api-version":"2026-01-01"}',
thinkingStyle: "enable_thinking",
});
});
expect(
await screen.findByRole("button", { name: /Company Gateway/ }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Add your own model provider" }),
).toBeInTheDocument();
});
});
+381
View File
@@ -0,0 +1,381 @@
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import { requestMutationMock, jsonResponse, settingsPayload, renderSettingsView, installSettingsViewTestHooks } from "@/tests/settings-test-utils";
const installedAnyGen = {
name: "anygen",
display_name: "AnyGen",
category: "generation",
description: "Generate docs, slides, websites and more via AnyGen cloud API",
requires: "ANYGEN_API_KEY",
source: "harness",
entry_point: "cli-anything-anygen",
install_supported: true,
installed: true,
available: true,
status: "installed",
logo_url: "https://www.google.com/s2/favicons?domain=anygen.io&sz=64",
brand_color: "#111827",
skill_installed: true,
};
describe("Settings system domains", () => {
installSettingsViewTestHooks();
it("does not show the Settings kicker on the standalone Automations surface", async () => {
const onBackToChat = vi.fn();
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/webui/automations") return jsonResponse({ jobs: [] });
return jsonResponse({});
}));
renderSettingsView({
initialSection: "automations",
initialSettings: settingsPayload(),
showSidebar: false,
onBackToChat,
});
expect(screen.getByRole("heading", { name: "Automations" })).toBeInTheDocument();
expect(await screen.findByText("No automations yet.")).toBeInTheDocument();
expect(screen.queryByText("Settings")).not.toBeInTheDocument();
expect(
screen.queryByPlaceholderText("Search task, message, linked chat, or schedule"),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Open a chat" }));
expect(onBackToChat).toHaveBeenCalledTimes(1);
});
it("offers a way out of an empty automations filter", async () => {
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/webui/automations") {
return jsonResponse({
jobs: [{
id: "job-1",
name: "Daily summary",
enabled: true,
schedule: { kind: "cron", expr: "0 9 * * *" },
payload: { message: "Summarize the day" },
state: {},
}],
});
}
return jsonResponse({});
}));
renderSettingsView({
initialSection: "automations",
initialSettings: settingsPayload(),
showSidebar: false,
});
expect(await screen.findByRole("heading", { name: "Daily summary" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Paused 0" }));
expect(await screen.findByText("No automations match this view.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
expect(await screen.findByRole("heading", { name: "Daily summary" })).toBeInTheDocument();
});
it("coalesces focus refreshes while automations are already loading", async () => {
let resolveAutomations!: (response: Response) => void;
const pendingAutomations = new Promise<Response>((resolve) => {
resolveAutomations = resolve;
});
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/webui/automations") return pendingAutomations;
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({
initialSection: "automations",
initialSettings: settingsPayload(),
showSidebar: false,
});
await waitFor(() => {
expect(fetchMock.mock.calls.filter(([input]) => (
String(input) === "/api/webui/automations"
))).toHaveLength(1);
});
window.dispatchEvent(new Event("focus"));
window.dispatchEvent(new Event("focus"));
expect(fetchMock.mock.calls.filter(([input]) => (
String(input) === "/api/webui/automations"
))).toHaveLength(1);
await act(async () => {
resolveAutomations(jsonResponse({ jobs: [] }));
await pendingAutomations;
});
});
it("starts the managed API server from System", async () => {
const base = settingsPayload();
const stopped = {
installed: false,
running: false,
managed: false,
host: "127.0.0.1",
port: 8900,
timeout: 120,
api_key_hint: null,
endpoint: "http://127.0.0.1:8900/v1",
command: "nanobot serve",
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(base);
if (url === "/api/settings/api-service") return jsonResponse(stopped);
if (url === "/api/settings/nanobot-features") {
return jsonResponse({ features: [], enabled_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce({
...stopped,
installed: true,
running: true,
managed: true,
});
renderSettingsView({ initialSection: "runtime", initialSettings: base, showSidebar: true });
const startButton = await screen.findByRole("button", { name: "Start API server" });
await waitFor(() => expect(startButton).toBeEnabled());
fireEvent.click(startButton);
await waitFor(() => {
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.api_service.start",
{ host: "127.0.0.1", port: 8900, timeout: 120 },
150_000,
);
});
});
it("shows a visible uninstall button for installed CLI apps and calls uninstall", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") {
return jsonResponse(settingsPayload());
}
if (url === "/api/settings/cli-apps") {
return jsonResponse({
apps: [installedAnyGen],
installed_count: 1,
catalog_updated_at: "2026-04-18",
});
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce({
apps: [{ ...installedAnyGen, installed: false, status: "available" }],
installed_count: 0,
catalog_updated_at: "2026-04-18",
last_action: {
ok: true,
message: "Uninstalled CLI for AnyGen.",
still_available: false,
},
});
renderSettingsView();
expect(screen.queryByRole("heading", { name: "Apps" })).not.toBeInTheDocument();
expect(await screen.findByText("AnyGen")).toBeInTheDocument();
const uninstall = screen.getByRole("button", { name: "Uninstall app" });
fireEvent.click(uninstall);
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.cli_app.uninstall",
{ name: "anygen" },
20_000,
),
);
expect(await screen.findByText("Uninstalled CLI for AnyGen.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument();
});
it("keeps runtime dependencies out of Apps and explains chat mentions", async () => {
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({
apps: [{ ...installedAnyGen, installed: false, status: "available" }],
installed_count: 0,
});
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
if (url === "/api/settings/nanobot-features") {
return jsonResponse({
features: [
{
name: "api",
display_name: "Api",
type: "feature",
enabled: true,
installed: true,
ready: true,
status: "enabled",
install_supported: true,
requires_restart: true,
},
],
enabled_count: 1,
});
}
return jsonResponse({});
}));
renderSettingsView({ initialSection: "apps" });
expect(await screen.findByText("AnyGen")).toBeInTheDocument();
expect(
screen.queryByText("Add tools to nanobot, then @ them in chat."),
).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Ready" })).toHaveAttribute("aria-pressed", "false");
expect(screen.getByRole("button", { name: "Apps" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "MCP" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Plugins" })).not.toBeInTheDocument();
expect(screen.queryByText("Api")).not.toBeInTheDocument();
expect(screen.queryByText("0 ready")).not.toBeInTheDocument();
});
it("shows nanobot optional features and enables one", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
if (url === "/api/settings/nanobot-features") {
return jsonResponse({
features: [{
name: "matrix",
display_name: "Matrix",
webui: "webui/index.ts",
type: "channel",
enabled: false,
installed: false,
ready: false,
status: "missing_dependency",
install_supported: true,
requires_restart: true,
}],
enabled_count: 0,
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockImplementation(async (action: string) => {
if (action === "settings.feature.enable") {
return {
features: [{
name: "matrix",
display_name: "Matrix",
webui: "webui/index.ts",
type: "channel",
enabled: true,
running: true,
runtime_status: "running",
installed: true,
ready: true,
status: "enabled",
install_supported: true,
requires_restart: true,
}],
enabled_count: 1,
last_action: { ok: true, message: "Enabled channel 'matrix'", enabled: true },
};
}
if (action === "settings.feature.disable") {
return {
features: [{
name: "matrix",
display_name: "Matrix",
webui: "webui/index.ts",
type: "channel",
enabled: false,
installed: true,
ready: false,
status: "not_enabled",
install_supported: true,
requires_restart: true,
}],
enabled_count: 0,
requires_restart: true,
last_action: { ok: true, message: "Disabled channel 'matrix'", enabled: false },
};
}
return settingsPayload();
});
renderSettingsView({ initialSection: "channels" });
const matrixRow = await screen.findByRole("button", { name: "View Matrix settings" });
expect(matrixRow).toHaveAttribute("aria-pressed", "true");
expect(screen.getAllByText("Matrix")).toHaveLength(2);
expect(screen.getAllByText("Use nanobot from Matrix rooms.")).toHaveLength(2);
expect(screen.queryByText(/Enabling Nanobot features may install Python packages/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("switch", { name: "Matrix channel" }));
expect(screen.getByRole("dialog", { name: "Install support for Matrix?" })).toBeInTheDocument();
expect(screen.getByText("nanobot will add what Matrix needs, then turn it on. Continue?")).toBeInTheDocument();
expect(requestMutationMock).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Install and enable" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.feature.enable",
{ name: "matrix" },
150_000,
),
);
await waitFor(() =>
expect(screen.getByRole("switch", { name: "Matrix channel" })).toHaveAttribute("aria-checked", "true"),
);
expect(screen.queryByText("Enabled channel 'matrix'")).not.toBeInTheDocument();
expect(screen.queryByText("Restart nanobot to apply updated channel support.")).not.toBeInTheDocument();
expect(screen.getAllByText("On").length).toBeGreaterThan(0);
expect(screen.getByLabelText("Homeserver")).toBeInTheDocument();
expect(screen.getByLabelText("User ID")).toBeInTheDocument();
expect(screen.getByLabelText("Device ID")).toBeInTheDocument();
expect(screen.queryByText("channels.matrix.homeserver")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("switch", { name: "Matrix channel" }));
await waitFor(() =>
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.feature.disable",
{ name: "matrix" },
20_000,
),
);
await waitFor(() =>
expect(screen.getByRole("switch", { name: "Matrix channel" })).toHaveAttribute("aria-checked", "false"),
);
expect(screen.queryByText("Disabled channel 'matrix'")).not.toBeInTheDocument();
});
});
+195
View File
@@ -0,0 +1,195 @@
import { cleanup, render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, vi } from "vitest";
import { SettingsView } from "@/components/settings/SettingsView";
import { ClientProvider } from "@/providers/ClientProvider";
import type { SettingsPayload } from "@/lib/types";
export const requestMutationMock = vi.fn();
export function jsonResponse(body: unknown): Response {
return {
ok: true,
status: 200,
json: async () => body,
} as Response;
}
export function settingsPayload(): SettingsPayload {
return {
agent: {
model: "openai/gpt-4o",
provider: "auto",
resolved_provider: "openai",
has_api_key: true,
model_preset: "primary",
max_tokens: 8192,
context_window_tokens: 200000,
temperature: 0.1,
reasoning_effort: null,
timezone: "UTC",
tool_hint_max_length: 40,
},
model_presets: [{
name: "primary",
label: "Primary",
active: true,
is_default: false,
model: "openai/gpt-4o",
provider: "auto",
resolved_provider: "openai",
max_tokens: 8192,
context_window_tokens: 200000,
temperature: 0.1,
reasoning_effort: null,
}],
model_call_order: ["primary"],
model_call_order_editable: true,
providers: [],
web_search: {
provider: "duckduckgo",
api_key_hint: null,
base_url: null,
max_results: 5,
timeout: 30,
providers: [{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" }],
},
web: {
enable: true,
proxy: null,
user_agent: null,
search: { max_results: 5, timeout: 30 },
fetch: { use_jina_reader: true },
},
api: {
host: "127.0.0.1",
port: 8900,
timeout: 120,
api_key_hint: null,
},
observability: {
provider: "langfuse",
configured: false,
base_url: "https://cloud.langfuse.com",
},
image_generation: {
enabled: false,
provider: "openrouter",
provider_configured: false,
model: "openai/gpt-5.4-image-2",
default_aspect_ratio: "1:1",
default_image_size: "1K",
max_images_per_turn: 4,
save_dir: "generated",
providers: [],
},
runtime: {
config_path: "/tmp/config.json",
workspace_path: "/tmp/workspace",
gateway_host: "127.0.0.1",
gateway_port: 18790,
heartbeat: {
enabled: true,
interval_s: 1800,
keep_recent_messages: 8,
},
dream: {
schedule: "every 2h",
},
unified_session: false,
},
advanced: {
restrict_to_workspace: false,
webui_allow_local_service_access: true,
webui_default_access_mode: "default",
private_service_protection_enabled: true,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
exec_sandbox: null,
exec_path_prepend_set: false,
exec_path_append_set: false,
},
requires_restart: false,
version: {
current: "0.2.2",
},
docs: {
version: "0.2.2",
base_url: "https://nanobot.wiki/docs/0.2.2",
chat_apps_url: "https://nanobot.wiki/docs/0.2.2/getting-started/chat-apps",
latest_url: "https://nanobot.wiki/docs/latest",
},
};
}
export function renderSettingsView(
options: {
initialSection?:
| "overview"
| "appearance"
| "apps"
| "channels"
| "automations"
| "advanced"
| "models"
| "image"
| "browser"
| "runtime";
initialSettings?: SettingsPayload;
showSidebar?: boolean;
onBackToChat?: () => void;
onSettingsChange?: (payload: SettingsPayload) => void;
onNativeEngineRestart?: () => Promise<string>;
} = {},
) {
render(
<ClientProvider client={{ requestMutation: requestMutationMock } as never} token="tok">
<SettingsView
theme="light"
initialSection={options.initialSection ?? "apps"}
initialSettings={options.initialSettings}
showSidebar={options.showSidebar}
onToggleTheme={() => {}}
onBackToChat={options.onBackToChat ?? (() => {})}
onModelNameChange={() => {}}
onSettingsChange={options.onSettingsChange}
onNativeEngineRestart={options.onNativeEngineRestart}
/>
</ClientProvider>,
);
}
export async function openPopover(trigger: HTMLElement) {
await userEvent.setup().click(trigger);
}
export function installSettingsViewTestHooks() {
beforeEach(() => {
requestMutationMock.mockReset().mockResolvedValue(settingsPayload());
vi.stubGlobal(
"matchMedia",
vi.fn((query: string) => ({
matches: query === "(min-width: 1280px)",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
);
vi.stubGlobal(
"fetch",
vi.fn(() => new Promise<Response>(() => {})),
);
});
afterEach(() => {
cleanup();
localStorage.removeItem("nanobot-webui.settings-preferences");
vi.useRealTimers();
vi.unstubAllGlobals();
});
}
File diff suppressed because it is too large Load Diff