From 25958ce86aab36358e4137fa8cfbfd3b76b1f1cc Mon Sep 17 00:00:00 2001 From: gile987 Date: Tue, 22 Sep 2026 15:26:47 +0200 Subject: [PATCH 1/3] fix(portal): show skill revisions in run details --- apps/portal/src/components/SkillPicker.tsx | 10 +---- .../components/SkillRevisionLinks.stories.tsx | 35 ++++++++++++++++ .../components/SkillRevisionLinks.test.tsx | 39 ++++++++++++++++++ .../src/components/SkillRevisionLinks.tsx | 36 ++++++++++++++++ apps/portal/src/lib/skill-spec.test.ts | 41 +++++++++++++++++++ apps/portal/src/lib/skill-spec.ts | 37 +++++++++++++++++ apps/portal/src/pages/ProfileDetail.tsx | 2 +- apps/portal/src/pages/RunDetail.tsx | 24 +++-------- docs/architecture/skills.md | 4 ++ 9 files changed, 200 insertions(+), 28 deletions(-) create mode 100644 apps/portal/src/components/SkillRevisionLinks.stories.tsx create mode 100644 apps/portal/src/components/SkillRevisionLinks.test.tsx create mode 100644 apps/portal/src/components/SkillRevisionLinks.tsx create mode 100644 apps/portal/src/lib/skill-spec.test.ts create mode 100644 apps/portal/src/lib/skill-spec.ts diff --git a/apps/portal/src/components/SkillPicker.tsx b/apps/portal/src/components/SkillPicker.tsx index f6b0fca26..5787bb8dd 100644 --- a/apps/portal/src/components/SkillPicker.tsx +++ b/apps/portal/src/components/SkillPicker.tsx @@ -13,15 +13,7 @@ import { X, Search, Download, Loader2, ChevronDown, ChevronUp, Globe, BookOpen } import type { SkillDocument, SkillRevisionDocument, SkillSearchResult } from "@/types"; import { toast } from "sonner"; import { SkillImportWizard } from "@/components/SkillImportWizard"; - -// --------------------------------------------------------------------------- -// Parse "slug@commitHash" → { slug, commitHash } or "slug" → { slug } -// --------------------------------------------------------------------------- -export function parseSkillSpec(spec: string): { slug: string; commitHash?: string } { - const at = spec.lastIndexOf("@"); - if (at > 0) return { slug: spec.substring(0, at), commitHash: spec.substring(at + 1) }; - return { slug: spec }; -} +import { parseSkillSpec } from "@/lib/skill-spec"; // --------------------------------------------------------------------------- // Revision selector for a single selected skill diff --git a/apps/portal/src/components/SkillRevisionLinks.stories.tsx b/apps/portal/src/components/SkillRevisionLinks.stories.tsx new file mode 100644 index 000000000..d5f3ad6a2 --- /dev/null +++ b/apps/portal/src/components/SkillRevisionLinks.stories.tsx @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { MemoryRouter } from "react-router-dom"; + +import { SkillRevisionLinks } from "./SkillRevisionLinks"; + +const meta = { + component: SkillRevisionLinks, + args: { + references: [ + "microsoft/example-skills/react-testing@1234567890abcdef", + "microsoft/example-skills/accessibility@abcdef1234567890", + ], + }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Versioned: Story = {}; + +export const LegacyUnversioned: Story = { + args: { + references: ["legacy/skill"], + }, +}; diff --git a/apps/portal/src/components/SkillRevisionLinks.test.tsx b/apps/portal/src/components/SkillRevisionLinks.test.tsx new file mode 100644 index 000000000..9ec8cf1e3 --- /dev/null +++ b/apps/portal/src/components/SkillRevisionLinks.test.tsx @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @vitest-environment happy-dom +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { MemoryRouter } from "react-router-dom"; + +import { SkillRevisionLinks } from "./SkillRevisionLinks"; + +afterEach(cleanup); + +function renderLinks(references: string[]) { + return render( + + + , + ); +} + +describe("SkillRevisionLinks", () => { + it("shows the short revision while linking by unversioned skill slug", () => { + renderLinks(["microsoft/example-skills/react-testing@1234567890abcdef"]); + + const link = screen.getByRole("link"); + expect(link.getAttribute("href")).toBe("/skills/microsoft/example-skills/react-testing"); + expect(link.textContent).toContain("microsoft/example-skills/react-testing"); + expect(link.textContent).toContain("@1234567"); + expect(screen.getByTitle("1234567890abcdef")).toBeTruthy(); + }); + + it("continues to render legacy unversioned skill slugs", () => { + renderLinks(["legacy/skill"]); + + const link = screen.getByRole("link"); + expect(link.getAttribute("href")).toBe("/skills/legacy/skill"); + expect(link.textContent).toBe("legacy/skill"); + }); +}); diff --git a/apps/portal/src/components/SkillRevisionLinks.tsx b/apps/portal/src/components/SkillRevisionLinks.tsx new file mode 100644 index 000000000..1b31c4d49 --- /dev/null +++ b/apps/portal/src/components/SkillRevisionLinks.tsx @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Link } from "react-router-dom"; + +import { Badge } from "@/components/ui/badge"; +import { parseSkillSpec, shortCommitHash } from "@/lib/skill-spec"; + +export interface SkillRevisionLinksProps { + references: readonly string[]; +} + +export function SkillRevisionLinks({ references }: SkillRevisionLinksProps) { + return ( +
+ {references.map((reference) => { + const { slug, commitHash } = parseSkillSpec(reference); + return ( + + + {slug} + {commitHash && ( + + @{shortCommitHash(commitHash)} + + )} + + + ); + })} +
+ ); +} diff --git a/apps/portal/src/lib/skill-spec.test.ts b/apps/portal/src/lib/skill-spec.test.ts new file mode 100644 index 000000000..5c309b66c --- /dev/null +++ b/apps/portal/src/lib/skill-spec.test.ts @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from "vitest"; + +import { + getRunSkillReferences, + parseSkillSpec, + shortCommitHash, +} from "./skill-spec"; + +describe("skill specs", () => { + it("parses a revision-qualified skill reference", () => { + expect(parseSkillSpec("microsoft/example-skills/react-testing@1234567890abcdef")).toEqual({ + slug: "microsoft/example-skills/react-testing", + commitHash: "1234567890abcdef", + }); + }); + + it("preserves legacy unversioned skill slugs", () => { + expect(parseSkillSpec("microsoft/example-skills/react-testing")).toEqual({ + slug: "microsoft/example-skills/react-testing", + }); + }); + + it("prefers immutable revision refs and de-duplicates exact entries", () => { + const revision = "microsoft/example-skills/react-testing@1234567890abcdef"; + expect(getRunSkillReferences({ + skills: ["legacy/skill"], + skillRevisions: [revision, revision], + })).toEqual([revision]); + }); + + it("falls back to legacy skill slugs", () => { + expect(getRunSkillReferences({ skills: ["legacy/skill"] })).toEqual(["legacy/skill"]); + }); + + it("uses the conventional seven-character short commit hash", () => { + expect(shortCommitHash("1234567890abcdef")).toBe("1234567"); + }); +}); diff --git a/apps/portal/src/lib/skill-spec.ts b/apps/portal/src/lib/skill-spec.ts new file mode 100644 index 000000000..10249185a --- /dev/null +++ b/apps/portal/src/lib/skill-spec.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +const SHORT_COMMIT_HASH_LENGTH = 7; + +export interface ParsedSkillSpec { + slug: string; + commitHash?: string; +} + +interface RunSkills { + skills?: readonly string[] | null; + skillRevisions?: readonly string[] | null; +} + +export function parseSkillSpec(spec: string): ParsedSkillSpec { + const at = spec.lastIndexOf("@"); + if (at > 0) { + return { + slug: spec.substring(0, at), + commitHash: spec.substring(at + 1) || undefined, + }; + } + return { slug: spec }; +} + +/** Use immutable revision refs when available, falling back to legacy skill slugs. */ +export function getRunSkillReferences(run: RunSkills): string[] { + const references = run.skillRevisions?.length + ? run.skillRevisions + : (run.skills ?? []); + return Array.from(new Set(references)); +} + +export function shortCommitHash(commitHash: string): string { + return commitHash.substring(0, SHORT_COMMIT_HASH_LENGTH); +} diff --git a/apps/portal/src/pages/ProfileDetail.tsx b/apps/portal/src/pages/ProfileDetail.tsx index 9a7afb10d..db80153ac 100644 --- a/apps/portal/src/pages/ProfileDetail.tsx +++ b/apps/portal/src/pages/ProfileDetail.tsx @@ -23,7 +23,7 @@ import { AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; -import { parseSkillSpec } from "@/components/SkillPicker"; +import { parseSkillSpec } from "@/lib/skill-spec"; import { KbdBadge } from "@/components/KbdBadge"; import { AgentBadge, agentDisplayName, useAgentCatalog } from "@/components/AgentBadge"; import { toast } from "sonner"; diff --git a/apps/portal/src/pages/RunDetail.tsx b/apps/portal/src/pages/RunDetail.tsx index 5b5e096f3..8b2cb9ec8 100644 --- a/apps/portal/src/pages/RunDetail.tsx +++ b/apps/portal/src/pages/RunDetail.tsx @@ -31,6 +31,7 @@ import { ReportThumbnail } from "@/components/ReportThumbnail"; import { CriteriaBadge } from "@/components/CriteriaBadge"; import { TaskPromptBadge } from "@/components/TaskPromptBadge"; import { AgentBadge } from "@/components/AgentBadge"; +import { SkillRevisionLinks } from "@/components/SkillRevisionLinks"; import { ArrowLeft, Copy, Check, Sparkles, CheckCircle2, XCircle, MinusCircle, FileText, Plus, Download, Loader2, Archive, Video, LayoutGrid, List, Puzzle, RotateCcw, ChevronDown, Clock, Pause, Play, ArrowUpDown, X } from "lucide-react"; import { formatDate, formatId, formatDuration, cn } from "@/lib/utils"; import { @@ -46,6 +47,7 @@ import { toast } from "sonner"; import type { RunState, LogEvent } from "@/types"; import { useShiftModifier } from "@/hooks/useShiftModifier"; import { getRetryButtonState } from "@/components/RetryButton"; +import { getRunSkillReferences } from "@/lib/skill-spec"; /** A compact labeled stat: a micro uppercase label above its value. */ function MetaItem({ label, value, title }: { label: string; value: ReactNode; title?: string }) { @@ -503,13 +505,7 @@ export function RunDetail() { ) : undefined); - const skillIdsFromRevisions = Array.from(new Set((run.skillRevisions ?? []).map((ref) => { - const at = ref.lastIndexOf("@"); - return at > 0 ? ref.substring(0, at) : ref; - }))); - const skillIds = skillIdsFromRevisions.length > 0 - ? skillIdsFromRevisions - : (run.skills ?? []); + const skillReferences = getRunSkillReferences(run); const gateSummaries = (run.gateSummaries ?? []) as GateRunSummary[]; const gateSummaryById = new Map(gateSummaries.map((summary) => [summary.gate, summary])); @@ -1389,23 +1385,15 @@ export function RunDetail() { )} {/* Skills card */} - {skillIds.length > 0 && ( + {skillReferences.length > 0 && ( - Skills ({skillIds.length}) + Skills ({skillReferences.length}) -
- {skillIds.map((skillId) => ( - - - {skillId} - - - ))} -
+
)} diff --git a/docs/architecture/skills.md b/docs/architecture/skills.md index 187bc1d32..561f6f493 100644 --- a/docs/architecture/skills.md +++ b/docs/architecture/skills.md @@ -70,6 +70,10 @@ erDiagram - **SkillRevision** — An immutable, content-addressed snapshot of a skill at a specific commit. The `ref` format is `owner/repo/skillName@commitHash`. - **Run.skillRevisions** — Array of skill revision refs attached to a run. These are resolved at submit time and remain immutable throughout the run lifecycle. +### Portal display + +Run details display each skill slug with the first seven characters of its pinned commit hash. Legacy runs containing only bare `skills` slugs remain supported. + ## Import Paths Skills can be added to Scope's internal library through two distinct flows. In both cases, **GitHub is always the source of skill content** — the actual SKILL.md files live in GitHub repositories. Skills.sh is a separate search/discovery registry that indexes publicly available skills. From be0278ba214b134d1b94da3f6de5cb5d97da36e4 Mon Sep 17 00:00:00 2001 From: gile987 Date: Tue, 22 Sep 2026 16:31:33 +0200 Subject: [PATCH 2/3] fix(portal): remove nested router from skill revision story --- apps/portal/src/components/SkillRevisionLinks.stories.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/apps/portal/src/components/SkillRevisionLinks.stories.tsx b/apps/portal/src/components/SkillRevisionLinks.stories.tsx index d5f3ad6a2..b3edf7415 100644 --- a/apps/portal/src/components/SkillRevisionLinks.stories.tsx +++ b/apps/portal/src/components/SkillRevisionLinks.stories.tsx @@ -2,7 +2,6 @@ // Licensed under the MIT License. import type { Meta, StoryObj } from "@storybook/react-vite"; -import { MemoryRouter } from "react-router-dom"; import { SkillRevisionLinks } from "./SkillRevisionLinks"; @@ -14,13 +13,6 @@ const meta = { "microsoft/example-skills/accessibility@abcdef1234567890", ], }, - decorators: [ - (Story) => ( - - - - ), - ], } satisfies Meta; export default meta; From c3feb1c15ad2d2e26d285d7a6765b763d627d566 Mon Sep 17 00:00:00 2001 From: gile987 Date: Tue, 22 Sep 2026 21:30:00 +0200 Subject: [PATCH 3/3] refactor(portal): reuse skill revision display helpers --- apps/portal/src/components/SkillPicker.tsx | 6 +++--- apps/portal/src/pages/ProfileDetail.tsx | 16 +++------------- apps/portal/src/pages/SkillDetail.tsx | 5 +++-- 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/apps/portal/src/components/SkillPicker.tsx b/apps/portal/src/components/SkillPicker.tsx index 5787bb8dd..9d9a232f5 100644 --- a/apps/portal/src/components/SkillPicker.tsx +++ b/apps/portal/src/components/SkillPicker.tsx @@ -13,7 +13,7 @@ import { X, Search, Download, Loader2, ChevronDown, ChevronUp, Globe, BookOpen } import type { SkillDocument, SkillRevisionDocument, SkillSearchResult } from "@/types"; import { toast } from "sonner"; import { SkillImportWizard } from "@/components/SkillImportWizard"; -import { parseSkillSpec } from "@/lib/skill-spec"; +import { parseSkillSpec, shortCommitHash } from "@/lib/skill-spec"; // --------------------------------------------------------------------------- // Revision selector for a single selected skill @@ -42,7 +42,7 @@ function RevisionSelector({ slug, currentCommitHash, onRevisionChange }: { {isLoading && Loading…} {revisions.map((r: SkillRevisionDocument, idx: number) => ( - {r.commitHash.substring(0, 7)}{idx === 0 ? " (latest)" : ""} — {new Date(r.resolvedAt).toLocaleDateString()} + {shortCommitHash(r.commitHash)}{idx === 0 ? " (latest)" : ""} — {new Date(r.resolvedAt).toLocaleDateString()} ))} @@ -270,7 +270,7 @@ export function SkillPicker({ selected, onChange, importOnly = false, disabled = {slug} {commitHash && ( - {commitHash.substring(0, 7)} + {shortCommitHash(commitHash)} )} {!commitHash && ( latest diff --git a/apps/portal/src/pages/ProfileDetail.tsx b/apps/portal/src/pages/ProfileDetail.tsx index db80153ac..993ba69a2 100644 --- a/apps/portal/src/pages/ProfileDetail.tsx +++ b/apps/portal/src/pages/ProfileDetail.tsx @@ -23,9 +23,9 @@ import { AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; -import { parseSkillSpec } from "@/lib/skill-spec"; import { KbdBadge } from "@/components/KbdBadge"; import { AgentBadge, agentDisplayName, useAgentCatalog } from "@/components/AgentBadge"; +import { SkillRevisionLinks } from "@/components/SkillRevisionLinks"; import { toast } from "sonner"; export function ProfileDetail() { @@ -343,18 +343,8 @@ export function ProfileDetail() {
Skills -
- {displayVersion.skillRevisions.map((s) => { - const { slug, commitHash } = parseSkillSpec(s); - return ( - - {slug} - {commitHash && ( - @{commitHash.substring(0, 7)} - )} - - ); - })} +
+
diff --git a/apps/portal/src/pages/SkillDetail.tsx b/apps/portal/src/pages/SkillDetail.tsx index 32f79e0a5..f191eb9fe 100644 --- a/apps/portal/src/pages/SkillDetail.tsx +++ b/apps/portal/src/pages/SkillDetail.tsx @@ -16,6 +16,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { ArrowLeft, Trash2, RefreshCw, Loader2, BookOpen, GitCommit, ExternalLink } from "lucide-react"; import { MarkdownRenderer } from "@/components/MarkdownRenderer"; import { formatDate } from "@/lib/utils"; +import { shortCommitHash } from "@/lib/skill-spec"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; @@ -160,7 +161,7 @@ export function SkillDetail() { SKILL.md {latestRevision && ( - {latestRevision.commitHash.slice(0, 7)} · {formatDate(latestRevision.resolvedAt)} + {shortCommitHash(latestRevision.commitHash)} · {formatDate(latestRevision.resolvedAt)} )}
@@ -286,7 +287,7 @@ export function SkillDetail() { )} > - {rev.commitHash.slice(0, 7)} + {shortCommitHash(rev.commitHash)} {formatDate(rev.resolvedAt)}