feat(web): /skill slash command with autocomplete

Trigger /<name>, dropdown lists all skills filtered by name prefix,
arg passthrough sends the rest as the user message. Synthetic
skill_use tool_use renders identically to model-invoked skills.
This commit is contained in:
2026-05-18 01:10:51 +00:00
parent eaacd432e8
commit 80fd3d9fa9
4 changed files with 282 additions and 2 deletions

View File

@@ -0,0 +1,43 @@
import { useEffect, useState } from 'react';
import { api } from '@/api/client';
import type { Skill } from '@/api/types';
// Batch 9.6: shared in-memory cache for the slash-command dropdown. One fetch
// per process; subsequent mounts of useSkills() return the cached list and
// don't re-hit /api/skills. Matches the useSidebar / useChatStatus module-
// singleton pattern so the dropdown stays cheap even with many ChatInputs
// mounted at once.
let cachedSkills: Skill[] | null = null;
let inflight: Promise<Skill[]> | null = null;
const subscribers = new Set<(s: Skill[]) => void>();
async function loadSkills(): Promise<Skill[]> {
if (inflight) return inflight;
inflight = api.skills
.list()
.then((r) => {
cachedSkills = r.skills;
for (const sub of subscribers) {
try { sub(cachedSkills); } catch { /* swallow */ }
}
return cachedSkills;
})
.finally(() => { inflight = null; });
return inflight;
}
export function useSkills(): { skills: Skill[]; loaded: boolean } {
const [skills, setSkills] = useState<Skill[]>(cachedSkills ?? []);
const [loaded, setLoaded] = useState<boolean>(cachedSkills !== null);
useEffect(() => {
subscribers.add(setSkills);
if (cachedSkills === null) {
void loadSkills().then(() => setLoaded(true)).catch(() => setLoaded(true));
}
return () => { subscribers.delete(setSkills); };
}, []);
return { skills, loaded };
}