diff --git a/.changeset/skills-detail-file-viewer.md b/.changeset/skills-detail-file-viewer.md new file mode 100644 index 0000000000..0b1f89cb74 --- /dev/null +++ b/.changeset/skills-detail-file-viewer.md @@ -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. diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 9e50900b22..e5ea421df8 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -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 { + 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 diff --git a/packages/dashboard/app/components/SkillsView.css b/packages/dashboard/app/components/SkillsView.css index 20bb37e708..6698252311 100644 --- a/packages/dashboard/app/components/SkillsView.css +++ b/packages/dashboard/app/components/SkillsView.css @@ -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
) 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;
diff --git a/packages/dashboard/app/components/SkillsView.tsx b/packages/dashboard/app/components/SkillsView.tsx
index 7b5277d4f9..c818f990af 100644
--- a/packages/dashboard/app/components/SkillsView.tsx
+++ b/packages/dashboard/app/components/SkillsView.tsx
@@ -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 
 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(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(null);
+  const [viewedFile, setViewedFile] = useState(null);
+  const [isLoadingFile, setIsLoadingFile] = useState(false);
+  const [fileError, setFileError] = useState(null);
+
   // Debounce timer for catalog search
   const debounceRef = useRef | 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 
; 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 (
+      
+ {t("skills.filesLabel", "Files")}: + {skillContent.files.map((file) => { + if (file.type === "directory") { + return ( + + {file.name}/ + + ); + } + const isActive = viewedFilePath === file.relativePath; + return ( + + ); + })} +
+ ); + }; + + /* + 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
. 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) {
         
       );
     }
-    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 (
-        <>
-          
-            {skillContent.skillMd || t("skills.noSkillMd", "(No SKILL.md found)")}
-          
- {skillContent.files.length > 0 && ( -
- {t("skills.filesLabel", "Files")}: - {skillContent.files.map((file) => ( - - {file.name} - {file.type === "directory" && "/"} - - ))} +
+
+ + {viewedFilePath} +
+ {isLoadingFile ? ( +
+ + {t("skills.loadingFile", "Loading file...")}
- )} - + ) : fileError ? ( +
+ + {fileError} + +
+ ) : viewedFile && !viewedFile.isText ? ( +
+ {t("skills.fileNotPreviewable", "This file cannot be previewed.")} +
+ ) : viewedFile ? ( + isMarkdownFile(viewedFile.relativePath) ? ( + + ) : ( +
+                {viewedFile.content || t("skills.emptyFile", "(Empty file)")}
+              
+ ) + ) : null} + {renderFilesStrip()} +
); } - return null; + + // FNXC:Skills 2026-06-23-04:15: SKILL.md view — rendered as markdown via the shared MailboxMessageContent. + return ( + <> + + {renderFilesStrip()} + + ); }; return ( diff --git a/packages/dashboard/app/components/__tests__/SkillsView.test.tsx b/packages/dashboard/app/components/__tests__/SkillsView.test.tsx index 9ae7e26c6c..ef74086244 100644 --- a/packages/dashboard/app/components/__tests__/SkillsView.test.tsx +++ b/packages/dashboard/app/components/__tests__/SkillsView.test.tsx @@ -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
. 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();
 
       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 
 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 

(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(); + 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
", 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");
+      });
+    });
+  });
 });
diff --git a/packages/dashboard/app/components/__tests__/skills-view-mobile.test.tsx b/packages/dashboard/app/components/__tests__/skills-view-mobile.test.tsx
index c6543dd24e..078b7faee5 100644
--- a/packages/dashboard/app/components/__tests__/skills-view-mobile.test.tsx
+++ b/packages/dashboard/app/components/__tests__/skills-view-mobile.test.tsx
@@ -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 {
diff --git a/packages/dashboard/src/__tests__/routes-skills.test.ts b/packages/dashboard/src/__tests__/routes-skills.test.ts
index 2b97758aca..3c9e7533ab 100644
--- a/packages/dashboard/src/__tests__/routes-skills.test.ts
+++ b/packages/dashboard/src/__tests__/routes-skills.test.ts
@@ -147,6 +147,13 @@ function createMockSkillsAdapter(overrides?: Partial): 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,
   };
 }
diff --git a/packages/dashboard/src/index.ts b/packages/dashboard/src/index.ts
index f8e2350bd7..ac7ce34e8d 100644
--- a/packages/dashboard/src/index.ts
+++ b/packages/dashboard/src/index.ts
@@ -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 {
diff --git a/packages/dashboard/src/routes/__tests__/register-agent-skills-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-agent-skills-routes.test.ts
index 5f882398f7..cc92128c07 100644
--- a/packages/dashboard/src/routes/__tests__/register-agent-skills-routes.test.ts
+++ b/packages/dashboard/src/routes/__tests__/register-agent-skills-routes.test.ts
@@ -34,6 +34,7 @@ function createSkillsAdapter(overrides?: Partial): 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" });
+  });
 });
diff --git a/packages/dashboard/src/routes/register-agent-skills-routes.ts b/packages/dashboard/src/routes/register-agent-skills-routes.ts
index 58d1423504..e135f5806b 100644
--- a/packages/dashboard/src/routes/register-agent-skills-routes.ts
+++ b/packages/dashboard/src/routes/register-agent-skills-routes.ts
@@ -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.
diff --git a/packages/dashboard/src/skills-adapter.ts b/packages/dashboard/src/skills-adapter.ts
index 283b1b30dd..34f833075d 100644
--- a/packages/dashboard/src/skills-adapter.ts
+++ b/packages/dashboard/src/skills-adapter.ts
@@ -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;
+
+  /*
+  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;
+}
+
+/*
+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 {
+      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,
+      };
+    },
   };
 }