feat(FN-1946): add slash skill autocomplete to chat input

- Fetch discovered skills per project and track autocomplete menu/filter/highlight state in ChatView
- Detect slash triggers in the composer, render filtered skill suggestions, and insert /skill:<name> on selection
- Support keyboard and focus UX for the menu (arrow navigation, Enter/Tab select, Escape dismiss, blur/focus timing)
- Add chat skill menu styling for desktop and mobile layouts using existing dashboard tokens
- Expand ChatView tests to cover menu rendering, filtering, selection flows, loading state, and fetch failures
This commit is contained in:
Fusion
2026-04-17 06:10:10 -07:00
committed by gsxdsm
parent d32db262fe
commit cab4b8390d
3 changed files with 469 additions and 11 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
MessageSquare,
Send,
@@ -11,8 +11,9 @@ import {
} from "lucide-react";
import { useChat } from "../hooks/useChat";
import { useViewportMode } from "./Header";
import { fetchAgents, fetchModels } from "../api";
import { fetchAgents, fetchDiscoveredSkills, fetchModels } from "../api";
import type { Agent } from "@fusion/core";
import type { DiscoveredSkill } from "@fusion/dashboard";
import type { ModelInfo } from "../api";
import { CustomModelDropdown } from "./CustomModelDropdown";
@@ -110,6 +111,22 @@ function formatModelTag(provider?: string | null, modelId?: string | null): stri
*/
const KB_AGENT_ID = "__kb_agent__";
function getSkillTriggerMatch(value: string): { filter: string; start: number; end: number } | null {
const triggerMatch = /(^|[\s])\/([^\s]*)$/.exec(value);
if (!triggerMatch) {
return null;
}
const prefix = triggerMatch[1] ?? "";
const filter = triggerMatch[2] ?? "";
const start = triggerMatch.index + prefix.length;
return {
filter,
start,
end: value.length,
};
}
interface NewChatDialogProps {
onClose: () => void;
onCreate: (input: { agentId: string; modelProvider?: string; modelId?: string }) => void;
@@ -263,13 +280,39 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [sidebarVisible, setSidebarVisible] = useState(true);
const [agentsMap, setAgentsMap] = useState<Map<string, Agent>>(new Map());
const [discoveredSkills, setDiscoveredSkills] = useState<DiscoveredSkill[]>([]);
const [skillsLoading, setSkillsLoading] = useState(true);
const [showSkillMenu, setShowSkillMenu] = useState(false);
const [skillFilter, setSkillFilter] = useState("");
const [highlightedSkillIndex, setHighlightedSkillIndex] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
const hideSkillMenuTimeoutRef = useRef<number | null>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const mode = useViewportMode();
const isMobile = mode === "mobile";
const filteredSkills = useMemo(() => {
const normalizedFilter = skillFilter.trim().toLowerCase();
const matchingSkills = normalizedFilter
? discoveredSkills.filter((skill) => skill.name.toLowerCase().includes(normalizedFilter))
: discoveredSkills;
return matchingSkills.slice(0, 10);
}, [discoveredSkills, skillFilter]);
useEffect(() => {
setHighlightedSkillIndex(0);
}, [filteredSkills]);
useEffect(() => {
return () => {
if (hideSkillMenuTimeoutRef.current !== null) {
window.clearTimeout(hideSkillMenuTimeoutRef.current);
}
};
}, []);
// Scroll to bottom on new messages or streaming
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -299,6 +342,33 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
});
}, []);
// Fetch discovered skills for slash command autocomplete
useEffect(() => {
let cancelled = false;
setSkillsLoading(true);
fetchDiscoveredSkills(projectId)
.then((skills) => {
if (!cancelled) {
setDiscoveredSkills(skills);
}
})
.catch(() => {
if (!cancelled) {
setDiscoveredSkills([]);
}
})
.finally(() => {
if (!cancelled) {
setSkillsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [projectId]);
// Handle create session
const handleCreateSession = useCallback(
async (input: { agentId: string; modelProvider?: string; modelId?: string }) => {
@@ -319,6 +389,8 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
const trimmed = messageInput.trim();
if (!trimmed || isStreaming || !activeSession) return;
setMessageInput("");
setShowSkillMenu(false);
setSkillFilter("");
try {
await sendMessage(trimmed);
} catch {
@@ -326,25 +398,116 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
}
}, [messageInput, isStreaming, activeSession, sendMessage, addToast]);
const handleSkillSelect = useCallback(
(skill: DiscoveredSkill) => {
setMessageInput((currentInput) => {
const triggerMatch = getSkillTriggerMatch(currentInput);
if (!triggerMatch) {
return currentInput;
}
const replacement = `/skill:${skill.name} `;
const nextInput =
currentInput.slice(0, triggerMatch.start) + replacement + currentInput.slice(triggerMatch.end);
window.requestAnimationFrame(() => {
if (!inputRef.current) return;
inputRef.current.style.height = "auto";
inputRef.current.style.height = `${Math.min(inputRef.current.scrollHeight, 120)}px`;
inputRef.current.focus();
});
return nextInput;
});
setShowSkillMenu(false);
setSkillFilter("");
setHighlightedSkillIndex(0);
},
[],
);
// Handle input key down
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (showSkillMenu && e.key === "ArrowDown") {
e.preventDefault();
if (filteredSkills.length > 0) {
setHighlightedSkillIndex((prev) => (prev + 1) % filteredSkills.length);
}
return;
}
if (showSkillMenu && e.key === "ArrowUp") {
e.preventDefault();
if (filteredSkills.length > 0) {
setHighlightedSkillIndex((prev) =>
prev === 0 ? filteredSkills.length - 1 : prev - 1,
);
}
return;
}
if (showSkillMenu && (e.key === "Enter" || e.key === "Tab") && filteredSkills.length > 0) {
e.preventDefault();
const skillToSelect = filteredSkills[highlightedSkillIndex] ?? filteredSkills[0];
if (skillToSelect) {
handleSkillSelect(skillToSelect);
}
return;
}
if (showSkillMenu && e.key === "Escape") {
e.preventDefault();
setShowSkillMenu(false);
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
},
[handleSend],
[showSkillMenu, filteredSkills, highlightedSkillIndex, handleSkillSelect, handleSend],
);
// Handle textarea resize
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const textarea = e.target;
setMessageInput(textarea.value);
const nextValue = textarea.value;
setMessageInput(nextValue);
const triggerMatch = getSkillTriggerMatch(nextValue);
if (triggerMatch) {
setShowSkillMenu(true);
setSkillFilter(triggerMatch.filter);
} else {
setShowSkillMenu(false);
setSkillFilter("");
}
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`;
}, []);
const handleInputBlur = useCallback(() => {
if (hideSkillMenuTimeoutRef.current !== null) {
window.clearTimeout(hideSkillMenuTimeoutRef.current);
}
hideSkillMenuTimeoutRef.current = window.setTimeout(() => {
setShowSkillMenu(false);
hideSkillMenuTimeoutRef.current = null;
}, 120);
}, []);
const handleInputFocus = useCallback(() => {
if (hideSkillMenuTimeoutRef.current !== null) {
window.clearTimeout(hideSkillMenuTimeoutRef.current);
hideSkillMenuTimeoutRef.current = null;
}
}, []);
// Handle archive
const handleArchive = useCallback(
async (id: string) => {
@@ -621,6 +784,35 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
{/* Input */}
{activeSession && (
<div className="chat-input-area">
{showSkillMenu && (
<div className="chat-skill-menu" data-testid="chat-skill-menu" role="listbox" aria-label="Skill suggestions">
{skillsLoading ? (
<div className="chat-skill-menu-empty">Loading skills</div>
) : filteredSkills.length === 0 ? (
<div className="chat-skill-menu-empty">
{skillFilter ? "No skills found" : "No skills available"}
</div>
) : (
filteredSkills.map((skill, index) => (
<button
key={skill.id}
type="button"
role="option"
aria-selected={index === highlightedSkillIndex}
className={`chat-skill-menu-item${index === highlightedSkillIndex ? " chat-skill-menu-item--highlighted" : ""}`}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setHighlightedSkillIndex(index)}
onClick={() => handleSkillSelect(skill)}
>
<span className="chat-skill-menu-item-name">{skill.name}</span>
<span className="chat-skill-menu-item-description" title={skill.relativePath}>
{skill.relativePath}
</span>
</button>
))
)}
</div>
)}
<textarea
ref={inputRef}
className="chat-input-textarea"
@@ -628,6 +820,8 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
value={messageInput}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onBlur={handleInputBlur}
onFocus={handleInputFocus}
disabled={isStreaming}
rows={1}
data-testid="chat-input"