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:
@@ -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"
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user