Skills
A controlled skill-picker panel for the Composer. The TipTap editor remains mounted and focused while the panel filters from text typed after /.
Nothing selected yet.
Installation
import {
Skill,
SkillChipExtension,
getSkillChipsFromDoc,
insertSkillChipAt,
useComposerSkillSuggestion,
} from "@o/pipeline/experimental/components/composer/skills";
Usage
Skill.Root is controlled. The host supplies the query from the active TipTap suggestion and forwards keyboard events through handleRef.
<Skill.Root
items={skills}
query={query}
onSkillSelect={selectSkill}
handleRef={skillListHandleRef}
>
<Skill.Header>
<Skill.HeaderTitle>Skills</Skill.HeaderTitle>
</Skill.Header>
<Skill.List>{(item) => <Skill.Item key={item.id} item={item} />}</Skill.List>
<Skill.Empty>
<Skill.EmptyMessage />
<Skill.Create />
</Skill.Empty>
</Skill.Root>
Mount the panel inside Composer.Editor, directly above Composer.EditorBody, so it sits on the composer’s own surface with the editor still visible and focused beneath it. Wrap it in a div whose onPointerDown calls preventDefault() so clicking a row never steals the editor’s focus or dissolves the active match.
Opening the picker
useComposerSkillSuggestion uses TipTap’s Suggestion utility. It opens after / at a block start or after whitespace, updates the controlled query, and exits when the match dissolves. matchRange() returns the active /query range; pass it to insertSkillChipAt so removing the typed trigger and inserting the chip land in one transaction. insertTrigger() lets a footer button insert / at the caret, with a leading space when necessary.
const { matchRange, insertTrigger } = useComposerSkillSuggestion({
onStart: (query) => setSuggestion({ query, hiddenBy: null }),
onUpdate: (query) => setSuggestion({ query, hiddenBy: null }),
onExit: () => setSuggestion(null),
onKeyDown: (event) => listHandleRef.current?.onKeyDown(event) ?? false,
});
An outside click should hide the host panel without disabling the plugin. Its next onUpdate then reopens the panel when the user resumes typing. Escape may remain hidden for the same match.
The picker currently uses a listbox rather than full combobox wiring. Associating the editor with the active item through aria-activedescendant is a known follow-up.
Chips
Register SkillChipExtension on Editor.Root. A selected chip is an inline atom inserted exactly where the trigger was typed: insertSkillChipAt(editor, matchRange() ?? collapsedSelection, attrs) replaces the /query text with the chip and a separator space. Selecting another skill adds another chip wherever its trigger was. getSkillChipsFromDoc returns every chip in document order at send time.