feat(FN-3119): add Quick Chat FAB with slash-triggered skill menu and plugi

This merge lands v0.15.0 with two major features: a slash-triggered quick chat skill menu (FN-3119, 8 steps covering skill discovery state, trigger matcher, input wiring, skill menu rendering, styles, local help assistant, and tests) and plugin dashboard views (FN-3079, FN-3066) with dependency grap

Fusion-Task-Id: FN-3119
This commit is contained in:
Fusion
2026-05-02 00:22:18 -07:00
committed by gsxdsm
parent 00d392ba7e
commit 8260320255
3 changed files with 343 additions and 3 deletions

View File

@@ -555,6 +555,67 @@
min-width: 0;
}
.chat-skill-menu {
position: absolute;
left: var(--space-lg);
bottom: calc(100% + var(--space-xs));
min-width: calc((var(--space-xl) * 10) + var(--space-2xl) + (var(--space-xs) * 2));
max-width: calc(100% - (var(--space-lg) * 2));
max-height: calc(var(--space-xl) * 10);
overflow-y: auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 50;
}
.chat-skill-menu-item {
width: 100%;
border: none;
background: transparent;
color: inherit;
text-align: left;
padding: var(--space-sm) var(--space-md);
cursor: pointer;
display: flex;
flex-direction: column;
gap: var(--space-xs);
transition: background var(--transition-fast);
}
.chat-skill-menu-item:hover,
.chat-skill-menu-item--highlighted {
background: var(--card-hover);
}
.chat-skill-menu-item:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
background: var(--card-hover);
}
.chat-skill-menu-item-name {
font-size: 0.8125rem;
color: var(--text);
font-family: var(--font-mono);
}
.chat-skill-menu-item-description {
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: calc((var(--space-xl) * 10) + var(--space-lg) + var(--space-xs));
}
.chat-skill-menu-empty {
padding: var(--space-sm) var(--space-md);
color: var(--text-muted);
font-size: 0.8125rem;
}
.quick-chat-input-wrapper--dragover {
border-color: var(--todo);
background: color-mix(in srgb, var(--todo) 5%, var(--bg));

View File

@@ -14,7 +14,8 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown";
import { Eye, EyeOff, MessageSquare, Paperclip, Plus, Send, Square, Wrench, X } from "lucide-react";
import { fetchModels, type Agent, type ModelInfo } from "../api";
import { fetchDiscoveredSkills, fetchModels, type Agent, type ModelInfo } from "../api";
import type { DiscoveredSkill } from "@fusion/dashboard";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ProviderIcon } from "./ProviderIcon";
import { AgentMentionPopup } from "./AgentMentionPopup";
@@ -227,6 +228,22 @@ const quickChatMarkdownComponents: Components = {
),
};
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,
};
}
function getMentionTriggerMatch(
value: string,
cursorPos: number,
@@ -821,11 +838,17 @@ export function QuickChatFAB({
const [selectedModel, setSelectedModel] = useState<string>("");
const [configuredDefaultModelSelection, setConfiguredDefaultModelSelection] = useState<string>("");
const [messageInput, setMessageInput] = useState("");
const [discoveredSkills, setDiscoveredSkills] = useState<DiscoveredSkill[]>([]);
const [skillsLoading, setSkillsLoading] = useState(false);
const [showSkillMenu, setShowSkillMenu] = useState(false);
const [skillFilter, setSkillFilter] = useState("");
const [highlightedSkillIndex, setHighlightedSkillIndex] = useState(0);
const [mentionFilter, setMentionFilter] = useState("");
const [mentionPopupVisible, setMentionPopupVisible] = useState(false);
const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0);
const [mentionStartPos, setMentionStartPos] = useState(-1);
const [plainTextMessageIds, setPlainTextMessageIds] = useState<Set<string>>(() => new Set());
const [helpMessageVisible, setHelpMessageVisible] = useState(false);
/** Pending attachments staged in the composer before being sent. */
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const [isAttachmentDragOver, setIsAttachmentDragOver] = useState(false);
@@ -856,6 +879,7 @@ export function QuickChatFAB({
const prevSessionTargetRef = useRef("");
const mentionCursorPosRef = useRef(0);
const hideMentionPopupTimeoutRef = useRef<number | null>(null);
const hideSkillMenuTimeoutRef = useRef<number | null>(null);
const dragDepthRef = useRef(0);
// Draggable hook for FAB positioning
@@ -1085,6 +1109,24 @@ export function QuickChatFAB({
});
}, [isOpen, agents.length, selectedModel]);
useEffect(() => {
if (!isOpen || !projectId) {
return;
}
setSkillsLoading(true);
fetchDiscoveredSkills(projectId)
.then((skills) => {
setDiscoveredSkills(skills);
})
.catch(() => {
setDiscoveredSkills([]);
})
.finally(() => {
setSkillsLoading(false);
});
}, [isOpen, projectId]);
useEffect(() => {
if (!isOpen) return;
void refreshSessions();
@@ -1140,6 +1182,9 @@ export function QuickChatFAB({
setMentionPopupVisible(false);
setMentionFilter("");
setMentionStartPos(-1);
setShowSkillMenu(false);
setSkillFilter("");
setHighlightedSkillIndex(0);
pendingAttachmentsRef.current.forEach((attachment) => {
if (attachment.previewUrl) {
URL.revokeObjectURL(attachment.previewUrl);
@@ -1225,6 +1270,14 @@ export function QuickChatFAB({
[agents, selectedAgentId],
);
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]);
const filteredMentionAgents = useMemo(() => {
const normalizedFilter = mentionFilter.trim().toLowerCase();
if (!normalizedFilter) {
@@ -1242,6 +1295,10 @@ export function QuickChatFAB({
return byName;
}, [agents]);
useEffect(() => {
setHighlightedSkillIndex(0);
}, [filteredSkills]);
useEffect(() => {
setMentionHighlightIndex(0);
}, [mentionFilter, mentionPopupVisible]);
@@ -1252,6 +1309,10 @@ export function QuickChatFAB({
window.clearTimeout(hideMentionPopupTimeoutRef.current);
hideMentionPopupTimeoutRef.current = null;
}
if (hideSkillMenuTimeoutRef.current !== null) {
window.clearTimeout(hideSkillMenuTimeoutRef.current);
hideSkillMenuTimeoutRef.current = null;
}
};
}, []);
@@ -1446,6 +1507,13 @@ export function QuickChatFAB({
setMentionFilter("");
setMentionStartPos(-1);
if (trimmed === "/help") {
setHelpMessageVisible(true);
focusComposerInput();
preserveComposerFocusRef.current = false;
return;
}
if (trimmed === "/clear") {
stopStreaming();
clearPendingMessage();
@@ -1476,6 +1544,7 @@ export function QuickChatFAB({
}
try {
setHelpMessageVisible(false);
await sendMessage(trimmed, attachmentsToSend.map((attachment) => attachment.file));
attachmentsToSend.forEach((attachment) => {
if (attachment.previewUrl) {
@@ -1544,6 +1613,29 @@ export function QuickChatFAB({
setMentionStartPos(-1);
}, []);
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.focus();
});
return nextInput;
});
setShowSkillMenu(false);
setSkillFilter("");
setHighlightedSkillIndex(0);
}, []);
const handleMentionSelect = useCallback(
(agent: Agent) => {
const input = inputRef.current;
@@ -1581,8 +1673,20 @@ export function QuickChatFAB({
const cursorPos = event.target.selectionStart ?? nextValue.length;
mentionCursorPosRef.current = cursorPos;
setMessageInput(nextValue);
if (helpMessageVisible && nextValue.trim().length > 0) {
setHelpMessageVisible(false);
}
updateMentionState(nextValue, cursorPos);
const skillTriggerMatch = getSkillTriggerMatch(nextValue);
if (skillTriggerMatch) {
setShowSkillMenu(true);
setSkillFilter(skillTriggerMatch.filter);
} else {
setShowSkillMenu(false);
setSkillFilter("");
}
// Detect file mentions
fileMention.detectMention(nextValue, cursorPos);
setFileMentionPopupVisible(fileMention.mentionActive);
@@ -1590,7 +1694,7 @@ export function QuickChatFAB({
updateFileMentionPosition(event.target);
}
},
[updateMentionState, fileMention, updateFileMentionPosition],
[helpMessageVisible, updateMentionState, fileMention, updateFileMentionPosition],
);
const handleInputBlur = useCallback(() => {
@@ -1631,6 +1735,15 @@ export function QuickChatFAB({
fileMention.dismissMention();
hideMentionPopupTimeoutRef.current = null;
}, 120);
if (hideSkillMenuTimeoutRef.current !== null) {
window.clearTimeout(hideSkillMenuTimeoutRef.current);
}
hideSkillMenuTimeoutRef.current = window.setTimeout(() => {
setShowSkillMenu(false);
hideSkillMenuTimeoutRef.current = null;
}, 120);
}, [fileMention, focusComposerInput]);
const handleInputFocus = useCallback(() => {
@@ -1642,6 +1755,10 @@ export function QuickChatFAB({
window.clearTimeout(hideMentionPopupTimeoutRef.current);
hideMentionPopupTimeoutRef.current = null;
}
if (hideSkillMenuTimeoutRef.current !== null) {
window.clearTimeout(hideSkillMenuTimeoutRef.current);
hideSkillMenuTimeoutRef.current = null;
}
}, []);
const handleInputSelectionChange = useCallback(
@@ -1756,6 +1873,34 @@ export function QuickChatFAB({
return;
}
if (showSkillMenu && filteredSkills.length > 0 && event.key === "ArrowDown") {
event.preventDefault();
setHighlightedSkillIndex((prev) => (prev + 1) % filteredSkills.length);
return;
}
if (showSkillMenu && filteredSkills.length > 0 && event.key === "ArrowUp") {
event.preventDefault();
setHighlightedSkillIndex((prev) => (prev === 0 ? filteredSkills.length - 1 : prev - 1));
return;
}
if (showSkillMenu && (event.key === "Enter" || event.key === "Tab")) {
event.preventDefault();
const selectedSkill = filteredSkills[highlightedSkillIndex] ?? filteredSkills[0];
if (selectedSkill) {
handleSkillSelect(selectedSkill);
}
return;
}
if (showSkillMenu && event.key === "Escape") {
event.preventDefault();
setShowSkillMenu(false);
setSkillFilter("");
return;
}
if (event.key !== "Enter" || event.shiftKey) return;
event.preventDefault();
void handleSendMessage();
@@ -1768,6 +1913,10 @@ export function QuickChatFAB({
handleSendMessage,
fileMention,
messageInput,
showSkillMenu,
filteredSkills,
highlightedSkillIndex,
handleSkillSelect,
],
);
@@ -2073,6 +2222,11 @@ export function QuickChatFAB({
onToggleRender={toggleMessageRenderMode}
/>
))}
{helpMessageVisible && (
<div className="quick-chat-panel-message quick-chat-panel-message--received" data-testid="quick-chat-help-message">
{renderAssistantMessageContent("Available commands:\n- `/clear` — Clear conversation and start fresh\n- `/skill:{name}` — Use a specific skill\n- `/help` — Show this help")}
</div>
)}
<div
className="quick-chat-panel-message quick-chat-panel-message--received quick-chat-panel-message--streaming"
data-testid="quick-chat-streaming-message"
@@ -2108,7 +2262,7 @@ export function QuickChatFAB({
</>
) : messagesLoading ? (
<div className="quick-chat-panel-empty">Loading conversation</div>
) : messages.length === 0 && !streamingText && !streamingThinking && !isStreaming ? (
) : messages.length === 0 && !streamingText && !streamingThinking && !isStreaming && !helpMessageVisible ? (
<div className="quick-chat-panel-empty">No messages yet. Start the conversation!</div>
) : (
<>
@@ -2121,6 +2275,11 @@ export function QuickChatFAB({
onToggleRender={toggleMessageRenderMode}
/>
))}
{helpMessageVisible && (
<div className="quick-chat-panel-message quick-chat-panel-message--received" data-testid="quick-chat-help-message">
{renderAssistantMessageContent("Available commands:\n- `/clear` — Clear conversation and start fresh\n- `/skill:{name}` — Use a specific skill\n- `/help` — Show this help")}
</div>
)}
</>
)}
</div>
@@ -2307,6 +2466,35 @@ export function QuickChatFAB({
}}
loading={fileMention.loading}
/>
{showSkillMenu && (
<div className="chat-skill-menu" data-testid="quick-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={(event) => event.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>
)}
{pendingMessage && (
<div className="chat-pending-message" data-testid="chat-pending-indicator">
<span>{`Queued: ${pendingPreview}`}</span>

View File

@@ -14,6 +14,7 @@ vi.mock("../../api", () => ({
streamChatResponse: vi.fn(),
cancelChatResponse: vi.fn(),
fetchModels: vi.fn(),
fetchDiscoveredSkills: vi.fn(),
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
}));
@@ -24,6 +25,7 @@ const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions);
const mockCreateChatSession = vi.mocked(apiModule.createChatSession);
const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
const mockFetchModels = vi.mocked(apiModule.fetchModels);
const mockFetchDiscoveredSkills = vi.mocked(apiModule.fetchDiscoveredSkills);
const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse);
const mockCancelChatResponse = vi.mocked(apiModule.cancelChatResponse);
const mockUseAgents = vi.mocked(useAgents);
@@ -87,6 +89,10 @@ describe("QuickChatFAB session-first UX", () => {
defaultProvider: "openai",
defaultModelId: "gpt-4o",
});
mockFetchDiscoveredSkills.mockResolvedValue([
{ id: "sk-1", name: "fusion-basics", relativePath: "skills/fusion-basics", source: "acme/skills" },
{ id: "sk-2", name: "deploy-helper", relativePath: "skills/deploy-helper", source: "acme/skills" },
]);
});
it("removes header mode toggle and renders session dropdown", async () => {
@@ -212,6 +218,91 @@ describe("QuickChatFAB session-first UX", () => {
});
});
it("shows skill menu when typing slash", async () => {
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const input = await screen.findByTestId("quick-chat-input");
await waitFor(() => expect(input).not.toBeDisabled());
fireEvent.change(input, { target: { value: "/" } });
expect(await screen.findByTestId("quick-chat-skill-menu")).toBeInTheDocument();
expect(screen.getByText("fusion-basics")).toBeInTheDocument();
});
it("filters skills from slash input", async () => {
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const input = await screen.findByTestId("quick-chat-input");
await waitFor(() => expect(input).not.toBeDisabled());
fireEvent.change(input, { target: { value: "/fusion" } });
expect(await screen.findByText("fusion-basics")).toBeInTheDocument();
expect(screen.queryByText("deploy-helper")).toBeNull();
});
it("supports keyboard navigation and enter selection for skills", async () => {
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const input = await screen.findByTestId("quick-chat-input");
await waitFor(() => expect(input).not.toBeDisabled());
fireEvent.change(input, { target: { value: "/" } });
await screen.findByTestId("quick-chat-skill-menu");
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "Enter" });
expect(input).toHaveValue("/skill:deploy-helper ");
});
it("selects skill from menu click and replaces slash trigger", async () => {
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const input = await screen.findByTestId("quick-chat-input");
await waitFor(() => expect(input).not.toBeDisabled());
fireEvent.change(input, { target: { value: "/" } });
const skillName = await screen.findByText("fusion-basics");
fireEvent.click(skillName.closest("button") as HTMLButtonElement);
expect(input).toHaveValue("/skill:fusion-basics ");
});
it("shows help message for exact /help command", async () => {
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const input = await screen.findByTestId("quick-chat-input");
await waitFor(() => expect(input).not.toBeDisabled());
fireEvent.change(input, { target: { value: "/help" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
expect(await screen.findByTestId("quick-chat-help-message")).toBeInTheDocument();
expect(mockStreamChatResponse).not.toHaveBeenCalled();
});
it("clears help message on next user message", async () => {
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const input = await screen.findByTestId("quick-chat-input");
await waitFor(() => expect(input).not.toBeDisabled());
fireEvent.change(input, { target: { value: "/help" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
expect(await screen.findByTestId("quick-chat-help-message")).toBeInTheDocument();
fireEvent.change(input, { target: { value: "hello" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
await waitFor(() => {
expect(screen.queryByTestId("quick-chat-help-message")).toBeNull();
expect(mockStreamChatResponse).toHaveBeenCalledWith("session-model", "hello", expect.any(Object), [], "proj-1");
});
});
it("switches existing sessions from dropdown without creating new session", async () => {
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));