Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
@@ -0,0 +1,249 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { memo, type PointerEvent as ReactPointerEvent, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { ProfileScope } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Loader2 } from '@/lib/icons'
|
||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $hubActions, installHubSkill, UPDATE_ALL_KEY, updateHubSkills } from '@/store/hub-actions'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $paneHeightOverride, setPaneHeightOverride } from '@/store/panes'
|
||||
|
||||
// The REAL Skills Hub page (docs site) embedded as a one-click picker — the
|
||||
// same trick the Bot Mode agent editor uses. `?embed=picker` hides the docs
|
||||
// chrome and adds a "+ Add to this Agent" button per card, which posts
|
||||
// { type: 'hermes-skill-pick', name, identifier, installCmd, source }
|
||||
// to the parent window. We validate the origin and route the install through
|
||||
// the standard hub action pipeline (background action + tailed log + Skills
|
||||
// list invalidation), scoped to the Capabilities profile selector.
|
||||
const HUB_ORIGIN = 'https://hermes-agent.nousresearch.com'
|
||||
const HUB_PICKER_URL = `${HUB_ORIGIN}/docs/skills?embed=picker`
|
||||
|
||||
// Hub viewport height: persisted through the shared pane store (same one the
|
||||
// terminal/editor panes use), dragged from the section's TOP edge — "pull the
|
||||
// hub up" — clamped so neither the hub nor the skills list above vanishes.
|
||||
const HUB_PANE_ID = 'capabilities-hub'
|
||||
const HUB_DEFAULT_PX = 380
|
||||
const HUB_MIN_PX = 120
|
||||
const HUB_MAX_VH = 0.75
|
||||
// Collapse threshold, mirroring DetailPane: a persisted height at/below this
|
||||
// reads as "collapsed to the header" (the toggle stores 0).
|
||||
const HUB_COLLAPSED_PX = 4
|
||||
// Room the sash must always leave for the content ABOVE the picker (the
|
||||
// installed-skills list plus its strip) so dragging the hub up can never
|
||||
// crush the list to zero and shove its chrome under the hub header.
|
||||
const HUB_LIST_RESERVED_PX = 176
|
||||
|
||||
interface SkillPickMessage {
|
||||
identifier?: string
|
||||
installCmd?: string
|
||||
name?: string
|
||||
source?: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
interface EmbeddedHubPickerProps {
|
||||
/** Kept mounted but fully hidden (display:none). The Capabilities view uses
|
||||
* this to preserve the loaded hub iframe across tab switches — a plain
|
||||
* unmount would reload the whole docs site on every return to Skills. */
|
||||
hidden?: boolean
|
||||
/** Names of skills already installed in the scoped profile — a pick that
|
||||
* matches is refused with a toast instead of re-running the install. */
|
||||
installedNames: ReadonlySet<string>
|
||||
/** Capabilities profile-scope override — installs land in THIS profile;
|
||||
* undefined/null targets the app-wide active profile. */
|
||||
profile?: ProfileScope
|
||||
}
|
||||
|
||||
/** The Skills Hub browser for the Skills tab: a resizable iframe of the live
|
||||
* hub where every card installs with one click. Expanded by default —
|
||||
* discovery IS the point — with a collapse toggle (persisted, like every
|
||||
* other pane) and an update-all action. Memoized: the iframe must not sit in
|
||||
* the parent's keystroke/re-render path. */
|
||||
export const EmbeddedHubPicker = memo(function EmbeddedHubPicker({
|
||||
hidden = false,
|
||||
installedNames,
|
||||
profile
|
||||
}: EmbeddedHubPickerProps) {
|
||||
const { t } = useI18n()
|
||||
const h = t.skills.hub
|
||||
// Subscribe to the ONE flag this header renders, not the whole action map —
|
||||
// $hubActions churns on every tailed log line during an install.
|
||||
const updating = useStoreSelector($hubActions, actions => actions[UPDATE_ALL_KEY]?.running ?? false)
|
||||
// Collapse state rides the same persisted height override the sash writes
|
||||
// (0 = collapsed to the header), so "Hide the hub browser" survives tab
|
||||
// switches and restarts instead of re-expanding — and re-loading the docs
|
||||
// site — on every visit. Same contract as DetailPane.
|
||||
const heightOverride = useStore($paneHeightOverride(HUB_PANE_ID))
|
||||
const height = heightOverride ?? HUB_DEFAULT_PX
|
||||
const open = height > HUB_COLLAPSED_PX
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const sectionRef = useRef<HTMLElement>(null)
|
||||
|
||||
// Top-edge sash: dragging UP grows the hub (shrinking the skills list above,
|
||||
// which is the flex-1 sibling). Same gesture as DetailPane / the shell's
|
||||
// bottom panes; double-click resets to the default height. The iframe gets
|
||||
// pointer-events disabled for the duration or it swallows the pointermoves.
|
||||
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const startY = event.clientY
|
||||
const startHeight = height
|
||||
// Clamp against the actual Capabilities column, not just the window: the
|
||||
// hub may never grow past "column minus the list's reserved strip", so
|
||||
// the installed list always keeps real height and its header/footer can't
|
||||
// end up sharing pixels with the hub header.
|
||||
const column = sectionRef.current?.parentElement
|
||||
const columnMax = column ? column.clientHeight - HUB_LIST_RESERVED_PX : Number.POSITIVE_INFINITY
|
||||
const max = Math.max(HUB_MIN_PX, Math.round(Math.min(window.innerHeight * HUB_MAX_VH, columnMax)))
|
||||
setDragging(true)
|
||||
|
||||
const onMove = (move: globalThis.PointerEvent) => {
|
||||
setPaneHeightOverride(
|
||||
HUB_PANE_ID,
|
||||
Math.round(Math.min(max, Math.max(HUB_MIN_PX, startHeight + (startY - move.clientY))))
|
||||
)
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
setDragging(false)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp, { once: true })
|
||||
}
|
||||
|
||||
// Picker messages from the embedded hub page. Origin-checked; installs route
|
||||
// through the same store pipeline the hub rows use, so the action log,
|
||||
// optimistic flips, and Skills-list refresh all come for free.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== HUB_ORIGIN) {
|
||||
return
|
||||
}
|
||||
|
||||
const data = event.data as SkillPickMessage | null
|
||||
|
||||
if (!data || data.type !== 'hermes-skill-pick' || !data.name) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = String(data.identifier || data.name)
|
||||
const label = String(data.name)
|
||||
|
||||
// Already installed in this scope → tell the user, don't reinstall.
|
||||
if (installedNames.has(label) || installedNames.has(target)) {
|
||||
notify({ kind: 'success', title: h.alreadyInstalled(label), message: '' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
notify({ kind: 'success', title: h.installStarted(label), message: h.actionLog })
|
||||
void installHubSkill(target, profile).catch(err => notifyError(err, h.actionFailed))
|
||||
}
|
||||
|
||||
window.addEventListener('message', onMessage)
|
||||
|
||||
return () => window.removeEventListener('message', onMessage)
|
||||
}, [h, installedNames, open, profile])
|
||||
|
||||
const updateAll = () => {
|
||||
notify({ kind: 'success', title: h.updateStarted, message: h.actionLog })
|
||||
void updateHubSkills(profile).catch(err => notifyError(err, h.actionFailed))
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
// Shrinkable (no shrink-0) + overflow-hidden: the picker is a flex
|
||||
// child of the Capabilities column. Before, its fixed-height viewport
|
||||
// made the section's min-content height rigid, so a short window (or a
|
||||
// tall persisted drag height) starved the installed list to 0px and
|
||||
// the list's strip/footer painted straight over this header. Now the
|
||||
// section clips its own content and gives height back to the list;
|
||||
// min-h keeps the header row itself always visible.
|
||||
'relative flex min-h-9 flex-col overflow-hidden border-t border-(--ui-stroke-secondary)',
|
||||
hidden && 'hidden'
|
||||
)}
|
||||
ref={sectionRef}
|
||||
>
|
||||
{/* Top-edge drag sash — pull the whole hub section up/down. */}
|
||||
<div
|
||||
className="group/hubsash absolute inset-x-0 top-0 z-10 h-1 -translate-y-1/2 cursor-row-resize"
|
||||
onDoubleClick={() => setPaneHeightOverride(HUB_PANE_ID, undefined)}
|
||||
onPointerDown={startDrag}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-x-0 top-1/2 h-px -translate-y-1/2 transition-colors',
|
||||
dragging ? 'bg-(--ui-stroke-secondary)' : 'group-hover/hubsash:bg-(--ui-stroke-secondary)'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-between px-3 py-1.5">
|
||||
<span className="text-[0.7rem] font-medium text-(--ui-text-tertiary)">{h.pickerTitle}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button disabled={updating} onClick={updateAll} size="xs" variant="text">
|
||||
{updating && <Loader2 className="size-3 animate-spin" />}
|
||||
{updating ? h.updating : h.updateAll}
|
||||
</Button>
|
||||
<Button onClick={() => setPaneHeightOverride(HUB_PANE_ID, open ? 0 : undefined)} size="xs" variant="text">
|
||||
{open ? h.pickerHide : h.pickerBrowse}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="flex min-h-0 flex-col gap-1 px-3 pb-2">
|
||||
{/* Resizable viewport: height comes from the top-edge drag sash
|
||||
above (persisted; double-click resets). flex-basis instead of a
|
||||
hard height so a short window shrinks the hub viewport rather
|
||||
than letting it spill over the list. The iframe is rendered
|
||||
oversized and scaled DOWN (133% × 0.75) so the hub page starts
|
||||
zoomed out — the cross-origin page itself can't be styled, but
|
||||
scaling the frame is ours. */}
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--ui-stroke-secondary)',
|
||||
borderRadius: 8,
|
||||
flex: `0 1 ${height}px`,
|
||||
maxWidth: '100%',
|
||||
minHeight: 0,
|
||||
minWidth: 320,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
src={HUB_PICKER_URL}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
height: '133.34%',
|
||||
// While the sash drags, the cross-origin iframe must not eat
|
||||
// the pointermove stream.
|
||||
pointerEvents: dragging ? 'none' : 'auto',
|
||||
transform: 'scale(0.75)',
|
||||
transformOrigin: 'top left',
|
||||
width: '133.34%'
|
||||
}}
|
||||
title={h.pickerTitle}
|
||||
/>
|
||||
</div>
|
||||
<p className="shrink-0 px-1 text-[0.65rem] leading-4 text-(--ui-text-quaternary)">{h.pickerHint}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,525 @@
|
||||
// @vitest-environment jsdom
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import type * as ReactRouterDom from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as HermesApi from '@/hermes'
|
||||
import { queryClient } from '@/lib/query-client'
|
||||
import type * as HubActions from '@/store/hub-actions'
|
||||
|
||||
const getSkills = vi.fn()
|
||||
const getToolsets = vi.fn()
|
||||
const setSkillEnabled = vi.fn()
|
||||
const setToolsetEnabled = vi.fn()
|
||||
const getToolsetConfig = vi.fn()
|
||||
const selectToolsetProvider = vi.fn()
|
||||
const getUsageAnalytics = vi.fn()
|
||||
const getProfiles = vi.fn()
|
||||
const getSkillContent = vi.fn()
|
||||
const getOfficialSkills = vi.fn()
|
||||
|
||||
// Partial mock: keep the real module (SkillsView pulls in @/store/profile,
|
||||
// whose import-time subscription calls setApiRequestProfile) and stub only the
|
||||
// calls we assert on. Args are forwarded so the per-profile scope arg is
|
||||
// observable.
|
||||
vi.mock('@/hermes', async importOriginal => ({
|
||||
...(await importOriginal<typeof HermesApi>()),
|
||||
getSkills: (profile?: null | string) => getSkills(profile),
|
||||
getToolsets: (profile?: null | string) => getToolsets(profile),
|
||||
setSkillEnabled: (name: string, enabled: boolean, profile?: null | string) => setSkillEnabled(name, enabled, profile),
|
||||
setToolsetEnabled: (name: string, enabled: boolean, profile?: null | string) =>
|
||||
setToolsetEnabled(name, enabled, profile),
|
||||
getToolsetConfig: (name: string, profile?: null | string) => getToolsetConfig(name, profile),
|
||||
selectToolsetProvider: (toolset: string, provider: string) => selectToolsetProvider(toolset, provider),
|
||||
getUsageAnalytics: (days: number, profile?: null | string) => getUsageAnalytics(days, profile),
|
||||
getProfiles: () => getProfiles(),
|
||||
getSkillContent: (name: string, profile?: null | string) => getSkillContent(name, profile),
|
||||
getOfficialSkills: (profile?: null | string) => getOfficialSkills(profile)
|
||||
}))
|
||||
|
||||
// Notifications hit nanostores/timers we don't care about here.
|
||||
vi.mock('@/store/notifications', () => ({
|
||||
notify: vi.fn(),
|
||||
notifyError: vi.fn()
|
||||
}))
|
||||
|
||||
// The catalog Install button routes through the hub action pipeline — stub the
|
||||
// action entrypoint (real module kept: SkillsView reads $hubActions and the
|
||||
// query keys from it).
|
||||
vi.mock('@/store/hub-actions', async importOriginal => ({
|
||||
...(await importOriginal<typeof HubActions>()),
|
||||
installHubSkill: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
// The vision detail navigates to Settings → Models via useNavigate; spy on it
|
||||
// so the deep-link target is assertable.
|
||||
const navigateSpy = vi.fn()
|
||||
|
||||
vi.mock('react-router', async importOriginal => ({
|
||||
...(await importOriginal<typeof ReactRouterDom>()),
|
||||
useNavigate: () => navigateSpy
|
||||
}))
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
async function renderSkills() {
|
||||
const { SkillsView } = await import('./index')
|
||||
let result: ReturnType<typeof render>
|
||||
await act(async () => {
|
||||
result = render(
|
||||
// SkillsView reads skills/toolsets via useQuery, so it needs a provider.
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/skills?tab=toolsets']}>
|
||||
<SkillsView />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
})
|
||||
|
||||
return result!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getSkills.mockResolvedValue([])
|
||||
getToolsets.mockResolvedValue([toolset()])
|
||||
setToolsetEnabled.mockResolvedValue({ ok: true, name: 'web', enabled: false })
|
||||
getToolsetConfig.mockResolvedValue({ has_category: true, active_provider: null, providers: [] })
|
||||
getUsageAnalytics.mockResolvedValue({ tools: [] })
|
||||
getOfficialSkills.mockResolvedValue({ skills: [] })
|
||||
getSkillContent.mockResolvedValue({
|
||||
name: 'web-research',
|
||||
path: '/skills/web-research/SKILL.md',
|
||||
content: '---\nname: web-research\nversion: 1.2.0\nauthor: Nous\n---\n\n# Web Research\n\nDeep research steps.'
|
||||
})
|
||||
// Single profile by default → the scope selector stays hidden (>1 gate),
|
||||
// so existing tests see unchanged single-profile behavior.
|
||||
getProfiles.mockResolvedValue({ profiles: [{ name: 'default', is_default: true }] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
// Shared singleton client — drop cached skills/toolsets so each test refetches.
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
// SkillsView is a heavy module: the first test pays the whole dynamic-import
|
||||
// cost, and the file legitimately runs ~14s on CI runners — right against the
|
||||
// global 15s per-test budget, so slow runners cascade-fail all 11 tests
|
||||
// (2× in a row on PR #93612, plus a main run the same hour). Give this file
|
||||
// headroom; the tests are not slow individually.
|
||||
describe('SkillsView toolset management', { timeout: 60_000 }, () => {
|
||||
it('renders a switch for each toolset and toggles it off', async () => {
|
||||
await renderSkills()
|
||||
|
||||
// The switch names the action, so an enabled toolset offers to turn it off.
|
||||
const sw = await screen.findByRole('switch', { name: 'Turn Web Search toolset off' })
|
||||
expect(sw.getAttribute('aria-checked')).toBe('true')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(sw)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(setToolsetEnabled).toHaveBeenCalled())
|
||||
expect(setToolsetEnabled.mock.calls[0].slice(0, 2)).toEqual(['web', false])
|
||||
})
|
||||
|
||||
it('renders toolset titles without leading emoji', async () => {
|
||||
getToolsets.mockResolvedValue([toolset({ name: 'cronjob', label: '⏰ Cron Jobs', description: 'cron tools' })])
|
||||
|
||||
await renderSkills()
|
||||
|
||||
// The label renders in both the row and the auto-selected detail header, so
|
||||
// assert via the switch's (emoji-stripped) accessible name and the absence
|
||||
// of the emoji rather than a single-match text lookup.
|
||||
await screen.findByRole('switch', { name: 'Turn Cron Jobs toolset off' })
|
||||
expect(screen.queryByText(/⏰/)).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the provider config panel inline for the selected toolset', async () => {
|
||||
// The master-detail UI dropped the resting "Configured" pill and the
|
||||
// "Configure" expander: the detail column auto-selects the first toolset
|
||||
// and renders its config panel directly, which fetches on mount.
|
||||
await renderSkills()
|
||||
|
||||
await screen.findByRole('switch', { name: 'Turn Web Search toolset off' })
|
||||
await waitFor(() => expect(getToolsetConfig).toHaveBeenCalled())
|
||||
expect(getToolsetConfig.mock.calls[0][0]).toBe('web')
|
||||
})
|
||||
|
||||
it('scopes Tools config to the profile chosen in the selector', async () => {
|
||||
// Two profiles → the "Configuring:" selector renders. Picking a non-active
|
||||
// profile must re-fetch toolsets scoped to THAT profile.
|
||||
// jsdom's scrollIntoView is missing/non-functional; Radix Select calls it
|
||||
// on open. Force a stub so the dropdown can render in the test env.
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
getProfiles.mockResolvedValue({
|
||||
profiles: [
|
||||
{ name: 'default', is_default: true },
|
||||
{ name: 'researcher', is_default: false }
|
||||
]
|
||||
})
|
||||
|
||||
const { SkillsView } = await import('./index')
|
||||
await act(async () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/skills?tab=toolsets']}>
|
||||
<SkillsView />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
})
|
||||
|
||||
// The selector appears with >1 profile.
|
||||
const trigger = await screen.findByRole('combobox')
|
||||
await act(async () => {
|
||||
fireEvent.click(trigger)
|
||||
})
|
||||
const option = await screen.findByRole('option', { name: 'researcher' })
|
||||
await act(async () => {
|
||||
fireEvent.click(option)
|
||||
})
|
||||
|
||||
// Toolsets refetch scoped to the picked profile.
|
||||
await waitFor(() => expect(getToolsets).toHaveBeenCalledWith('researcher'))
|
||||
})
|
||||
|
||||
it('scopes the Skills tab (and skill toggles) to the profile chosen in the selector', async () => {
|
||||
// The selector is Capabilities-WIDE: picking a profile on the Skills tab
|
||||
// must refetch the skill list scoped to it, and route toggles there too.
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
getProfiles.mockResolvedValue({
|
||||
profiles: [
|
||||
{ name: 'default', is_default: true },
|
||||
{ name: 'researcher', is_default: false }
|
||||
]
|
||||
})
|
||||
getSkills.mockResolvedValue([
|
||||
{
|
||||
name: 'web-research',
|
||||
description: 'Research the web',
|
||||
category: 'research',
|
||||
enabled: true,
|
||||
usage: 3,
|
||||
provenance: 'bundled'
|
||||
}
|
||||
])
|
||||
|
||||
const { SkillsView } = await import('./index')
|
||||
await act(async () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/skills?tab=skills']}>
|
||||
<SkillsView />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
})
|
||||
|
||||
// The selector renders on the Skills tab too (Capabilities-wide).
|
||||
const trigger = await screen.findByRole('combobox')
|
||||
await act(async () => {
|
||||
fireEvent.click(trigger)
|
||||
})
|
||||
const option = await screen.findByRole('option', { name: 'researcher' })
|
||||
await act(async () => {
|
||||
fireEvent.click(option)
|
||||
})
|
||||
|
||||
// Skills refetch scoped to the picked profile...
|
||||
await waitFor(() => expect(getSkills).toHaveBeenCalledWith('researcher'))
|
||||
|
||||
// ...and a toggle routes its write to that profile as well.
|
||||
const sw = await screen.findByRole('switch', { name: 'web-research' })
|
||||
await act(async () => {
|
||||
fireEvent.click(sw)
|
||||
})
|
||||
await waitFor(() => expect(setSkillEnabled).toHaveBeenCalledWith('web-research', false, 'researcher'))
|
||||
})
|
||||
|
||||
it('shows the FULL skill in the detail pane — frontmatter metadata + body', async () => {
|
||||
getSkills.mockResolvedValue([
|
||||
{
|
||||
name: 'web-research',
|
||||
description: 'Research the web',
|
||||
category: 'research',
|
||||
enabled: true,
|
||||
usage: 3,
|
||||
provenance: 'bundled'
|
||||
}
|
||||
])
|
||||
|
||||
const { SkillsView } = await import('./index')
|
||||
await act(async () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/skills?tab=skills']}>
|
||||
<SkillsView />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
})
|
||||
|
||||
// Frontmatter renders as metadata rows, the body as full text — not just
|
||||
// the one-line description.
|
||||
await waitFor(() => expect(getSkillContent).toHaveBeenCalled())
|
||||
expect(getSkillContent.mock.calls[0][0]).toBe('web-research')
|
||||
expect(await screen.findByText('version')).toBeTruthy()
|
||||
expect(await screen.findByText('1.2.0')).toBeTruthy()
|
||||
expect(await screen.findByText(/Deep research steps/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hub picker refuses to reinstall an already-installed skill', async () => {
|
||||
const { notify } = await import('@/store/notifications')
|
||||
const { EmbeddedHubPicker } = await import('./embedded-hub-picker')
|
||||
|
||||
render(<EmbeddedHubPicker installedNames={new Set(['web-research'])} profile={null} />)
|
||||
|
||||
// The picker is expanded by default — the hub iframe is live on mount.
|
||||
expect(document.querySelector('iframe')).toBeTruthy()
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: { type: 'hermes-skill-pick', name: 'web-research', identifier: 'web-research' },
|
||||
origin: 'https://hermes-agent.nousresearch.com'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
// Refused with an informational toast, no install action spawned.
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(notify)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: '"web-research" is already installed' })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('mounts the hub iframe lazily and keeps it (hidden) across tab switches', async () => {
|
||||
// On a non-Skills tab the docs-site iframe must not exist at all — an
|
||||
// eagerly mounted hub is exactly the Capabilities lag bug.
|
||||
await renderSkills() // ?tab=toolsets
|
||||
await screen.findByRole('switch', { name: 'Turn Web Search toolset off' })
|
||||
expect(document.querySelector('iframe')).toBeNull()
|
||||
cleanup()
|
||||
|
||||
// Embedded mode drives tabs through local state (the route hooks are
|
||||
// mocked here), starting on Skills: the picker mounts with the tab.
|
||||
const { SkillsView } = await import('./index')
|
||||
await act(async () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/skills']}>
|
||||
<SkillsView embedded />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
})
|
||||
|
||||
const iframe = document.querySelector('iframe')
|
||||
expect(iframe).toBeTruthy()
|
||||
expect(iframe!.closest('section')!.classList.contains('hidden')).toBe(false)
|
||||
|
||||
// Switch to Tools → the iframe STAYS mounted (no docs-site reload on the
|
||||
// next visit) but its section is fully hidden, so nothing from the hub
|
||||
// can paint over the toolsets UI.
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Tools/ }))
|
||||
})
|
||||
const kept = document.querySelector('iframe')
|
||||
expect(kept).toBeTruthy()
|
||||
expect(kept!.closest('section')!.classList.contains('hidden')).toBe(true)
|
||||
})
|
||||
|
||||
it('shows a vision explainer that deep-links to Settings → Models', async () => {
|
||||
// Vision has no TOOL_CATEGORIES provider matrix — its model lives in the
|
||||
// auxiliary model config, so the detail pane must point there instead of
|
||||
// rendering an empty panel.
|
||||
getToolsets.mockResolvedValue([
|
||||
toolset({
|
||||
name: 'vision',
|
||||
label: 'Vision / Image Analysis',
|
||||
description: 'vision_analyze',
|
||||
tools: ['vision_analyze']
|
||||
})
|
||||
])
|
||||
getToolsetConfig.mockResolvedValue({ has_category: false, active_provider: null, providers: [] })
|
||||
|
||||
await renderSkills()
|
||||
|
||||
expect(await screen.findByText(/auxiliary model configuration/)).toBeTruthy()
|
||||
const link = screen.getByRole('button', { name: /Choose vision model in Settings/ })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(link)
|
||||
})
|
||||
|
||||
// Internal route change into the Models section with the aux slot target —
|
||||
// consumed by ModelSettings' deep-link highlight. Never an external URL.
|
||||
await waitFor(() => expect(navigateSpy).toHaveBeenCalledWith('/settings?tab=config:model&aux=vision'))
|
||||
})
|
||||
|
||||
it('fixedConnection pins every read to the target connection', async () => {
|
||||
// Bot Mode's remote-target door: a bot on another registered gateway gets
|
||||
// the live surface pointed at ITS backend — the reads must carry the
|
||||
// (connection, profile) pin, not a bare profile name that would resolve
|
||||
// against the ACTIVE gateway (the wrong-machine bug).
|
||||
const { SkillsView } = await import('./index')
|
||||
await act(async () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/skills']}>
|
||||
<SkillsView embedded fixedConnection="homelab" fixedProfile="inbox-bot" />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(getSkills).toHaveBeenCalled())
|
||||
expect(getSkills.mock.calls[0][0]).toEqual({ connectionId: 'homelab', profile: 'inbox-bot' })
|
||||
expect(getToolsets.mock.calls[0][0]).toEqual({ connectionId: 'homelab', profile: 'inbox-bot' })
|
||||
// Pinned scope → no roster/profiles fetch, selector hidden.
|
||||
expect(getProfiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers (connection, profile) scope rows on multi-connection desktops', async () => {
|
||||
// With a v2 registry holding >1 connection, the scope selector lists the
|
||||
// union agent roster — profile + owning device — instead of the local
|
||||
// profiles list, so a selection identifies WHICH gateway's capabilities
|
||||
// are being configured.
|
||||
const connections = {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
version: 2,
|
||||
primary: 'local',
|
||||
secureTokenStorage: true,
|
||||
connections: [
|
||||
{ id: 'local', kind: 'local', label: 'This device', tokenSet: false, tokenPreview: null },
|
||||
{ id: 'homelab', kind: 'remote', label: 'Homelab', tokenSet: true, tokenPreview: '…' }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const getAgentRoster = vi.fn().mockResolvedValue({
|
||||
agents: [
|
||||
{
|
||||
connectionId: 'local',
|
||||
connectionKind: 'local',
|
||||
connectionLabel: 'This device',
|
||||
profile: 'default',
|
||||
handle: 'default'
|
||||
},
|
||||
{
|
||||
connectionId: 'homelab',
|
||||
connectionKind: 'remote',
|
||||
connectionLabel: 'Homelab',
|
||||
profile: 'inbox-bot',
|
||||
handle: 'inbox-bot-homelab'
|
||||
}
|
||||
],
|
||||
sources: []
|
||||
})
|
||||
|
||||
;(window as { hermesDesktop?: unknown }).hermesDesktop = { connections, getAgentRoster }
|
||||
|
||||
try {
|
||||
await renderSkills()
|
||||
|
||||
await waitFor(() => expect(getAgentRoster).toHaveBeenCalled())
|
||||
// The selector paints roster rows labeled profile — device.
|
||||
expect(await screen.findByText('default — This device (current)')).toBeTruthy()
|
||||
} finally {
|
||||
delete (window as { hermesDesktop?: unknown }).hermesDesktop
|
||||
}
|
||||
})
|
||||
|
||||
it('lists the built-in optional-skills catalog with Install buttons that route through the hub pipeline', async () => {
|
||||
// The full official catalog renders BELOW the installed list; each row
|
||||
// carries an Install button (no toggle until installed) that routes
|
||||
// through the standard hub action pipeline scoped to the Capabilities
|
||||
// profile. Already-installed catalog entries are filtered out.
|
||||
const { installHubSkill } = await import('@/store/hub-actions')
|
||||
|
||||
getSkills.mockResolvedValue([
|
||||
{
|
||||
name: 'web-research',
|
||||
description: 'Research the web',
|
||||
category: 'research',
|
||||
enabled: true,
|
||||
usage: 3,
|
||||
provenance: 'bundled'
|
||||
}
|
||||
])
|
||||
getOfficialSkills.mockResolvedValue({
|
||||
skills: [
|
||||
{
|
||||
name: 'gif-search',
|
||||
description: 'Search GIFs',
|
||||
identifier: 'official/gifs/gif-search',
|
||||
category: 'gifs',
|
||||
installed: false,
|
||||
tags: ['gifs']
|
||||
},
|
||||
{
|
||||
name: 'web-research',
|
||||
description: 'already here under a different source',
|
||||
identifier: 'official/research/web-research',
|
||||
category: 'research',
|
||||
installed: false,
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
name: 'ascii-art',
|
||||
description: 'ASCII art',
|
||||
identifier: 'official/creative/ascii-art',
|
||||
category: 'creative',
|
||||
installed: true,
|
||||
tags: []
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const { SkillsView } = await import('./index')
|
||||
await act(async () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/skills?tab=skills']}>
|
||||
<SkillsView />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
})
|
||||
|
||||
// Catalog section header + the one genuinely-available row. Rows already
|
||||
// installed (lock flag OR name collision with the installed list) are gone.
|
||||
expect(await screen.findByText('Available to install')).toBeTruthy()
|
||||
expect(await screen.findByText('gif-search')).toBeTruthy()
|
||||
expect(screen.queryByText('ascii-art')).toBeNull()
|
||||
|
||||
// The installed skill still shows its toggle; the catalog row shows
|
||||
// Install instead of a switch.
|
||||
expect(screen.getByRole('switch', { name: 'web-research' })).toBeTruthy()
|
||||
const install = screen.getByRole('button', { name: 'Install' })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(install)
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(installHubSkill)).toHaveBeenCalledWith('official/gifs/gif-search', expect.anything())
|
||||
)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,6 @@
|
||||
import { Codecs, persistentAtom } from '@/lib/persisted'
|
||||
|
||||
// Per-view sort direction for the Capabilities lists — persisted so each tab
|
||||
// remembers most/least-used across navigations and restarts.
|
||||
export const $skillsSortDesc = persistentAtom('hermes.desktop.capabilities.skillsSortDesc', true, Codecs.bool)
|
||||
export const $toolsetsSortDesc = persistentAtom('hermes.desktop.capabilities.toolsetsSortDesc', true, Codecs.bool)
|
||||
Reference in new issue
Block a user