feat(dashboard): Skills detail renders SKILL.md as markdown + compact file browser

SKILL.md now renders via MailboxMessageContent (markdown + sanitized HTML + mermaid) instead of raw <pre>. Files strip is compact and lists all files; clicking a file loads its content in the detail pane (markdown via MailboxMessageContent, other text via <pre>, binary/oversized notice) with a Back-to-SKILL.md affordance. New GET /api/skills/:id/file endpoint (readSkillFileContent: path-traversal-guarded, 2MB ceiling, binary detection) + fetchSkillFileContent client fn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-22 11:49:52 -07:00
parent c47f0df59f
commit 5697d2c98b
11 changed files with 701 additions and 42 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Skills view detail pane: render SKILL.md as Markdown (GFM + sanitized HTML + mermaid), compact the referenced-files area while showing all files, and make each file clickable to view its content with a "Back to SKILL.md" affordance. Adds a `GET /api/skills/:id/file` endpoint for per-file content.

View File

@@ -96,7 +96,7 @@ import type {
} from "@fusion/core";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import type { GithubIssueAction, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
import type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult, SkillContent, SkillFileEntry } from "@fusion/dashboard";
import type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult, SkillContent, SkillFileEntry, SkillFileContent } from "@fusion/dashboard";
import type { MilestoneValidationTelemetry, MissionInterviewDraftSummary } from "../components/mission-types";
import type {
ResearchAvailability,
@@ -113,7 +113,7 @@ import { dedupe, type DedupeOptions } from "./dedupe";
export type FetchOptions = DedupeOptions;
// Re-export skills types for use by hooks and components
export type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult, SkillContent, SkillFileEntry };
export type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult, SkillContent, SkillFileEntry, SkillFileContent };
export type { CommitAssociationDiffBackfillReport };
export class ApiRequestError extends Error {
@@ -9576,6 +9576,19 @@ export async function fetchSkillContent(skillId: string, projectId?: string): Pr
return response.content;
}
/*
FNXC:Skills 2026-06-23-04:15:
Fetch one supplementary file's content for the SkillsView detail-pane file viewer. The skill-dir-relative path is passed as an encoded `path` query param; the server resolves + traversal-guards it. Returns isText:false for binary/oversized files so the UI shows a non-previewable notice.
*/
export async function fetchSkillFileContent(skillId: string, relativePath: string, projectId?: string): Promise<SkillFileContent> {
const base = withProjectId(`/skills/${encodeURIComponent(skillId)}/file`, projectId);
const sep = base.includes("?") ? "&" : "?";
const response = await api<{ file: SkillFileContent }>(
`${base}${sep}path=${encodeURIComponent(relativePath)}`
);
return response.file;
}
// ── Chat API ─────────────────────────────────────────────────────────────────
// EnrichedChatSession is imported from @fusion/core above

View File

@@ -432,20 +432,91 @@ Detail-pane header bar: BACK (narrow only) on the left, truncating skill name in
overflow-y: auto;
}
/*
FNXC:Skills 2026-06-23-04:15:
Compact referenced-files strip. Smaller gap/padding and a smaller font so the section no longer dominates the detail pane. Holds ALL referenced files (no cap). File entries are clickable buttons; directories are static badges.
*/
.skills-view-detail-files {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
margin-top: var(--space-md);
padding-top: var(--space-sm);
gap: calc(var(--space-xs) / 2);
margin-top: var(--space-sm);
padding-top: var(--space-xs);
border-top: 1px solid var(--border);
font-size: 0.85em;
}
.skills-view-detail-files-label {
color: var(--text-muted);
}
/* FNXC:Skills 2026-06-23-04:15: clickable file badge in the compact strip. Reset native button chrome so the .badge styling reads as a chip; pointer + hover signal it loads the file. */
.skills-view-detail-file {
appearance: none;
border: 1px solid var(--border);
background: var(--card);
color: var(--text);
cursor: pointer;
font: inherit;
line-height: 1.2;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.skills-view-detail-file:hover {
background: var(--card-hover);
border-color: var(--text-muted);
}
.skills-view-detail-file--active {
border-color: var(--todo);
background: var(--card-hover);
}
/* FNXC:Skills 2026-06-23-04:15: directories are not previewable -> static, dimmed badge. */
.skills-view-detail-file--dir {
color: var(--text-dim);
}
/*
FNXC:Skills 2026-06-23-04:15:
File viewer column inside the detail body. Header bar carries the "← Back to SKILL.md" affordance + the open file's relative path; the content (markdown or <pre>) and the compact files strip follow.
*/
.skills-view-file-viewer {
display: flex;
flex-direction: column;
gap: var(--space-sm);
min-width: 0;
}
.skills-view-file-viewer-bar {
display: flex;
align-items: center;
gap: var(--space-sm);
min-width: 0;
}
.skills-view-file-back {
flex-shrink: 0;
}
.skills-view-file-viewer-name {
flex: 1 1 auto;
min-width: 0;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.9em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* FNXC:Skills 2026-06-23-04:15: markdown render wrapper (SKILL.md + markdown files) reuses the shared .mailbox-markdown styling from MailboxMessageContent; this just bounds its width inside the pane. */
.skills-view-detail-markdown {
min-width: 0;
word-break: break-word;
}
.skills-view-detail-loading {
display: flex;
align-items: center;

View File

@@ -3,14 +3,31 @@ import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } fr
import { useTranslation } from "react-i18next";
import { Zap, RefreshCw, X, ChevronRight, ChevronDown, AlertCircle, Loader2, ArrowLeft } from "lucide-react";
import { ViewHeader } from "./ViewHeader";
import { MailboxMessageContent } from "./MailboxMessageContent";
import {
fetchDiscoveredSkills,
toggleExecutionSkill,
installSkill,
fetchSkillsCatalog,
fetchSkillContent,
fetchSkillFileContent,
} from "../api";
import type { DiscoveredSkill, CatalogEntry, SkillContent } from "@fusion/dashboard";
import type { DiscoveredSkill, CatalogEntry, SkillContent, SkillFileContent } from "@fusion/dashboard";
/*
FNXC:Skills 2026-06-23-04:15:
Treat these extensions as markdown so the file viewer renders them via MailboxMessageContent (GitHub-flavored markdown + sanitized HTML + mermaid). Everything else renders as plain <pre> text (or a non-previewable notice when the server flags isText:false). SKILL.md itself always renders as markdown regardless of this set.
*/
const MARKDOWN_FILE_EXTENSIONS = new Set([".md", ".markdown", ".mdx"]);
function isMarkdownFile(relativePath: string): boolean {
const lower = relativePath.toLowerCase();
const dot = lower.lastIndexOf(".");
if (dot < 0) {
return false;
}
return MARKDOWN_FILE_EXTENSIONS.has(lower.slice(dot));
}
import type { ToastType } from "../hooks/useToast";
interface SkillsViewProps {
@@ -39,6 +56,15 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
const [isLoadingContent, setIsLoadingContent] = useState(false);
const [contentError, setContentError] = useState<string | null>(null);
/*
FNXC:Skills 2026-06-23-04:15:
File-viewer state for the detail pane. `viewedFilePath` is the skill-dir-relative path of the file currently shown (null = the SKILL.md markdown view). When non-null the detail body renders the file's content with a "← Back to SKILL.md" affordance instead of the SKILL.md body. The files area stays reachable so the user can switch between files.
*/
const [viewedFilePath, setViewedFilePath] = useState<string | null>(null);
const [viewedFile, setViewedFile] = useState<SkillFileContent | null>(null);
const [isLoadingFile, setIsLoadingFile] = useState(false);
const [fileError, setFileError] = useState<string | null>(null);
// Debounce timer for catalog search
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [debouncedQuery, setDebouncedQuery] = useState("");
@@ -192,6 +218,10 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
setIsLoadingContent(true);
setContentError(null);
setSkillContent(null);
// FNXC:Skills 2026-06-23-04:15: switching skills always returns to the SKILL.md view (clear any open file).
setViewedFilePath(null);
setViewedFile(null);
setFileError(null);
try {
const content = await fetchSkillContent(skillId, projectId);
@@ -217,6 +247,10 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
setSelectedSkillId(null);
setSkillContent(null);
setContentError(null);
// FNXC:Skills 2026-06-23-04:15: collapsing the selected skill also drops any open file view.
setViewedFilePath(null);
setViewedFile(null);
setFileError(null);
return;
}
@@ -231,6 +265,34 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
void loadSkillContent(skillId);
}, [loadSkillContent, selectedSkillId]);
/*
FNXC:Skills 2026-06-23-04:15:
Load a single referenced file into the detail pane. Sets viewedFilePath immediately so the pane swaps to the file viewer (with the back affordance) while the body fetches. Markdown files render via MailboxMessageContent; other text files render as <pre>; binary/oversized files (isText:false) show a non-previewable notice.
*/
const loadSkillFile = useCallback(async (skillId: string, relativePath: string) => {
setViewedFilePath(relativePath);
setViewedFile(null);
setFileError(null);
setIsLoadingFile(true);
try {
const file = await fetchSkillFileContent(skillId, relativePath, projectId);
setViewedFile(file);
} catch (err) {
const message = err instanceof Error ? err.message : t("skills.loadFileError", "Failed to load file");
setFileError(message);
} finally {
setIsLoadingFile(false);
}
}, [projectId]);
// FNXC:Skills 2026-06-23-04:15: BACK affordance from a file view -> the SKILL.md markdown view (keeps the skill selected).
const backToSkillMd = useCallback(() => {
setViewedFilePath(null);
setViewedFile(null);
setFileError(null);
setIsLoadingFile(false);
}, []);
/*
FNXC:Skills 2026-06-23-01:45:
Master/detail clear. Returns the list from the narrow single-panel detail view (the BACK affordance) and also backs the detail-pane Close button. Mirrors DockFilesView's handleBack: drop the selection + cached content so the right pane shows its empty-state (wide) or the list reappears (narrow).
@@ -239,6 +301,10 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
setSelectedSkillId(null);
setSkillContent(null);
setContentError(null);
// FNXC:Skills 2026-06-23-04:15: leaving the detail pane also drops any open file view.
setViewedFilePath(null);
setViewedFile(null);
setFileError(null);
}, []);
// FNXC:Skills 2026-06-23-01:45: the detail pane renders the SELECTED skill's row data (name/path) alongside its fetched content. Resolve it once from the loaded list so the pane header stays correct even when the search filter would otherwise hide the row.
@@ -254,6 +320,48 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
- NARROW (default, e.g. embedded sidebar dock + mobile): single-panel master→detail stack. The list fills the root; selecting a skill (root [data-selected="true"]) reveals the detail pane ON TOP and hides the list. The BACK button (data-testid="skills-detail-back") returns to the list.
`data-selected` on the root lets the container query distinguish "no skill selected" (narrow: detail hidden, list shows) from "skill selected" (narrow: detail covers the stack). When wide both panes are always visible regardless of this flag — same deterministic fallback path DockFilesView documents if the @container proves unreliable, except SkillsView always lives in a full-width main panel so the query fires reliably here.
*/
/*
FNXC:Skills 2026-06-23-04:15:
Compact files strip rendered under BOTH the SKILL.md view and any open file view, so the user can switch between referenced files without leaving the detail pane. Renders ALL of skillContent.files (no cap/truncation). Files are clickable (data-testid="skill-file-item") and load into the viewer; directories are non-clickable (they have no previewable content). The active file is marked --active. The strip uses the compact `.skills-view-detail-files` styling (smaller padding/row height/font) so it no longer dominates the pane.
*/
const renderFilesStrip = () => {
if (!skillContent || skillContent.files.length === 0) {
return null;
}
return (
<div className="skills-view-detail-files" data-testid="skill-files">
<span className="skills-view-detail-files-label">{t("skills.filesLabel", "Files")}:</span>
{skillContent.files.map((file) => {
if (file.type === "directory") {
return (
<span key={file.relativePath} className="badge badge--sm skills-view-detail-file--dir">
{file.name}/
</span>
);
}
const isActive = viewedFilePath === file.relativePath;
return (
<button
key={file.relativePath}
type="button"
data-testid="skill-file-item"
className={`badge badge--sm skills-view-detail-file${isActive ? " skills-view-detail-file--active" : ""}`}
onClick={() => selectedSkillId && void loadSkillFile(selectedSkillId, file.relativePath)}
aria-pressed={isActive}
aria-label={t("skills.viewFile", "View {{name}}", { name: file.name })}
>
{file.name}
</button>
);
})}
</div>
);
};
/*
FNXC:Skills 2026-06-23-04:15:
Detail body branches: empty-state -> loading -> error -> [file viewer | SKILL.md]. The SKILL.md body now renders as MARKDOWN via MailboxMessageContent (GFM + sanitized HTML + mermaid) instead of a raw <pre>. The compact files strip renders below either content view so files stay reachable.
*/
const renderDetailBody = () => {
if (!selectedSkillId) {
return (
@@ -284,27 +392,76 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
</div>
);
}
if (skillContent) {
if (!skillContent) {
return null;
}
// FNXC:Skills 2026-06-23-04:15: file view — a referenced file is open. Back affordance returns to the SKILL.md markdown view.
if (viewedFilePath !== null) {
return (
<>
<pre className="skills-view-detail-content">
{skillContent.skillMd || t("skills.noSkillMd", "(No SKILL.md found)")}
</pre>
{skillContent.files.length > 0 && (
<div className="skills-view-detail-files">
<span className="skills-view-detail-files-label">{t("skills.filesLabel", "Files")}:</span>
{skillContent.files.map((file) => (
<span key={file.relativePath} className="badge badge--sm">
{file.name}
{file.type === "directory" && "/"}
</span>
))}
<div className="skills-view-file-viewer" data-testid="skill-file-viewer">
<div className="skills-view-file-viewer-bar">
<button
type="button"
className="btn btn-sm skills-view-file-back"
onClick={backToSkillMd}
data-testid="skill-file-back"
aria-label={t("skills.backToSkillMd", "Back to SKILL.md")}
>
<ArrowLeft size={14} />
{t("skills.backToSkillMd", "Back to SKILL.md")}
</button>
<span className="skills-view-file-viewer-name">{viewedFilePath}</span>
</div>
{isLoadingFile ? (
<div className="skills-view-detail-loading">
<Loader2 size={16} className="spin" />
{t("skills.loadingFile", "Loading file...")}
</div>
)}
</>
) : fileError ? (
<div className="skills-view-detail-error">
<AlertCircle size={14} />
<span>{fileError}</span>
<button
className="btn btn-sm"
onClick={() => selectedSkillId && void loadSkillFile(selectedSkillId, viewedFilePath)}
>
{t("common.retry", "Retry")}
</button>
</div>
) : viewedFile && !viewedFile.isText ? (
<div className="skills-view-detail-empty">
{t("skills.fileNotPreviewable", "This file cannot be previewed.")}
</div>
) : viewedFile ? (
isMarkdownFile(viewedFile.relativePath) ? (
<MailboxMessageContent
content={viewedFile.content || t("skills.emptyFile", "(Empty file)")}
className="skills-view-detail-markdown"
testId="skills-view-detail-markdown"
/>
) : (
<pre className="skills-view-detail-content">
{viewedFile.content || t("skills.emptyFile", "(Empty file)")}
</pre>
)
) : null}
{renderFilesStrip()}
</div>
);
}
return null;
// FNXC:Skills 2026-06-23-04:15: SKILL.md view — rendered as markdown via the shared MailboxMessageContent.
return (
<>
<MailboxMessageContent
content={skillContent.skillMd || t("skills.noSkillMd", "(No SKILL.md found)")}
className="skills-view-detail-markdown"
testId="skills-view-detail-markdown"
/>
{renderFilesStrip()}
</>
);
};
return (

View File

@@ -11,6 +11,7 @@ vi.mock("../../api", () => ({
installSkill: vi.fn(),
fetchSkillsCatalog: vi.fn(),
fetchSkillContent: vi.fn(),
fetchSkillFileContent: vi.fn(),
}));
const mockFetchDiscoveredSkills = vi.mocked(apiModule.fetchDiscoveredSkills);
@@ -18,6 +19,7 @@ const mockToggleExecutionSkill = vi.mocked(apiModule.toggleExecutionSkill);
const mockInstallSkill = vi.mocked(apiModule.installSkill);
const mockFetchSkillsCatalog = vi.mocked(apiModule.fetchSkillsCatalog);
const mockFetchSkillContent = vi.mocked(apiModule.fetchSkillContent);
const mockFetchSkillFileContent = vi.mocked(apiModule.fetchSkillFileContent);
describe("SkillsView", () => {
const mockAddToast = vi.fn();
@@ -856,7 +858,12 @@ describe("SkillsView", () => {
expect(mockFetchSkillContent).toHaveBeenCalledWith("npm::skills/test-skill", undefined);
});
it("displays skill content when loaded", async () => {
it("displays skill content rendered as markdown when loaded", async () => {
// FNXC:Skills 2026-06-23-04:15: SKILL.md now renders via MailboxMessageContent
// (GitHub-flavored markdown) instead of a raw <pre>. Assert the markdown
// wrapper (.mailbox-markdown / data-testid="skills-view-detail-markdown")
// renders the heading text and the body, and that the (compact) files strip
// shows the supplementary files.
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
@@ -870,10 +877,15 @@ describe("SkillsView", () => {
});
await waitFor(() => {
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.");
const markdown = screen.getByTestId("skills-view-detail-markdown");
expect(markdown).toBeTruthy();
expect(markdown.classList.contains("mailbox-markdown")).toBe(true);
// No raw <pre> for the SKILL.md body anymore.
expect(document.querySelector(".skills-view-detail-content")).toBeNull();
expect(markdown.textContent).toContain("Test Skill");
expect(markdown.textContent).toContain("This is the skill content.");
// Heading renders as a real <h1> (markdown), not a literal "# Test Skill".
expect(markdown.querySelector("h1")).toBeTruthy();
const fileBadges = document.querySelectorAll(".skills-view-detail-files .badge");
expect(fileBadges.length).toBe(2);
});
@@ -897,7 +909,7 @@ describe("SkillsView", () => {
});
await waitFor(() => {
expect(document.querySelector(".skills-view-detail-content")).toBeTruthy();
expect(screen.getByTestId("skills-view-detail-markdown")).toBeTruthy();
});
// Click again to collapse
@@ -907,7 +919,7 @@ describe("SkillsView", () => {
await waitFor(() => {
expect(screen.queryByTestId("skill-detail")).toBeTruthy();
expect(document.querySelector(".skills-view-detail-content")).toBeNull();
expect(screen.queryByTestId("skills-view-detail-markdown")).toBeNull();
expect(screen.getByTestId("skills-detail-empty")).toBeTruthy();
expect(document.querySelector(".skills-view-item--selected")).toBeNull();
});
@@ -960,9 +972,9 @@ describe("SkillsView", () => {
});
await waitFor(() => {
const preElement = document.querySelector(".skills-view-detail-content");
expect(preElement).toBeTruthy();
expect(preElement!.textContent).toContain("# Test Skill");
const markdown = screen.getByTestId("skills-view-detail-markdown");
expect(markdown).toBeTruthy();
expect(markdown.textContent).toContain("Test Skill");
});
});
@@ -1012,9 +1024,9 @@ describe("SkillsView", () => {
});
await waitFor(() => {
const preElement = document.querySelector(".skills-view-detail-content");
expect(preElement).toBeTruthy();
expect(preElement!.textContent).toContain("# Test Skill");
const markdown = screen.getByTestId("skills-view-detail-markdown");
expect(markdown).toBeTruthy();
expect(markdown.textContent).toContain("Test Skill");
});
expect(mockFetchSkillContent).toHaveBeenCalledTimes(2);
@@ -1034,7 +1046,7 @@ describe("SkillsView", () => {
});
await waitFor(() => {
expect(document.querySelector(".skills-view-detail-content")).toBeTruthy();
expect(screen.getByTestId("skills-view-detail-markdown")).toBeTruthy();
});
// Click close button (detail-pane close, not the view close)
@@ -1046,7 +1058,7 @@ describe("SkillsView", () => {
// Close clears the selection so it returns to the empty-state placeholder.
await waitFor(() => {
expect(screen.queryByTestId("skill-detail")).toBeTruthy();
expect(document.querySelector(".skills-view-detail-content")).toBeNull();
expect(screen.queryByTestId("skills-view-detail-markdown")).toBeNull();
expect(screen.getByTestId("skills-detail-empty")).toBeTruthy();
});
});
@@ -1068,7 +1080,7 @@ describe("SkillsView", () => {
});
await waitFor(() => {
expect(document.querySelector(".skills-view-detail-content")).toBeTruthy();
expect(screen.getByTestId("skills-view-detail-markdown")).toBeTruthy();
expect(screen.getByTestId("skills-view").getAttribute("data-selected")).toBe("true");
});
@@ -1081,7 +1093,7 @@ describe("SkillsView", () => {
await waitFor(() => {
expect(screen.getByTestId("skills-view").getAttribute("data-selected")).toBe("false");
expect(document.querySelector(".skills-view-detail-content")).toBeNull();
expect(screen.queryByTestId("skills-view-detail-markdown")).toBeNull();
expect(screen.getByTestId("skills-detail-empty")).toBeTruthy();
expect(document.querySelector(".skills-view-item--selected")).toBeNull();
});
@@ -1148,4 +1160,169 @@ describe("SkillsView", () => {
});
});
});
describe("skill file viewer", () => {
// FNXC:Skills 2026-06-23-04:15: click-to-view-file + back flow. The files
// strip lists ALL referenced files; file-type entries are clickable
// (data-testid="skill-file-item") and load their content into the detail
// pane (data-testid="skill-file-viewer"); a back affordance
// (data-testid="skill-file-back") returns to the SKILL.md markdown view.
const mockSkillContentWithFile: SkillContent = {
name: "test-skill",
skillMd: "# Test Skill\n\nSKILL body.",
files: [
{ name: "reference.md", relativePath: "reference.md", type: "file" },
{ name: "script.sh", relativePath: "script.sh", type: "file" },
{ name: "references", relativePath: "references", type: "directory" },
],
};
beforeEach(() => {
mockFetchSkillContent.mockResolvedValue(mockSkillContentWithFile);
});
async function openTestSkill() {
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.getByTestId("skills-view-detail-markdown")).toBeTruthy();
});
}
it("renders ALL referenced files in the strip (files clickable, directories static)", async () => {
await openTestSkill();
const fileItems = screen.getAllByTestId("skill-file-item");
// The two file entries are clickable buttons; the directory is not.
expect(fileItems.length).toBe(2);
expect(fileItems.map((el) => el.textContent)).toEqual(["reference.md", "script.sh"]);
// Directory still rendered (all files shown), just not as a skill-file-item.
expect(screen.getByText("references/")).toBeTruthy();
});
it("loads and renders a markdown file via MailboxMessageContent on click, then back returns to SKILL.md", async () => {
mockFetchSkillFileContent.mockResolvedValue({
name: "reference.md",
relativePath: "reference.md",
content: "## Reference\n\nFile body here.",
isText: true,
});
await openTestSkill();
const fileItems = screen.getAllByTestId("skill-file-item");
await act(async () => {
fireEvent.click(fileItems[0]!);
});
expect(mockFetchSkillFileContent).toHaveBeenCalledWith(
"npm::skills/test-skill",
"reference.md",
undefined,
);
await waitFor(() => {
const viewer = screen.getByTestId("skill-file-viewer");
expect(viewer).toBeTruthy();
// Markdown file -> rendered via MailboxMessageContent (mailbox-markdown wrapper).
const markdown = viewer.querySelector(".mailbox-markdown");
expect(markdown).toBeTruthy();
expect(markdown!.textContent).toContain("Reference");
expect(markdown!.textContent).toContain("File body here.");
expect(markdown!.querySelector("h2")).toBeTruthy();
});
// Back to SKILL.md
const back = screen.getByTestId("skill-file-back");
await act(async () => {
fireEvent.click(back);
});
await waitFor(() => {
expect(screen.queryByTestId("skill-file-viewer")).toBeNull();
expect(screen.getByTestId("skills-view-detail-markdown")).toBeTruthy();
expect(screen.getByTestId("skills-view-detail-markdown").textContent).toContain("SKILL body.");
});
});
it("renders a non-markdown text file in a <pre>", async () => {
mockFetchSkillFileContent.mockResolvedValue({
name: "script.sh",
relativePath: "script.sh",
content: "#!/bin/sh\necho hi",
isText: true,
});
await openTestSkill();
const fileItems = screen.getAllByTestId("skill-file-item");
await act(async () => {
fireEvent.click(fileItems[1]!);
});
await waitFor(() => {
const viewer = screen.getByTestId("skill-file-viewer");
const pre = viewer.querySelector("pre.skills-view-detail-content");
expect(pre).toBeTruthy();
expect(pre!.textContent).toContain("echo hi");
});
});
it("shows a non-previewable notice for binary files", async () => {
mockFetchSkillFileContent.mockResolvedValue({
name: "script.sh",
relativePath: "script.sh",
content: "",
isText: false,
});
await openTestSkill();
const fileItems = screen.getAllByTestId("skill-file-item");
await act(async () => {
fireEvent.click(fileItems[1]!);
});
await waitFor(() => {
expect(screen.getByText("This file cannot be previewed.")).toBeTruthy();
});
});
it("shows an error + retry when file fetch fails", async () => {
mockFetchSkillFileContent
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValueOnce({
name: "reference.md",
relativePath: "reference.md",
content: "ok",
isText: true,
});
await openTestSkill();
const fileItems = screen.getAllByTestId("skill-file-item");
await act(async () => {
fireEvent.click(fileItems[0]!);
});
await waitFor(() => {
expect(screen.getByText("boom")).toBeTruthy();
expect(screen.getByText("Retry")).toBeTruthy();
});
await act(async () => {
fireEvent.click(screen.getByText("Retry"));
});
await waitFor(() => {
expect(screen.getByTestId("skill-file-viewer").textContent).toContain("ok");
});
});
});
});

View File

@@ -17,6 +17,9 @@ vi.mock("../../api", () => ({
fetchSkillsCatalog: (...args: unknown[]) => mockFetchSkillsCatalog(...args),
toggleExecutionSkill: (...args: unknown[]) => mockToggleExecutionSkill(...args),
fetchSkillContent: (...args: unknown[]) => mockFetchSkillContent(...args),
// FNXC:Skills 2026-06-23-04:15: SkillsView now imports fetchSkillFileContent for the file viewer; stub it so the mock module is complete.
fetchSkillFileContent: vi.fn().mockResolvedValue({ name: "", relativePath: "", content: "", isText: true }),
installSkill: vi.fn().mockResolvedValue({ success: true }),
}));
function extractRuleBlock(css: string, selector: string): string {

View File

@@ -147,6 +147,13 @@ function createMockSkillsAdapter(overrides?: Partial<SkillsAdapter>): SkillsAdap
],
};
}),
// FNXC:Skills 2026-06-23-04:15: per-file content read backing the detail-pane file viewer.
readSkillFileContent: vi.fn().mockResolvedValue({
name: "notes.txt",
relativePath: "notes.txt",
content: "note body",
isText: true,
}),
...overrides,
};
}

View File

@@ -10,7 +10,7 @@ export {
type RuntimeLogLevel,
type RuntimeLogSink,
} from "./runtime-logger.js";
export { createSkillsAdapter, getProjectSettingsPath, type SkillsAdapter, type DiscoveredSkill, type CatalogEntry, type CatalogFetchResult, type ToggleSkillResult, type UpstreamError, type UpstreamErrorCode, type SkillContent, type SkillFileEntry } from "./skills-adapter.js";
export { createSkillsAdapter, getProjectSettingsPath, type SkillsAdapter, type DiscoveredSkill, type CatalogEntry, type CatalogFetchResult, type ToggleSkillResult, type UpstreamError, type UpstreamErrorCode, type SkillContent, type SkillFileEntry, type SkillFileContent } from "./skills-adapter.js";
export { GitHubClient, isPrMergeReady, closeGroupPullRequest, reconcileGroupPullRequest, type GitHubClientOptions, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type UpdatePrParams, type ClosePrParams, type FindPrParams, type CreateIssueParams, type CreatedIssue, type CreateGroupPrResult } from "./github.js";
export { generatePrMetadata, type GeneratedPrMetadata } from "./pr-metadata-generator.js";
export {

View File

@@ -34,6 +34,7 @@ function createSkillsAdapter(overrides?: Partial<SkillsAdapter>): SkillsAdapter
auth: { mode: "unauthenticated", tokenPresent: false, fallbackUsed: false },
}),
readSkillContent: vi.fn(),
readSkillFileContent: vi.fn(),
...overrides,
} as SkillsAdapter;
}
@@ -141,4 +142,87 @@ describe("register-agent-skills-routes", () => {
expect(res.status).toBe(502);
expect(res.body).toEqual({ error: "installer failed", code: "install_failed" });
});
// FNXC:Skills 2026-06-23-04:15: per-file content endpoint backing the detail-pane file viewer.
it("GET /api/skills/:id/file returns a file's content", async () => {
const skillsAdapter = createSkillsAdapter({
readSkillFileContent: vi.fn().mockResolvedValue({
name: "reference.md",
relativePath: "reference.md",
content: "# Ref",
isText: true,
}),
});
const res = await request(
app(skillsAdapter, "/tmp/file-root"),
"GET",
"/api/skills/npm%3A%3Askills%2Ftest-skill/file?path=reference.md",
);
expect(res.status).toBe(200);
expect(res.body).toEqual({
file: { name: "reference.md", relativePath: "reference.md", content: "# Ref", isText: true },
});
expect(skillsAdapter.readSkillFileContent).toHaveBeenCalledWith(
"/tmp/file-root",
"npm::skills/test-skill",
"reference.md",
);
});
it("GET /api/skills/:id/file returns 400 when path is missing", async () => {
const skillsAdapter = createSkillsAdapter();
const res = await request(
app(skillsAdapter),
"GET",
"/api/skills/npm%3A%3Askills%2Ftest-skill/file",
);
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: "path is required", code: "invalid_path" });
expect(skillsAdapter.readSkillFileContent).not.toHaveBeenCalled();
});
it("GET /api/skills/:id/file returns 404 when the file is missing", async () => {
const skillsAdapter = createSkillsAdapter({
readSkillFileContent: vi.fn().mockRejectedValue(new Error("Skill file not found: nope.md")),
});
const res = await request(
app(skillsAdapter),
"GET",
"/api/skills/npm%3A%3Askills%2Ftest-skill/file?path=nope.md",
);
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: "Skill file not found", code: "skill_file_not_found" });
});
it("GET /api/skills/:id/file returns 400 for a traversal path", async () => {
const skillsAdapter = createSkillsAdapter({
readSkillFileContent: vi.fn().mockRejectedValue(new Error("Invalid skill file path: ../secret")),
});
const res = await request(
app(skillsAdapter),
"GET",
"/api/skills/npm%3A%3Askills%2Ftest-skill/file?path=..%2Fsecret",
);
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: "Invalid skill file path: ../secret", code: "invalid_path" });
});
it("GET /api/skills/:id/file returns 404 without a skills adapter", async () => {
const res = await request(
app(undefined),
"GET",
"/api/skills/npm%3A%3Askills%2Ftest-skill/file?path=reference.md",
);
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: "Skills adapter not configured", code: "adapter_not_configured" });
});
});

View File

@@ -79,6 +79,63 @@ export function registerAgentSkillsRoutes(ctx: ApiRoutesContext): void {
}
});
/*
FNXC:Skills 2026-06-23-04:15:
GET /api/skills/:id/file — return a single supplementary file's text for the SkillsView detail-pane file viewer. The /content endpoint lists files (name/path/type) but not their bodies; clicking a file in the detail pane needs its content. The skill-dir-relative path arrives URL-encoded in the `path` query param; the adapter resolves + traversal-guards it against the skill directory.
Params: id (URL-encoded skill ID)
Query: path (skill-dir-relative file path), projectId (optional)
Response: { file: SkillFileContent }
Error: 404 { code: "skill_not_found" | "skill_file_not_found" }, 400 { code: "invalid_skill_id" | "invalid_path" }
*/
router.get("/skills/:id/file", 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 rawPath = typeof req.query.path === "string" ? req.query.path : "";
if (!rawPath.trim()) {
res.status(400).json({ error: "path is required", code: "invalid_path" });
return;
}
const rootDir = scopedStore.getRootDir();
const file = await skillsAdapter.readSkillFileContent(rootDir, skillId, rawPath);
res.json({ file });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof Error && err.message.includes("Skill file not found")) {
res.status(404).json({ error: "Skill file not found", code: "skill_file_not_found" });
return;
}
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") || err.message.includes("Invalid skill file path"))) {
res.status(400).json({ error: err.message, code: "invalid_path" });
return;
}
rethrowAsApiError(err, "Failed to read skill file");
}
});
/**
* PATCH /api/skills/execution
* Toggle a skill's enabled/disabled state.

View File

@@ -6,7 +6,7 @@
*/
import { access, readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises";
import { join, relative, dirname } from "node:path";
import { join, relative, dirname, resolve, sep } from "node:path";
import { superviseSpawn } from "@fusion/core";
import type { ChildProcess } from "node:child_process";
@@ -178,6 +178,23 @@ export interface SkillsAdapter {
* Read the contents of a skill's SKILL.md file and list supplementary files.
*/
readSkillContent(rootDir: string, skillId: string): Promise<SkillContent>;
/*
FNXC:Skills 2026-06-23-04:15:
Read a single supplementary file's text for the detail-pane file viewer. The SkillsView detail pane lists referenced files; clicking one must show its content. The `files` array carried only name/path/type, so a per-file content endpoint is required. `relativePath` is the skill-dir-relative path returned by readSkillContent; it is resolved + path-traversal-guarded against the skill directory so a request can never escape the skill root.
*/
readSkillFileContent(rootDir: string, skillId: string, relativePath: string): Promise<SkillFileContent>;
}
/*
FNXC:Skills 2026-06-23-04:15:
Payload for the per-file viewer. `isText` is false for binary/oversized files so the UI renders a "cannot preview" notice instead of garbled bytes; `content` is empty in that case.
*/
export interface SkillFileContent {
name: string;
relativePath: string;
content: string;
isText: boolean;
}
/**
@@ -767,6 +784,74 @@ export function createSkillsAdapter(options: {
files,
};
},
/*
FNXC:Skills 2026-06-23-04:15:
Per-file content read for the detail-pane viewer. Resolves the skill directory the same way readSkillContent does, then joins the requested relativePath. Guards against path traversal (resolved target must stay inside the skill dir) and refuses to read SKILL.md through this path (the SKILL.md view has its own endpoint). Binary/oversized files return isText:false with empty content so the UI shows a non-previewable notice rather than garbled output.
*/
async readSkillFileContent(rootDir: string, skillId: string, relativePath: string): Promise<SkillFileContent> {
const parsed = parseSkillId(skillId);
if (!parsed) {
throw new Error(`Invalid skill ID format: ${skillId}`);
}
const discovered = await this.discoverSkills(rootDir);
const skill = discovered.find((entry) => entry.id === skillId);
if (!skill) {
throw new Error(`Skill not found: ${skillId}`);
}
if (skill.metadata.source.startsWith("plugin:")) {
throw new Error(`Skill file not found: ${relativePath}`);
}
let skillDir = skill.path;
try {
const skillPathStat = await stat(skill.path);
skillDir = skillPathStat.isFile() ? dirname(skill.path) : skill.path;
} catch {
skillDir = dirname(skill.path);
}
const normalizedRelative = relativePath.replaceAll("\\", "/");
const resolvedSkillDir = resolve(skillDir);
const targetPath = resolve(resolvedSkillDir, normalizedRelative);
// Path-traversal guard: the resolved target must stay inside the skill dir.
if (targetPath !== resolvedSkillDir && !targetPath.startsWith(resolvedSkillDir + sep)) {
throw new Error(`Invalid skill file path: ${relativePath}`);
}
let fileStat;
try {
fileStat = await stat(targetPath);
} catch {
throw new Error(`Skill file not found: ${relativePath}`);
}
if (fileStat.isDirectory()) {
throw new Error(`Skill file not found: ${relativePath}`);
}
const name = normalizedRelative.split("/").filter(Boolean).pop() ?? normalizedRelative;
// 2 MB ceiling keeps the viewer responsive and avoids streaming huge blobs.
const MAX_PREVIEW_BYTES = 2 * 1024 * 1024;
if (fileStat.size > MAX_PREVIEW_BYTES) {
return { name, relativePath: normalizedRelative, content: "", isText: false };
}
const buffer = await readFile(targetPath);
// Heuristic: a NUL byte in the first chunk means binary -> non-previewable.
const sample = buffer.subarray(0, Math.min(buffer.length, 8000));
const isBinary = sample.includes(0);
if (isBinary) {
return { name, relativePath: normalizedRelative, content: "", isText: false };
}
return {
name,
relativePath: normalizedRelative,
content: buffer.toString("utf-8"),
isText: true,
};
},
};
}