open-design/apps/web/src/components/SkillsSection.tsx
mrcfps 1a3caf04f8 fix(web): merge main to resolve BoardComposerPopover conflict
Generated-By: looper 0.9.2 (runner=fixer, agent=opencode)
2026-05-29 15:06:54 +08:00

920 lines
29 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { Button } from '@open-design/components';
import { useI18n, useT } from '../i18n';
import {
localizeSkillDescription,
localizeSkillName,
} from '../i18n/content';
import { Icon } from './Icon';
import type { AppConfig } from '../types';
import type { SkillSummary } from '@open-design/contracts';
import {
deleteSkill,
fetchSkill,
fetchSkillFiles,
fetchSkills,
importSkill,
updateSkill,
type SkillFileEntry,
} from '../providers/registry';
// Functional skills only — design templates render in EntryView's
// Templates tab and are managed under their own daemon registry. See
// specs/current/skills-and-design-templates.md.
//
// Layout mirrors the External MCP servers panel: a single vertical
// stack of collapsible rows. Each row is a skill — the header is
// always visible (enable toggle, name, mode badge, source badge,
// actions); the body (SKILL.md preview, file tree, inline edit form)
// is revealed only when the row is expanded. Replaces the previous
// left-list / right-detail two-column workspace, which felt cramped
// inside the settings dialog content column and left a wasteful empty
// detail panel whenever no skill was selected.
interface Props {
cfg: AppConfig;
setCfg: Dispatch<SetStateAction<AppConfig>>;
onSkillsRefresh?: () => Promise<void> | void;
/**
* Fires after every successful skill registry mutation so the App
* shell can refresh derived state and evict any preview iframe whose
* project depends on the affected skill — body-only edits do not move
* any SkillSummary field, so ProjectView's signature-based eviction
* cannot see them on its own.
*/
onSkillsChanged?: (affectedSkillId?: string) => void;
}
type SourceFilter = 'all' | 'user' | 'built-in';
interface DraftState {
name: string;
description: string;
triggers: string;
body: string;
}
const EMPTY_DRAFT: DraftState = {
name: '',
description: '',
triggers: '',
body: '',
};
function summaryToDraft(skill: SkillSummary, body: string): DraftState {
return {
name: skill.name,
description: skill.description,
triggers: Array.isArray(skill.triggers) ? skill.triggers.join(', ') : '',
body,
};
}
function parseTriggers(raw: string): string[] {
return raw
.split(/[,\n]/)
.map((t) => t.trim())
.filter(Boolean);
}
export function SkillsSection({ cfg, setCfg, onSkillsRefresh, onSkillsChanged }: Props) {
const t = useT();
const [skills, setSkills] = useState<SkillSummary[]>([]);
const [search, setSearch] = useState('');
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all');
const [modeFilter, setModeFilter] = useState<string>('all');
const [categoryFilter, setCategoryFilter] = useState<string>('all');
// Body for the currently-expanded skill — fetched lazily so the
// initial list payload stays small. `undefined` means 'not yet
// fetched'; `''` means 'fetched but empty'.
const [bodyById, setBodyById] = useState<Record<string, string>>({});
const [bodyLoadingId, setBodyLoadingId] = useState<string | null>(null);
// File tree, cached the same way as bodies so re-expanding the same
// row is instant after the first fetch.
const [filesById, setFilesById] = useState<Record<string, SkillFileEntry[]>>({});
const [filesLoadingId, setFilesLoadingId] = useState<string | null>(null);
// One row expanded at a time — keeps the section scannable. `null`
// means every row is collapsed.
const [expandedId, setExpandedId] = useState<string | null>(null);
// Editing happens inline inside an expanded row. Holds the id of the
// skill currently being edited, or `null` when no edit is in flight.
const [editingId, setEditingId] = useState<string | null>(null);
// Top-of-list create form. Toggled by the header 'New skill' button.
const [creating, setCreating] = useState(false);
// Editing draft + status. The draft is held in local state so the
// user can collapse a row and come back without losing progress
// (we drop it only on Save / Cancel).
const [draft, setDraft] = useState<DraftState>(EMPTY_DRAFT);
const [draftError, setDraftError] = useState<string | null>(null);
const [draftSaving, setDraftSaving] = useState(false);
// Inline delete confirmation — replaces the old window.confirm() call.
// Only one skill can be in the 'confirm pending' state at a time; the
// user clicks once to arm, twice to commit.
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
// Editing a built-in skill writes a user-owned shadow copy and hides
// the built-in entry from the list. Arm an inline confirmation first
// so the listing change doesn't feel like a silent conversion (#1378).
const [confirmBuiltInEditId, setConfirmBuiltInEditId] = useState<
string | null
>(null);
const refresh = useCallback(async () => {
const list = await fetchSkills();
setSkills(list);
return list;
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const disabledSkills = useMemo(
() => new Set(cfg.disabledSkills ?? []),
[cfg.disabledSkills],
);
const modeOptions = useMemo(() => {
const counts = new Map<string, number>();
for (const s of skills) {
counts.set(s.mode, (counts.get(s.mode) ?? 0) + 1);
}
return Array.from(counts.entries()).sort((a, b) => a[0].localeCompare(b[0]));
}, [skills]);
// Categories are optional per-skill metadata (`od.category` in the
// SKILL.md frontmatter). The pill row only renders when at least one
// skill in the listing carries one, so a project that ships only the
// baseline functional skills doesn't see an empty filter row.
const categoryOptions = useMemo(() => {
const counts = new Map<string, number>();
for (const s of skills) {
const cat = s.category;
if (typeof cat !== 'string' || !cat) continue;
counts.set(cat, (counts.get(cat) ?? 0) + 1);
}
return Array.from(counts.entries()).sort((a, b) => a[0].localeCompare(b[0]));
}, [skills]);
const filteredSkills = useMemo(() => {
const q = search.toLowerCase().trim();
return skills.filter((s) => {
if (modeFilter !== 'all' && s.mode !== modeFilter) return false;
if (sourceFilter !== 'all' && s.source !== sourceFilter) return false;
if (categoryFilter !== 'all' && s.category !== categoryFilter)
return false;
if (!q) return true;
const hay = `${s.name}\n${s.description}\n${(s.triggers ?? []).join(
' ',
)}\n${s.category ?? ''}`;
return hay.toLowerCase().includes(q);
});
}, [skills, modeFilter, sourceFilter, categoryFilter, search]);
const ensureBody = useCallback(
async (id: string) => {
if (bodyById[id] !== undefined) return bodyById[id];
setBodyLoadingId(id);
try {
const detail = await fetchSkill(id);
const body = detail?.body ?? '';
setBodyById((cur) => ({ ...cur, [id]: body }));
return body;
} finally {
setBodyLoadingId((cur) => (cur === id ? null : cur));
}
},
[bodyById],
);
const ensureFiles = useCallback(
async (id: string) => {
if (filesById[id]) return filesById[id]!;
setFilesLoadingId(id);
try {
const files = await fetchSkillFiles(id);
setFilesById((cur) => ({ ...cur, [id]: files }));
return files;
} finally {
setFilesLoadingId((cur) => (cur === id ? null : cur));
}
},
[filesById],
);
const toggleExpanded = useCallback(
(id: string) => {
setExpandedId((cur) => {
if (cur === id) return null;
void ensureBody(id);
void ensureFiles(id);
return id;
});
// Switching rows aborts any in-flight edit on the previous row.
setEditingId((cur) => (cur === id ? cur : null));
setConfirmDeleteId(null);
setConfirmBuiltInEditId(null);
},
[ensureBody, ensureFiles],
);
const startCreate = useCallback(() => {
setCreating(true);
setDraft(EMPTY_DRAFT);
setDraftError(null);
setEditingId(null);
setConfirmDeleteId(null);
setConfirmBuiltInEditId(null);
}, []);
const startEdit = useCallback(
async (skill: SkillSummary) => {
const body = await ensureBody(skill.id);
setDraft(summaryToDraft(skill, body ?? ''));
setDraftError(null);
setEditingId(skill.id);
setExpandedId(skill.id);
setCreating(false);
setConfirmDeleteId(null);
setConfirmBuiltInEditId(null);
},
[ensureBody],
);
const requestEdit = useCallback(
(skill: SkillSummary) => {
if (skill.source === 'built-in') {
setConfirmBuiltInEditId(skill.id);
setConfirmDeleteId(null);
return;
}
void startEdit(skill);
},
[startEdit],
);
const cancelBuiltInEdit = useCallback(() => {
setConfirmBuiltInEditId(null);
}, []);
const cancelDraft = useCallback(() => {
setDraft(EMPTY_DRAFT);
setDraftError(null);
setEditingId(null);
setCreating(false);
}, []);
const submitDraft = useCallback(async () => {
if (draftSaving) return;
const name = draft.name.trim();
const body = draft.body.trim();
if (!name) {
setDraftError(t('settings.skillsNameRequired'));
return;
}
if (!body) {
setDraftError(t('settings.skillsBodyRequired'));
return;
}
const triggers = parseTriggers(draft.triggers);
const payload = {
name,
description: draft.description.trim() || undefined,
body,
triggers,
};
setDraftSaving(true);
setDraftError(null);
const result =
editingId
? await updateSkill(editingId, payload)
: await importSkill(payload);
setDraftSaving(false);
if ('error' in result) {
setDraftError(result.error.message);
return;
}
const updated = result.skill;
await refresh();
await onSkillsRefresh?.();
setBodyById((cur) => ({ ...cur, [updated.id]: body }));
// Drop the cached file tree for this id so the next expand
// re-walks the on-disk folder; SKILL.md may have been the only
// file before, but the user might have meant to add more.
setFilesById((cur) => {
const next = { ...cur };
delete next[updated.id];
return next;
});
setExpandedId(updated.id);
setEditingId(null);
setCreating(false);
setDraft(EMPTY_DRAFT);
onSkillsChanged?.(updated.id);
}, [draft, draftSaving, editingId, onSkillsChanged, onSkillsRefresh, refresh]);
const armDelete = useCallback((id: string) => {
setConfirmDeleteId(id);
}, []);
const cancelDelete = useCallback(() => {
setConfirmDeleteId(null);
}, []);
const commitDelete = useCallback(
async (id: string) => {
const result = await deleteSkill(id);
if ('error' in result) {
setDraftError(result.error.message);
return;
}
setConfirmDeleteId(null);
await refresh();
await onSkillsRefresh?.();
setBodyById((cur) => {
const next = { ...cur };
delete next[id];
return next;
});
setFilesById((cur) => {
const next = { ...cur };
delete next[id];
return next;
});
// Clear the disabled-skill flag so deleting a skill that was
// toggled off doesn't leave dangling preferences behind.
setCfg((c) => {
const set = new Set(c.disabledSkills ?? []);
set.delete(id);
return { ...c, disabledSkills: [...set] };
});
if (expandedId === id) setExpandedId(null);
if (editingId === id) {
setEditingId(null);
setDraft(EMPTY_DRAFT);
}
onSkillsChanged?.(id);
},
[editingId, expandedId, onSkillsChanged, onSkillsRefresh, refresh, setCfg],
);
const toggleEnabled = useCallback(
(id: string, enabled: boolean) => {
setCfg((c) => {
const set = new Set(c.disabledSkills ?? []);
if (enabled) set.delete(id);
else set.add(id);
return { ...c, disabledSkills: [...set] };
});
},
[setCfg],
);
return (
<section className="settings-section settings-skills">
<div className="library-toolbar skills-toolbar">
{/* Row 1: search + New skill button */}
<div className="skills-toolbar-top">
<input
type="search"
className="library-search"
placeholder={t('settings.librarySearch')}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<button
type="button"
className="primary skills-add-btn"
onClick={startCreate}
data-testid="skills-new"
>
<Icon name="plus" size={13} />
<span>{t('settings.skillsNew')}</span>
</button>
</div>
{/* Row 2: filter dropdowns */}
<div className="library-filter-selects">
<label className="library-filter-select">
<span className="library-filter-select-label">Source</span>
<select
value={sourceFilter}
data-active={sourceFilter !== 'all' ? 'true' : undefined}
onChange={(e) => setSourceFilter(e.target.value as SourceFilter)}
>
<option value="all">
{t('settings.libraryAll')} ({skills.length})
</option>
{(['user', 'built-in'] as const).map((s) => {
const count = skills.filter((sk) => sk.source === s).length;
return (
<option key={s} value={s}>
{s} ({count})
</option>
);
})}
</select>
</label>
<label className="library-filter-select">
<span className="library-filter-select-label">Type</span>
<select
value={modeFilter}
data-active={modeFilter !== 'all' ? 'true' : undefined}
onChange={(e) => setModeFilter(e.target.value)}
>
<option value="all">
{t('settings.libraryAll')} ({skills.length})
</option>
{modeOptions.map(([mode, count]) => (
<option key={mode} value={mode}>
{mode} ({count})
</option>
))}
</select>
</label>
{categoryOptions.length > 0 ? (
<label
className="library-filter-select"
data-testid="skills-category-filters"
>
<span className="library-filter-select-label">Category</span>
<select
value={categoryFilter}
data-active={categoryFilter !== 'all' ? 'true' : undefined}
onChange={(e) => setCategoryFilter(e.target.value)}
>
<option value="all">
{t('settings.libraryAll')} ({skills.length})
</option>
{categoryOptions.map(([cat, count]) => (
<option key={cat} value={cat}>
{humanizeCategory(cat)} ({count})
</option>
))}
</select>
</label>
) : null}
</div>
</div>
{creating ? (
<SkillDraftForm
heading={t('settings.skillsNew')}
subheading={null}
draft={draft}
setDraft={setDraft}
error={draftError}
saving={draftSaving}
isEdit={false}
onCancel={cancelDraft}
onSubmit={() => void submitDraft()}
/>
) : null}
{filteredSkills.length === 0 ? (
<div className="empty-card">
<strong>{t('settings.libraryNoResults')}</strong>
</div>
) : (
<div className="skills-rows" data-testid="skills-list">
{filteredSkills.map((skill) => {
const enabled = !disabledSkills.has(skill.id);
const isExpanded = expandedId === skill.id;
const isEditing = editingId === skill.id;
return (
<SkillRow
key={skill.id}
skill={skill}
enabled={enabled}
expanded={isExpanded}
editing={isEditing}
body={bodyById[skill.id]}
bodyLoading={bodyLoadingId === skill.id}
files={filesById[skill.id] ?? null}
filesLoading={filesLoadingId === skill.id}
confirmDelete={confirmDeleteId === skill.id}
confirmBuiltInEdit={confirmBuiltInEditId === skill.id}
draft={isEditing ? draft : null}
draftError={isEditing ? draftError : null}
draftSaving={isEditing && draftSaving}
setDraft={setDraft}
onToggleExpanded={() => toggleExpanded(skill.id)}
onToggleEnabled={(e) => toggleEnabled(skill.id, e)}
onStartEdit={() => requestEdit(skill)}
onConfirmBuiltInEdit={() => void startEdit(skill)}
onCancelBuiltInEdit={cancelBuiltInEdit}
onArmDelete={() => armDelete(skill.id)}
onCancelDelete={cancelDelete}
onCommitDelete={() => void commitDelete(skill.id)}
onCancelEdit={cancelDraft}
onSubmitEdit={() => void submitDraft()}
/>
);
})}
</div>
)}
</section>
);
}
interface SkillRowProps {
skill: SkillSummary;
enabled: boolean;
expanded: boolean;
editing: boolean;
body: string | undefined;
bodyLoading: boolean;
files: SkillFileEntry[] | null;
filesLoading: boolean;
confirmDelete: boolean;
confirmBuiltInEdit: boolean;
draft: DraftState | null;
draftError: string | null;
draftSaving: boolean;
setDraft: Dispatch<SetStateAction<DraftState>>;
onToggleExpanded: () => void;
onToggleEnabled: (enabled: boolean) => void;
onStartEdit: () => void;
onConfirmBuiltInEdit: () => void;
onCancelBuiltInEdit: () => void;
onArmDelete: () => void;
onCancelDelete: () => void;
onCommitDelete: () => void;
onCancelEdit: () => void;
onSubmitEdit: () => void;
}
function SkillRow({
skill,
enabled,
expanded,
editing,
body,
bodyLoading,
files,
filesLoading,
confirmDelete,
confirmBuiltInEdit,
draft,
draftError,
draftSaving,
setDraft,
onToggleExpanded,
onToggleEnabled,
onStartEdit,
onConfirmBuiltInEdit,
onCancelBuiltInEdit,
onArmDelete,
onCancelDelete,
onCommitDelete,
onCancelEdit,
onSubmitEdit,
}: SkillRowProps) {
const t = useT();
const { locale } = useI18n();
const summaryName = localizeSkillName(locale, skill) || skill.id;
const summaryDescription = localizeSkillDescription(locale, skill);
const canDelete = skill.source === 'user';
return (
<div
className={`skills-row${enabled ? '' : ' skills-row-disabled'}${
expanded ? ' skills-row-expanded' : ''
}${editing ? ' skills-row-editing' : ''}`}
data-testid={`skill-row-${skill.id}`}
>
<div className="skills-row-head">
<button
type="button"
className="skills-row-summary-btn"
onClick={onToggleExpanded}
aria-expanded={expanded}
title={expanded ? 'Collapse' : 'Expand'}
>
<span className="skills-row-icon" aria-hidden>
<Icon name="grid" size={14} />
</span>
<span className="skills-row-summary">
<span className="skills-row-summary-line">
<span className="skills-row-summary-name">{summaryName}</span>
<span className="skills-row-summary-mode">{skill.mode}</span>
{skill.category ? (
<span
className="skills-row-summary-category"
title={`Category: ${humanizeCategory(skill.category)}`}
>
{humanizeCategory(skill.category)}
</span>
) : null}
{skill.source === 'user' ? (
<span
className="skills-row-summary-source"
title="User-imported skill"
>
user
</span>
) : null}
</span>
{summaryDescription ? (
<span className="skills-row-summary-desc">{summaryDescription}</span>
) : null}
</span>
<span className="skills-row-chevron" aria-hidden>
<Icon name="chevron-down" size={14} />
</span>
</button>
<div className="skills-row-actions">
{canDelete && confirmDelete ? (
<span className="skills-delete-confirm" role="group">
<button
type="button"
className="btn danger"
onClick={onCommitDelete}
data-testid="skills-delete-confirm"
>
{t('settings.skillsDeleteConfirm')}
</button>
<button
type="button"
className="btn ghost"
onClick={onCancelDelete}
>
{t('common.cancel')}
</button>
</span>
) : (
<>
<Button
size="icon"
onClick={onStartEdit}
title={t('settings.skillsEdit')}
data-testid="skills-edit"
>
<Icon name="edit" size={13} />
</Button>
{canDelete ? (
<Button
size="icon"
onClick={onArmDelete}
title={t('settings.skillsDelete')}
data-testid="skills-delete"
>
<Icon name="close" size={13} />
</Button>
) : null}
</>
)}
<label
className="toggle-switch toggle-switch-sm skills-row-enable"
title={t('settings.libraryToggleLabel')}
>
<input
type="checkbox"
checked={enabled}
onChange={(e) => onToggleEnabled(e.target.checked)}
aria-label={t('settings.libraryToggleLabel')}
/>
<span className="toggle-slider" />
</label>
</div>
</div>
{confirmBuiltInEdit ? (
<div
className="skills-edit-builtin-warning"
role="alert"
data-testid="skills-edit-builtin-warning"
>
<p>
Editing this built-in skill creates a user override. The built-in
entry will be hidden from the list until you delete the override.
Continue?
</p>
<div className="skills-edit-builtin-actions">
<button
type="button"
className="btn ghost"
onClick={onCancelBuiltInEdit}
data-testid="skills-edit-builtin-cancel"
>
{t('common.cancel')}
</button>
<button
type="button"
className="btn primary"
onClick={onConfirmBuiltInEdit}
data-testid="skills-edit-builtin-confirm"
>
{t('settings.skillsEdit')}
</button>
</div>
</div>
) : null}
{expanded && !editing ? (
<div className="skills-row-detail">
<div className="skills-row-section">
<h5>SKILL.md</h5>
{bodyLoading ? (
<p className="library-empty">{t('settings.libraryLoading')}</p>
) : (
<pre className="library-preview-body">{body ?? ''}</pre>
)}
</div>
<div className="skills-row-section">
<h5>{t('settings.skillsFiles')}</h5>
{filesLoading ? (
<p className="library-empty">{t('settings.libraryLoading')}</p>
) : !files || files.length === 0 ? (
<p className="library-empty">{t('settings.skillsNoFiles')}</p>
) : (
<ul className="skills-file-tree">
{files.map((entry) => (
<li
key={entry.path}
className={`skills-file-entry skills-file-entry-${entry.kind}`}
style={{ paddingLeft: depthIndent(entry.path) }}
>
<Icon
name={entry.kind === 'directory' ? 'folder' : 'file'}
size={12}
/>
<span>{leafName(entry.path)}</span>
{entry.kind === 'file' && typeof entry.size === 'number' ? (
<span className="skills-file-size">
{formatSize(entry.size)}
</span>
) : null}
</li>
))}
</ul>
)}
</div>
</div>
) : null}
{editing && draft ? (
<SkillDraftForm
heading={t('settings.skillsEdit')}
subheading={skill.id}
draft={draft}
setDraft={setDraft}
error={draftError}
saving={draftSaving}
isEdit
onCancel={onCancelEdit}
onSubmit={onSubmitEdit}
/>
) : null}
</div>
);
}
interface SkillDraftFormProps {
heading: string;
subheading: string | null;
draft: DraftState;
setDraft: Dispatch<SetStateAction<DraftState>>;
error: string | null;
saving: boolean;
isEdit: boolean;
onCancel: () => void;
onSubmit: () => void;
}
function SkillDraftForm({
heading,
subheading,
draft,
setDraft,
error,
saving,
isEdit,
onCancel,
onSubmit,
}: SkillDraftFormProps) {
const t = useT();
return (
<div
className="skills-draft library-import-form"
data-testid={isEdit ? 'skills-edit-form' : 'skills-create-form'}
>
<header className="skills-draft-head">
<div>
<h4>{heading}</h4>
{subheading ? <p className="skills-draft-sub">{subheading}</p> : null}
</div>
</header>
<div className="library-import-row">
<label>
<span>{t('settings.skillsName')}</span>
<input
type="text"
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
placeholder="my-skill"
disabled={isEdit}
/>
</label>
<label>
<span>{t('settings.skillsTriggers')}</span>
<input
type="text"
value={draft.triggers}
onChange={(e) =>
setDraft((d) => ({ ...d, triggers: e.target.value }))
}
placeholder="search the web, summarize"
/>
</label>
</div>
<label className="library-import-block">
<span>{t('settings.skillsDescription')}</span>
<textarea
rows={2}
value={draft.description}
onChange={(e) =>
setDraft((d) => ({ ...d, description: e.target.value }))
}
placeholder="What does this skill do? When should the agent reach for it?"
/>
</label>
<label className="library-import-block">
<span>{t('settings.skillsBody')}</span>
<textarea
rows={14}
value={draft.body}
onChange={(e) => setDraft((d) => ({ ...d, body: e.target.value }))}
placeholder={'# My skill\n\n1. Explain the workflow.\n2. Describe the inputs and outputs.'}
/>
</label>
{error ? (
<div className="library-import-error" role="alert">
{error}
</div>
) : null}
<div className="library-import-actions">
<button
type="button"
className="btn ghost"
onClick={onCancel}
disabled={saving}
>
{t('common.cancel')}
</button>
<button
type="button"
className="btn primary"
onClick={onSubmit}
disabled={saving}
data-testid="skills-save"
>
{saving
? t('settings.skillsSaving')
: isEdit
? t('settings.skillsSave')
: t('settings.skillsCreate')}
</button>
</div>
</div>
);
}
// Each `/`-separated segment indents by 12px so a small assets/ tree
// reads as a tree without us building a nested list. Capped at 4 levels
// so bundles with deep folder hierarchies don't push the file label
// past the panel.
function depthIndent(p: string): number {
const depth = Math.min(4, p.split('/').length - 1);
return depth * 12;
}
function leafName(p: string): string {
const idx = p.lastIndexOf('/');
return idx >= 0 ? p.slice(idx + 1) : p;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// Frontmatter-style category slugs come in as kebab-case
// ("image-generation"). Render them as Title Case in the filter pill so
// the row reads as a category list rather than a raw enum dump.
function humanizeCategory(slug: string): string {
if (!slug) return slug;
return slug
.split('-')
.map((word) =>
word.length === 0
? word
: word.charAt(0).toUpperCase() + word.slice(1),
)
.join(' ');
}