diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 5ccf58787f3..4bffdea6671 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -122,7 +122,7 @@ import { } from "./views/agents-utils.ts"; import { renderChat } from "./views/chat.ts"; import { renderCommandPalette } from "./views/command-palette.ts"; -import { renderConfig } from "./views/config.ts"; +import { renderConfig, type ConfigProps } from "./views/config.ts"; import { renderDreaming } from "./views/dreaming.ts"; import { renderExecApprovalPrompt } from "./views/exec-approval.ts"; import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation.ts"; @@ -320,11 +320,61 @@ const AI_AGENTS_SECTION_KEYS = [ "memory", "session", ] as const; -type CommunicationSectionKey = (typeof COMMUNICATION_SECTION_KEYS)[number]; -type AppearanceSectionKey = (typeof APPEARANCE_SECTION_KEYS)[number]; -type AutomationSectionKey = (typeof AUTOMATION_SECTION_KEYS)[number]; -type InfrastructureSectionKey = (typeof INFRASTRUCTURE_SECTION_KEYS)[number]; -type AiAgentsSectionKey = (typeof AI_AGENTS_SECTION_KEYS)[number]; +type ConfigSectionSelection = { + activeSection: string | null; + activeSubsection: string | null; +}; + +type ConfigTabOverrides = Pick< + ConfigProps, + | "formMode" + | "searchQuery" + | "activeSection" + | "activeSubsection" + | "onFormModeChange" + | "onSearchChange" + | "onSectionChange" + | "onSubsectionChange" +> & + Partial< + Pick< + ConfigProps, + | "showModeToggle" + | "navRootLabel" + | "includeSections" + | "excludeSections" + | "includeVirtualSections" + > + >; + +const SCOPED_CONFIG_SECTION_KEYS = new Set([ + ...COMMUNICATION_SECTION_KEYS, + ...APPEARANCE_SECTION_KEYS, + ...AUTOMATION_SECTION_KEYS, + ...INFRASTRUCTURE_SECTION_KEYS, + ...AI_AGENTS_SECTION_KEYS, +]); + +function normalizeMainConfigSelection( + activeSection: string | null, + activeSubsection: string | null, +): ConfigSectionSelection { + if (activeSection && SCOPED_CONFIG_SECTION_KEYS.has(activeSection)) { + return { activeSection: null, activeSubsection: null }; + } + return { activeSection, activeSubsection }; +} + +function normalizeScopedConfigSelection( + activeSection: string | null, + activeSubsection: string | null, + includedSections: readonly string[], +): ConfigSectionSelection { + if (activeSection && !includedSections.includes(activeSection)) { + return { activeSection: null, activeSubsection: null }; + } + return { activeSection, activeSubsection }; +} function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { const list = state.agentsList?.agents ?? []; @@ -459,6 +509,204 @@ export function renderApp(state: AppViewState) { state.cronForm.deliveryMode === "webhook" ? rawDeliveryToSuggestions.filter((value) => isHttpUrl(value)) : rawDeliveryToSuggestions; + const commonConfigProps = { + raw: state.configRaw, + originalRaw: state.configRawOriginal, + valid: state.configValid, + issues: state.configIssues, + loading: state.configLoading, + saving: state.configSaving, + applying: state.configApplying, + updating: state.updateRunning, + connected: state.connected, + schema: state.configSchema, + schemaLoading: state.configSchemaLoading, + uiHints: state.configUiHints, + formValue: state.configForm, + originalValue: state.configFormOriginal, + onRawChange: (next: string) => { + state.configRaw = next; + }, + onRequestUpdate: requestHostUpdate, + onFormPatch: (path: Array, value: unknown) => + updateConfigFormValue(state, path, value), + onReload: () => loadConfig(state), + onSave: () => saveConfig(state), + onApply: () => applyConfig(state), + onUpdate: () => runUpdate(state), + onOpenFile: () => openConfigFile(state), + version: state.hello?.server?.version ?? "", + theme: state.theme, + themeMode: state.themeMode, + setTheme: (theme, context) => state.setTheme(theme, context), + setThemeMode: (mode, context) => state.setThemeMode(mode, context), + borderRadius: state.settings.borderRadius, + setBorderRadius: (value) => state.setBorderRadius(value), + gatewayUrl: state.settings.gatewayUrl, + assistantName: state.assistantName, + configPath: state.configSnapshot?.path ?? null, + rawAvailable: typeof state.configSnapshot?.raw === "string", + } satisfies Omit< + ConfigProps, + | "formMode" + | "searchQuery" + | "activeSection" + | "activeSubsection" + | "onFormModeChange" + | "onSearchChange" + | "onSectionChange" + | "onSubsectionChange" + | "showModeToggle" + | "navRootLabel" + | "includeSections" + | "excludeSections" + | "includeVirtualSections" + >; + const renderConfigTab = (overrides: ConfigTabOverrides) => + renderConfig({ + ...commonConfigProps, + includeVirtualSections: false, + ...overrides, + }); + const configSelection = normalizeMainConfigSelection( + state.configActiveSection, + state.configActiveSubsection, + ); + const communicationsSelection = normalizeScopedConfigSelection( + state.communicationsActiveSection, + state.communicationsActiveSubsection, + COMMUNICATION_SECTION_KEYS, + ); + const appearanceSelection = normalizeScopedConfigSelection( + state.appearanceActiveSection, + state.appearanceActiveSubsection, + APPEARANCE_SECTION_KEYS, + ); + const automationSelection = normalizeScopedConfigSelection( + state.automationActiveSection, + state.automationActiveSubsection, + AUTOMATION_SECTION_KEYS, + ); + const infrastructureSelection = normalizeScopedConfigSelection( + state.infrastructureActiveSection, + state.infrastructureActiveSubsection, + INFRASTRUCTURE_SECTION_KEYS, + ); + const aiAgentsSelection = normalizeScopedConfigSelection( + state.aiAgentsActiveSection, + state.aiAgentsActiveSubsection, + AI_AGENTS_SECTION_KEYS, + ); + const renderConfigTabForActiveTab = () => { + switch (state.tab) { + case "config": + return renderConfigTab({ + formMode: state.configFormMode, + searchQuery: state.configSearchQuery, + activeSection: configSelection.activeSection, + activeSubsection: configSelection.activeSubsection, + onFormModeChange: (mode) => (state.configFormMode = mode), + onSearchChange: (query) => (state.configSearchQuery = query), + onSectionChange: (section) => { + state.configActiveSection = section; + state.configActiveSubsection = null; + }, + onSubsectionChange: (section) => (state.configActiveSubsection = section), + showModeToggle: true, + excludeSections: [ + ...COMMUNICATION_SECTION_KEYS, + ...AUTOMATION_SECTION_KEYS, + ...INFRASTRUCTURE_SECTION_KEYS, + ...AI_AGENTS_SECTION_KEYS, + "ui", + "wizard", + ], + }); + case "communications": + return renderConfigTab({ + formMode: state.communicationsFormMode, + searchQuery: state.communicationsSearchQuery, + activeSection: communicationsSelection.activeSection, + activeSubsection: communicationsSelection.activeSubsection, + onFormModeChange: (mode) => (state.communicationsFormMode = mode), + onSearchChange: (query) => (state.communicationsSearchQuery = query), + onSectionChange: (section) => { + state.communicationsActiveSection = section; + state.communicationsActiveSubsection = null; + }, + onSubsectionChange: (section) => (state.communicationsActiveSubsection = section), + navRootLabel: "Communication", + includeSections: [...COMMUNICATION_SECTION_KEYS], + }); + case "appearance": + return renderConfigTab({ + formMode: state.appearanceFormMode, + searchQuery: state.appearanceSearchQuery, + activeSection: appearanceSelection.activeSection, + activeSubsection: appearanceSelection.activeSubsection, + onFormModeChange: (mode) => (state.appearanceFormMode = mode), + onSearchChange: (query) => (state.appearanceSearchQuery = query), + onSectionChange: (section) => { + state.appearanceActiveSection = section; + state.appearanceActiveSubsection = null; + }, + onSubsectionChange: (section) => (state.appearanceActiveSubsection = section), + navRootLabel: t("tabs.appearance"), + includeSections: [...APPEARANCE_SECTION_KEYS], + includeVirtualSections: true, + }); + case "automation": + return renderConfigTab({ + formMode: state.automationFormMode, + searchQuery: state.automationSearchQuery, + activeSection: automationSelection.activeSection, + activeSubsection: automationSelection.activeSubsection, + onFormModeChange: (mode) => (state.automationFormMode = mode), + onSearchChange: (query) => (state.automationSearchQuery = query), + onSectionChange: (section) => { + state.automationActiveSection = section; + state.automationActiveSubsection = null; + }, + onSubsectionChange: (section) => (state.automationActiveSubsection = section), + navRootLabel: "Automation", + includeSections: [...AUTOMATION_SECTION_KEYS], + }); + case "infrastructure": + return renderConfigTab({ + formMode: state.infrastructureFormMode, + searchQuery: state.infrastructureSearchQuery, + activeSection: infrastructureSelection.activeSection, + activeSubsection: infrastructureSelection.activeSubsection, + onFormModeChange: (mode) => (state.infrastructureFormMode = mode), + onSearchChange: (query) => (state.infrastructureSearchQuery = query), + onSectionChange: (section) => { + state.infrastructureActiveSection = section; + state.infrastructureActiveSubsection = null; + }, + onSubsectionChange: (section) => (state.infrastructureActiveSubsection = section), + navRootLabel: "Infrastructure", + includeSections: [...INFRASTRUCTURE_SECTION_KEYS], + }); + case "aiAgents": + return renderConfigTab({ + formMode: state.aiAgentsFormMode, + searchQuery: state.aiAgentsSearchQuery, + activeSection: aiAgentsSelection.activeSection, + activeSubsection: aiAgentsSelection.activeSubsection, + onFormModeChange: (mode) => (state.aiAgentsFormMode = mode), + onSearchChange: (query) => (state.aiAgentsSearchQuery = query), + onSectionChange: (section) => { + state.aiAgentsActiveSection = section; + state.aiAgentsActiveSubsection = null; + }, + onSubsectionChange: (section) => (state.aiAgentsActiveSubsection = section), + navRootLabel: "AI & Agents", + includeSections: [...AI_AGENTS_SECTION_KEYS], + }); + default: + return nothing; + } + }; return html` ${renderCommandPalette({ @@ -1659,419 +1907,7 @@ export function renderApp(state: AppViewState) { basePath: state.basePath ?? "", }) : nothing} - ${state.tab === "config" - ? renderConfig({ - raw: state.configRaw, - originalRaw: state.configRawOriginal, - valid: state.configValid, - issues: state.configIssues, - loading: state.configLoading, - saving: state.configSaving, - applying: state.configApplying, - updating: state.updateRunning, - connected: state.connected, - schema: state.configSchema, - schemaLoading: state.configSchemaLoading, - uiHints: state.configUiHints, - formMode: state.configFormMode, - showModeToggle: true, - formValue: state.configForm, - originalValue: state.configFormOriginal, - searchQuery: state.configSearchQuery, - activeSection: - state.configActiveSection && - (COMMUNICATION_SECTION_KEYS.includes( - state.configActiveSection as CommunicationSectionKey, - ) || - APPEARANCE_SECTION_KEYS.includes( - state.configActiveSection as AppearanceSectionKey, - ) || - AUTOMATION_SECTION_KEYS.includes( - state.configActiveSection as AutomationSectionKey, - ) || - INFRASTRUCTURE_SECTION_KEYS.includes( - state.configActiveSection as InfrastructureSectionKey, - ) || - AI_AGENTS_SECTION_KEYS.includes(state.configActiveSection as AiAgentsSectionKey)) - ? null - : state.configActiveSection, - activeSubsection: - state.configActiveSection && - (COMMUNICATION_SECTION_KEYS.includes( - state.configActiveSection as CommunicationSectionKey, - ) || - APPEARANCE_SECTION_KEYS.includes( - state.configActiveSection as AppearanceSectionKey, - ) || - AUTOMATION_SECTION_KEYS.includes( - state.configActiveSection as AutomationSectionKey, - ) || - INFRASTRUCTURE_SECTION_KEYS.includes( - state.configActiveSection as InfrastructureSectionKey, - ) || - AI_AGENTS_SECTION_KEYS.includes(state.configActiveSection as AiAgentsSectionKey)) - ? null - : state.configActiveSubsection, - onRawChange: (next) => { - state.configRaw = next; - }, - onRequestUpdate: requestHostUpdate, - onFormModeChange: (mode) => (state.configFormMode = mode), - onFormPatch: (path, value) => updateConfigFormValue(state, path, value), - onSearchChange: (query) => (state.configSearchQuery = query), - onSectionChange: (section) => { - state.configActiveSection = section; - state.configActiveSubsection = null; - }, - onSubsectionChange: (section) => (state.configActiveSubsection = section), - onReload: () => loadConfig(state), - onSave: () => saveConfig(state), - onApply: () => applyConfig(state), - onUpdate: () => runUpdate(state), - onOpenFile: () => openConfigFile(state), - version: state.hello?.server?.version ?? "", - theme: state.theme, - themeMode: state.themeMode, - setTheme: (t, ctx) => state.setTheme(t, ctx), - setThemeMode: (m, ctx) => state.setThemeMode(m, ctx), - borderRadius: state.settings.borderRadius, - setBorderRadius: (v) => state.setBorderRadius(v), - gatewayUrl: state.settings.gatewayUrl, - assistantName: state.assistantName, - configPath: state.configSnapshot?.path ?? null, - rawAvailable: typeof state.configSnapshot?.raw === "string", - excludeSections: [ - ...COMMUNICATION_SECTION_KEYS, - ...AUTOMATION_SECTION_KEYS, - ...INFRASTRUCTURE_SECTION_KEYS, - ...AI_AGENTS_SECTION_KEYS, - "ui", - "wizard", - ], - includeVirtualSections: false, - }) - : nothing} - ${state.tab === "communications" - ? renderConfig({ - raw: state.configRaw, - originalRaw: state.configRawOriginal, - valid: state.configValid, - issues: state.configIssues, - loading: state.configLoading, - saving: state.configSaving, - applying: state.configApplying, - updating: state.updateRunning, - connected: state.connected, - schema: state.configSchema, - schemaLoading: state.configSchemaLoading, - uiHints: state.configUiHints, - formMode: state.communicationsFormMode, - formValue: state.configForm, - originalValue: state.configFormOriginal, - searchQuery: state.communicationsSearchQuery, - activeSection: - state.communicationsActiveSection && - !COMMUNICATION_SECTION_KEYS.includes( - state.communicationsActiveSection as CommunicationSectionKey, - ) - ? null - : state.communicationsActiveSection, - activeSubsection: - state.communicationsActiveSection && - !COMMUNICATION_SECTION_KEYS.includes( - state.communicationsActiveSection as CommunicationSectionKey, - ) - ? null - : state.communicationsActiveSubsection, - onRawChange: (next) => { - state.configRaw = next; - }, - onRequestUpdate: requestHostUpdate, - onFormModeChange: (mode) => (state.communicationsFormMode = mode), - onFormPatch: (path, value) => updateConfigFormValue(state, path, value), - onSearchChange: (query) => (state.communicationsSearchQuery = query), - onSectionChange: (section) => { - state.communicationsActiveSection = section; - state.communicationsActiveSubsection = null; - }, - onSubsectionChange: (section) => (state.communicationsActiveSubsection = section), - onReload: () => loadConfig(state), - onSave: () => saveConfig(state), - onApply: () => applyConfig(state), - onUpdate: () => runUpdate(state), - onOpenFile: () => openConfigFile(state), - version: state.hello?.server?.version ?? "", - theme: state.theme, - themeMode: state.themeMode, - setTheme: (t, ctx) => state.setTheme(t, ctx), - setThemeMode: (m, ctx) => state.setThemeMode(m, ctx), - borderRadius: state.settings.borderRadius, - setBorderRadius: (v) => state.setBorderRadius(v), - gatewayUrl: state.settings.gatewayUrl, - assistantName: state.assistantName, - configPath: state.configSnapshot?.path ?? null, - rawAvailable: typeof state.configSnapshot?.raw === "string", - navRootLabel: "Communication", - includeSections: [...COMMUNICATION_SECTION_KEYS], - includeVirtualSections: false, - }) - : nothing} - ${state.tab === "appearance" - ? renderConfig({ - raw: state.configRaw, - originalRaw: state.configRawOriginal, - valid: state.configValid, - issues: state.configIssues, - loading: state.configLoading, - saving: state.configSaving, - applying: state.configApplying, - updating: state.updateRunning, - connected: state.connected, - schema: state.configSchema, - schemaLoading: state.configSchemaLoading, - uiHints: state.configUiHints, - formMode: state.appearanceFormMode, - formValue: state.configForm, - originalValue: state.configFormOriginal, - searchQuery: state.appearanceSearchQuery, - activeSection: - state.appearanceActiveSection && - !APPEARANCE_SECTION_KEYS.includes( - state.appearanceActiveSection as AppearanceSectionKey, - ) - ? null - : state.appearanceActiveSection, - activeSubsection: - state.appearanceActiveSection && - !APPEARANCE_SECTION_KEYS.includes( - state.appearanceActiveSection as AppearanceSectionKey, - ) - ? null - : state.appearanceActiveSubsection, - onRawChange: (next) => { - state.configRaw = next; - }, - onRequestUpdate: requestHostUpdate, - onFormModeChange: (mode) => (state.appearanceFormMode = mode), - onFormPatch: (path, value) => updateConfigFormValue(state, path, value), - onSearchChange: (query) => (state.appearanceSearchQuery = query), - onSectionChange: (section) => { - state.appearanceActiveSection = section; - state.appearanceActiveSubsection = null; - }, - onSubsectionChange: (section) => (state.appearanceActiveSubsection = section), - onReload: () => loadConfig(state), - onSave: () => saveConfig(state), - onApply: () => applyConfig(state), - onUpdate: () => runUpdate(state), - onOpenFile: () => openConfigFile(state), - version: state.hello?.server?.version ?? "", - theme: state.theme, - themeMode: state.themeMode, - setTheme: (t, ctx) => state.setTheme(t, ctx), - setThemeMode: (m, ctx) => state.setThemeMode(m, ctx), - borderRadius: state.settings.borderRadius, - setBorderRadius: (v) => state.setBorderRadius(v), - gatewayUrl: state.settings.gatewayUrl, - assistantName: state.assistantName, - configPath: state.configSnapshot?.path ?? null, - rawAvailable: typeof state.configSnapshot?.raw === "string", - navRootLabel: t("tabs.appearance"), - includeSections: [...APPEARANCE_SECTION_KEYS], - includeVirtualSections: true, - }) - : nothing} - ${state.tab === "automation" - ? renderConfig({ - raw: state.configRaw, - originalRaw: state.configRawOriginal, - valid: state.configValid, - issues: state.configIssues, - loading: state.configLoading, - saving: state.configSaving, - applying: state.configApplying, - updating: state.updateRunning, - connected: state.connected, - schema: state.configSchema, - schemaLoading: state.configSchemaLoading, - uiHints: state.configUiHints, - formMode: state.automationFormMode, - formValue: state.configForm, - originalValue: state.configFormOriginal, - searchQuery: state.automationSearchQuery, - activeSection: - state.automationActiveSection && - !AUTOMATION_SECTION_KEYS.includes( - state.automationActiveSection as AutomationSectionKey, - ) - ? null - : state.automationActiveSection, - activeSubsection: - state.automationActiveSection && - !AUTOMATION_SECTION_KEYS.includes( - state.automationActiveSection as AutomationSectionKey, - ) - ? null - : state.automationActiveSubsection, - onRawChange: (next) => { - state.configRaw = next; - }, - onRequestUpdate: requestHostUpdate, - onFormModeChange: (mode) => (state.automationFormMode = mode), - onFormPatch: (path, value) => updateConfigFormValue(state, path, value), - onSearchChange: (query) => (state.automationSearchQuery = query), - onSectionChange: (section) => { - state.automationActiveSection = section; - state.automationActiveSubsection = null; - }, - onSubsectionChange: (section) => (state.automationActiveSubsection = section), - onReload: () => loadConfig(state), - onSave: () => saveConfig(state), - onApply: () => applyConfig(state), - onUpdate: () => runUpdate(state), - onOpenFile: () => openConfigFile(state), - version: state.hello?.server?.version ?? "", - theme: state.theme, - themeMode: state.themeMode, - setTheme: (t, ctx) => state.setTheme(t, ctx), - setThemeMode: (m, ctx) => state.setThemeMode(m, ctx), - borderRadius: state.settings.borderRadius, - setBorderRadius: (v) => state.setBorderRadius(v), - gatewayUrl: state.settings.gatewayUrl, - assistantName: state.assistantName, - configPath: state.configSnapshot?.path ?? null, - rawAvailable: typeof state.configSnapshot?.raw === "string", - navRootLabel: "Automation", - includeSections: [...AUTOMATION_SECTION_KEYS], - includeVirtualSections: false, - }) - : nothing} - ${state.tab === "infrastructure" - ? renderConfig({ - raw: state.configRaw, - originalRaw: state.configRawOriginal, - valid: state.configValid, - issues: state.configIssues, - loading: state.configLoading, - saving: state.configSaving, - applying: state.configApplying, - updating: state.updateRunning, - connected: state.connected, - schema: state.configSchema, - schemaLoading: state.configSchemaLoading, - uiHints: state.configUiHints, - formMode: state.infrastructureFormMode, - formValue: state.configForm, - originalValue: state.configFormOriginal, - searchQuery: state.infrastructureSearchQuery, - activeSection: - state.infrastructureActiveSection && - !INFRASTRUCTURE_SECTION_KEYS.includes( - state.infrastructureActiveSection as InfrastructureSectionKey, - ) - ? null - : state.infrastructureActiveSection, - activeSubsection: - state.infrastructureActiveSection && - !INFRASTRUCTURE_SECTION_KEYS.includes( - state.infrastructureActiveSection as InfrastructureSectionKey, - ) - ? null - : state.infrastructureActiveSubsection, - onRawChange: (next) => { - state.configRaw = next; - }, - onRequestUpdate: requestHostUpdate, - onFormModeChange: (mode) => (state.infrastructureFormMode = mode), - onFormPatch: (path, value) => updateConfigFormValue(state, path, value), - onSearchChange: (query) => (state.infrastructureSearchQuery = query), - onSectionChange: (section) => { - state.infrastructureActiveSection = section; - state.infrastructureActiveSubsection = null; - }, - onSubsectionChange: (section) => (state.infrastructureActiveSubsection = section), - onReload: () => loadConfig(state), - onSave: () => saveConfig(state), - onApply: () => applyConfig(state), - onUpdate: () => runUpdate(state), - onOpenFile: () => openConfigFile(state), - version: state.hello?.server?.version ?? "", - theme: state.theme, - themeMode: state.themeMode, - setTheme: (t, ctx) => state.setTheme(t, ctx), - setThemeMode: (m, ctx) => state.setThemeMode(m, ctx), - borderRadius: state.settings.borderRadius, - setBorderRadius: (v) => state.setBorderRadius(v), - gatewayUrl: state.settings.gatewayUrl, - assistantName: state.assistantName, - configPath: state.configSnapshot?.path ?? null, - rawAvailable: typeof state.configSnapshot?.raw === "string", - navRootLabel: "Infrastructure", - includeSections: [...INFRASTRUCTURE_SECTION_KEYS], - includeVirtualSections: false, - }) - : nothing} - ${state.tab === "aiAgents" - ? renderConfig({ - raw: state.configRaw, - originalRaw: state.configRawOriginal, - valid: state.configValid, - issues: state.configIssues, - loading: state.configLoading, - saving: state.configSaving, - applying: state.configApplying, - updating: state.updateRunning, - connected: state.connected, - schema: state.configSchema, - schemaLoading: state.configSchemaLoading, - uiHints: state.configUiHints, - formMode: state.aiAgentsFormMode, - formValue: state.configForm, - originalValue: state.configFormOriginal, - searchQuery: state.aiAgentsSearchQuery, - activeSection: - state.aiAgentsActiveSection && - !AI_AGENTS_SECTION_KEYS.includes(state.aiAgentsActiveSection as AiAgentsSectionKey) - ? null - : state.aiAgentsActiveSection, - activeSubsection: - state.aiAgentsActiveSection && - !AI_AGENTS_SECTION_KEYS.includes(state.aiAgentsActiveSection as AiAgentsSectionKey) - ? null - : state.aiAgentsActiveSubsection, - onRawChange: (next) => { - state.configRaw = next; - }, - onRequestUpdate: requestHostUpdate, - onFormModeChange: (mode) => (state.aiAgentsFormMode = mode), - onFormPatch: (path, value) => updateConfigFormValue(state, path, value), - onSearchChange: (query) => (state.aiAgentsSearchQuery = query), - onSectionChange: (section) => { - state.aiAgentsActiveSection = section; - state.aiAgentsActiveSubsection = null; - }, - onSubsectionChange: (section) => (state.aiAgentsActiveSubsection = section), - onReload: () => loadConfig(state), - onSave: () => saveConfig(state), - onApply: () => applyConfig(state), - onUpdate: () => runUpdate(state), - onOpenFile: () => openConfigFile(state), - version: state.hello?.server?.version ?? "", - theme: state.theme, - themeMode: state.themeMode, - setTheme: (t, ctx) => state.setTheme(t, ctx), - setThemeMode: (m, ctx) => state.setThemeMode(m, ctx), - borderRadius: state.settings.borderRadius, - setBorderRadius: (v) => state.setBorderRadius(v), - gatewayUrl: state.settings.gatewayUrl, - assistantName: state.assistantName, - configPath: state.configSnapshot?.path ?? null, - rawAvailable: typeof state.configSnapshot?.raw === "string", - navRootLabel: "AI & Agents", - includeSections: [...AI_AGENTS_SECTION_KEYS], - includeVirtualSections: false, - }) - : nothing} + ${renderConfigTabForActiveTab()} ${state.tab === "debug" ? lazyRender(lazyDebug, (m) => m.renderDebug({ diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 890f3b0c3f9..0301759f08b 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -237,91 +237,96 @@ export function setThemeMode( } export async function refreshActiveTab(host: SettingsHost) { - if (host.tab === "overview") { - await loadOverview(host); - } - if (host.tab === "channels") { - await loadChannelsTab(host); - } - if (host.tab === "instances") { - await loadPresence(host as unknown as OpenClawApp); - } - if (host.tab === "usage") { - await loadUsage(host as unknown as OpenClawApp); - } - if (host.tab === "sessions") { - await loadSessions(host as unknown as OpenClawApp); - } - if (host.tab === "cron") { - await loadCron(host); - } - if (host.tab === "skills") { - await loadSkills(host as unknown as OpenClawApp); - } - if (host.tab === "agents") { - await loadAgents(host as unknown as OpenClawApp); - await loadConfig(host as unknown as OpenClawApp); - const agentIds = host.agentsList?.agents?.map((entry) => entry.id) ?? []; - if (agentIds.length > 0) { - void loadAgentIdentities(host as unknown as OpenClawApp, agentIds); - } - const agentId = - host.agentsSelectedId ?? host.agentsList?.defaultId ?? host.agentsList?.agents?.[0]?.id; - if (agentId) { - void loadAgentIdentity(host as unknown as OpenClawApp, agentId); - if (host.agentsPanel === "files") { - void loadAgentFiles(host as unknown as OpenClawApp, agentId); + const app = host as unknown as OpenClawApp; + switch (host.tab) { + case "overview": + await loadOverview(host); + return; + case "channels": + await loadChannelsTab(host); + return; + case "instances": + await loadPresence(app); + return; + case "usage": + await loadUsage(app); + return; + case "sessions": + await loadSessions(app); + return; + case "cron": + await loadCron(host); + return; + case "skills": + await loadSkills(app); + return; + case "agents": { + await loadAgents(app); + await loadConfig(app); + const agentIds = host.agentsList?.agents?.map((entry) => entry.id) ?? []; + if (agentIds.length > 0) { + void loadAgentIdentities(app, agentIds); } - if (host.agentsPanel === "skills") { - void loadAgentSkills(host as unknown as OpenClawApp, agentId); + const agentId = + host.agentsSelectedId ?? host.agentsList?.defaultId ?? host.agentsList?.agents?.[0]?.id; + if (!agentId) { + return; } - if (host.agentsPanel === "channels") { - void loadChannels(host as unknown as OpenClawApp, false); - } - if (host.agentsPanel === "cron") { - void loadCron(host); + void loadAgentIdentity(app, agentId); + switch (host.agentsPanel) { + case "files": + void loadAgentFiles(app, agentId); + return; + case "skills": + void loadAgentSkills(app, agentId); + return; + case "channels": + void loadChannels(app, false); + return; + case "cron": + void loadCron(host); + return; + default: + return; } } - } - if (host.tab === "nodes") { - await loadNodes(host as unknown as OpenClawApp); - await loadDevices(host as unknown as OpenClawApp); - await loadConfig(host as unknown as OpenClawApp); - await loadExecApprovals(host as unknown as OpenClawApp); - } - if (host.tab === "dreams") { - await loadConfig(host as unknown as OpenClawApp); - await Promise.all([ - loadDreamingStatus(host as unknown as OpenClawApp), - loadDreamDiary(host as unknown as OpenClawApp), - ]); - } - if (host.tab === "chat") { - await refreshChat(host as unknown as Parameters[0]); - scheduleChatScroll( - host as unknown as Parameters[0], - !host.chatHasAutoScrolled, - ); - } - if ( - host.tab === "config" || - host.tab === "communications" || - host.tab === "appearance" || - host.tab === "automation" || - host.tab === "infrastructure" || - host.tab === "aiAgents" - ) { - await loadConfigSchema(host as unknown as OpenClawApp); - await loadConfig(host as unknown as OpenClawApp); - } - if (host.tab === "debug") { - await loadDebug(host as unknown as OpenClawApp); - host.eventLog = host.eventLogBuffer; - } - if (host.tab === "logs") { - host.logsAtBottom = true; - await loadLogs(host as unknown as OpenClawApp, { reset: true }); - scheduleLogsScroll(host as unknown as Parameters[0], true); + case "nodes": + await loadNodes(app); + await loadDevices(app); + await loadConfig(app); + await loadExecApprovals(app); + return; + case "dreams": + await loadConfig(app); + await Promise.all([loadDreamingStatus(app), loadDreamDiary(app)]); + return; + case "chat": + await refreshChat(host as unknown as Parameters[0]); + scheduleChatScroll( + host as unknown as Parameters[0], + !host.chatHasAutoScrolled, + ); + return; + case "config": + case "communications": + case "appearance": + case "automation": + case "infrastructure": + case "aiAgents": + await loadConfigSchema(app); + await loadConfig(app); + return; + case "debug": + await loadDebug(app); + host.eventLog = host.eventLogBuffer; + return; + case "logs": + host.logsAtBottom = true; + await loadLogs(app, { reset: true }); + scheduleLogsScroll(host as unknown as Parameters[0], true); + return; + default: + return; } } diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index 5155d2b2dcc..b8735a210e5 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -134,11 +134,19 @@ function serializeFormForSubmit(state: ConfigState): string { return serializeConfigForm(form); } -export async function saveConfig(state: ConfigState) { +type ConfigSubmitMethod = "config.set" | "config.apply"; +type ConfigSubmitBusyKey = "configSaving" | "configApplying"; + +async function submitConfigChange( + state: ConfigState, + method: ConfigSubmitMethod, + busyKey: ConfigSubmitBusyKey, + extraParams: Record = {}, +) { if (!state.client || !state.connected) { return; } - state.configSaving = true; + state[busyKey] = true; state.lastError = null; try { const raw = serializeFormForSubmit(state); @@ -147,41 +155,24 @@ export async function saveConfig(state: ConfigState) { state.lastError = "Config hash missing; reload and retry."; return; } - await state.client.request("config.set", { raw, baseHash }); + await state.client.request(method, { raw, baseHash, ...extraParams }); state.configFormDirty = false; await loadConfig(state); } catch (err) { state.lastError = String(err); } finally { - state.configSaving = false; + state[busyKey] = false; } } +export async function saveConfig(state: ConfigState) { + await submitConfigChange(state, "config.set", "configSaving"); +} + export async function applyConfig(state: ConfigState) { - if (!state.client || !state.connected) { - return; - } - state.configApplying = true; - state.lastError = null; - try { - const raw = serializeFormForSubmit(state); - const baseHash = state.configSnapshot?.hash; - if (!baseHash) { - state.lastError = "Config hash missing; reload and retry."; - return; - } - await state.client.request("config.apply", { - raw, - baseHash, - sessionKey: state.applySessionKey, - }); - state.configFormDirty = false; - await loadConfig(state); - } catch (err) { - state.lastError = String(err); - } finally { - state.configApplying = false; - } + await submitConfigChange(state, "config.apply", "configApplying", { + sessionKey: state.applySessionKey, + }); } export async function runUpdate(state: ConfigState) { @@ -209,13 +200,9 @@ export async function runUpdate(state: ConfigState) { } } -export function updateConfigFormValue( - state: ConfigState, - path: Array, - value: unknown, -) { +function mutateConfigForm(state: ConfigState, mutate: (draft: Record) => void) { const base = cloneConfigObject(state.configForm ?? state.configSnapshot?.config ?? {}); - setPathValue(base, path, value); + mutate(base); state.configForm = base; state.configFormDirty = true; if (state.configFormMode === "form") { @@ -223,14 +210,16 @@ export function updateConfigFormValue( } } +export function updateConfigFormValue( + state: ConfigState, + path: Array, + value: unknown, +) { + mutateConfigForm(state, (draft) => setPathValue(draft, path, value)); +} + export function removeConfigFormValue(state: ConfigState, path: Array) { - const base = cloneConfigObject(state.configForm ?? state.configSnapshot?.config ?? {}); - removePathValue(base, path); - state.configForm = base; - state.configFormDirty = true; - if (state.configFormMode === "form") { - state.configRaw = serializeConfigForm(base); - } + mutateConfigForm(state, (draft) => removePathValue(draft, path)); } export function findAgentConfigEntryIndex( diff --git a/ui/src/ui/controllers/skills.test.ts b/ui/src/ui/controllers/skills.test.ts index b824b0d9f6a..ba5ea2324f5 100644 --- a/ui/src/ui/controllers/skills.test.ts +++ b/ui/src/ui/controllers/skills.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it, vi } from "vitest"; -import { searchClawHub, setClawHubSearchQuery, type SkillsState } from "./skills.ts"; +import { + installSkill, + saveSkillApiKey, + searchClawHub, + setClawHubSearchQuery, + updateSkillEnabled, + type SkillsState, +} from "./skills.ts"; function createState(): { state: SkillsState; request: ReturnType } { const request = vi.fn(); @@ -107,3 +114,86 @@ describe("searchClawHub", () => { expect(state.clawhubSearchLoading).toBe(false); }); }); + +describe("skill mutations", () => { + it("updates skill enablement and records a success message", async () => { + const { state, request } = createState(); + request.mockImplementation(async (method: string) => { + if (method === "skills.status") { + return {}; + } + return {}; + }); + + await updateSkillEnabled(state, "github", true); + + expect(request).toHaveBeenCalledWith("skills.update", { skillKey: "github", enabled: true }); + expect(state.skillMessages.github).toEqual({ kind: "success", message: "Skill enabled" }); + expect(state.skillsBusyKey).toBeNull(); + expect(state.skillsError).toBeNull(); + }); + + it("saves API keys and reports success", async () => { + const { state, request } = createState(); + state.skillEdits.github = "sk-test"; + request.mockImplementation(async (method: string) => { + if (method === "skills.status") { + return {}; + } + return {}; + }); + + await saveSkillApiKey(state, "github"); + + expect(request).toHaveBeenCalledWith("skills.update", { + skillKey: "github", + apiKey: "sk-test", + }); + expect(state.skillMessages.github).toEqual({ + kind: "success", + message: "API key saved — stored in openclaw.json (skills.entries.github)", + }); + expect(state.skillsBusyKey).toBeNull(); + }); + + it("installs skills and uses server success messages", async () => { + const { state, request } = createState(); + request.mockImplementation(async (method: string) => { + if (method === "skills.install") { + return { message: "Installed from registry" }; + } + if (method === "skills.status") { + return {}; + } + return {}; + }); + + await installSkill(state, "github", "GitHub", "install-123", true); + + expect(request).toHaveBeenCalledWith("skills.install", { + name: "GitHub", + installId: "install-123", + dangerouslyForceUnsafeInstall: true, + timeoutMs: 120000, + }); + expect(state.skillMessages.github).toEqual({ + kind: "success", + message: "Installed from registry", + }); + expect(state.skillsBusyKey).toBeNull(); + }); + + it("records errors from failed mutations", async () => { + const { state, request } = createState(); + request.mockRejectedValue(new Error("skills update failed")); + + await updateSkillEnabled(state, "github", false); + + expect(state.skillsError).toBe("skills update failed"); + expect(state.skillMessages.github).toEqual({ + kind: "error", + message: "skills update failed", + }); + expect(state.skillsBusyKey).toBeNull(); + }); +}); diff --git a/ui/src/ui/controllers/skills.ts b/ui/src/ui/controllers/skills.ts index 5bb85fd08e1..f619bd81fd0 100644 --- a/ui/src/ui/controllers/skills.ts +++ b/ui/src/ui/controllers/skills.ts @@ -123,19 +123,21 @@ export function updateSkillEdit(state: SkillsState, skillKey: string, value: str state.skillEdits = { ...state.skillEdits, [skillKey]: value }; } -export async function updateSkillEnabled(state: SkillsState, skillKey: string, enabled: boolean) { - if (!state.client || !state.connected) { +async function runSkillMutation( + state: SkillsState, + skillKey: string, + run: (client: GatewayBrowserClient) => Promise, +) { + const client = state.client; + if (!client || !state.connected) { return; } state.skillsBusyKey = skillKey; state.skillsError = null; try { - await state.client.request("skills.update", { skillKey, enabled }); + const message = await run(client); await loadSkills(state); - setSkillMessage(state, skillKey, { - kind: "success", - message: enabled ? "Skill enabled" : "Skill disabled", - }); + setSkillMessage(state, skillKey, message); } catch (err) { const message = getErrorMessage(err); state.skillsError = message; @@ -148,30 +150,25 @@ export async function updateSkillEnabled(state: SkillsState, skillKey: string, e } } +export async function updateSkillEnabled(state: SkillsState, skillKey: string, enabled: boolean) { + await runSkillMutation(state, skillKey, async (client) => { + await client.request("skills.update", { skillKey, enabled }); + return { + kind: "success", + message: enabled ? "Skill enabled" : "Skill disabled", + }; + }); +} + export async function saveSkillApiKey(state: SkillsState, skillKey: string) { - if (!state.client || !state.connected) { - return; - } - state.skillsBusyKey = skillKey; - state.skillsError = null; - try { + await runSkillMutation(state, skillKey, async (client) => { const apiKey = state.skillEdits[skillKey] ?? ""; - await state.client.request("skills.update", { skillKey, apiKey }); - await loadSkills(state); - setSkillMessage(state, skillKey, { + await client.request("skills.update", { skillKey, apiKey }); + return { kind: "success", message: `API key saved — stored in openclaw.json (skills.entries.${skillKey})`, - }); - } catch (err) { - const message = getErrorMessage(err); - state.skillsError = message; - setSkillMessage(state, skillKey, { - kind: "error", - message, - }); - } finally { - state.skillsBusyKey = null; - } + }; + }); } export async function installSkill( @@ -181,33 +178,18 @@ export async function installSkill( installId: string, dangerouslyForceUnsafeInstall = false, ) { - if (!state.client || !state.connected) { - return; - } - state.skillsBusyKey = skillKey; - state.skillsError = null; - try { - const result = await state.client.request<{ message?: string }>("skills.install", { + await runSkillMutation(state, skillKey, async (client) => { + const result = await client.request<{ message?: string }>("skills.install", { name, installId, dangerouslyForceUnsafeInstall, timeoutMs: 120000, }); - await loadSkills(state); - setSkillMessage(state, skillKey, { + return { kind: "success", message: result?.message ?? "Installed", - }); - } catch (err) { - const message = getErrorMessage(err); - state.skillsError = message; - setSkillMessage(state, skillKey, { - kind: "error", - message, - }); - } finally { - state.skillsBusyKey = null; - } + }; + }); } export async function searchClawHub(state: SkillsState, query: string) {