UI: consolidate config/tab/skills flows

This commit is contained in:
joshavant
2026-04-09 14:11:16 -05:00
committed by Josh Avant
parent 78389b1f02
commit 786823fd70
5 changed files with 491 additions and 589 deletions

View File

@@ -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<string>([
...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<string | number>, 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({

View File

@@ -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<typeof refreshChat>[0]);
scheduleChatScroll(
host as unknown as Parameters<typeof scheduleChatScroll>[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<typeof scheduleLogsScroll>[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<typeof refreshChat>[0]);
scheduleChatScroll(
host as unknown as Parameters<typeof scheduleChatScroll>[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<typeof scheduleLogsScroll>[0], true);
return;
default:
return;
}
}

View File

@@ -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<string, unknown> = {},
) {
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<string | number>,
value: unknown,
) {
function mutateConfigForm(state: ConfigState, mutate: (draft: Record<string, unknown>) => 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<string | number>,
value: unknown,
) {
mutateConfigForm(state, (draft) => setPathValue(draft, path, value));
}
export function removeConfigFormValue(state: ConfigState, path: Array<string | number>) {
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(

View File

@@ -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<typeof vi.fn> } {
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();
});
});

View File

@@ -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<SkillMessage>,
) {
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) {