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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user