feat(dashboard): Skills view is a responsive master/detail two-pane

Wide (>=640px container): skills list left + skill detail right (both visible). Narrow/mobile: single-panel master->detail — selecting a skill shows the detail on top with a Back affordance. CSS container query, mirroring DockFilesView.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-22 10:37:28 -07:00
parent 7b51205d48
commit f5b8d42e44
3 changed files with 305 additions and 71 deletions

View File

@@ -11,6 +11,120 @@ Skills mounts as a flex child of the flex-row .project-content. A flex item with
min-width: 0;
width: 100%;
overflow: hidden;
/*
FNXC:Skills 2026-06-23-01:45:
Establish the query container so the master/detail body can switch between the single-panel stack (narrow) and the two-pane split (wide) based on the view's OWN width — not the global viewport. Modeled on DockFilesView (container-name: dock-files). SkillsView always fills the full main panel, so the inline-size query fires reliably (unlike the right-dock pop-out, which needed DockFilesView's deterministic fallback).
*/
container-type: inline-size;
container-name: skills-view;
}
/*
FNXC:Skills 2026-06-23-01:45:
Master/detail body below the shared ViewHeader. Holds BOTH always-rendered panes.
- NARROW default: single column. The list (.skills-view__list) fills the body; the detail (.skills-view__detail) is hidden until a skill is selected, then it covers the stack (BACK returns to the list).
- WIDE (@container >=640px below): flex-row two-pane — list pinned left (clamped, scrolls), detail flex:1 right (scrolls).
*/
.skills-view-body {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow: hidden;
}
/* FNXC:Skills 2026-06-23-01:45: NARROW default — master list fills the body as the single panel. */
.skills-view__list {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow: hidden;
}
/*
FNXC:Skills 2026-06-23-01:45: NARROW default — detail is the stacked second panel.
Hidden until a skill is selected; when selected ([data-selected="true"]) it overlays the list as the single visible panel.
*/
.skills-view__detail {
display: none;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.skills-view[data-selected="true"] .skills-view__list {
display: none;
}
.skills-view[data-selected="true"] .skills-view__detail {
display: flex;
}
/* FNXC:Skills 2026-06-23-01:45: detail content scrolls inside the pane body so the SKILL.md pre + file badges never overflow the pane. */
.skills-view-detail-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-md);
}
/* FNXC:Skills 2026-06-23-01:45: empty-state placeholder shown in the wide right pane until a skill is selected. */
.skills-view-detail-placeholder {
display: flex;
align-items: center;
justify-content: center;
flex: 1 1 auto;
text-align: center;
color: var(--text-muted);
}
.skills-view-detail-back {
flex-shrink: 0;
}
/*
FNXC:Skills 2026-06-23-01:45:
WIDE container (>=640px): two-pane side-by-side master/detail. Both panes always visible (data-selected no longer toggles visibility here), so the BACK button is hidden — the list never disappears. Mirrors DockFilesView's @container rule.
*/
@container skills-view (min-width: 640px) {
.skills-view-body {
flex-direction: row;
}
/* List pinned LEFT: clamped, scrolls independently, divider against the detail pane. */
.skills-view__list {
display: flex;
flex: 0 0 clamp(280px, 38%, 460px);
min-width: 0;
overflow: hidden;
border-right: 1px solid var(--border);
}
/* Detail fills the remaining width; always visible (empty-state until a skill is selected). */
.skills-view__detail,
.skills-view[data-selected="true"] .skills-view__detail {
display: flex;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
}
.skills-view[data-selected="true"] .skills-view__list {
display: flex;
}
/* BACK is meaningless when the list is always visible. */
.skills-view__detail .skills-view-detail-back {
display: none;
}
}
.skills-view-count {
@@ -277,17 +391,22 @@ The shared ViewHeader already supplies the top + side --space-lg padding, so the
padding: var(--space-lg);
}
/*
FNXC:Skills 2026-06-23-01:45:
Detail-pane header bar: BACK (narrow only) on the left, truncating skill name in the middle, Close on the right. Mirrors DockFilesView's viewer header. Now a flex:0 bar inside the detail pane (the pane itself carries no padding; the header + body each supply their own).
*/
.skills-view-detail-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-md);
padding-bottom: var(--space-md);
gap: var(--space-sm);
flex: 0 0 auto;
padding: var(--space-sm) var(--space-lg);
border-bottom: 1px solid var(--border);
}
.skills-view-detail-title {
flex: 1 1 auto;
min-width: 0;
font-weight: 600;
color: var(--text);
overflow: hidden;

View File

@@ -1,7 +1,7 @@
import "./SkillsView.css";
import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { Zap, RefreshCw, X, ChevronRight, ChevronDown, AlertCircle, Loader2 } from "lucide-react";
import { Zap, RefreshCw, X, ChevronRight, ChevronDown, AlertCircle, Loader2, ArrowLeft } from "lucide-react";
import { ViewHeader } from "./ViewHeader";
import {
fetchDiscoveredSkills,
@@ -231,9 +231,88 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
void loadSkillContent(skillId);
}, [loadSkillContent, selectedSkillId]);
/*
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).
*/
const clearSelection = useCallback(() => {
setSelectedSkillId(null);
setSkillContent(null);
setContentError(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.
const selectedSkill = useMemo(
() => discoveredSkills.find((s) => s.id === selectedSkillId) ?? null,
[discoveredSkills, selectedSkillId],
);
/*
FNXC:Skills 2026-06-23-01:45:
Responsive master/detail, modeled exactly on DockFilesView (RightDockFiles). The root `.skills-view` is a query container (container-type: inline-size, container-name: skills-view). BOTH panes — `.skills-view__list` (left) and `.skills-view__detail` (right) — are ALWAYS rendered in the DOM; CSS decides what is visible per container width.
- WIDE (@container min-width: 640px): two-pane side-by-side. List pinned LEFT (clamped width, scrolls), detail flex:1 on the RIGHT (scrolls), empty-state until a skill is selected. Both always visible, so the BACK button is hidden (the list never disappears). Selecting a skill updates the right pane in place.
- 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.
*/
const renderDetailBody = () => {
if (!selectedSkillId) {
return (
<div className="skills-view-detail-placeholder" data-testid="skills-detail-empty">
{t("skills.selectASkill", "Select a skill to view its details")}
</div>
);
}
if (isLoadingContent) {
return (
<div className="skills-view-detail-loading">
<Loader2 size={16} className="spin" />
{t("skills.loadingContent", "Loading skill content...")}
</div>
);
}
if (contentError) {
return (
<div className="skills-view-detail-error">
<AlertCircle size={14} />
<span>{contentError}</span>
<button
className="btn btn-sm"
onClick={() => handleRetrySkillContent(selectedSkillId)}
>
{t("common.retry", "Retry")}
</button>
</div>
);
}
if (skillContent) {
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>
)}
</>
);
}
return null;
};
return (
<div className="skills-view" data-testid="skills-view">
<div
className="skills-view"
data-testid="skills-view"
data-selected={selectedSkillId ? "true" : "false"}
>
{/*
FNXC:Navigation 2026-06-22-01:10:
Skills adopts the shared ViewHeader (Command Center-modeled) for a consistent main-content title row. Icon matches the left-sidebar nav (Zap). The discovered-count badge plus Close and Refresh controls move into the header actions cluster so they keep working.
@@ -264,6 +343,13 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
}
/>
{/*
FNXC:Skills 2026-06-23-01:45:
Master/detail body. Holds the two always-rendered panes. CSS (container query on the `.skills-view` root) lays them out side-by-side when wide and stacks them (list, then detail-on-top) when narrow.
*/}
<div className="skills-view-body">
{/* FNXC:Skills 2026-06-23-01:45: LEFT pane = master list (search + discovered + catalog). Always in the DOM; CSS hides it only in the narrow stack once a skill is selected. */}
<div className="skills-view__list" data-testid="skills-list">
{/* Scrollable content area */}
<div className="skills-view-content">
{/* Search — at top for both sections */}
@@ -339,62 +425,6 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
<span className="skills-view-toggle-slider" />
</label>
</div>
{/* Skill Content Detail Panel */}
{isSelected && (
<div className="skills-view-detail" data-testid="skill-detail">
<div className="skills-view-detail-header">
<span className="skills-view-detail-title">{skill.name}</span>
<button
className="btn btn-sm skills-view-detail-close"
onClick={() => {
setSelectedSkillId(null);
setSkillContent(null);
setContentError(null);
}}
aria-label={t("skills.closeDetail", "Close skill detail")}
>
<X size={14} />
{t("common.close", "Close")}
</button>
</div>
{isLoadingContent ? (
<div className="skills-view-detail-loading">
<Loader2 size={16} className="spin" />
{t("skills.loadingContent", "Loading skill content...")}
</div>
) : contentError ? (
<div className="skills-view-detail-error">
<AlertCircle size={14} />
<span>{contentError}</span>
<button
className="btn btn-sm"
onClick={() => handleRetrySkillContent(skill.id)}
>
{t("common.retry", "Retry")}
</button>
</div>
) : skillContent ? (
<>
<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>
)}
</>
) : null}
</div>
)}
</div>
);
})}
@@ -480,6 +510,44 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
)}
</section>
</div>
</div>
{/*
FNXC:Skills 2026-06-23-01:45:
RIGHT pane = detail. Always in the DOM; CSS shows it side-by-side when wide (empty-state until a skill is selected), or as the single-panel stack overlay when narrow + a skill is selected. Preserves the original detail content: SKILL.md body, file badges, load/error/retry states.
*/}
<div className="skills-view__detail" data-testid="skill-detail">
<div className="skills-view-detail-header">
{/* FNXC:Skills 2026-06-23-01:45: BACK only matters in the narrow stack (returns to the list); CSS hides it when wide since the list is always visible. Mirrors DockFilesView's back affordance. */}
<button
type="button"
className="btn btn-sm btn-icon skills-view-detail-back"
onClick={clearSelection}
aria-label={t("skills.backToList", "Back to skills")}
title={t("skills.backToList", "Back to skills")}
data-testid="skills-detail-back"
>
<ArrowLeft size={14} />
</button>
<span className="skills-view-detail-title">
{selectedSkill?.name ?? t("skills.detailTitle", "Skill")}
</span>
{/* FNXC:Skills 2026-06-23-01:45: Close clears the selection. In the wide two-pane layout it returns the detail to its empty-state; in the narrow stack it returns to the list (same effect as BACK). */}
<button
className="btn btn-sm skills-view-detail-close"
onClick={clearSelection}
disabled={!selectedSkillId}
aria-label={t("skills.closeDetail", "Close skill detail")}
>
<X size={14} />
{t("common.close", "Close")}
</button>
</div>
<div className="skills-view-detail-body">
{renderDetailBody()}
</div>
</div>
</div>
</div>
);
}

View File

@@ -879,7 +879,11 @@ describe("SkillsView", () => {
});
});
it("collapses detail when clicking the same skill again", async () => {
it("collapses detail back to the empty-state when clicking the same skill again", async () => {
// FNXC:Skills 2026-06-23-01:45: in the two-pane master/detail layout the
// detail PANE (data-testid="skill-detail") is always mounted; clicking the
// selected skill again clears the selection so the pane returns to its
// empty-state placeholder (content gone), rather than unmounting.
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
@@ -893,7 +897,7 @@ describe("SkillsView", () => {
});
await waitFor(() => {
expect(screen.getByTestId("skill-detail")).toBeTruthy();
expect(document.querySelector(".skills-view-detail-content")).toBeTruthy();
});
// Click again to collapse
@@ -902,7 +906,10 @@ describe("SkillsView", () => {
});
await waitFor(() => {
expect(screen.queryByTestId("skill-detail")).toBeNull();
expect(screen.queryByTestId("skill-detail")).toBeTruthy();
expect(document.querySelector(".skills-view-detail-content")).toBeNull();
expect(screen.getByTestId("skills-detail-empty")).toBeTruthy();
expect(document.querySelector(".skills-view-item--selected")).toBeNull();
});
});
@@ -1013,7 +1020,7 @@ describe("SkillsView", () => {
expect(mockFetchSkillContent).toHaveBeenCalledTimes(2);
});
it("collapses detail when close button is clicked", async () => {
it("clears the detail pane when the close button is clicked", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
@@ -1027,16 +1034,56 @@ describe("SkillsView", () => {
});
await waitFor(() => {
expect(screen.getByTestId("skill-detail")).toBeTruthy();
expect(document.querySelector(".skills-view-detail-content")).toBeTruthy();
});
// Click close button
// Click close button (detail-pane close, not the view close)
await act(async () => {
fireEvent.click(screen.getByText("Close"));
fireEvent.click(screen.getByLabelText("Close skill detail"));
});
// FNXC:Skills 2026-06-23-01:45: the detail pane persists (two-pane layout);
// 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.getByTestId("skills-detail-empty")).toBeTruthy();
});
});
it("returns to the list via the narrow-mode back button (master→detail flow)", async () => {
// FNXC:Skills 2026-06-23-01:45: NARROW single-panel master→detail flow.
// Selecting a skill shows the detail ON TOP; the BACK affordance
// (data-testid="skills-detail-back") clears the selection and returns to
// the list. Asserts the back control exists and restores the empty-state.
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.queryByTestId("skill-detail")).toBeNull();
expect(document.querySelector(".skills-view-detail-content")).toBeTruthy();
expect(screen.getByTestId("skills-view").getAttribute("data-selected")).toBe("true");
});
const backButton = screen.getByTestId("skills-detail-back");
expect(backButton).toBeTruthy();
await act(async () => {
fireEvent.click(backButton);
});
await waitFor(() => {
expect(screen.getByTestId("skills-view").getAttribute("data-selected")).toBe("false");
expect(document.querySelector(".skills-view-detail-content")).toBeNull();
expect(screen.getByTestId("skills-detail-empty")).toBeTruthy();
expect(document.querySelector(".skills-view-item--selected")).toBeNull();
});
});