Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
yilsem committed 2026-09-05 13:26:46 +03:00
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>
)
})
+525
View File
@@ -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
+6
View File
@@ -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)