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 ad94051c79
commit ff2f29644b
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"

View File

@@ -9,17 +9,20 @@ import { render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { userEvent } from "@testing-library/user-event";
import { ChatView } from "../ChatView";
import type { DiscoveredSkill } from "@fusion/dashboard";
const stylesPath = path.resolve(__dirname, "../../styles.css");
// Mock scrollIntoView for JSDOM
Element.prototype.scrollIntoView = vi.fn();
import * as useChatModule from "../../hooks/useChat";
import * as apiModule from "../../api";
// Mock the hooks
vi.mock("../../hooks/useChat");
const mockUseChat = vi.mocked(useChatModule.useChat);
const mockFetchDiscoveredSkills = vi.mocked(apiModule.fetchDiscoveredSkills);
// Mock lucide-react icons - spread actual module and override specific icons
vi.mock("lucide-react", async (importOriginal) => {
@@ -77,6 +80,7 @@ vi.mock("../../api", () => ({
{ id: "agent-001", name: "Alpha", role: "executor", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} },
{ id: "agent-002", name: "Beta", role: "reviewer", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} },
]),
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
}));
const defaultChatState = {
@@ -99,21 +103,48 @@ const defaultChatState = {
setSearchQuery: vi.fn(),
filteredSessions: [],
refreshSessions: vi.fn(),
agentsMap: new Map(),
};
const activeSessionFixture = {
id: "session-001",
agentId: "agent-001",
status: "active",
title: "Test Chat",
updatedAt: "2026-04-08T00:00:00.000Z",
};
function createMockSkill(overrides: Partial<DiscoveredSkill>): DiscoveredSkill {
return {
id: "skill-id",
name: "skill/name",
path: "/tmp/skills/skill.md",
relativePath: "skills/skill.md",
enabled: true,
metadata: {
source: "*",
scope: "project",
origin: "top-level",
},
...overrides,
};
}
function setupMockChat(overrides: Partial<typeof defaultChatState> = {}) {
const state = { ...defaultChatState, ...overrides };
mockUseChat.mockReturnValue(state as any);
}
describe("ChatView", () => {
beforeEach(() => {
vi.clearAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
mockFetchDiscoveredSkills.mockResolvedValue([]);
});
afterEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("ChatView", () => {
it("renders empty state when no session is selected", () => {
setupMockChat({ sessions: [] });
@@ -391,6 +422,169 @@ describe("ChatView", () => {
expect(sendMessage).not.toHaveBeenCalled();
});
describe("slash skill autocomplete", () => {
it("shows the skill menu when typing slash in the chat input", async () => {
mockFetchDiscoveredSkills.mockResolvedValueOnce([
createMockSkill({ id: "skill-refactor", name: "refactor/code", relativePath: "skills/refactor/code.md" }),
]);
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "/");
expect(await screen.findByTestId("chat-skill-menu")).toBeInTheDocument();
expect(screen.getByText("refactor/code")).toBeInTheDocument();
});
it("filters discovered skills from slash input", async () => {
mockFetchDiscoveredSkills.mockResolvedValueOnce([
createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }),
createMockSkill({ id: "skill-deploy", name: "deploy/app", relativePath: "skills/deploy/app.md" }),
]);
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "/re");
expect(await screen.findByText("review/pr")).toBeInTheDocument();
expect(screen.queryByText("deploy/app")).not.toBeInTheDocument();
});
it("inserts /skill command when clicking a menu item", async () => {
mockFetchDiscoveredSkills.mockResolvedValueOnce([
createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }),
]);
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "/re");
await userEvent.click(await screen.findByRole("option", { name: /review\/pr/i }));
expect(textarea).toHaveValue("/skill:review/pr ");
expect(screen.queryByTestId("chat-skill-menu")).not.toBeInTheDocument();
});
it("supports arrow navigation with wrapping and Enter selection", async () => {
mockFetchDiscoveredSkills.mockResolvedValueOnce([
createMockSkill({ id: "skill-alpha", name: "alpha", relativePath: "skills/alpha.md" }),
createMockSkill({ id: "skill-beta", name: "beta", relativePath: "skills/beta.md" }),
createMockSkill({ id: "skill-gamma", name: "gamma", relativePath: "skills/gamma.md" }),
]);
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "/");
await screen.findByRole("option", { name: /alpha/i });
// Wrap to bottom from the first item.
await userEvent.keyboard("{ArrowUp}");
expect(screen.getByRole("option", { name: /gamma/i })).toHaveClass(
"chat-skill-menu-item--highlighted",
);
await userEvent.keyboard("{Enter}");
expect(textarea).toHaveValue("/skill:gamma ");
});
it("supports selecting highlighted skill with Tab", async () => {
mockFetchDiscoveredSkills.mockResolvedValueOnce([
createMockSkill({ id: "skill-alpha", name: "alpha", relativePath: "skills/alpha.md" }),
createMockSkill({ id: "skill-beta", name: "beta", relativePath: "skills/beta.md" }),
]);
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "/");
await screen.findByRole("option", { name: /alpha/i });
await userEvent.keyboard("{ArrowDown}");
expect(screen.getByRole("option", { name: /beta/i })).toHaveClass(
"chat-skill-menu-item--highlighted",
);
await userEvent.keyboard("{Tab}");
expect(textarea).toHaveValue("/skill:beta ");
});
it("closes the menu when pressing Escape", async () => {
mockFetchDiscoveredSkills.mockResolvedValueOnce([
createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }),
]);
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "/");
expect(await screen.findByTestId("chat-skill-menu")).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
expect(screen.queryByTestId("chat-skill-menu")).not.toBeInTheDocument();
});
it("closes the menu when slash trigger pattern no longer matches", async () => {
mockFetchDiscoveredSkills.mockResolvedValueOnce([
createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }),
]);
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "/re");
expect(await screen.findByTestId("chat-skill-menu")).toBeInTheDocument();
await userEvent.type(textarea, " ");
expect(screen.queryByTestId("chat-skill-menu")).not.toBeInTheDocument();
});
it("shows loading indicator while discovered skills are still loading", async () => {
let resolveSkills: ((skills: DiscoveredSkill[]) => void) | undefined;
mockFetchDiscoveredSkills.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSkills = resolve;
}),
);
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "/");
expect(await screen.findByText("Loading skills…")).toBeInTheDocument();
resolveSkills?.([createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" })]);
await waitFor(() => {
expect(screen.getByText("review/pr")).toBeInTheDocument();
});
});
it("does not crash when discovered skills fail to load", async () => {
mockFetchDiscoveredSkills.mockRejectedValueOnce(new Error("skills endpoint unavailable"));
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
await userEvent.type(textarea, "/");
expect(await screen.findByText("No skills available")).toBeInTheDocument();
});
});
it("disables send button when input is empty", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },