feat(FN-1792): merge fusion/fn-1792

This commit is contained in:
gsxdsm
2026-04-15 04:00:56 -07:00
parent f86f5456ae
commit cd5d4551b6
8 changed files with 811 additions and 9 deletions

View File

@@ -9,6 +9,7 @@ import { MissionManager } from "./components/MissionManager";
import { NodesView } from "./components/NodesView";
import { ChatView } from "./components/ChatView";
import { RoadmapsView } from "./components/RoadmapsView";
import { SkillsView } from "./components/SkillsView";
import { PageErrorBoundary } from "./components/ErrorBoundary";
import { AppModals } from "./components/AppModals";
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
@@ -314,6 +315,14 @@ function AppInner() {
}
// Project view
if (taskView === "skills") {
return (
<PageErrorBoundary>
<SkillsView addToast={addToast} projectId={currentProject?.id} />
</PageErrorBoundary>
);
}
if (taskView === "chat") {
return (
<PageErrorBoundary>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Server, Workflow, Bot, ChevronLeft, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Map } from "lucide-react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Server, Workflow, Bot, ChevronLeft, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Map, Zap } from "lucide-react";
import type { ProjectInfo } from "../api";
import type { NodeConfig, ProjectStatus } from "@fusion/core";
import { fetchScripts } from "../api";
@@ -180,8 +180,8 @@ export interface HeaderProps {
enginePaused?: boolean;
onToggleGlobalPause?: () => void;
onToggleEnginePause?: () => void;
view?: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps";
onChangeView?: (view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps") => void;
view?: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills";
onChangeView?: (view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills") => void;
searchQuery?: string;
onSearchChange?: (query: string) => void;
/** Multi-project props */
@@ -675,6 +675,15 @@ export function Header({
>
<MessageSquare size={16} />
</button>
<button
className={`view-toggle-btn${view === "skills" ? " active" : ""}`}
onClick={() => onChangeView("skills")}
title="Skills view"
aria-label="Skills view"
aria-pressed={view === "skills"}
>
<Zap size={16} />
</button>
<button
className={`view-toggle-btn${view === "roadmaps" ? " active" : ""}`}
onClick={() => onChangeView("roadmaps")}

View File

@@ -21,15 +21,16 @@ import {
Terminal,
Workflow,
Map,
Zap,
} from "lucide-react";
import { fetchScripts } from "../api";
import { useViewportMode } from "./Header";
export interface MobileNavBarProps {
/** Current task view mode */
view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps";
view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills";
/** Change task view handler */
onChangeView: (view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps") => void;
onChangeView: (view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills") => void;
/** Whether the ExecutorStatusBar footer is visible */
footerVisible: boolean;
/** Whether any full-screen modal is currently open (hides the tab bar) */
@@ -228,6 +229,18 @@ export function MobileNavBar({
<span className="mobile-nav-tab-label">Chat</span>
</button>
<button
type="button"
className={`mobile-nav-tab${view === "skills" ? " mobile-nav-tab--active" : ""}`}
data-testid="mobile-nav-tab-skills"
role="tab"
aria-selected={view === "skills"}
onClick={() => onChangeView("skills")}
>
<Zap />
<span className="mobile-nav-tab-label">Skills</span>
</button>
<button
type="button"
className={`mobile-nav-tab${view === "roadmaps" ? " mobile-nav-tab--active" : ""}`}

View File

@@ -0,0 +1,270 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Wrench, RefreshCw, X } from "lucide-react";
import {
fetchDiscoveredSkills,
toggleExecutionSkill,
fetchSkillsCatalog,
} from "../api";
import type { DiscoveredSkill, CatalogEntry, CatalogFetchResult } from "@fusion/dashboard";
import type { ToastType } from "../hooks/useToast";
interface SkillsViewProps {
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
onClose: () => void;
}
export interface DiscoveredSkillDisplay extends DiscoveredSkill {
toggling?: boolean;
}
export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
const [discoveredSkills, setDiscoveredSkills] = useState<DiscoveredSkillDisplay[]>([]);
const [isLoadingDiscovered, setIsLoadingDiscovered] = useState(true);
const [isLoadingCatalog, setIsLoadingCatalog] = useState(false);
const [catalogError, setCatalogError] = useState<string | null>(null);
const [catalogEntries, setCatalogEntries] = useState<CatalogEntry[]>([]);
const [searchQuery, setSearchQuery] = useState("");
// Debounce timer for catalog search
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [debouncedQuery, setDebouncedQuery] = useState("");
// Fetch discovered skills
const loadDiscoveredSkills = useCallback(async () => {
setIsLoadingDiscovered(true);
try {
const skills = await fetchDiscoveredSkills(projectId);
setDiscoveredSkills(skills);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to load discovered skills";
addToast(message, "error");
} finally {
setIsLoadingDiscovered(false);
}
}, [projectId, addToast]);
// Fetch catalog
const loadCatalog = useCallback(async (query: string) => {
setIsLoadingCatalog(true);
setCatalogError(null);
try {
const result = await fetchSkillsCatalog(query, 20, projectId);
setCatalogEntries(result.entries);
} catch (err) {
// Check for upstream error with code (502 etc.)
const error = err as { error?: string; code?: string };
if (error.error && error.code) {
setCatalogError("Catalog is temporarily unavailable. Please try again later.");
} else {
const message = err instanceof Error ? err.message : "Failed to load catalog";
setCatalogError(message);
}
} finally {
setIsLoadingCatalog(false);
}
}, [projectId]);
// Initial load
useEffect(() => {
void loadDiscoveredSkills();
void loadCatalog("");
}, [loadDiscoveredSkills, loadCatalog]);
// Handle search input with debounce
const handleSearchChange = useCallback((value: string) => {
setSearchQuery(value);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
setDebouncedQuery(value);
}, 300);
}, []);
// Fetch catalog when debounced query changes
useEffect(() => {
void loadCatalog(debouncedQuery);
}, [debouncedQuery, loadCatalog]);
// Handle toggle skill
const handleToggleSkill = useCallback(async (skillId: string, currentEnabled: boolean) => {
const newEnabled = !currentEnabled;
// Optimistic update
setDiscoveredSkills((prev) =>
prev.map((s) => (s.id === skillId ? { ...s, toggling: true } : s))
);
try {
await toggleExecutionSkill(skillId, newEnabled, projectId);
// Update local state with new enabled value
setDiscoveredSkills((prev) =>
prev.map((s) => (s.id === skillId ? { ...s, enabled: newEnabled, toggling: false } : s))
);
addToast(`Skill ${newEnabled ? "enabled" : "disabled"}`, "success");
} catch (err) {
// Revert optimistic update
setDiscoveredSkills((prev) =>
prev.map((s) => (s.id === skillId ? { ...s, toggling: false } : s))
);
const message = err instanceof Error ? err.message : "Failed to toggle skill";
addToast(`Failed to toggle skill: ${message}`, "error");
}
}, [projectId, addToast]);
// Stats
const stats = useMemo(() => ({
discovered: discoveredSkills.length,
enabled: discoveredSkills.filter((s) => s.enabled).length,
catalogEntries: catalogEntries.length,
}), [discoveredSkills, catalogEntries]);
return (
<div className="skills-view" data-testid="skills-view">
{/* Header */}
<div className="skills-view-header">
<div className="skills-view-title">
<h2>
<Wrench size={20} />
Skills
</h2>
<span className="skills-view-count">{discoveredSkills.length} discovered</span>
</div>
<div className="skills-view-actions">
<button
className="btn-icon skills-view-close"
onClick={onClose}
aria-label="Close skills view"
>
<X size={16} />
</button>
<button
className="btn btn-sm"
onClick={() => void loadDiscoveredSkills()}
disabled={isLoadingDiscovered}
>
<RefreshCw size={14} className={isLoadingDiscovered ? "spin" : ""} />
Refresh
</button>
</div>
</div>
{/* Discovered Skills Section */}
<section className="skills-view-section" aria-labelledby="discovered-skills-title">
<h3 id="discovered-skills-title" className="skills-view-section-title">
Discovered Skills
</h3>
{isLoadingDiscovered ? (
<div className="skills-view-loading">
<span className="spinner" />
Loading discovered skills...
</div>
) : discoveredSkills.length === 0 ? (
<div className="skills-view-empty">
<p>No skills discovered in this project.</p>
</div>
) : (
<div className="skills-view-list">
{discoveredSkills.map((skill) => (
<div key={skill.id} className="skills-view-item">
<div className="skills-view-item-info">
<span className="skills-view-item-name">{skill.name}</span>
<span className="skills-view-item-path">{skill.relativePath}</span>
<span className="skills-view-item-source">{skill.metadata.source}</span>
</div>
<label className="skills-view-item-toggle">
<input
type="checkbox"
checked={skill.enabled}
disabled={skill.toggling}
onChange={() => void handleToggleSkill(skill.id, skill.enabled)}
aria-label={`${skill.enabled ? "Disable" : "Enable"} ${skill.name}`}
/>
<span className="skills-view-toggle-slider" />
</label>
</div>
))}
</div>
)}
</section>
{/* Catalog Section */}
<section className="skills-view-section" aria-labelledby="catalog-title">
<h3 id="catalog-title" className="skills-view-section-title">
Skills Catalog
</h3>
{/* Search */}
<div className="skills-view-search">
<input
type="text"
className="form-input"
placeholder="Search skills..."
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
aria-label="Search skills catalog"
/>
</div>
{/* Catalog Content */}
{catalogError ? (
<div className="skills-view-error">
<p>{catalogError}</p>
<button
className="btn btn-sm"
onClick={() => void loadCatalog(debouncedQuery)}
>
Try Again
</button>
</div>
) : isLoadingCatalog ? (
<div className="skills-view-loading">
<span className="spinner" />
Loading catalog...
</div>
) : catalogEntries.length === 0 ? (
<div className="skills-view-empty">
{searchQuery ? (
<p>No skills match your search.</p>
) : (
<p>No skills available in the catalog.</p>
)}
</div>
) : (
<div className="skills-view-grid">
{catalogEntries.map((entry) => (
<div key={entry.id} className="skills-view-card">
<h4 className="skills-view-card-title">{entry.name}</h4>
{entry.description && (
<p className="skills-view-card-description">{entry.description}</p>
)}
{entry.tags && entry.tags.length > 0 && (
<div className="skills-view-card-tags">
{entry.tags.map((tag) => (
<span key={tag} className="badge badge--sm">
{tag}
</span>
))}
</div>
)}
{entry.installs !== undefined && (
<span className="skills-view-card-installs">
{entry.installs.toLocaleString()} installs
</span>
)}
</div>
))}
</div>
)}
</section>
</div>
);
}

View File

@@ -328,6 +328,39 @@ describe("Header", () => {
expect(missionsBtn.getAttribute("aria-pressed")).toBe("false");
});
// ── Skills View Toggle ─────────────────────────────────────────
it("renders skills view button in view toggle when onChangeView is provided", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
const skillsBtn = screen.getByTitle("Skills view");
expect(skillsBtn).toBeDefined();
});
it("calls onChangeView with 'skills' when skills view button is clicked", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
const skillsBtn = screen.getByTitle("Skills view");
fireEvent.click(skillsBtn);
expect(onChangeView).toHaveBeenCalledWith("skills");
});
it("marks skills view button as active when view is 'skills'", () => {
const onChangeView = vi.fn();
render(<Header view="skills" onChangeView={onChangeView} />);
const skillsBtn = screen.getByTitle("Skills view");
expect(skillsBtn.className).toContain("active");
expect(skillsBtn.getAttribute("aria-pressed")).toBe("true");
});
it("does not mark skills view button as active when view is 'board'", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
const skillsBtn = screen.getByTitle("Skills view");
expect(skillsBtn.className).not.toContain("active");
expect(skillsBtn.getAttribute("aria-pressed")).toBe("false");
});
// ── Roadmaps View Toggle ───────────────────────────────────────
it("renders roadmaps view button in view toggle when onChangeView is provided", () => {
@@ -497,6 +530,20 @@ describe("Header", () => {
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
});
it("hides search input and toggle when view is 'skills'", () => {
const onSearchChange = vi.fn();
render(
<Header
view="skills"
onChangeView={vi.fn()}
searchQuery=""
onSearchChange={onSearchChange}
/>
);
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
});
// ── Terminal Button ─────────────────────────────────────────────
it("renders terminal button with correct title", () => {

View File

@@ -56,13 +56,16 @@ describe("MobileNavBar", () => {
mockViewport("mobile");
});
it("renders five tab buttons (board + list + agents + missions + more)", () => {
it("renders eight tab buttons (board + list + agents + missions + chat + skills + roadmaps + more)", () => {
render(<MobileNavBar {...createDefaultProps()} />);
expect(screen.getByTestId("mobile-nav-tab-board")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-list")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-agents")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-missions")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-chat")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-skills")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-roadmaps")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-more")).toBeDefined();
});
@@ -124,6 +127,24 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-nav-tab-missions").className).not.toContain("mobile-nav-tab--active");
});
it("skills tab calls onChangeView with 'skills'", () => {
const props = createDefaultProps();
render(<MobileNavBar {...props} view="board" />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-skills"));
expect(props.onChangeView).toHaveBeenCalledWith("skills");
});
it("skills tab is active when view is 'skills'", () => {
render(<MobileNavBar {...createDefaultProps()} view="skills" />);
expect(screen.getByTestId("mobile-nav-tab-skills").className).toContain("mobile-nav-tab--active");
});
it("skills tab is not active when view is 'board'", () => {
render(<MobileNavBar {...createDefaultProps()} view="board" />);
expect(screen.getByTestId("mobile-nav-tab-skills").className).not.toContain("mobile-nav-tab--active");
});
it("opens and toggles the more sheet", () => {
const props = createDefaultProps();
const { container } = render(<MobileNavBar {...props} />);

View File

@@ -0,0 +1,433 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { SkillsView } from "../SkillsView";
import * as apiModule from "../../api";
import type { DiscoveredSkill, CatalogEntry } from "@fusion/dashboard";
// Mock the API module
vi.mock("../../api", () => ({
fetchDiscoveredSkills: vi.fn(),
toggleExecutionSkill: vi.fn(),
fetchSkillsCatalog: vi.fn(),
}));
const mockFetchDiscoveredSkills = vi.mocked(apiModule.fetchDiscoveredSkills);
const mockToggleExecutionSkill = vi.mocked(apiModule.toggleExecutionSkill);
const mockFetchSkillsCatalog = vi.mocked(apiModule.fetchSkillsCatalog);
describe("SkillsView", () => {
const mockAddToast = vi.fn();
const projectId = "proj_123";
const onClose = vi.fn();
const mockDiscoveredSkills: DiscoveredSkill[] = [
{
id: "npm::skills/test-skill",
name: "test-skill",
path: "/project/.fusion/skills/test-skill",
relativePath: "skills/test-skill",
enabled: true,
metadata: {
source: "npm",
scope: "project",
origin: "top-level",
},
},
{
id: "github::skills/another-skill",
name: "another-skill",
path: "/project/.fusion/skills/another-skill",
relativePath: "skills/another-skill",
enabled: false,
metadata: {
source: "github",
scope: "project",
origin: "package",
},
},
];
const mockCatalogEntries: CatalogEntry[] = [
{
id: "cat-001",
slug: "test-skill",
name: "Test Skill",
description: "A test skill for testing",
tags: ["testing", "example"],
installs: 1234,
installation: {
installed: false,
matchingSkillIds: [],
matchingPaths: [],
},
},
{
id: "cat-002",
slug: "another-skill",
name: "Another Skill",
description: "Another example skill",
tags: ["utility"],
installs: 5678,
installation: {
installed: true,
matchingSkillIds: ["npm::skills/another-skill"],
matchingPaths: ["skills/another-skill"],
},
},
];
beforeEach(() => {
vi.clearAllMocks();
mockFetchDiscoveredSkills.mockResolvedValue(mockDiscoveredSkills);
mockToggleExecutionSkill.mockResolvedValue({
settingsPath: "skills",
pattern: "+test-skill",
targetFile: "/project/.fusion/settings.json",
});
mockFetchSkillsCatalog.mockResolvedValue({
entries: mockCatalogEntries,
auth: {
mode: "unauthenticated",
tokenPresent: false,
fallbackUsed: false,
},
});
});
describe("rendering", () => {
it("renders the skills view with header", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByTestId("skills-view")).toBeTruthy();
expect(screen.getByText("Skills")).toBeTruthy();
expect(screen.getByText(/discovered/)).toBeTruthy();
});
});
it("renders both sections after data loads", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("Discovered Skills")).toBeTruthy();
expect(screen.getByText("Skills Catalog")).toBeTruthy();
});
});
it("displays discovered skill count in header", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText(/2 discovered/)).toBeTruthy();
});
});
it("renders discovered skills list", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("test-skill")).toBeTruthy();
expect(screen.getByText("another-skill")).toBeTruthy();
expect(screen.getByText("skills/test-skill")).toBeTruthy();
expect(screen.getByText("skills/another-skill")).toBeTruthy();
});
});
it("renders source metadata for discovered skills", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
const npmSources = screen.getAllByText("npm");
expect(npmSources.length).toBe(1);
const githubSources = screen.getAllByText("github");
expect(githubSources.length).toBe(1);
});
});
it("renders catalog entries as cards", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("Test Skill")).toBeTruthy();
expect(screen.getByText("Another Skill")).toBeTruthy();
expect(screen.getByText("A test skill for testing")).toBeTruthy();
expect(screen.getByText("Another example skill")).toBeTruthy();
});
});
it("renders catalog tags as badges", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("testing")).toBeTruthy();
expect(screen.getByText("example")).toBeTruthy();
expect(screen.getByText("utility")).toBeTruthy();
});
});
it("renders install counts", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText(/1,234 installs/)).toBeTruthy();
expect(screen.getByText(/5,678 installs/)).toBeTruthy();
});
});
it("shows loading state while fetching discovered skills", async () => {
let resolveSkills: ((value: DiscoveredSkill[]) => void) | undefined;
mockFetchDiscoveredSkills.mockImplementation(
() => new Promise((resolve) => { resolveSkills = resolve as unknown as (value: DiscoveredSkill[]) => void; })
);
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
expect(screen.getByText("Loading discovered skills...")).toBeTruthy();
// Complete the fetch
await act(async () => {
resolveSkills!(mockDiscoveredSkills);
});
});
it("shows empty state when no discovered skills", async () => {
mockFetchDiscoveredSkills.mockResolvedValue([]);
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("No skills discovered in this project.")).toBeTruthy();
});
});
it("shows empty state when no catalog entries", async () => {
mockFetchSkillsCatalog.mockResolvedValue({
entries: [],
auth: { mode: "unauthenticated", tokenPresent: false, fallbackUsed: false },
});
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("No skills available in the catalog.")).toBeTruthy();
});
});
});
describe("toggle skill", () => {
it("calls toggleExecutionSkill when toggle is clicked", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("test-skill")).toBeTruthy();
});
// Find and click the toggle for test-skill (enabled skill)
const toggles = screen.getAllByRole("checkbox");
const enabledToggle = toggles.find(t => (t as HTMLInputElement).checked) as HTMLInputElement;
expect(enabledToggle).toBeTruthy();
await act(async () => {
fireEvent.click(enabledToggle);
});
expect(mockToggleExecutionSkill).toHaveBeenCalledWith(
"npm::skills/test-skill",
false,
undefined
);
});
it("updates checked state on successful toggle", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("test-skill")).toBeTruthy();
});
// Find and click the toggle for test-skill (enabled skill)
const toggles = screen.getAllByRole("checkbox");
const enabledToggle = toggles.find(t => (t as HTMLInputElement).checked) as HTMLInputElement;
await act(async () => {
fireEvent.click(enabledToggle);
});
await waitFor(() => {
expect((enabledToggle as HTMLInputElement).checked).toBe(false);
});
});
it("shows success toast on successful toggle", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("test-skill")).toBeTruthy();
});
const toggles = screen.getAllByRole("checkbox");
const enabledToggle = toggles.find(t => (t as HTMLInputElement).checked) as HTMLInputElement;
await act(async () => {
fireEvent.click(enabledToggle);
});
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith(
"Skill disabled",
"success"
);
});
});
it("reverts toggle and shows error toast on failed toggle", async () => {
mockToggleExecutionSkill.mockRejectedValue(new Error("Toggle failed"));
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("test-skill")).toBeTruthy();
});
const toggles = screen.getAllByRole("checkbox");
const enabledToggle = toggles.find(t => (t as HTMLInputElement).checked) as HTMLInputElement;
await act(async () => {
fireEvent.click(enabledToggle);
});
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith(
expect.stringContaining("Failed to toggle skill"),
"error"
);
});
// Should revert to original state
expect((enabledToggle as HTMLInputElement).checked).toBe(true);
});
});
describe("catalog search", () => {
it("calls fetchSkillsCatalog with projectId when provided", async () => {
render(<SkillsView projectId={projectId} addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(mockFetchDiscoveredSkills).toHaveBeenCalledWith(projectId);
});
expect(mockFetchSkillsCatalog).toHaveBeenCalledWith("", 20, projectId);
});
it("shows search input and updates on change", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByPlaceholderText("Search skills...")).toBeTruthy();
});
const searchInput = screen.getByPlaceholderText("Search skills...");
fireEvent.change(searchInput, { target: { value: "test" } });
expect((searchInput as HTMLInputElement).value).toBe("test");
});
});
describe("error handling", () => {
it("shows error message for catalog fetch with upstream error", async () => {
mockFetchSkillsCatalog.mockRejectedValue({
error: "Service unavailable",
code: "upstream_http_error",
});
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("Catalog is temporarily unavailable. Please try again later.")).toBeTruthy();
});
});
it("shows error toast when fetchDiscoveredSkills fails", async () => {
mockFetchDiscoveredSkills.mockRejectedValue(new Error("Failed to load"));
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith(
expect.stringContaining("Failed to load"),
"error"
);
});
});
it("shows Try Again button for catalog error", async () => {
mockFetchSkillsCatalog.mockRejectedValue({
error: "Service unavailable",
code: "upstream_http_error",
});
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("Try Again")).toBeTruthy();
});
});
});
describe("refresh functionality", () => {
it("refreshes discovered skills when refresh button is clicked", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("Refresh")).toBeTruthy();
});
mockFetchDiscoveredSkills.mockClear();
await act(async () => {
fireEvent.click(screen.getByText("Refresh"));
});
expect(mockFetchDiscoveredSkills).toHaveBeenCalled();
});
it("refresh button is disabled while loading", async () => {
let resolveSkills: ((value: DiscoveredSkill[]) => void) | undefined;
mockFetchDiscoveredSkills.mockImplementation(
() => new Promise((resolve) => { resolveSkills = resolve as unknown as (value: DiscoveredSkill[]) => void; })
);
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
// Trigger refresh
await act(async () => {
fireEvent.click(screen.getByText("Refresh"));
});
// Button should be disabled during loading
expect(screen.getByText("Refresh").closest("button")?.hasAttribute("disabled")).toBe(true);
expect(document.querySelector(".spin")).toBeTruthy();
// Complete the fetch
await act(async () => {
resolveSkills!(mockDiscoveredSkills);
});
});
});
describe("close button", () => {
it("calls onClose when close button is clicked", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByLabelText("Close skills view")).toBeTruthy();
});
await act(async () => {
fireEvent.click(screen.getByLabelText("Close skills view"));
});
expect(onClose).toHaveBeenCalled();
});
});
});

View File

@@ -4,7 +4,7 @@ import type { ProjectInfo } from "../api";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
export type ViewMode = "overview" | "project";
export type TaskView = "board" | "list" | "agents" | "missions" | "chat" | "roadmaps";
export type TaskView = "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills";
interface UseViewStateOptions {
projectsLoading: boolean;
@@ -48,7 +48,7 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
const [taskView, setTaskView] = useState<TaskView>(() => {
const saved = getScopedItem("kb-dashboard-task-view");
if (saved === "board" || saved === "list" || saved === "agents" || saved === "missions" || saved === "chat" || saved === "roadmaps") return saved as TaskView;
if (saved === "board" || saved === "list" || saved === "agents" || saved === "missions" || saved === "chat" || saved === "roadmaps" || saved === "skills") return saved as TaskView;
return "board";
});
@@ -58,7 +58,7 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
useEffect(() => {
const saved = getScopedItem("kb-dashboard-task-view", currentProject?.id);
if (saved === "board" || saved === "list" || saved === "agents" || saved === "missions" || saved === "chat" || saved === "roadmaps") {
if (saved === "board" || saved === "list" || saved === "agents" || saved === "missions" || saved === "chat" || saved === "roadmaps" || saved === "skills") {
setTaskView(saved as TaskView);
return;
}