Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 3 additions & 11 deletions apps/portal/src/components/SkillPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, shortCommitHash } from "@/lib/skill-spec";

// ---------------------------------------------------------------------------
// Revision selector for a single selected skill
Expand Down Expand Up @@ -50,7 +42,7 @@ function RevisionSelector({ slug, currentCommitHash, onRevisionChange }: {
{isLoading && <SelectItem value="__loading__" disabled>Loading…</SelectItem>}
{revisions.map((r: SkillRevisionDocument, idx: number) => (
<SelectItem key={r.ref} value={r.commitHash}>
{r.commitHash.substring(0, 7)}{idx === 0 ? " (latest)" : ""} — {new Date(r.resolvedAt).toLocaleDateString()}
{shortCommitHash(r.commitHash)}{idx === 0 ? " (latest)" : ""} — {new Date(r.resolvedAt).toLocaleDateString()}
</SelectItem>
))}
</SelectContent>
Expand Down Expand Up @@ -278,7 +270,7 @@ export function SkillPicker({ selected, onChange, importOnly = false, disabled =
<BookOpen className="h-3 w-3 text-muted-foreground" />
<span className="font-mono text-xs font-medium flex-1">{slug}</span>
{commitHash && (
<Badge variant="outline" className="text-[10px] font-mono">{commitHash.substring(0, 7)}</Badge>
<Badge variant="outline" className="text-[10px] font-mono">{shortCommitHash(commitHash)}</Badge>
)}
{!commitHash && (
<Badge variant="secondary" className="text-[10px]">latest</Badge>
Expand Down
27 changes: 27 additions & 0 deletions apps/portal/src/components/SkillRevisionLinks.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import type { Meta, StoryObj } from "@storybook/react-vite";

import { SkillRevisionLinks } from "./SkillRevisionLinks";

const meta = {
component: SkillRevisionLinks,
args: {
references: [
"microsoft/example-skills/react-testing@1234567890abcdef",
"microsoft/example-skills/accessibility@abcdef1234567890",
],
},
} satisfies Meta<typeof SkillRevisionLinks>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Versioned: Story = {};

export const LegacyUnversioned: Story = {
args: {
references: ["legacy/skill"],
},
};
39 changes: 39 additions & 0 deletions apps/portal/src/components/SkillRevisionLinks.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter>
<SkillRevisionLinks references={references} />
</MemoryRouter>,
);
}

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");
});
});
36 changes: 36 additions & 0 deletions apps/portal/src/components/SkillRevisionLinks.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-wrap gap-1.5">
{references.map((reference) => {
const { slug, commitHash } = parseSkillSpec(reference);
return (
<Link key={reference} to={`/skills/${slug}`}>
<Badge
variant="secondary"
className="gap-1 font-mono text-xs transition-colors hover:bg-accent"
>
{slug}
{commitHash && (
<span className="text-muted-foreground" title={commitHash}>
@{shortCommitHash(commitHash)}
</span>
)}
</Badge>
</Link>
);
})}
</div>
);
}
41 changes: 41 additions & 0 deletions apps/portal/src/lib/skill-spec.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
37 changes: 37 additions & 0 deletions apps/portal/src/lib/skill-spec.ts
Original file line number Diff line number Diff line change
@@ -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);
}
16 changes: 3 additions & 13 deletions apps/portal/src/pages/ProfileDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ import {
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { parseSkillSpec } from "@/components/SkillPicker";
import { KbdBadge } from "@/components/KbdBadge";
import { AgentBadge, agentDisplayName, useAgentCatalog } from "@/components/AgentBadge";
import { SkillRevisionLinks } from "@/components/SkillRevisionLinks";
import { toast } from "sonner";

export function ProfileDetail() {
Expand Down Expand Up @@ -343,18 +343,8 @@ export function ProfileDetail() {
<Separator />
<div>
<FieldLabel>Skills</FieldLabel>
<div className="flex flex-wrap gap-1 mt-1">
{displayVersion.skillRevisions.map((s) => {
const { slug, commitHash } = parseSkillSpec(s);
return (
<Badge key={s} variant="outline" className="font-mono text-xs gap-1">
{slug}
{commitHash && (
<span className="text-muted-foreground">@{commitHash.substring(0, 7)}</span>
)}
</Badge>
);
})}
<div className="mt-1">
<SkillRevisionLinks references={displayVersion.skillRevisions} />
</div>
</div>
</>
Expand Down
24 changes: 6 additions & 18 deletions apps/portal/src/pages/RunDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 }) {
Expand Down Expand Up @@ -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]));
Expand Down Expand Up @@ -1389,23 +1385,15 @@ export function RunDetail() {
)}

{/* Skills card */}
{skillIds.length > 0 && (
{skillReferences.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
Skills ({skillIds.length})
Skills ({skillReferences.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-1.5">
{skillIds.map((skillId) => (
<Link key={skillId} to={`/skills/${skillId}`}>
<Badge variant="secondary" className="font-mono text-xs hover:bg-accent transition-colors">
{skillId}
</Badge>
</Link>
))}
</div>
<SkillRevisionLinks references={skillReferences} />
</CardContent>
</Card>
)}
Expand Down
5 changes: 3 additions & 2 deletions apps/portal/src/pages/SkillDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -160,7 +161,7 @@ export function SkillDetail() {
<CardTitle className="text-base">SKILL.md</CardTitle>
{latestRevision && (
<span className="text-xs text-muted-foreground font-mono">
{latestRevision.commitHash.slice(0, 7)} · {formatDate(latestRevision.resolvedAt)}
{shortCommitHash(latestRevision.commitHash)} · {formatDate(latestRevision.resolvedAt)}
</span>
)}
</div>
Expand Down Expand Up @@ -286,7 +287,7 @@ export function SkillDetail() {
)}
>
<GitCommit className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="font-mono">{rev.commitHash.slice(0, 7)}</span>
<span className="font-mono">{shortCommitHash(rev.commitHash)}</span>
<span className="text-muted-foreground ml-auto whitespace-nowrap">
{formatDate(rev.resolvedAt)}
</span>
Expand Down
4 changes: 4 additions & 0 deletions docs/architecture/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down