Skillberry Registry Combobox Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the plain text input for "Skill Name in Registry" in the From Registry tab with a filterable combobox that auto-fetches available skills from the skillberry-store API when a valid URL is entered, and auto-fills all form fields when a skill is selected.
Architecture: Pure frontend change — no backend involved. The browser fetches GET {registryUrl}/skills/ directly from the skillberry-store (CORS verified). A useEffect debounces the fetch on registryUrl/registryType changes. The PF5 typeahead combobox pattern uses Select + MenuToggle variant="typeahead" + TextInputGroup inside the toggle.
Tech Stack: React 18, PatternFly 5 (@patternfly/react-core), Vitest (unit tests), TypeScript
File Map
| File | Change |
|---|---|
rossoctl/ui-v2/src/utils/validation.ts | Add isValidUrl helper |
rossoctl/ui-v2/src/utils/validation.test.ts | Add tests for isValidUrl |
rossoctl/ui-v2/src/pages/ImportSkillPage.tsx | New state, useEffect, combobox, auto-fill, error Alert |
All changes are in the rossoctl/ui-v2/ directory. Run all commands from there.
Task 1: isValidUrl utility
Files:
-
Modify:
rossoctl/ui-v2/src/utils/validation.ts -
Modify:
rossoctl/ui-v2/src/utils/validation.test.ts -
Step 1.1: Write the failing tests
Append to rossoctl/ui-v2/src/utils/validation.test.ts:
describe('isValidUrl', () => {
it('accepts http URLs', () => {
expect(isValidUrl('http://localhost:8000')).toBe(true);
expect(isValidUrl('http://172.26.89.33:8000')).toBe(true);
expect(isValidUrl('http://host.docker.internal:8000')).toBe(true);
});
it('accepts https URLs', () => {
expect(isValidUrl('https://skillberry.example.com')).toBe(true);
});
it('rejects empty string', () => {
expect(isValidUrl('')).toBe(false);
});
it('rejects plain text without protocol', () => {
expect(isValidUrl('notaurl')).toBe(false);
expect(isValidUrl('localhost:8000')).toBe(false);
});
it('rejects partial URLs', () => {
expect(isValidUrl('http://')).toBe(false);
});
});
Update the import at the top of the test file to include isValidUrl:
import { isValidEnvVarName, isValidContainerImage, isValidImageTag, isValidUrl } from './validation';
- Step 1.2: Run tests — expect FAIL
npm run test:unit -- --reporter=verbose 2>&1 | grep -E "FAIL|isValidUrl|Cannot find"
Expected: error like isValidUrl is not a function or similar import failure.
- Step 1.3: Implement
isValidUrlinvalidation.ts
Append to rossoctl/ui-v2/src/utils/validation.ts:
/**
* Return true if url is a syntactically valid absolute URL.
* Requires a protocol (http/https). Used to gate skillberry registry fetches.
*/
export const isValidUrl = (url: string): boolean => {
if (!url) return false;
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
};
- Step 1.4: Run tests — expect PASS
npm run test:unit -- --reporter=verbose 2>&1 | grep -E "PASS|FAIL|isValidUrl"
Expected: all isValidUrl tests pass, no regressions.
- Step 1.5: Commit
git add rossoctl/ui-v2/src/utils/validation.ts rossoctl/ui-v2/src/utils/validation.test.ts
git commit -s -m "feat(ui): add isValidUrl helper to validation utilities"
Task 2: State, type, auto-fetch effect, and error Alert in ImportSkillPage
Files:
-
Modify:
rossoctl/ui-v2/src/pages/ImportSkillPage.tsx -
Step 2.1: Add
SkillberrySkillinterface and new state
After the existing interface AdditionalFile block (around line 46), add:
interface SkillberrySkill {
name: string;
description: string;
version: string;
uuid: string;
}
After the existing registry state declarations (after line 77, const [registryCategory, setRegistryCategory] = React.useState('');), add:
const [registrySkills, setRegistrySkills] = useState<SkillberrySkill[]>([]);
const [registrySkillsLoading, setRegistrySkillsLoading] = useState(false);
const [registrySkillsError, setRegistrySkillsError] = useState<string | null>(null);
const [registrySkillNameOpen, setRegistrySkillNameOpen] = useState(false);
const [registrySkillNameFilter, setRegistrySkillNameFilter] = useState('');
- Step 2.2: Add
isValidUrlimport
Update the import from @/utils/githubSkillImporter:
import { importSkillFromGitHub, isValidGitHubUrl } from '@/utils/githubSkillImporter';
import { isValidUrl } from '@/utils/validation';
- Step 2.3: Add the auto-fetch
useEffect
After the existing GitHub import useEffect block (after the }, [url]); closing), add:
useEffect(() => {
if (registryType !== 'skillberry' || !isValidUrl(registryUrl)) {
setRegistrySkills([]);
setRegistrySkillsError(null);
setRegistrySkillNameFilter('');
setRegistrySkillName('');
return;
}
setRegistrySkillsLoading(true);
setRegistrySkillsError(null);
const controller = new AbortController();
const timeoutId = setTimeout(() => {
fetch(`${registryUrl}/skills/`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((skills: SkillberrySkill[]) => {
setRegistrySkills(skills);
setRegistrySkillsLoading(false);
})
.catch((err) => {
if (err.name === 'AbortError') return;
setRegistrySkillsError(err.message || 'Failed to load skills from registry');
setRegistrySkills([]);
setRegistrySkillsLoading(false);
});
}, 500);
return () => {
clearTimeout(timeoutId);
controller.abort();
};
}, [registryUrl, registryType]);
- Step 2.4: Add fetch error Alert below the Registry URL FormGroup
Find the Registry URL FormGroup closing tag (after the TextInput for reg-url):
<FormGroup label="Registry URL" isRequired fieldId="reg-url">
<TextInput
id="reg-url"
value={registryUrl}
onChange={(_e, v) => setRegistryUrl(v)}
placeholder="https://skillberry.example.com"
/>
</FormGroup>
Replace it with:
<FormGroup label="Registry URL" isRequired fieldId="reg-url">
<TextInput
id="reg-url"
value={registryUrl}
onChange={(_e, v) => setRegistryUrl(v)}
placeholder="http://host.docker.internal:8000"
/>
{registrySkillsError && (
<Alert
variant="danger"
title="Could not load skills from registry"
isInline
isPlain
style={{ marginTop: '0.5rem' }}
>
{registrySkillsError}
</Alert>
)}
</FormGroup>
- Step 2.5: Typecheck
npm run typecheck 2>&1 | grep -E "error TS|ImportSkillPage"
Expected: no errors. Fix any type errors before continuing.
- Step 2.6: Commit
git add rossoctl/ui-v2/src/pages/ImportSkillPage.tsx
git commit -s -m "feat(ui): add skillberry skill list auto-fetch to ImportSkillPage"
Task 3: Replace TextInput with filterable combobox and wire auto-fill
Files:
-
Modify:
rossoctl/ui-v2/src/pages/ImportSkillPage.tsx -
Step 3.1: Add new PF and icon imports
Update the @patternfly/react-core import block to add TextInputGroup, TextInputGroupMain, TextInputGroupUtilities:
import {
PageSection,
Title,
Text,
TextContent,
Card,
CardTitle,
CardBody,
Form,
FormGroup,
TextInput,
TextInputGroup,
TextInputGroupMain,
TextInputGroupUtilities,
TextArea,
Button,
Alert,
ActionGroup,
HelperText,
HelperTextItem,
Split,
SplitItem,
List,
ListItem,
Label,
Spinner,
Tabs,
Tab,
TabTitleText,
Select,
SelectList,
SelectOption,
MenuToggle,
} from '@patternfly/react-core';
Update the @patternfly/react-icons import to add TimesCircleIcon:
import { PlusCircleIcon, TrashIcon, GithubIcon, TimesCircleIcon } from '@patternfly/react-icons';
- Step 3.2: Add
isSkillNameDisabledcomputed variable
Directly before the return ( statement, add:
const isSkillNameDisabled =
registryType !== 'skillberry' || !isValidUrl(registryUrl) || registrySkillsLoading;
- Step 3.3: Replace the Skill Name TextInput with the combobox
Find and replace the current "Skill Name in Registry" FormGroup:
<FormGroup label="Skill Name in Registry" isRequired fieldId="reg-skill-name">
<TextInput
id="reg-skill-name"
value={registrySkillName}
onChange={(_e, v) => setRegistrySkillName(v)}
placeholder="code-review"
/>
</FormGroup>
Replace with:
<FormGroup label="Skill Name in Registry" isRequired fieldId="reg-skill-name">
<Select
isOpen={registrySkillNameOpen}
onOpenChange={(isOpen) => setRegistrySkillNameOpen(isOpen)}
onSelect={(_e, val) => {
const skill = registrySkills.find((s) => s.name === val);
if (skill) {
setRegistrySkillName(skill.name);
setRegistrySkillNameFilter(skill.name);
setRegistrySkillVersion(skill.version);
setRegistryName(skill.name);
setRegistryDescription(skill.description);
}
setRegistrySkillNameOpen(false);
}}
toggle={(ref) => (
<MenuToggle
ref={ref}
variant="typeahead"
onClick={() => {
if (!isSkillNameDisabled) setRegistrySkillNameOpen(!registrySkillNameOpen);
}}
isExpanded={registrySkillNameOpen}
isDisabled={isSkillNameDisabled}
style={{ width: '100%' }}
>
{registrySkillsLoading ? (
<Split hasGutter>
<SplitItem><Spinner size="sm" /></SplitItem>
<SplitItem>Loading skills...</SplitItem>
</Split>
) : (
<TextInputGroup isPlain>
<TextInputGroupMain
value={registrySkillNameFilter}
onClick={() => setRegistrySkillNameOpen(true)}
onChange={(_e, val) => {
setRegistrySkillNameFilter(val);
if (val !== registrySkillName) setRegistrySkillName('');
if (!registrySkillNameOpen) setRegistrySkillNameOpen(true);
}}
autoComplete="off"
placeholder={isSkillNameDisabled ? 'Enter a valid Registry URL first' : 'Select or type a skill name'}
/>
{registrySkillNameFilter && (
<TextInputGroupUtilities>
<Button
variant="plain"
onClick={() => {
setRegistrySkillNameFilter('');
setRegistrySkillName('');
setRegistrySkillVersion('');
setRegistryName('');
setRegistryDescription('');
}}
aria-label="Clear skill selection"
>
<TimesCircleIcon />
</Button>
</TextInputGroupUtilities>
)}
</TextInputGroup>
)}
</MenuToggle>
)}
>
<SelectList>
{registrySkills
.filter(
(s) =>
!registrySkillNameFilter ||
s.name.toLowerCase().includes(registrySkillNameFilter.toLowerCase())
)
.map((s) => (
<SelectOption key={s.uuid} value={s.name} description={s.description}>
{s.name}
</SelectOption>
))}
{!registrySkillsLoading && registrySkills.length === 0 && !registrySkillsError && isValidUrl(registryUrl) && (
<SelectOption isDisabled value="">
No skills found in registry
</SelectOption>
)}
</SelectList>
</Select>
</FormGroup>
- Step 3.4: Update the Register button's
isDisabledcondition
The current condition is:
isDisabled={
!registryName || !registryUrl || !registrySkillName || registryMutation.isPending
}
This is correct — registrySkillName is set when a skill is selected from the combobox. No change needed.
- Step 3.5: Typecheck
npm run typecheck 2>&1 | grep -E "error TS|ImportSkillPage"
Expected: no errors. Common fixes if needed:
-
SelectOption isDisabledmay needkeyprop — addkey="empty". -
MenuToggle reftype:refis passed by PF internally via thetogglecallback, no explicit typing needed. -
Step 3.6: Lint
npm run lint 2>&1 | grep -E "ImportSkillPage|error|warning" | head -20
Expected: no new errors. Fix any unused variable warnings.
- Step 3.7: Commit
git add rossoctl/ui-v2/src/pages/ImportSkillPage.tsx
git commit -s -m "feat(ui): replace skill name text input with filterable combobox in From Registry tab
- Auto-fetch GET /skills/ from skillberry-store when registry URL is valid
- Skill Name field becomes a typeahead combobox filtered by typed text
- Selecting a skill auto-fills Version, Display Name, and Description
- Combobox disabled when URL is invalid/empty or skills are loading
- Clear button resets all auto-filled fields
- Fetch error shown inline below Registry URL field
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>"
Manual Test Checklist
After all tasks are committed, verify these scenarios with the running UI (http://rossoctl-ui.localtest.me:8080):
- Open Skills → Import Skill → From Registry tab
- With skillberry-store running at
http://localhost:8000:- Enter
http://localhost:8000in Registry URL → spinner appears briefly → combobox becomes enabled → dropdown shows skill names - Type "sum" in the combobox → list filters to skills containing "sum"
- Select "summarizer" → Version, Display Name, Description auto-fill
- Click the × clear button → all auto-filled fields reset to empty
- Enter
- Enter an invalid URL (e.g.
notaurl) → combobox shows "Enter a valid Registry URL first", is disabled - Clear the URL after a successful load → combobox resets, no stale skills shown
- Enter URL of an unreachable server (e.g.
http://localhost:9999) → red Alert appears below Registry URL field - Switch Registry Type to "generic" → combobox is disabled (not skillberry)
- With valid URL + skill selected → click "Register External Skill" → navigates to skill catalog, new external skill visible with External badge