diff --git a/src/components/sidebar.js b/src/components/sidebar.js
index 8f65066c..eb22af54 100644
--- a/src/components/sidebar.js
+++ b/src/components/sidebar.js
@@ -74,9 +74,9 @@ const NAV_ITEMS_SETUP = [
const ICONS = {
setup: '',
- dashboard: '',
+ dashboard: '',
chat: '',
- services: '',
+ services: '',
logs: '',
models: '',
agents: '',
@@ -86,7 +86,7 @@ const ICONS = {
about: '',
assistant: '',
security: '',
- skills: '',
+ skills: '',
channels: '',
clock: '',
'bar-chart': '',
@@ -109,11 +109,31 @@ function _checkMultiInstances(el) {
}
const LS_SIDEBAR_COLLAPSED = 'clawpanel_sidebar_collapsed'
+const LS_SIDEBAR_GROUPS = 'clawpanel_sidebar_groups'
function _isDesktopSidebarCollapsed() {
try { return localStorage.getItem(LS_SIDEBAR_COLLAPSED) === '1' } catch { return false }
}
+function _getSidebarGroupState() {
+ try {
+ return JSON.parse(localStorage.getItem(LS_SIDEBAR_GROUPS) || '{}') || {}
+ } catch {
+ return {}
+ }
+}
+
+function _isSidebarGroupOpen(key) {
+ const state = _getSidebarGroupState()
+ return state[key] !== false
+}
+
+function _setSidebarGroupOpen(key, open) {
+ const state = _getSidebarGroupState()
+ state[key] = !!open
+ try { localStorage.setItem(LS_SIDEBAR_GROUPS, JSON.stringify(state)) } catch {}
+}
+
function _setDesktopSidebarCollapsed(collapsed) {
try { localStorage.setItem(LS_SIDEBAR_COLLAPSED, collapsed ? '1' : '0') } catch {}
const sidebar = document.getElementById('sidebar')
@@ -155,8 +175,16 @@ export function renderSidebar(el) {
const navItems = isOpenclawReady() ? NAV_ITEMS_FULL : NAV_ITEMS_SETUP
for (const section of navItems) {
- html += `
-
${section.section}
`
+ const sectionKey = section.section || `section-${section.items.map(item => item.route).join('|')}`
+ const expanded = section.section ? _isSidebarGroupOpen(sectionKey) : true
+ html += `
`
+ if (section.section) {
+ html += `
`
+ }
+ html += `
`
for (const item of section.items) {
const active = current === item.route ? ' active' : ''
@@ -165,7 +193,7 @@ export function renderSidebar(el) {
${item.label}
`
}
- html += '
'
+ html += '
'
}
html += ''
@@ -200,6 +228,22 @@ export function renderSidebar(el) {
if (!_delegated) {
_delegated = true
el.addEventListener('click', (e) => {
+ const sectionToggle = e.target.closest('[data-section-toggle]')
+ if (sectionToggle) {
+ const key = sectionToggle.dataset.sectionToggle
+ const sectionEl = e.target.closest('.nav-section[data-section]')
+ const itemsEl = sectionEl?.querySelector('.nav-section-items')
+ const nextOpen = sectionEl?.classList.contains('collapsed')
+ if (sectionEl) {
+ sectionEl.classList.toggle('expanded', !!nextOpen)
+ sectionEl.classList.toggle('collapsed', !nextOpen)
+ }
+ if (itemsEl) itemsEl.style.display = nextOpen ? '' : 'none'
+ const chevron = sectionToggle.querySelector('.nav-section-chevron')
+ if (chevron) chevron.textContent = nextOpen ? '−' : '+'
+ if (key) _setSidebarGroupOpen(key, !!nextOpen)
+ return
+ }
// 导航点击
const navItem = e.target.closest('.nav-item[data-route]')
if (navItem) {
diff --git a/src/lib/skills-catalog.js b/src/lib/skills-catalog.js
new file mode 100644
index 00000000..7e9f92ce
--- /dev/null
+++ b/src/lib/skills-catalog.js
@@ -0,0 +1,64 @@
+import { api } from './tauri-api.js'
+
+const SKILLS_CACHE_TTL_MS = 20_000
+
+let _skillsCache = {
+ data: null,
+ expiresAt: 0,
+ pending: null,
+}
+
+function normalizeSkillsData(data) {
+ return {
+ ...data,
+ skills: Array.isArray(data?.skills) ? data.skills : [],
+ }
+}
+
+export function summarizeSkillsCatalog(data) {
+ const skills = Array.isArray(data?.skills) ? data.skills : []
+ const eligible = skills.filter(s => s.eligible && !s.disabled)
+ const missing = skills.filter(s => !s.eligible && !s.disabled && !s.blockedByAllowlist)
+ const disabled = skills.filter(s => s.disabled)
+ const blocked = skills.filter(s => s.blockedByAllowlist && !s.disabled)
+ return {
+ total: skills.length,
+ eligible: eligible.length,
+ missing: missing.length,
+ disabled: disabled.length,
+ blocked: blocked.length,
+ }
+}
+
+export function getCachedSkillsCatalog() {
+ if (!_skillsCache.data) return null
+ if (Date.now() > _skillsCache.expiresAt) return null
+ return _skillsCache.data
+}
+
+export function invalidateSkillsCatalog() {
+ _skillsCache.expiresAt = 0
+}
+
+export async function loadSkillsCatalog(options = {}) {
+ const force = !!options.force
+ const now = Date.now()
+ if (!force && _skillsCache.data && now <= _skillsCache.expiresAt) {
+ return _skillsCache.data
+ }
+ if (!force && _skillsCache.pending) {
+ return _skillsCache.pending
+ }
+ const request = api.skillsList()
+ .then(normalizeSkillsData)
+ .then(data => {
+ _skillsCache.data = data
+ _skillsCache.expiresAt = Date.now() + SKILLS_CACHE_TTL_MS
+ return data
+ })
+ .finally(() => {
+ if (_skillsCache.pending === request) _skillsCache.pending = null
+ })
+ _skillsCache.pending = request
+ return request
+}
diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js
index b73e1bd9..eecaf32c 100644
--- a/src/pages/dashboard.js
+++ b/src/pages/dashboard.js
@@ -4,6 +4,7 @@
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { onGatewayChange } from '../lib/app-state.js'
+import { loadSkillsCatalog, summarizeSkillsCatalog } from '../lib/skills-catalog.js'
import { navigate } from '../router.js'
let _unsubGw = null
@@ -80,7 +81,7 @@ async function loadDashboardData(page, fullRefresh = false) {
]), 15000)
const secondaryP = withTimeout(Promise.allSettled([
api.listAgents(),
- api.readMcpConfig(),
+ loadSkillsCatalog({ force: fullRefresh }),
api.listBackups(),
// getStatusSummary 是最重的调用(spawn openclaw status --json),只在首次加载时调用
(!_dashboardInitialized || fullRefresh) ? api.getStatusSummary() : Promise.resolve(null),
@@ -122,15 +123,15 @@ async function loadDashboardData(page, fullRefresh = false) {
renderStatCards(page, services, version, [], config)
- // 第二波:Agent、MCP、备份 → 更新卡片 + 渲染总览
- const [agentsRes, mcpRes, backupsRes, statusRes] = await secondaryP
+ // 第二波:Agent、Skills、备份 → 更新卡片 + 渲染总览
+ const [agentsRes, skillsRes, backupsRes, statusRes] = await secondaryP
const agents = agentsRes.status === 'fulfilled' ? agentsRes.value : []
- const mcpConfig = mcpRes.status === 'fulfilled' ? mcpRes.value : null
+ const skillsData = skillsRes.status === 'fulfilled' ? skillsRes.value : null
const backups = backupsRes.status === 'fulfilled' ? backupsRes.value : []
const statusSummary = statusRes.status === 'fulfilled' ? statusRes.value : null
renderStatCards(page, services, version, agents, config)
- renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary)
+ renderOverview(page, services, skillsData, backups, config, agents, statusSummary)
// 第三波:日志(最低优先级)
const logs = await logsP
@@ -199,10 +200,11 @@ function renderStatCards(page, services, version, agents, config) {
`
}
-function renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary) {
+function renderOverview(page, services, skillsData, backups, config, agents, statusSummary) {
+ services = Array.isArray(services) ? services : []
const containerEl = page.querySelector('#dashboard-overview-container')
const gw = services.find(s => s.label === 'ai.openclaw.gateway')
- const mcpCount = mcpConfig?.mcpServers ? Object.keys(mcpConfig.mcpServers).length : 0
+ const skillSummary = summarizeSkillsCatalog(skillsData)
const formatDate = (timestamp) => {
if (!timestamp) return '——'
@@ -255,12 +257,12 @@ function renderOverview(page, services, mcpConfig, backups, config, agents, stat
-
MCP 工具
-
${mcpCount} 个
-
已挂载扩展
+
Skills
+
${skillSummary.total} 个
+
${skillSummary.eligible} 可用 · ${skillSummary.missing + skillSummary.blocked} 待处理 · ${skillSummary.disabled} 已禁用
diff --git a/src/pages/settings.js b/src/pages/settings.js
index 123d5c77..5d01b420 100644
--- a/src/pages/settings.js
+++ b/src/pages/settings.js
@@ -142,6 +142,12 @@ async function loadRegistry(page) {
// ===== 事件绑定 =====
function bindEvents(page) {
+ page.addEventListener('change', (e) => {
+ if (e.target?.matches?.('[data-name="cloudflared-mode"], [data-name="cloudflared-expose"], [data-name="cloudflared-port"]')) {
+ syncCloudflaredFormState(page)
+ }
+ })
+
page.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]')
if (!btn) return
@@ -244,3 +250,344 @@ async function handleSaveRegistry(page) {
await api.setNpmRegistry(registry)
toast('npm 源已保存', 'success')
}
+
+// ===== Cloudflared 公网访问 =====
+
+function getCloudflaredForm(page) {
+ const mode = page.querySelector('[data-name="cloudflared-mode"]')?.value || 'quick'
+ const exposeTarget = page.querySelector('[data-name="cloudflared-expose"]')?.value || 'gateway'
+ const customPort = Number(page.querySelector('[data-name="cloudflared-port"]')?.value || 0)
+ const useHttp2 = !!page.querySelector('[data-name="cloudflared-http2"]')?.checked
+ const tunnelName = (page.querySelector('[data-name="cloudflared-tunnel"]')?.value || '').trim()
+ const hostname = (page.querySelector('[data-name="cloudflared-hostname"]')?.value || '').trim()
+ return { mode, exposeTarget, customPort, useHttp2, tunnelName, hostname }
+}
+
+function resolveExposePort(form) {
+ if (form.exposeTarget === 'webui') return 1420
+ if (form.exposeTarget === 'custom') return form.customPort || 18789
+ return 18789
+}
+
+function validateCloudflaredForm(form) {
+ if (form.exposeTarget === 'custom' && !(Number(form.customPort) > 0)) {
+ return '自定义端口模式下必须填写有效端口'
+ }
+ if (form.mode === 'named' && !form.tunnelName) {
+ return '命名隧道模式下必须填写隧道名称'
+ }
+ if (form.mode === 'named' && !form.hostname) {
+ return '命名隧道模式下必须填写绑定域名'
+ }
+ return ''
+}
+
+function syncCloudflaredFormState(page) {
+ const form = getCloudflaredForm(page)
+ const modeBlocks = page.querySelectorAll('[data-cloudflared-mode-block]')
+ const exposeBlocks = page.querySelectorAll('[data-cloudflared-expose-block]')
+ modeBlocks.forEach(node => {
+ node.style.display = node.dataset.cloudflaredModeBlock === form.mode ? '' : 'none'
+ })
+ exposeBlocks.forEach(node => {
+ node.style.display = node.dataset.cloudflaredExposeBlock === form.exposeTarget ? '' : 'none'
+ })
+ const resolvedPortEl = page.querySelector('[data-cloudflared-resolved-port]')
+ if (resolvedPortEl) resolvedPortEl.textContent = String(resolveExposePort(form))
+ const customPortInput = page.querySelector('[data-name="cloudflared-port"]')
+ if (customPortInput) customPortInput.disabled = form.exposeTarget !== 'custom'
+ const tunnelNameInput = page.querySelector('[data-name="cloudflared-tunnel"]')
+ const hostnameInput = page.querySelector('[data-name="cloudflared-hostname"]')
+ if (tunnelNameInput) tunnelNameInput.disabled = form.mode !== 'named'
+ if (hostnameInput) hostnameInput.disabled = form.mode !== 'named'
+ const validationEl = page.querySelector('[data-cloudflared-validation]')
+ const errorText = validateCloudflaredForm(form)
+ if (validationEl) {
+ validationEl.textContent = errorText || '当前配置可直接保存;启动前会按你的选择自动计算实际端口。'
+ validationEl.style.color = errorText ? 'var(--warning)' : 'var(--text-tertiary)'
+ }
+ const startBtn = page.querySelector('[data-action="cloudflared-start"]')
+ if (startBtn) startBtn.disabled = !!errorText
+}
+
+async function loadCloudflared(page) {
+ const el = page.querySelector('#cloudflared-bar')
+ if (!el) return
+
+ const cfg = await api.readPanelConfig()
+ if (!cfg.cloudflared || typeof cfg.cloudflared !== 'object') {
+ cfg.cloudflared = { mode: 'quick', exposeTarget: 'gateway', customPort: '', useHttp2: true, tunnelName: '', hostname: '' }
+ await api.writePanelConfig(cfg)
+ }
+ const saved = cfg.cloudflared || {}
+ const status = await api.cloudflaredGetStatus().catch(() => ({ installed: false, running: false }))
+ page.__cloudflaredInstalled = !!status.installed
+
+ const mode = saved.mode || 'quick'
+ const exposeTarget = saved.exposeTarget || 'gateway'
+ const customPort = saved.customPort || ''
+ const useHttp2 = saved.useHttp2 !== false
+ const tunnelName = saved.tunnelName || ''
+ const hostname = saved.hostname || ''
+
+ el.innerHTML = `
+
+
+
+
${status.running ? '运行中' : '未运行'}
+
版本 ${escapeHtml(status.version || '未知')}
+
+
+
+
${mode === 'named' ? '命名隧道' : '快速隧道'}
+
暴露 ${exposeTarget === 'gateway' ? 'Gateway' : exposeTarget === 'webui' ? 'Web UI' : '自定义端口'}
+
+
+
+
${resolveExposePort({ mode, exposeTarget, customPort, useHttp2, tunnelName, hostname })}
+
启动时传给 cloudflared
+
+
+
+
${status.url ? '已生成' : '未生成'}
+
+
+
+
+
+
+
+ ${status.running
+ ? ''
+ : ''
+ }
+
+
+
+
+
+
1. 选择暴露目标
+
+
+
推荐默认暴露 Gateway。选择 Gateway 时,会自动把 Cloudflare URL 写入 gateway.controlUi.allowedOrigins。
+
+
+
固定暴露 OpenClaw Gateway,端口 18789,无需额外输入。
+
+
+
固定暴露 ClawPanel Web UI,端口 1420,适合只开放管理面板。
+
+
+
+
只有在“自定义端口”模式下这里才生效。
+
+
+
+
+
2. 选择隧道模式
+
+
+
+
+
+
快速隧道无需隧道名和域名,适合临时开放,启动后自动生成公网地址。
+
+
+
+
+
+ 推荐顺序:安装 → 登录 Cloudflare → 选择暴露目标 → 选择隧道模式 → 保存设置 → 启动公网访问。
+
+ `
+ syncCloudflaredFormState(page)
+}
+
+async function handleCloudflaredSave(page) {
+ const cfg = await api.readPanelConfig()
+ const form = getCloudflaredForm(page)
+ cfg.cloudflared = {
+ mode: form.mode,
+ exposeTarget: form.exposeTarget,
+ customPort: form.customPort,
+ useHttp2: form.useHttp2,
+ tunnelName: form.tunnelName,
+ hostname: form.hostname,
+ }
+ await api.writePanelConfig(cfg)
+ toast('Cloudflared 设置已保存', 'success')
+}
+
+async function handleCloudflaredInstall(page) {
+ await api.cloudflaredInstall()
+ await loadCloudflared(page)
+ toast('Cloudflared 已安装', 'success')
+}
+
+async function handleCloudflaredLogin(page) {
+ if (page.__cloudflaredInstalled === false) {
+ toast('请先安装 Cloudflared', 'warning')
+ syncCloudflaredFormState(page)
+ return
+ }
+ await api.cloudflaredLogin()
+ await loadCloudflared(page)
+ toast('Cloudflared 登录完成', 'success')
+}
+
+async function handleCloudflaredStart(page) {
+ const form = getCloudflaredForm(page)
+ if (page.__cloudflaredInstalled === false) {
+ syncCloudflaredFormState(page)
+ toast('请先安装 Cloudflared', 'warning')
+ return
+ }
+ const validationError = validateCloudflaredForm(form)
+ if (validationError) {
+ syncCloudflaredFormState(page)
+ toast(validationError, 'warning')
+ return
+ }
+ const port = resolveExposePort(form)
+ await handleCloudflaredSave(page)
+ await api.cloudflaredStart({
+ mode: form.mode,
+ port,
+ use_http2: form.useHttp2,
+ tunnel_name: form.tunnelName || null,
+ hostname: form.hostname || null,
+ add_allowed_origins: true,
+ expose_target: form.exposeTarget,
+ })
+ await loadCloudflared(page)
+ toast('Cloudflared 已启动', 'success')
+}
+
+async function handleCloudflaredStop(page) {
+ await api.cloudflaredStop()
+ await loadCloudflared(page)
+ toast('Cloudflared 已停止', 'success')
+}
+
+// ===== OpenClaw CLI =====
+
+async function loadOpenclawCli(page) {
+ const bar = page.querySelector('#openclaw-bar')
+ if (!bar) return
+ try {
+ const [cfg, services] = await Promise.all([
+ api.readPanelConfig(),
+ api.getServicesStatus(),
+ ])
+ const svc = Array.isArray(services)
+ ? services.find(s => s.label === 'ai.openclaw.gateway' || s.id === 'ai.openclaw.gateway' || s.name === 'ai.openclaw.gateway' || s.label === 'openclaw' || s.id === 'openclaw')
+ : null
+ const overridePath = cfg?.openclawPath || ''
+ const cliMeta = buildOpenclawCliMeta(svc, { overridePath })
+ const detectedPath = cliMeta.path || ''
+ const detectedVersion = cliMeta.version || ''
+ const candidates = Array.isArray(svc?.cli_candidates) ? svc.cli_candidates : []
+ const selectedValue = overridePath || detectedPath || ''
+
+ bar.innerHTML = `
+
+
+
+ ${escapeHtml(cliMeta.statusLabel)}
+ ${detectedVersion ? `CLI 版本: ${escapeHtml(detectedVersion)}` : ''}
+ 路径来源: ${escapeHtml(cliMeta.pathSourceLabel)}
+ 路径策略: ${escapeHtml(cliMeta.strategyLabel)}
+
+ ${detectedPath ? `
CLI 路径: ${escapeHtml(detectedPath)}
` : ''}
+ ${detectedVersion ? `
版本来源: ${escapeHtml(cliMeta.versionSourceLabel)}
` : ''}
+
+
+
+
+
+
+
+
+
+ 保存路径后将优先使用该路径检测与启动 Gateway。若检测到多条 CLI 路径,可先在下拉框中选中再保存。清除覆盖会回退到自动检测。
+
+
+ `
+
+ const candidateSelect = bar.querySelector('[data-name="openclaw-candidate"]')
+ const pathInput = bar.querySelector('[data-name="openclaw-path"]')
+ candidateSelect?.addEventListener('change', () => {
+ if (candidateSelect.value) pathInput.value = candidateSelect.value
+ })
+ } catch (e) {
+ bar.innerHTML = `加载失败: ${escapeHtml(String(e))}
`
+ }
+}
+
+async function handleOpenclawSave(page) {
+ const candidate = page.querySelector('[data-name="openclaw-candidate"]')
+ const input = page.querySelector('[data-name="openclaw-path"]')
+ const inputValue = String(input?.value || '').trim()
+ const candidateValue = String(candidate?.value || '').trim()
+ const value = inputValue || candidateValue
+ const cfg = await api.readPanelConfig()
+ if (!value) {
+ delete cfg.openclawPath
+ await api.writePanelConfig(cfg)
+ toast('路径为空,已清除覆盖', 'info')
+ await loadOpenclawCli(page)
+ return
+ }
+ cfg.openclawPath = value
+ await api.writePanelConfig(cfg)
+ toast('OpenClaw 路径已保存', 'success')
+ await loadOpenclawCli(page)
+}
+
+async function handleOpenclawClear(page) {
+ const cfg = await api.readPanelConfig()
+ delete cfg.openclawPath
+ await api.writePanelConfig(cfg)
+ toast('OpenClaw 路径覆盖已清除', 'success')
+ await loadOpenclawCli(page)
+}
+
+async function handleOpenclawSetup() {
+ try {
+ const cfg = await api.readPanelConfig().catch(() => ({}))
+ await api.writePanelConfig({ ...cfg, forceSetup: true, skipSetup: false })
+ window.location.hash = '#/setup'
+ } catch (e) {
+ toast('进入初始化设置失败: ' + (e?.message || e), 'error')
+ }
+}
diff --git a/src/pages/skills.js b/src/pages/skills.js
index af7b3215..ef1014cc 100644
--- a/src/pages/skills.js
+++ b/src/pages/skills.js
@@ -4,6 +4,7 @@
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
+import { invalidateSkillsCatalog, getCachedSkillsCatalog, loadSkillsCatalog } from '../lib/skills-catalog.js'
let _loadSeq = 0
@@ -51,22 +52,29 @@ export async function render() {
return page
}
-async function loadSkills(page) {
+async function loadSkills(page, options = {}) {
const el = page.querySelector('#skills-tab-installed')
if (!el) return
const seq = ++_loadSeq
+ const force = !!options.force
+ const cached = !force ? getCachedSkillsCatalog() : null
- el.innerHTML = ``
+ if (cached) {
+ renderSkills(el, cached)
+ } else {
+ el.innerHTML = ``
+ }
try {
- const data = await api.skillsList()
+ const data = await loadSkillsCatalog({ force })
if (seq !== _loadSeq) return
renderSkills(el, data)
} catch (e) {
if (seq !== _loadSeq) return
+ if (cached) return
el.innerHTML = `
加载失败: ${esc(e?.message || e)}
请确认 OpenClaw 已安装并可用
@@ -83,7 +91,7 @@ function renderSkills(el, data) {
const disabled = skills.filter(s => s.disabled)
const blocked = skills.filter(s => s.blockedByAllowlist && !s.disabled)
- const summary = `${eligible.length} 可用 / ${missing.length} 缺依赖 / ${disabled.length} 已禁用`
+ const summary = `${eligible.length} 可用 / ${missing.length} 缺依赖 / ${disabled.length} 已禁用 / ${blocked.length} 已阻止`
el.innerHTML = `
@@ -93,10 +101,37 @@ function renderSkills(el, data) {
${!cliAvailable ? 'CLI 不可用,仅显示本地扫描结果' : ''}
+
+
+
+
${skills.length}
+
已扫描本地可见技能
+
+
+
+
${eligible.length}
+
环境与依赖已满足
+
+
+
+
${missing.length + blocked.length}
+
${missing.length} 缺依赖 · ${blocked.length} 已阻止
+
+
+
+
${disabled.length}
+
当前已禁用
+
+
+
共 ${skills.length} 个 Skills: ${summary}
+
+ 当前过滤条件下没有匹配的 Skills
+
+
${eligible.length ? `
✓ 可用 (${eligible.length})
@@ -145,14 +180,19 @@ function renderSkills(el, data) {
// 实时过滤
const input = el.querySelector('#skill-filter-input')
+ const emptyEl = el.querySelector('#skill-filter-empty')
if (input) {
input.addEventListener('input', () => {
const q = input.value.trim().toLowerCase()
+ let visibleCount = 0
el.querySelectorAll('.skill-card-item').forEach(card => {
const name = (card.dataset.name || '').toLowerCase()
const desc = (card.dataset.desc || '').toLowerCase()
- card.style.display = (!q || name.includes(q) || desc.includes(q)) ? '' : 'none'
+ const visible = !q || name.includes(q) || desc.includes(q)
+ card.style.display = visible ? '' : 'none'
+ if (visible) visibleCount += 1
})
+ if (emptyEl) emptyEl.style.display = q && visibleCount === 0 ? '' : 'none'
})
}
}
@@ -431,7 +471,8 @@ function bindEvents(page) {
if (!btn) return
switch (btn.dataset.action) {
case 'skill-retry':
- await loadSkills(page)
+ invalidateSkillsCatalog()
+ await loadSkills(page, { force: true })
break
case 'skill-info':
await handleInfo(page, btn.dataset.name)
diff --git a/src/style/layout.css b/src/style/layout.css
index 77a84c3f..72629dc1 100644
--- a/src/style/layout.css
+++ b/src/style/layout.css
@@ -19,7 +19,8 @@
#sidebar.sidebar-collapsed .nav-section-title,
#sidebar.sidebar-collapsed .nav-item span,
#sidebar.sidebar-collapsed .sidebar-meta,
-#sidebar.sidebar-collapsed .instance-switcher {
+#sidebar.sidebar-collapsed .instance-switcher,
+#sidebar.sidebar-collapsed .nav-section-toggle {
display: none;
}
#sidebar.sidebar-collapsed .sidebar-header {
@@ -247,13 +248,44 @@
margin-bottom: var(--space-md);
}
+.nav-section-toggle {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-sm);
+ padding: 6px var(--space-sm);
+ margin-bottom: var(--space-xs);
+ border: 0;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--text-tertiary);
+ cursor: pointer;
+ transition: background 0.15s ease, color 0.15s ease;
+}
+
+.nav-section-toggle:hover {
+ background: var(--bg-glass-hover);
+ color: var(--text-secondary);
+}
+
.nav-section-title {
font-size: var(--font-size-xs);
- color: var(--text-tertiary);
+ color: inherit;
text-transform: uppercase;
letter-spacing: 0.5px;
- padding: var(--space-sm) var(--space-sm);
- margin-bottom: var(--space-xs);
+ text-align: left;
+}
+
+.nav-section-chevron {
+ min-width: 16px;
+ text-align: center;
+ font-size: 14px;
+ line-height: 1;
+}
+
+.nav-section-items {
+ display: block;
}
.nav-item {