refactor(desktop): consolidate skills + tools management into one pane
The left-nav Skills pane and Settings > Skills & Tools rendered the same getSkills()/getToolsets() data with the same helpers and toggles — genuine duplication that drifted (different default category labels, sort orders). Make the left pane the single home: it keeps its category-tabbed browsing and now gains the functional bits it lacked — a real toolset enable/disable switch (was a read-only pill) and the expandable ToolsetConfigPanel for provider selection + per-key credential config. Remove the Tools section from Settings (nav item, view branch, query slot, type union entries) and delete tools-settings.tsx, migrating its toggle coverage into the skills pane test. Relabel the entry point to 'Skills & Tools' in the sidebar and command center.
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getSkills = vi.fn()
|
||||
const getToolsets = vi.fn()
|
||||
const toggleSkill = vi.fn()
|
||||
const toggleToolset = vi.fn()
|
||||
const getToolsetConfig = vi.fn()
|
||||
const selectToolsetProvider = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getSkills: () => getSkills(),
|
||||
getToolsets: () => getToolsets(),
|
||||
toggleSkill: (name: string, enabled: boolean) => toggleSkill(name, enabled),
|
||||
toggleToolset: (name: string, enabled: boolean) => toggleToolset(name, enabled),
|
||||
getToolsetConfig: (name: string) => getToolsetConfig(name),
|
||||
selectToolsetProvider: (toolset: string, provider: string) => selectToolsetProvider(toolset, provider),
|
||||
deleteEnvVar: vi.fn(),
|
||||
revealEnvVar: vi.fn(),
|
||||
setEnvVar: vi.fn()
|
||||
}))
|
||||
|
||||
// Notifications hit nanostores/timers we don't care about here.
|
||||
vi.mock('@/store/notifications', () => ({
|
||||
notify: vi.fn(),
|
||||
notifyError: vi.fn()
|
||||
}))
|
||||
|
||||
function toolset(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: 'web',
|
||||
label: 'Web Search',
|
||||
description: 'web_search, web_extract',
|
||||
enabled: true,
|
||||
available: true,
|
||||
configured: true,
|
||||
tools: ['web_search', 'web_extract'],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderSkills() {
|
||||
return import('./index').then(({ SkillsView }) =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/skills?tab=toolsets']}>
|
||||
<SkillsView />
|
||||
</MemoryRouter>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getSkills.mockResolvedValue([])
|
||||
getToolsets.mockResolvedValue([toolset()])
|
||||
toggleToolset.mockResolvedValue({ ok: true, name: 'web', enabled: false })
|
||||
getToolsetConfig.mockResolvedValue({ has_category: false, active_provider: null, providers: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('SkillsView toolset management', () => {
|
||||
it('renders a switch for each toolset and toggles it off', async () => {
|
||||
await renderSkills()
|
||||
|
||||
const sw = await screen.findByRole('switch', { name: 'Toggle Web Search toolset' })
|
||||
expect(sw.getAttribute('aria-checked')).toBe('true')
|
||||
|
||||
fireEvent.click(sw)
|
||||
|
||||
await waitFor(() => expect(toggleToolset).toHaveBeenCalledWith('web', false))
|
||||
})
|
||||
|
||||
it('keeps the configured pill alongside the switch', async () => {
|
||||
await renderSkills()
|
||||
|
||||
await screen.findByRole('switch', { name: 'Toggle Web Search toolset' })
|
||||
expect(screen.getByText('Configured')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('expands the provider config panel when the configured pill is clicked', async () => {
|
||||
await renderSkills()
|
||||
|
||||
const configureBtn = await screen.findByRole('button', { name: 'Configure Web Search' })
|
||||
fireEvent.click(configureBtn)
|
||||
|
||||
await waitFor(() => expect(getToolsetConfig).toHaveBeenCalledWith('web'))
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
|
||||
import { getSkills, getToolsets, toggleSkill } from '@/hermes'
|
||||
import { getSkills, getToolsets, toggleSkill, toggleToolset } from '@/hermes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
|
||||
@@ -14,6 +14,7 @@ import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import { asText, includesQuery, prettyName, toolNames } from '../settings/helpers'
|
||||
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
const SKILLS_MODES = ['skills', 'toolsets'] as const
|
||||
@@ -73,6 +74,8 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [savingSkill, setSavingSkill] = useState<string | null>(null)
|
||||
const [savingToolset, setSavingToolset] = useState<string | null>(null)
|
||||
const [expandedToolset, setExpandedToolset] = useState<string | null>(null)
|
||||
|
||||
const refreshCapabilities = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
@@ -88,6 +91,12 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshToolsets = useCallback(() => {
|
||||
getToolsets()
|
||||
.then(setToolsets)
|
||||
.catch(err => notifyError(err, 'Toolsets failed to refresh'))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshCapabilities()
|
||||
}, [refreshCapabilities])
|
||||
@@ -148,6 +157,26 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleToolset(toolset: ToolsetInfo, enabled: boolean) {
|
||||
setSavingToolset(toolset.name)
|
||||
|
||||
try {
|
||||
await toggleToolset(toolset.name, enabled)
|
||||
setToolsets(current =>
|
||||
current?.map(row => (row.name === toolset.name ? { ...row, enabled, available: enabled } : row)) ?? current
|
||||
)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? 'Toolset enabled' : 'Toolset disabled',
|
||||
message: `${asText(toolset.label || toolset.name)} applies to new sessions.`
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to update ${asText(toolset.label || toolset.name)}`)
|
||||
} finally {
|
||||
setSavingToolset(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
@@ -248,16 +277,30 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
{visibleToolsets.map(toolset => {
|
||||
const tools = toolNames(toolset)
|
||||
const label = asText(toolset.label || toolset.name)
|
||||
const expanded = expandedToolset === toolset.name
|
||||
|
||||
return (
|
||||
<div className="px-0 py-2.5" key={toolset.name}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="truncate text-sm font-medium">{label}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusPill active={toolset.enabled}>{toolset.enabled ? 'Enabled' : 'Disabled'}</StatusPill>
|
||||
<StatusPill active={toolset.configured}>
|
||||
{toolset.configured ? 'Configured' : 'Needs keys'}
|
||||
</StatusPill>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
aria-label={`Configure ${label}`}
|
||||
className="cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
onClick={() => setExpandedToolset(current => (current === toolset.name ? null : toolset.name))}
|
||||
type="button"
|
||||
>
|
||||
<StatusPill active={toolset.configured}>
|
||||
{toolset.configured ? 'Configured' : 'Needs keys'}
|
||||
</StatusPill>
|
||||
</button>
|
||||
<Switch
|
||||
aria-label={`Toggle ${label} toolset`}
|
||||
checked={toolset.enabled}
|
||||
disabled={savingToolset === toolset.name}
|
||||
onCheckedChange={checked => void handleToggleToolset(toolset, checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
@@ -275,6 +318,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{expanded && <ToolsetConfigPanel onConfiguredChange={refreshToolsets} toolset={toolset.name} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user