From 4747788dd481b6eb4609dc378c509dac16ed41c1 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 1 Oct 2026 00:33:15 +1000 Subject: [PATCH 01/16] feat(registry): add file explorer and context cost to Skill pages The Skill page buried its files: below 1536px the tree sat fourth in the rail with no sizes, and the header showed the repository push date while Provenance showed the SKILL.md date. Borrowed from upm.sh's package page: the tree now sits under Use it with sizes, one sticky viewer bar replaces three stacked header rows, and the header carries fact chips, the commit SHA, and a star trend. Context cost in tokens per loading stage is the Skill equivalent of install size. --- .../registry/app/components/SkillDetail.vue | 682 +++++++++++------- .../registry/app/components/SkillFileTree.vue | 6 +- .../app/components/SkillFileTreeNode.vue | 19 + .../app/components/_SkillStarTrend.vue | 139 ++++ .../registry/app/utils/skill-context-cost.ts | 77 ++ test/unit/skill-context-cost.test.ts | 38 + 6 files changed, 708 insertions(+), 253 deletions(-) create mode 100644 layers/registry/app/components/_SkillStarTrend.vue create mode 100644 layers/registry/app/utils/skill-context-cost.ts create mode 100644 test/unit/skill-context-cost.test.ts diff --git a/layers/registry/app/components/SkillDetail.vue b/layers/registry/app/components/SkillDetail.vue index 6e0be472..a06d3a4c 100644 --- a/layers/registry/app/components/SkillDetail.vue +++ b/layers/registry/app/components/SkillDetail.vue @@ -4,12 +4,14 @@ import { formatTimeAgo } from '@vueuse/core' import { resolveSkillAuditOverview } from '~~/app/utils/skill-audit-overview' import { avatarProxyUrl, githubAvatarProxyUrl } from '#shared/image-proxy' import { skillPageUrl as exactSkillPageUrl, skillInstallCmd, skillRunCmd, skillRunPrompt } from '#shared/skill-commands' +import { formatByteSize, formatTokenCount, resolveSkillContextCost } from '../utils/skill-context-cost' import { partitionMetadataEntries } from '../utils/skill-metadata' import { resolveSkillPageState } from '../utils/skill-page-state' import { resolveSkillRawUrl } from '../utils/skill-raw-url' import { resolveSkillTitle } from '../utils/skill-title' import SkillCommandPanel from './_SkillCommandPanel.vue' import SkillReceiptsPanel from './_SkillReceiptsPanel.vue' +import SkillStarTrend from './_SkillStarTrend.vue' import SkillThirdPartyChecks from './_SkillThirdPartyChecks.vue' const props = defineProps<{ @@ -296,6 +298,21 @@ if (import.meta.server) await Promise.all([skillFetch, liveSkillFetch]) const { data, status, error, refresh } = skillFetch + +interface StarHistoryResponse { + starHistory: { _tag: 'ready', approximate: boolean, points: { at: number, value: number }[] } | { _tag: string } +} +// Browser only: the trend is decoration, so crawlers never pay a D1 read for it. +const { data: starHistoryData } = useLazyFetch( + () => `/api/repos/${owner.value}/${repo.value}/history`, + { server: false, watch: [owner, repo] }, +) +const starTrend = computed(() => { + const history = starHistoryData.value?.starHistory + if (!history || history._tag !== 'ready' || !('points' in history) || history.points.length < 2) + return null + return history +}) const { data: liveSkill } = liveSkillFetch const isMissingSkill = computed(() => error.value?.statusCode === 404) @@ -379,6 +396,15 @@ const treeAssets = computed(() => { }) const treeAssetCount = computed(() => skillFiles.value?.total ?? data.value?.assetCount ?? treeAssets.value.length) +const contextCost = computed(() => data.value + ? resolveSkillContextCost({ + raw: data.value.raw, + name: data.value.name, + description: data.value.description, + files: treeAssets.value, + }) + : null) + const audits = computed(() => liveSkill.value?.audits ?? []) const auditOverview = computed(() => resolveSkillAuditOverview(audits.value)) @@ -517,9 +543,27 @@ const frontmatterEntries = computed(() => { }) const pushedAtDate = computed(() => new Date(data.value?.pushedAt || 0)) -const pushedAtAgo = useTimeAgo(pushedAtDate) const maturity = computed(() => data.value?.maturity ?? null) +// The SKILL.md commit date, which is what the Agent reads. The repository push +// date moves on any unrelated commit, so the header used to contradict History. +const skillUpdatedDate = computed(() => { + const modifiedAt = data.value?.provenance?.modifiedAt + return modifiedAt ? new Date(modifiedAt * 1000) : pushedAtDate.value +}) +const skillUpdatedAgo = useTimeAgo(skillUpdatedDate) +const shortSha = computed(() => data.value?.provenance?.sourceCommitSha?.slice(0, 7) ?? null) + +// Frontmatter licences are free text ("Proprietary. LICENSE.txt has complete +// terms"). The chip keeps the first clause; the title carries the rest. +const licenseLabel = computed(() => { + const license = data.value?.license?.trim() + if (!license) + return null + const first = license.split(/[.;(]/)[0]!.trim() + return first.length > 24 ? `${first.slice(0, 23)}…` : first +}) + const verifiedSummary = computed<{ verified: number, total: number } | null>(() => { const list = relatedData.value?.commits ?? [] if (!list.length) @@ -552,7 +596,7 @@ const rawHtml = ref(null) const rawPlain = ref(null) const rawError = ref(null) -import { highlightLangFromPath } from '../utils/skill-file-tree' +import { fileIcon, highlightLangFromPath } from '../utils/skill-file-tree' // Path of the doc currently active in the viewer, relative to the skill folder. // Empty string === SKILL.md. Used to highlight the file tree. @@ -699,6 +743,15 @@ watch([contentView, currentRaw], ([view, raw], [, prevRaw]) => { renderRaw(raw) }) +const skillMdBytes = computed(() => data.value?.raw ? new TextEncoder().encode(data.value.raw).byteLength : 0) +const viewerCrumbs = computed(() => currentDocLabel.value.split('/')) +const viewerFileSize = computed(() => { + if (!activeDocPath.value) + return skillMdBytes.value || null + return treeAssets.value.find(asset => asset.path === activeDocPath.value)?.size ?? null +}) +const viewerFileExt = computed(() => currentDocLabel.value.split('.').pop()?.toLowerCase() ?? '') + const isNonMarkdownDoc = computed(() => { const p = activeDocPath.value if (!p) @@ -707,15 +760,6 @@ const isNonMarkdownDoc = computed(() => { return !lower.endsWith('.md') && !lower.endsWith('.markdown') }) -const contentTabs = computed(() => { - if (isNonMarkdownDoc.value) - return [{ label: 'Raw', value: 'markdown', icon: 'i-lucide-file-text' }] - return [ - { label: 'Preview', value: 'preview', icon: 'i-lucide-eye' }, - { label: 'Raw', value: 'markdown', icon: 'i-lucide-file-text' }, - ] -}) - // Non-md files have no Preview tab — pin to Raw whenever the active doc is // not markdown, and snap back to Preview when returning to a markdown doc. watch(isNonMarkdownDoc, (nonMd) => { @@ -1155,6 +1199,14 @@ useHead(computed(() => ({ > /{{ data.name }} + @{{ shortSha }} ({ +