feat(FN-1986): improve skills content loading and mobile detail UX

- Normalize GET /api/skills/:id/content handling by decoding encoded IDs and returning consistent invalid-id and not-found responses
- Update readSkillContent to use stat-based file vs directory detection, read SKILL.md robustly, and list non-SKILL.md entries
- Refine SkillsView interactions to ignore toggle clicks, support explicit retry of failed content fetches, and show a SKILL.md fallback placeholder
- Add/adjust dashboard route, component, and mobile CSS tests to cover content route behavior, retry flow, and touch-friendly detail panel styles
This commit is contained in:
Fusion
2026-04-18 14:09:47 -07:00
committed by gsxdsm
parent 7affac3d4b
commit 5499405fc9
7 changed files with 290 additions and 165 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react";
import { Wrench, RefreshCw, X, ChevronRight, ChevronDown, AlertCircle, Loader2 } from "lucide-react";
import {
fetchDiscoveredSkills,
@@ -134,21 +134,10 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
}
}, [projectId, addToast]);
// Handle click on discovered skill to view content
const handleSkillClick = useCallback(async (skillId: string) => {
// If clicking the same skill that's already selected, deselect it
if (selectedSkillId === skillId) {
setSelectedSkillId(null);
setSkillContent(null);
setContentError(null);
return;
}
// Select the new skill and fetch its content
setSelectedSkillId(skillId);
setSkillContent(null);
setContentError(null);
const loadSkillContent = useCallback(async (skillId: string) => {
setIsLoadingContent(true);
setContentError(null);
setSkillContent(null);
try {
const content = await fetchSkillContent(skillId, projectId);
@@ -159,17 +148,34 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
} finally {
setIsLoadingContent(false);
}
}, [selectedSkillId, projectId]);
}, [projectId]);
// Handle click on skill item, but not on toggle
const handleSkillItemClick = useCallback((e: React.MouseEvent, skillId: string) => {
// Don't trigger content fetch when clicking the toggle switch
const target = e.target as HTMLElement;
if (target.closest(".skills-view-item-toggle")) {
// Handle click on discovered skill to view content
const handleSkillClick = useCallback((skillId: string, event?: MouseEvent<HTMLElement>) => {
if (event) {
const target = event.target as Element;
if (target.closest(".skills-view-item-toggle")) {
return;
}
}
if (selectedSkillId === skillId) {
setSelectedSkillId(null);
setSkillContent(null);
setContentError(null);
return;
}
void handleSkillClick(skillId);
}, [handleSkillClick]);
setSelectedSkillId(skillId);
void loadSkillContent(skillId);
}, [selectedSkillId, loadSkillContent]);
const handleRetrySkillContent = useCallback((skillId: string) => {
if (selectedSkillId !== skillId) {
setSelectedSkillId(skillId);
}
void loadSkillContent(skillId);
}, [loadSkillContent, selectedSkillId]);
return (
@@ -244,13 +250,13 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
<div key={skill.id}>
<div
className={`skills-view-item${isSelected ? " skills-view-item--selected" : ""}`}
onClick={(e) => handleSkillItemClick(e, skill.id)}
onClick={(event) => handleSkillClick(skill.id, event)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
void handleSkillClick(skill.id);
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleSkillClick(skill.id);
}
}}
aria-expanded={isSelected}
@@ -285,7 +291,7 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
<div className="skills-view-detail-header">
<span className="skills-view-detail-title">{skill.name}</span>
<button
className="btn btn-sm"
className="btn btn-sm skills-view-detail-close"
onClick={() => {
setSelectedSkillId(null);
setSkillContent(null);
@@ -309,18 +315,16 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
<span>{contentError}</span>
<button
className="btn btn-sm"
onClick={() => void handleSkillClick(skill.id)}
onClick={() => handleRetrySkillContent(skill.id)}
>
Retry
</button>
</div>
) : skillContent ? (
<>
{skillContent.skillMd && (
<div className="skills-view-detail-content">
<pre>{skillContent.skillMd}</pre>
</div>
)}
<pre className="skills-view-detail-content">
{skillContent.skillMd || "(No SKILL.md found)"}
</pre>
{skillContent.files.length > 0 && (
<div className="skills-view-detail-files">
<span className="skills-view-detail-files-label">Files:</span>
@@ -332,11 +336,6 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
))}
</div>
)}
{!skillContent.skillMd && skillContent.files.length === 0 && (
<div className="skills-view-detail-empty">
No content available for this skill.
</div>
)}
</>
) : null}
</div>

View File

@@ -739,7 +739,7 @@ describe("SkillsView", () => {
});
await waitFor(() => {
const preElement = document.querySelector(".skills-view-detail-content pre");
const preElement = document.querySelector(".skills-view-detail-content");
expect(preElement).toBeTruthy();
expect(preElement!.textContent).toContain("# Test Skill");
expect(preElement!.textContent).toContain("This is the skill content.");
@@ -822,7 +822,7 @@ describe("SkillsView", () => {
});
await waitFor(() => {
const preElement = document.querySelector(".skills-view-detail-content pre");
const preElement = document.querySelector(".skills-view-detail-content");
expect(preElement).toBeTruthy();
expect(preElement!.textContent).toContain("# Test Skill");
});
@@ -849,6 +849,39 @@ describe("SkillsView", () => {
});
});
it("retries skill content fetch when retry button is clicked", async () => {
mockFetchSkillContent
.mockRejectedValueOnce(new Error("Failed to load content"))
.mockResolvedValueOnce(mockSkillContent);
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("test-skill")).toBeTruthy();
});
const testSkillItem = screen.getByText("test-skill").closest(".skills-view-item");
await act(async () => {
fireEvent.click(testSkillItem!);
});
await waitFor(() => {
expect(screen.getByText("Retry")).toBeTruthy();
});
await act(async () => {
fireEvent.click(screen.getByText("Retry"));
});
await waitFor(() => {
const preElement = document.querySelector(".skills-view-detail-content");
expect(preElement).toBeTruthy();
expect(preElement!.textContent).toContain("# Test Skill");
});
expect(mockFetchSkillContent).toHaveBeenCalledTimes(2);
});
it("collapses detail when close button is clicked", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
@@ -914,7 +947,7 @@ describe("SkillsView", () => {
});
});
it("shows empty state when skill has no content", async () => {
it("shows SKILL.md fallback text when skill content is empty", async () => {
mockFetchSkillContent.mockResolvedValue({
name: "empty-skill",
skillMd: "",
@@ -933,7 +966,7 @@ describe("SkillsView", () => {
});
await waitFor(() => {
expect(screen.getByText("No content available for this skill.")).toBeTruthy();
expect(screen.getByText("(No SKILL.md found)")).toBeTruthy();
});
});
});

View File

@@ -7,11 +7,17 @@ import { resolve } from "path";
const mockFetchDiscoveredSkills = vi.fn().mockResolvedValue([]);
const mockFetchSkillsCatalog = vi.fn().mockResolvedValue({ entries: [] });
const mockToggleExecutionSkill = vi.fn().mockResolvedValue(undefined);
const mockFetchSkillContent = vi.fn().mockResolvedValue({
name: "test-skill",
skillMd: "",
files: [],
});
vi.mock("../../../api", () => ({
vi.mock("../../api", () => ({
fetchDiscoveredSkills: (...args: unknown[]) => mockFetchDiscoveredSkills(...args),
fetchSkillsCatalog: (...args: unknown[]) => mockFetchSkillsCatalog(...args),
toggleExecutionSkill: (...args: unknown[]) => mockToggleExecutionSkill(...args),
fetchSkillContent: (...args: unknown[]) => mockFetchSkillContent(...args),
}));
function extractRuleBlock(css: string, selector: string): string {
@@ -200,22 +206,32 @@ describe("skills-view mobile css", () => {
expect(block).toContain("padding: var(--space-md)");
});
it("defines .skills-view-detail-content with smaller font on mobile", () => {
it("defines .skills-view-detail-content viewport max-height on mobile", () => {
expect(mobileMediaBlock).toContain(".skills-view-detail-content");
const block = extractRuleBlock(mobileMediaBlock, ".skills-view-detail-content");
expect(block).toContain("font-size: 11px");
expect(block).toContain("max-height: calc(60dvh - 200px)");
});
it("defines .skills-view-detail-header with flex-wrap on mobile", () => {
expect(mobileMediaBlock).toContain(".skills-view-detail-header");
const block = extractRuleBlock(mobileMediaBlock, ".skills-view-detail-header");
expect(block).toContain("flex-wrap: wrap");
it("defines .skills-view-detail-content momentum scrolling on mobile", () => {
const block = extractRuleBlock(mobileMediaBlock, ".skills-view-detail-content");
expect(block).toContain("-webkit-overflow-scrolling: touch");
});
it("defines .skills-view-detail-title with smaller font on mobile", () => {
expect(mobileMediaBlock).toContain(".skills-view-detail-title");
const block = extractRuleBlock(mobileMediaBlock, ".skills-view-detail-title");
expect(block).toContain("font-size: 13px");
it("defines .skills-view-detail-close with minimum touch target on mobile", () => {
const block = extractRuleBlock(mobileMediaBlock, ".skills-view-detail-close");
expect(block).toContain("min-height: 36px");
expect(block).toContain("min-width: 36px");
});
it("defines .skills-view-item--selected in mobile media block", () => {
const block = extractRuleBlock(mobileMediaBlock, ".skills-view-item--selected");
expect(block).toContain("border-color: var(--todo)");
});
it("defines .skills-view-item with min-height on mobile", () => {
const block = extractRuleBlock(mobileMediaBlock, ".skills-view-item");
expect(block).toContain("min-height: 36px");
});
it("skill detail base styles are defined in styles.css", () => {

View File

@@ -27621,6 +27621,7 @@ html .column.drag-over * {
flex-wrap: wrap;
gap: var(--space-sm);
padding: var(--space-md);
min-height: 36px;
}
.skills-view-item-info {
@@ -27660,6 +27661,26 @@ html .column.drag-over * {
padding: var(--space-md);
}
.skills-view-item--selected {
border-color: var(--todo);
}
.skills-view-detail {
padding: var(--space-md);
border-left-width: 2px;
}
.skills-view-detail-content {
font-size: 11px;
max-height: calc(60dvh - 200px);
-webkit-overflow-scrolling: touch;
}
.skills-view-detail-close {
min-height: 36px;
min-width: 36px;
}
.agent-tree__indent--1 { padding-left: 16px; }
.agent-tree__indent--2 { padding-left: 32px; }
.agent-tree__indent--3 { padding-left: 48px; }
@@ -29658,6 +29679,7 @@ html .column.drag-over * {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
@@ -29876,12 +29898,19 @@ html .column.drag-over * {
font-weight: 600;
font-size: 14px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.skills-view-detail-close {
flex-shrink: 0;
}
.skills-view-detail-content {
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
line-height: 1.5;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
@@ -29897,9 +29926,9 @@ html .column.drag-over * {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-sm);
gap: var(--space-xs);
margin-top: var(--space-md);
padding-top: var(--space-md);
padding-top: var(--space-sm);
border-top: 1px solid var(--border);
}
@@ -29919,6 +29948,7 @@ html .column.drag-over * {
.skills-view-detail-error {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-sm);
padding: var(--space-md);
@@ -29926,10 +29956,7 @@ html .column.drag-over * {
font-size: 13px;
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border-radius: var(--radius-sm);
}
.skills-view-detail-error .btn {
margin-left: auto;
text-align: center;
}
.skills-view-detail-empty {
@@ -36491,24 +36518,6 @@ html .column.drag-over * {
opacity: 1;
}
/* Skill detail panel mobile overrides */
.skills-view-detail {
padding: var(--space-md);
}
.skills-view-detail-content {
font-size: 11px;
max-height: 300px;
}
.skills-view-detail-header {
flex-wrap: wrap;
gap: var(--space-sm);
}
.skills-view-detail-title {
font-size: 13px;
}
}
/* === MemoryView === */

View File

@@ -130,6 +130,23 @@ function createMockSkillsAdapter(overrides?: Partial<SkillsAdapter>): SkillsAdap
fallbackUsed: false,
},
}),
readSkillContent: vi.fn().mockImplementation(async (_rootDir: string, skillId: string) => {
if (skillId === "npm::skills/nonexistent") {
throw new Error(`Skill not found: ${skillId}`);
}
if (skillId === "invalid") {
throw new Error(`Invalid skill ID format: ${skillId}`);
}
return {
name: "example/SKILL.md",
skillMd: "# Example Skill\n\nDetails here.",
files: [
{ name: "references", relativePath: "references", type: "directory" as const },
{ name: "notes.txt", relativePath: "notes.txt", type: "file" as const },
],
};
}),
...overrides,
};
}
@@ -200,6 +217,78 @@ describe("Skills routes", () => {
});
});
describe("GET /api/skills/:id/content", () => {
it("returns skill content for a valid skill ID", async () => {
const mockAdapter = createMockSkillsAdapter();
const store = new MockStore();
const app = createServer(store as any, { skillsAdapter: mockAdapter as SkillsAdapter });
const skillId = "npm%253A%2540example%252Fskill%3A%3Askills%2Fexample%2FSKILL.md";
const res = await request(app, "GET", `/api/skills/${skillId}/content`);
expect(res.status).toBe(200);
expect(res.body).toEqual({
content: {
name: "example/SKILL.md",
skillMd: "# Example Skill\n\nDetails here.",
files: [
{ name: "references", relativePath: "references", type: "directory" },
{ name: "notes.txt", relativePath: "notes.txt", type: "file" },
],
},
});
expect(mockAdapter.readSkillContent).toHaveBeenCalledWith(
"/tmp/fn-skills",
"npm:@example/skill::skills/example/SKILL.md",
);
});
it("returns 404 when skills adapter is not configured", async () => {
const store = new MockStore();
const app = createServer(store as any, {});
const res = await request(app, "GET", "/api/skills/npm%253A%2540example%252Fskill%3A%3Askills%2Fexample%2FSKILL.md/content");
expect(res.status).toBe(404);
expect(res.body).toEqual({
error: "Skills adapter not configured",
code: "adapter_not_configured",
});
});
it("returns 404 for non-existent skill", async () => {
const mockAdapter = createMockSkillsAdapter({
readSkillContent: vi.fn().mockRejectedValue(new Error("Skill not found: npm::skills/nonexistent")),
});
const store = new MockStore();
const app = createServer(store as any, { skillsAdapter: mockAdapter as SkillsAdapter });
const res = await request(app, "GET", "/api/skills/npm%253A%253Askills%252Fnonexistent/content");
expect(res.status).toBe(404);
expect(res.body).toEqual({
error: "Skill not found",
code: "skill_not_found",
});
});
it("returns 400 for invalid skill IDs", async () => {
const mockAdapter = createMockSkillsAdapter({
readSkillContent: vi.fn().mockRejectedValue(new Error("Invalid skill ID format: invalid")),
});
const store = new MockStore();
const app = createServer(store as any, { skillsAdapter: mockAdapter as SkillsAdapter });
const res = await request(app, "GET", "/api/skills/invalid/content");
expect(res.status).toBe(400);
expect(res.body).toEqual({
error: "Invalid skill ID format: invalid",
code: "invalid_skill_id",
});
});
});
describe("PATCH /api/skills/execution", () => {
it("toggles skill execution successfully", async () => {
const mockAdapter = createMockSkillsAdapter();

View File

@@ -17332,6 +17332,53 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
/**
* GET /api/skills/:id/content
* Read the contents of a skill's SKILL.md file and list supplementary files.
* Params: id (URL-encoded skill ID)
* Query: projectId (optional) for multi-project context
* Response: { content: SkillContent }
* Error: 404 { error: string; code: "skill_not_found" | "adapter_not_configured" }
*/
router.get("/skills/:id/content", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const skillsAdapter = options?.skillsAdapter;
if (!skillsAdapter) {
res.status(404).json({ error: "Skills adapter not configured", code: "adapter_not_configured" });
return;
}
const encodedSkillId = req.params.id as string;
let skillId = encodedSkillId;
try {
skillId = decodeURIComponent(encodedSkillId);
} catch {
res.status(400).json({ error: "Invalid skill ID", code: "invalid_skill_id" });
return;
}
const rootDir = scopedStore.getRootDir();
const content = await skillsAdapter.readSkillContent(rootDir, skillId);
res.json({ content });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof Error && err.message.includes("Skill not found")) {
res.status(404).json({ error: "Skill not found", code: "skill_not_found" });
return;
}
if (err instanceof Error && err.message.includes("Invalid skill ID")) {
res.status(400).json({ error: err.message, code: "invalid_skill_id" });
return;
}
rethrowAsApiError(err, "Failed to read skill content");
}
});
/**
* PATCH /api/skills/execution
* Toggle a skill's enabled/disabled state.
@@ -17431,48 +17478,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
/**
* GET /api/skills/:id/content
* Read the contents of a skill's SKILL.md file and list supplementary files.
* Params: id (URL-encoded skill ID)
* Query: projectId (optional) for multi-project context
* Response: { content: SkillContent }
* Error: 404 { error: string; code: "skill_not_found" | "adapter_not_configured" }
*/
router.get("/skills/:id/content", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const skillsAdapter = options?.skillsAdapter;
if (!skillsAdapter) {
res.status(404).json({ error: "Skills adapter not configured", code: "adapter_not_configured" });
return;
}
const rootDir = scopedStore.getRootDir();
const skillId = req.params.id as string;
const content = await skillsAdapter.readSkillContent(rootDir, skillId);
res.json({ content });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof Error) {
if (err.message.includes("Invalid skill ID")) {
res.status(400).json({ error: err.message, code: "invalid_skill_id" });
return;
}
if (err.message.includes("Skill not found")) {
res.status(404).json({ error: err.message, code: "skill_not_found" });
return;
}
}
rethrowAsApiError(err, "Failed to read skill content");
}
});
// ── Remote Node Proxy Routes ───────────────────────────────────────────
/** GET /api/proxy/:nodeId/health — Forward health check to remote node */

View File

@@ -5,8 +5,7 @@
* by integrating with the pi-coding-agent package manager and skills.sh API.
*/
import { access } from "node:fs/promises";
import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
import { access, readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises";
import { join, relative, dirname } from "node:path";
/**
@@ -479,69 +478,44 @@ export function createSkillsAdapter(options: {
},
async readSkillContent(rootDir: string, skillId: string): Promise<SkillContent> {
// Parse the skill ID to get source and relativePath
const parsed = parseSkillId(skillId);
if (!parsed) {
throw new Error(`Invalid skill ID format: ${skillId}`);
}
// Find the skill in discovered skills to get its path
const discovered = await this.discoverSkills(rootDir);
const skill = discovered.find((s) => s.id === skillId);
const skill = discovered.find((entry) => entry.id === skillId);
if (!skill) {
throw new Error(`Skill not found: ${skillId}`);
}
// Determine the skill directory
// If path points to a file (e.g., SKILL.md), use dirname(path)
// If path points to a directory, use it directly
let skillDir = skill.path;
try {
const stat = await access(skill.path);
// Path exists, check if it's a file or directory
// We can't easily check with access(), so we try to read as file first
try {
await readFile(skill.path, "utf-8");
// It's a file, get the directory
skillDir = dirname(skill.path);
} catch {
// Not a file (might be a directory or error), use path as-is
skillDir = skill.path;
}
const skillPathStat = await stat(skill.path);
skillDir = skillPathStat.isFile() ? dirname(skill.path) : skill.path;
} catch {
// Path doesn't exist, use dirname as fallback
skillDir = dirname(skill.path);
}
// Read SKILL.md from the skill directory
const skillMdPath = join(skillDir, "SKILL.md");
let skillMd = "";
if (await pathExists(skillMdPath)) {
try {
skillMd = await readFile(skillMdPath, "utf-8");
} catch {
// Ignore read errors, keep empty string
try {
skillMd = await readFile(skillMdPath, "utf-8");
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code !== "ENOENT") {
throw error;
}
}
// List files in the skill directory (non-recursive for MVP)
const files: SkillFileEntry[] = [];
try {
const entries = await readdir(skillDir, { withFileTypes: true });
for (const entry of entries) {
// Skip SKILL.md as it's shown separately
if (entry.name === "SKILL.md") {
continue;
}
files.push({
name: entry.name,
relativePath: entry.name,
type: entry.isDirectory() ? "directory" : "file",
});
}
} catch {
// Ignore readdir errors, return empty files array
}
const entries = await readdir(skillDir, { withFileTypes: true }).catch(() => []);
const files: SkillFileEntry[] = entries
.filter((entry) => entry.name !== "SKILL.md")
.map((entry) => ({
name: entry.name,
relativePath: entry.name,
type: entry.isDirectory() ? "directory" : "file",
}));
return {
name: skill.name,