feat: refactor the Skills detail pane into collapsible sections (#2263)

The pane was one scrolling column: SKILL.md sat below the manifest behind a
"View SKILL.md" button, the frontmatter pushed it further out of view, and
nothing could be put away.

It is now a flex column that does not scroll as a whole. Conformance,
Resources, Frontmatter and Skill Resource are four collapsible sections of one
`disclosure` accordion, and the file viewer claims the remaining height and
scrolls internally.

The viewer's flex-basis is 0, not auto, and that is load-bearing: `auto` makes
an item's basis its content height, and a rendered document is ~1,795px for the
data-analysis fixture. That basis joined the sum the container distributes,
over-constraining the column so the shrink factors crushed the siblings —
Conformance to a 2px panel, Resources to zero — with their contents spilling
over the headers below. Collapsing the viewer removed the giant basis, so the
symptom appeared on collapse-then-reopen. Open sections also carry a `mih`
floor, since the disclosure CSS sets `min-height: 0` on an active item.

Frontmatter now follows the displayed file rather than the selected skill.
Both halves come from one `splitSkillFile` call, so the section and the viewer
cannot disagree, and a file with no frontmatter renders no section at all.

Also: SKILL.md loads on selection (a `resources/read` is not a load under
SEP-2640), the manifest highlights on hover with each URI a button that swaps
the viewed file, both whole-skill actions moved to the pane header, and the
Conformance badge goes yellow for a warnings-only entry instead of green.

Flat CSS properties moved out of call sites into theme variants (ThemeText
gains four; a new ThemeTable takes the manifest font size).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4441JdNLZerCVM7mDDSfB
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-05 20:55:19 -04:00
commit e67eb3408e
9 changed files with 1070 additions and 311 deletions

View file

@ -17,7 +17,10 @@ import {
import { EMPTY_SKILLS_UI } from "../screenUiState";
const REF_TEXT = "# Column rules\n";
const SELF_TEXT = "# data-analysis\n";
// A real SKILL.md carries frontmatter, and the screen now splits it out of the
// served bytes (#2263) — so the fixture has to have some, or the Frontmatter
// section it drives would never render here.
const SELF_TEXT = "---\nname: data-analysis\n---\n\n# data-analysis\n";
const NOTES_TEXT = "different\n";
// Computed once at module load so each fixture's advertised digest really is
// the digest of the bytes the fake read returns — a hard-coded constant would
@ -138,6 +141,14 @@ function ControlledSkillsScreen(props: Partial<SkillsScreenProps> = {}) {
);
}
// Mantine puts a Badge's colour on the ROOT as CSS custom properties, while
// `getByText` matches the inner label span — so the colour has to be read from
// the enclosing root rather than from the matched node.
function badgeStyle(text: RegExp): string {
const root = screen.getByText(text).closest(".mantine-Badge-root");
return root?.getAttribute("style") ?? "";
}
describe("SkillsScreen", () => {
it("renders the empty state until a skill is selected", () => {
renderWithMantine(<SkillsScreen {...baseProps} />);
@ -199,6 +210,28 @@ describe("SkillsScreen", () => {
expect(screen.queryByTestId("skill-issues")).not.toBeInTheDocument();
});
it("badges a warning-only entry yellow, not green", async () => {
// Green reads as "nothing to see", which would hide the only signal the
// section carries for an entry whose findings are all warnings (#2263).
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("dynamic-report"));
// `dynamic-resources` is a warning, and the only finding on this fixture.
const style = badgeStyle(/0 error\(s\), 1 warning\(s\)/);
expect(style).toContain("yellow");
expect(style).not.toContain("green");
});
it("badges a clean entry green and a broken one red", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("data-analysis"));
expect(badgeStyle(/0 error\(s\), 0 warning\(s\)/)).toContain("green");
await user.click(screen.getByText("right-name"));
expect(badgeStyle(/1 error\(s\), 0 warning\(s\)/)).toContain("red");
});
it("shows the name/path mismatch as a distinct, named finding", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
@ -262,7 +295,10 @@ describe("SkillsScreen", () => {
await user.click(screen.getByRole("button", { name: /Verify all/ }));
// One alert per file in the manifest — both reads failed.
expect(await screen.findAllByText("Could not read file")).toHaveLength(2);
expect(screen.getAllByText("403")).toHaveLength(2);
// Three, not two: the same rejecting read also serves the SKILL.md the
// viewer loads on selection (#2263), so the message appears once per
// manifest row plus once in the viewer.
expect(screen.getAllByText("403")).toHaveLength(3);
});
it("wraps a non-Error read rejection", async () => {
@ -273,7 +309,9 @@ describe("SkillsScreen", () => {
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /Verify all/ }));
expect(await screen.findAllByText("plain string")).toHaveLength(2);
// Three: one per manifest row, plus the viewer's own auto-loaded SKILL.md
// read, which the same mock rejects (#2263).
expect(await screen.findAllByText("plain string")).toHaveLength(3);
});
it("titles a size disagreement a size mismatch, not a digest one", async () => {
@ -336,7 +374,7 @@ describe("SkillsScreen", () => {
it("renders a base64 SKILL.md preview instead of a blank one", async () => {
// `onReadSkillFile` supports blob content, and verification reads it
// correctly; dropping it in the preview would paint an empty box for a
// correctly; dropping it in the viewer would paint an empty box for a
// file the screen had just checked.
const user = userEvent.setup();
const onReadSkillFile = vi.fn().mockResolvedValue({
@ -347,9 +385,8 @@ describe("SkillsScreen", () => {
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
const preview = await screen.findByTestId("skill-md-preview");
expect(preview).toHaveTextContent("from a blob");
const viewer = screen.getByTestId("skill-resource-viewer");
await waitFor(() => expect(viewer).toHaveTextContent("from a blob"));
});
it("keeps the newest verdict when two verifications of one row overlap", async () => {
@ -382,18 +419,21 @@ describe("SkillsScreen", () => {
/>,
);
await user.click(screen.getByText("data-analysis"));
// Selecting the skill already issued the viewer's own SKILL.md read
// (#2263), so the two clicks below are the reads AFTER that one.
const base = resolvers.length;
const rowVerify = screen.getByRole("button", {
name: "Verify skill://data-analysis/SKILL.md",
});
await user.click(rowVerify);
await user.click(rowVerify);
expect(resolvers).toHaveLength(2);
expect(resolvers).toHaveLength(base + 2);
// The SECOND read answers first with the matching bytes, then the first
// read answers with bytes that would verify as a mismatch.
resolvers[1]({ text: SELF_TEXT });
resolvers[base + 1]({ text: SELF_TEXT });
expect(await screen.findByText("verified")).toBeInTheDocument();
resolvers[0]({ text: "stale bytes\n" });
resolvers[base]({ text: "stale bytes\n" });
// Still the newer verdict.
expect(await screen.findByText("verified")).toBeInTheDocument();
expect(screen.queryByText("mismatch")).not.toBeInTheDocument();
@ -608,19 +648,20 @@ describe("SkillsScreen", () => {
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
const view = screen.getByRole("button", { name: /View SKILL.md/ });
// Past the viewer's own read for the selection (#2263).
const base = resolvers.length;
const view = screen.getByRole("button", {
name: "skill://data-analysis/reference.md",
});
await user.click(view);
await user.click(view);
expect(resolvers).toHaveLength(2);
expect(resolvers).toHaveLength(base + 2);
resolvers[1]({ text: "# newest\n" });
expect(await screen.findByTestId("skill-md-preview")).toHaveTextContent(
"newest",
);
resolvers[0]({ text: "# stale\n" });
expect(screen.getByTestId("skill-md-preview")).not.toHaveTextContent(
"stale",
);
const viewer = screen.getByTestId("skill-resource-viewer");
resolvers[base + 1]({ text: "# newest\n" });
await waitFor(() => expect(viewer).toHaveTextContent("newest"));
resolvers[base]({ text: "# stale\n" });
expect(viewer).not.toHaveTextContent("stale");
});
it("keeps the newest skills/get result when two fetches overlap", async () => {
@ -804,18 +845,21 @@ describe("SkillsScreen", () => {
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
const view = screen.getByRole("button", { name: /View SKILL.md/ });
// Past the viewer's own read for the selection (#2263).
const base = resolvers.length;
const view = screen.getByRole("button", {
name: "skill://data-analysis/reference.md",
});
await user.click(view);
await user.click(view);
expect(resolvers).toHaveLength(2);
expect(resolvers).toHaveLength(base + 2);
const viewer = screen.getByTestId("skill-resource-viewer");
// The OLDER read answers first, while the newer one is still in flight.
resolvers[0]({ text: "# stale\n" });
expect(screen.queryByTestId("skill-md-preview")).not.toBeInTheDocument();
resolvers[1]({ text: "# newest\n" });
expect(await screen.findByTestId("skill-md-preview")).toHaveTextContent(
"newest",
);
resolvers[base]({ text: "# stale\n" });
expect(viewer).not.toHaveTextContent("stale");
resolvers[base + 1]({ text: "# newest\n" });
await waitFor(() => expect(viewer).toHaveTextContent("newest"));
});
it("rejects an older skills/get even when it resolves FIRST", async () => {
@ -849,12 +893,111 @@ describe("SkillsScreen", () => {
).toBeInTheDocument();
});
it("shows the SKILL.md preview on demand", async () => {
it("shows the skill's own SKILL.md as soon as it is selected", async () => {
// No button to press (#2263): the viewer opens on the skill's own file, so
// selecting it is the whole interaction.
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
expect(await screen.findByTestId("skill-md-preview")).toBeInTheDocument();
const viewer = screen.getByTestId("skill-resource-viewer");
await waitFor(() => expect(viewer).toHaveTextContent("data-analysis"));
expect(
screen.queryByRole("button", { name: /View SKILL.md/ }),
).not.toBeInTheDocument();
});
it("heads the viewer with the displayed file, not the section's purpose", async () => {
// The heading is static so it does not change shape as the file changes;
// the file name sits beside it (#2263).
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("data-analysis"));
// The heading lives on the section's control (it is the collapsible
// section's own header), so it is queried at screen level rather than
// inside the panel.
const control = within(
screen.getByRole("button", { name: /Skill Resource/ }),
);
expect(control.getByText("Skill Resource")).toBeInTheDocument();
expect(control.getByText("SKILL.md")).toBeInTheDocument();
});
it("swaps the displayed file when a manifest URI is clicked", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("data-analysis"));
const viewer = screen.getByTestId("skill-resource-viewer");
await waitFor(() => expect(viewer).toHaveTextContent("data-analysis"));
await user.click(
screen.getByRole("button", {
name: "skill://data-analysis/reference.md",
}),
);
await waitFor(() => expect(viewer).toHaveTextContent("Column rules"));
// The section header follows the file, and the previous contents are gone.
expect(
within(screen.getByRole("button", { name: /Skill Resource/ })).getByText(
"reference.md",
),
).toBeInTheDocument();
expect(viewer).not.toHaveTextContent("data-analysis");
});
it("shows the frontmatter of the file on display, and hides the section when it has none", async () => {
// Both halves come from one split (#2263), so the section can never show
// one file's frontmatter beside another file's body — and the viewer never
// repeats what the section is already showing.
const user = userEvent.setup();
const onReadSkillFile = vi.fn(async (uri: string) =>
uri.endsWith("reference.md")
? { text: "# Ref\n\nNo frontmatter here.\n" }
: { text: "---\nname: data-analysis\n---\n\n# The body\n" },
);
renderWithMantine(
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
const viewer = screen.getByTestId("skill-resource-viewer");
await waitFor(() => expect(viewer).toHaveTextContent("The body"));
// Shown once, in its own section — not again in the viewer.
expect(
screen.getByRole("button", { name: /Frontmatter/ }),
).toBeInTheDocument();
expect(viewer).not.toHaveTextContent("name: data-analysis");
// reference.md has no frontmatter, so the section goes away entirely
// rather than lingering with SKILL.md's fields.
await user.click(
screen.getByRole("button", {
name: "skill://data-analysis/reference.md",
}),
);
await waitFor(() =>
expect(viewer).toHaveTextContent("No frontmatter here"),
);
expect(
screen.queryByRole("button", { name: /Frontmatter/ }),
).not.toBeInTheDocument();
});
it("marks the row whose file the viewer is showing", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("data-analysis"));
const self = screen.getByRole("button", {
name: "skill://data-analysis/SKILL.md",
});
const other = screen.getByRole("button", {
name: "skill://data-analysis/reference.md",
});
// The skill's own file is what the viewer opens on, so its row is current.
expect(self).toHaveAttribute("aria-current", "true");
expect(other).not.toHaveAttribute("aria-current");
await user.click(other);
await waitFor(() => expect(other).toHaveAttribute("aria-current", "true"));
expect(self).not.toHaveAttribute("aria-current");
});
it("reports a failed SKILL.md read", async () => {
@ -864,10 +1007,10 @@ describe("SkillsScreen", () => {
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
expect(
await screen.findByText("Could not read SKILL.md"),
await screen.findByText("Could not read this resource"),
).toBeInTheDocument();
expect(screen.getByText("gone")).toBeInTheDocument();
});
it("wraps a non-Error SKILL.md rejection", async () => {
@ -877,10 +1020,36 @@ describe("SkillsScreen", () => {
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
expect(await screen.findByText("bare")).toBeInTheDocument();
});
it("keeps the three sections independently collapsible", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("data-analysis"));
// Conformance and Resources open by default; Frontmatter is closed.
expect(screen.getByRole("button", { name: /Conformance/ })).toHaveAttribute(
"aria-expanded",
"true",
);
expect(screen.getByRole("button", { name: /Frontmatter/ })).toHaveAttribute(
"aria-expanded",
"false",
);
// Collapsing one leaves the others alone — `multiple`, not a single-open
// accordion.
await user.click(screen.getByRole("button", { name: /Conformance/ }));
expect(screen.getByRole("button", { name: /Conformance/ })).toHaveAttribute(
"aria-expanded",
"false",
);
expect(screen.getByRole("button", { name: /Resources/ })).toHaveAttribute(
"aria-expanded",
"true",
);
});
it("drops verification results when the selection changes", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
@ -895,14 +1064,27 @@ describe("SkillsScreen", () => {
expect(screen.getAllByText("—")).toHaveLength(2);
});
it("drops the SKILL.md preview when the selection changes", async () => {
it("re-points the viewer at the newly selected skill's own file", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
const onReadSkillFile = vi.fn(async (uri: string) => ({
text: `contents of ${uri}\n`,
}));
renderWithMantine(
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
expect(await screen.findByTestId("skill-md-preview")).toBeInTheDocument();
const viewer = screen.getByTestId("skill-resource-viewer");
await waitFor(() =>
expect(viewer).toHaveTextContent("contents of skill://data-analysis"),
);
// The previous skill's contents must not survive the switch: the viewer
// follows the selection rather than holding whatever was last read.
await user.click(screen.getByText("tampered"));
expect(screen.queryByTestId("skill-md-preview")).not.toBeInTheDocument();
await waitFor(() =>
expect(viewer).toHaveTextContent("contents of skill://tampered/SKILL.md"),
);
expect(viewer).not.toHaveTextContent("contents of skill://data-analysis");
});
it("renders an em dash for a manifest entry with no size or digest", async () => {
@ -1032,10 +1214,15 @@ describe("SkillsScreen", () => {
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
// `release` now holds the resolver for the SECOND skill's auto-read; the
// first skill's is stranded, which is the point — resolving the older one
// must not publish into the newer selection.
const stale = release;
await user.click(screen.getByText("tampered"));
release?.({ text: SELF_TEXT });
expect(screen.queryByTestId("skill-md-preview")).not.toBeInTheDocument();
stale?.({ text: "# from the abandoned skill\n" });
expect(screen.getByTestId("skill-resource-viewer")).not.toHaveTextContent(
"abandoned",
);
});
it("discards a failed SKILL.md read that resolves after the selection moved on", async () => {
@ -1051,9 +1238,10 @@ describe("SkillsScreen", () => {
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
// The abandoned skill's own read, stranded by the selection change below.
const stale = fail;
await user.click(screen.getByText("tampered"));
fail?.(new Error("too late"));
stale?.(new Error("too late"));
expect(screen.queryByText("too late")).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,14 @@
import { Table } from "@mantine/core";
export const ThemeTable = Table.extend({
// The Skills resource manifest (#2263). `striped`, `withTableBorder`,
// `highlightOnHover` and `verticalSpacing` are Mantine's own API and stay
// props at the call site; the font size is a flat CSS property, so it lives
// here rather than as an `fz` prop on the screen's `.withProps()` constant.
styles: (_theme, props) => {
if (props.variant === "manifest") {
return { table: { fontSize: "var(--mantine-font-size-xs)" } };
}
return {};
},
});

View file

@ -42,6 +42,42 @@ export const ThemeText = Text.extend({
// footer's `space-between` Group. Both are grey, single-line, and out of the
// text-selection flow. (Superseded the fixed bottom-corner badges of #1639
// now that the footer is a real, full-width AppShell row.)
// The four typographic treatments the Skills screen repeats (#2263). They
// live here rather than as `fw`/`size`/`c`/`ff` props on the screen's
// `.withProps()` constants because they are flat CSS properties, which
// AGENTS.md places in the theme; the constants keep only layout.
//
// `sectionHeading` labels a collapsible section; `skillTitle` names the
// selected skill; `monoCaption` is the dimmed monospace line used for URIs
// and digests; `emptyState` is the centred placeholder shown before a
// selection exists.
if (props.variant === "sectionHeading") {
return {
root: { fontWeight: 600, fontSize: "var(--mantine-font-size-sm)" },
};
}
if (props.variant === "skillTitle") {
return {
root: { fontWeight: 600, fontSize: "var(--mantine-font-size-lg)" },
};
}
if (props.variant === "monoCaption") {
return {
root: {
fontSize: "var(--mantine-font-size-xs)",
fontFamily: "var(--mantine-font-family-monospace)",
color: "var(--inspector-text-secondary)",
},
};
}
if (props.variant === "emptyState") {
return {
root: {
color: "var(--inspector-text-secondary)",
textAlign: "center",
},
};
}
if (
props.variant === "versionBadge" ||
props.variant === "copyrightBadge"

View file

@ -16,6 +16,7 @@ export { ThemePaper } from "./Paper";
export { ThemeScrollArea, ThemeScrollAreaAutosize } from "./ScrollArea";
export { ThemeSelect } from "./Select";
export { ThemeSwitch } from "./Switch";
export { ThemeTable } from "./Table";
export { ThemeText } from "./Text";
export { ThemeTextInput } from "./TextInput";
export { ThemeTitle } from "./Title";

View file

@ -19,6 +19,7 @@ import {
ThemeScrollAreaAutosize,
ThemeSelect,
ThemeSwitch,
ThemeTable,
ThemeText,
ThemeTextInput,
ThemeTitle,
@ -201,6 +202,7 @@ export const theme = createTheme({
ScrollAreaAutosize: ThemeScrollAreaAutosize,
Select: ThemeSelect,
Switch: ThemeSwitch,
Table: ThemeTable,
Text: ThemeText,
TextInput: ThemeTextInput,
Title: ThemeTitle,

View file

@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import { splitSkillFile } from "./splitSkillFile";
describe("splitSkillFile", () => {
it("separates a leading frontmatter fence from the body", () => {
expect(splitSkillFile("---\nname: a\n---\n\n# Title\n\nBody\n")).toEqual({
frontmatter: "name: a",
body: "# Title\n\nBody\n",
});
});
it("reports no frontmatter for a file that has none", () => {
expect(splitSkillFile("# Title\n\nBody\n")).toEqual({
body: "# Title\n\nBody\n",
});
});
it("leaves an unterminated opening fence alone rather than eating the file", () => {
// `---` with no closing fence is not frontmatter. Treating it as such would
// truncate the document to nothing, which is far worse than showing it.
const text = "---\nnot really frontmatter\n\n# Title\n";
expect(splitSkillFile(text)).toEqual({ body: text });
});
it("does not treat a horizontal rule mid-file as frontmatter", () => {
const text = "# Title\n\n---\n\nAfter the rule\n";
expect(splitSkillFile(text)).toEqual({ body: text });
});
it("handles CRLF line endings", () => {
expect(splitSkillFile("---\r\nname: a\r\n---\r\n\r\n# Title\r\n")).toEqual({
frontmatter: "name: a",
body: "# Title\r\n",
});
});
it("returns an empty body when the file is nothing but frontmatter", () => {
expect(splitSkillFile("---\nname: a\n---\n")).toEqual({
frontmatter: "name: a",
body: "",
});
});
it("handles a closing fence with no trailing newline", () => {
// The file ends ON the fence, so there is no newline after it to split at.
expect(splitSkillFile("---\nname: a\n---")).toEqual({
frontmatter: "name: a",
body: "",
});
});
it("keeps a body that follows the fence with no blank line", () => {
// The blank line between fence and body is a convention, not a rule —
// stripping unconditionally would eat the first line of a file without one.
expect(splitSkillFile("---\nname: a\n---\nBody\n")).toEqual({
frontmatter: "name: a",
body: "Body\n",
});
});
it("keeps every frontmatter line, not just the first", () => {
expect(
splitSkillFile("---\nname: a\ndescription: b\n---\n\nBody\n"),
).toEqual({ frontmatter: "name: a\ndescription: b", body: "Body\n" });
});
});

View file

@ -0,0 +1,55 @@
/**
* Split a skill file into its YAML frontmatter and its body (#2263).
*
* A pure transform with no I/O and no subsystem of its own, so it lives in
* `utils/` rather than `lib/` — and in its own module rather than in
* `SkillsScreen.tsx`, because a component file that also exports a function
* defeats React Fast Refresh (`react-refresh/only-export-components`).
*/
export interface SkillFileParts {
/**
* The raw YAML between the fences, fences excluded — `undefined` when the
* file has no frontmatter at all. Raw rather than parsed: this app carries no
* YAML parser, and showing the bytes the server actually served is the more
* useful answer for a conformance tool anyway.
*/
frontmatter?: string;
/** Everything after the closing fence, or the whole file when there is none. */
body: string;
}
/**
* Separate a leading YAML frontmatter fence from the rest of a skill file.
*
* The Skills screen renders the two halves in different places — the
* frontmatter in its own collapsible section, the body in the file viewer — and
* deriving both from **one** split is what stops them disagreeing: the section
* can never show one file's frontmatter while the viewer shows another's, and a
* file with no frontmatter cannot leave a stale section on screen.
*
* It also matters for rendering: the markdown renderer has no frontmatter
* support, so an un-split `---\nname: …\n---` is read as a setext heading and
* painted as a title above the document's real one.
*
* Two deliberate conservatisms, because this must never eat content:
*
* - Only a fence at the very **start** of the file counts. A `---` anywhere
* else is a horizontal rule and is left in the body.
* - A file that opens with `---` but never closes the fence is **not**
* frontmatter; the whole file is returned as the body rather than being
* truncated to nothing.
*/
export function splitSkillFile(text: string): SkillFileParts {
if (!/^---[ \t]*\r?\n/.test(text)) return { body: text };
const rest = text.slice(text.indexOf("\n") + 1);
const close = rest.search(/^---[ \t]*\r?$/m);
if (close === -1) return { body: text };
const frontmatter = rest.slice(0, close).replace(/\r?\n$/, "");
const after = rest.slice(close);
const newline = after.indexOf("\n");
if (newline === -1) return { frontmatter, body: "" };
// Drop the blank line conventionally left between the fence and the body, so
// the document does not open with dead space.
return { frontmatter, body: after.slice(newline + 1).replace(/^\r?\n/, "") };
}

View file

@ -126,9 +126,74 @@ const DATA_ANALYSIS_FM: Frontmatter = {
name: "data-analysis",
description: "Analyze a CSV and summarize its columns",
};
// Deliberately the long one. Every other fixture file here is two or three
// lines, which is not enough to exercise the detail pane's resource viewer:
// with a short file the viewer never scrolls, so a regression that let the
// whole pane scroll instead — the thing #2263 fixed — would look identical to
// the fix. One fixture has to be taller than the viewport for that difference
// to be visible at all.
const DATA_ANALYSIS_MD = skillMd(
DATA_ANALYSIS_FM,
"# Data analysis\n\nLoad the CSV, then follow `reference.md` for the column rules.",
[
"# Data analysis",
"",
"Load the CSV, then follow `reference.md` for the column rules.",
"",
"## Reading the file",
"",
"Read the file as UTF-8 and sniff the delimiter from the header line rather",
"than assuming a comma: exports from spreadsheet tools frequently use a",
"semicolon, and a mis-sniffed delimiter yields a single column whose name is",
"the entire header, which then reads as a valid — if useless — result.",
"",
"If the first line is not a header, every column name becomes a data value",
"and the row count is off by one. Prefer an explicit `has_header` flag over a",
"heuristic when the caller can supply it.",
"",
"## Typing the columns",
"",
"Infer a column's type from a sample rather than from its first value. A",
"column of integers with one empty cell is still numeric; a column of numbers",
"with a single stray `N/A` is not, and coercing it silently turns a data",
"quality problem into a wrong answer.",
"",
"Treat these as missing: the empty string, `NA`, `N/A`, `null`, and `-`.",
"Anything else that fails to parse is a value, not a gap, and belongs in the",
"report as such.",
"",
"## Summarising",
"",
"Numeric columns get min, max, mean and a null count. Report the null count",
"even when it is zero — its absence is indistinguishable from a column that",
"was skipped, and a reader cannot tell which they are looking at.",
"",
"Text columns get a distinct-value count and the five most common values with",
"their frequencies. Cap the distinct count: a free-text column can have as",
"many distinct values as rows, and enumerating them is not a summary.",
"",
"Date columns get an earliest and a latest. Do not attempt a mean of dates.",
"",
"## Reporting",
"",
"Lead with the shape — rows, columns — then the per-column detail. A reader",
"scanning the top of the report should learn whether the file is what they",
"expected before they read anything else.",
"",
"State the delimiter and the encoding you used. When either was guessed, say",
"that it was guessed: a summary computed from a mis-parsed file is worse than",
"no summary, because it looks the same as a correct one.",
"",
"## Failure modes worth naming",
"",
"A ragged file — rows with differing column counts — is a parse failure, not",
"a row to drop quietly. Report the first offending line number.",
"",
"A file whose every column types as text usually means the delimiter was",
"wrong. Say so rather than reporting fifty text columns as a finding.",
"",
"An empty file is not an error, but a summary of it must say the file was",
"empty rather than returning zeroed statistics that read like real ones.",
].join("\n"),
);
const DATA_ANALYSIS_REF =
"# Column rules\n\nNumeric columns get min/max/mean; text columns get a value count.\n";