fix(ui): localize plugins and skills

This commit is contained in:
Vincent Koc
2026-07-12 13:45:11 +08:00
committed by Vincent Koc
parent ca5195418b
commit 65f8e27900
7 changed files with 258 additions and 199 deletions
+8 -7
View File
@@ -1,5 +1,6 @@
// Shared pure skill grouping helper.
import type { SkillStatusEntry } from "../api/types.ts";
import { t } from "../i18n/index.ts";
export type SkillGroup = {
id: string;
@@ -7,20 +8,20 @@ export type SkillGroup = {
skills: SkillStatusEntry[];
};
const SKILL_SOURCE_GROUPS: Array<{ id: string; label: string; sources: string[] }> = [
{ id: "workspace", label: "Workspace Skills", sources: ["openclaw-workspace"] },
{ id: "built-in", label: "Built-in Skills", sources: ["openclaw-bundled"] },
{ id: "installed", label: "Installed Skills", sources: ["openclaw-managed"] },
{ id: "extra", label: "Extra Skills", sources: ["openclaw-extra"] },
const SKILL_SOURCE_GROUPS: Array<{ id: string; labelKey: string; sources: string[] }> = [
{ id: "workspace", labelKey: "skillGroups.workspace", sources: ["openclaw-workspace"] },
{ id: "built-in", labelKey: "skillGroups.builtIn", sources: ["openclaw-bundled"] },
{ id: "installed", labelKey: "skillGroups.installed", sources: ["openclaw-managed"] },
{ id: "extra", labelKey: "skillGroups.extra", sources: ["openclaw-extra"] },
];
export function groupSkills(skills: SkillStatusEntry[]): SkillGroup[] {
const groups = new Map<string, SkillGroup>();
for (const def of SKILL_SOURCE_GROUPS) {
groups.set(def.id, { id: def.id, label: def.label, skills: [] });
groups.set(def.id, { id: def.id, label: t(def.labelKey), skills: [] });
}
const builtInGroup = SKILL_SOURCE_GROUPS.find((group) => group.id === "built-in");
const other: SkillGroup = { id: "other", label: "Other Skills", skills: [] };
const other: SkillGroup = { id: "other", label: t("skillGroups.other"), skills: [] };
for (const skill of skills) {
const match = skill.bundled
? builtInGroup
+9 -6
View File
@@ -1,6 +1,7 @@
// Shared skill status rendering and classification helpers.
import { html, nothing } from "lit";
import type { SkillStatusEntry } from "../api/types.ts";
import { t } from "../i18n/index.ts";
export function computeSkillMissing(skill: SkillStatusEntry): string[] {
return [
@@ -14,13 +15,13 @@ export function computeSkillMissing(skill: SkillStatusEntry): string[] {
export function computeSkillReasons(skill: SkillStatusEntry): string[] {
const reasons: string[] = [];
if (skill.disabled) {
reasons.push("disabled");
reasons.push(t("skillStatus.disabled"));
}
if (skill.blockedByAllowlist) {
reasons.push("blocked by allowlist");
reasons.push(t("skillStatus.blockedAllowlist"));
}
if (skill.blockedByAgentFilter) {
reasons.push("blocked by agent filter");
reasons.push(t("skillStatus.blockedAgentFilter"));
}
return reasons;
}
@@ -39,11 +40,13 @@ export function renderSkillStatusChips(params: {
return html`
<div class="chip-row" style="margin-top: 6px;">
<span class="chip">${skill.source}</span>
${showBundledBadge ? html` <span class="chip">bundled</span> ` : nothing}
${showBundledBadge ? html` <span class="chip">${t("skillStatus.bundled")}</span> ` : nothing}
<span class="chip ${available ? "chip-ok" : "chip-warn"}">
${available ? "eligible" : "blocked"}
${available ? t("skillStatus.eligible") : t("skillStatus.blocked")}
</span>
${skill.disabled ? html` <span class="chip chip-warn">disabled</span> ` : nothing}
${skill.disabled
? html` <span class="chip chip-warn">${t("skillStatus.disabled")}</span> `
: nothing}
</div>
`;
}
+30 -31
View File
@@ -275,15 +275,14 @@ export const CONNECTOR_GROUP_ORDER: readonly ConnectorGroup[] = ["work", "dev",
export type ConnectorSuggestion = {
id: string;
name: string;
description: string;
descriptionKey: string;
group: ConnectorGroup;
action: { kind: "mcp"; mcp: ConnectorMcpTemplate } | { kind: "clawhub"; query: string };
};
/**
* Curated connector shelf: one-click MCP servers for official hosted endpoints
* plus ClawHub searches proven to return live packages. Descriptions are
* catalog data (like manifest descriptions), not localized UI chrome.
* plus ClawHub searches proven to return live packages.
*/
export const CONNECTOR_SUGGESTIONS: readonly ConnectorSuggestion[] = [
// --- Work & productivity ---
@@ -291,7 +290,7 @@ export const CONNECTOR_SUGGESTIONS: readonly ConnectorSuggestion[] = [
id: "notion",
group: "work",
name: "Notion",
description: "Search, create, and update pages and databases in your Notion workspace.",
descriptionKey: "pluginsPage.connectorDescriptions.notion",
action: {
kind: "mcp",
mcp: {
@@ -306,7 +305,7 @@ export const CONNECTOR_SUGGESTIONS: readonly ConnectorSuggestion[] = [
id: "linear",
group: "work",
name: "Linear",
description: "Triage issues, update cycles, and file bugs straight from chat.",
descriptionKey: "pluginsPage.connectorDescriptions.linear",
action: {
kind: "mcp",
mcp: {
@@ -322,7 +321,7 @@ export const CONNECTOR_SUGGESTIONS: readonly ConnectorSuggestion[] = [
id: "todoist",
group: "work",
name: "Todoist",
description: "Read, add, and complete tasks and projects in Todoist.",
descriptionKey: "pluginsPage.connectorDescriptions.todoist",
action: {
kind: "mcp",
mcp: {
@@ -338,7 +337,7 @@ export const CONNECTOR_SUGGESTIONS: readonly ConnectorSuggestion[] = [
id: "airtable",
group: "work",
name: "Airtable",
description: "Query and update records, tables, and bases in Airtable.",
descriptionKey: "pluginsPage.connectorDescriptions.airtable",
action: {
kind: "mcp",
mcp: {
@@ -357,14 +356,14 @@ export const CONNECTOR_SUGGESTIONS: readonly ConnectorSuggestion[] = [
id: "jira",
group: "work",
name: "Jira",
description: "Create, search, and triage Jira tickets from chat.",
descriptionKey: "pluginsPage.connectorDescriptions.jira",
action: { kind: "clawhub", query: "jira" },
},
{
id: "canva",
group: "work",
name: "Canva",
description: "Create and edit Canva designs, manage assets, and export results.",
descriptionKey: "pluginsPage.connectorDescriptions.canva",
action: {
kind: "mcp",
mcp: {
@@ -379,7 +378,7 @@ export const CONNECTOR_SUGGESTIONS: readonly ConnectorSuggestion[] = [
id: "stripe",
group: "work",
name: "Stripe",
description: "Check payments, customers, invoices, and subscriptions in your Stripe account.",
descriptionKey: "pluginsPage.connectorDescriptions.stripe",
action: {
kind: "mcp",
mcp: {
@@ -394,28 +393,28 @@ export const CONNECTOR_SUGGESTIONS: readonly ConnectorSuggestion[] = [
id: "google-calendar",
group: "work",
name: "Calendar",
description: "Read, create, and get briefed on events — your agent owns your schedule.",
descriptionKey: "pluginsPage.connectorDescriptions.googleCalendar",
action: { kind: "clawhub", query: "google calendar" },
},
{
id: "email-inbox",
group: "work",
name: "Email",
description: "Mailbox triage, summaries, and drafts with send-on-approval.",
descriptionKey: "pluginsPage.connectorDescriptions.email",
action: { kind: "clawhub", query: "email" },
},
{
id: "pdf-tools",
group: "work",
name: "PDF",
description: "Extract, merge, convert, and OCR PDF documents.",
descriptionKey: "pluginsPage.connectorDescriptions.pdf",
action: { kind: "clawhub", query: "pdf" },
},
{
id: "transcription",
group: "work",
name: "Transcription",
description: "Turn audio and video into clean, structured transcripts.",
descriptionKey: "pluginsPage.connectorDescriptions.transcription",
action: { kind: "clawhub", query: "transcription" },
},
// --- Coding & infrastructure ---
@@ -423,7 +422,7 @@ export const CONNECTOR_SUGGESTIONS: readonly ConnectorSuggestion[] = [
id: "github",
group: "dev",
name: "GitHub",
description: "PR review queues, issue triage, and repo Q&A through the official GitHub MCP.",
descriptionKey: "pluginsPage.connectorDescriptions.github",
action: {
kind: "mcp",
mcp: {
@@ -444,7 +443,7 @@ export const CONNECTOR_SUGGESTIONS: readonly ConnectorSuggestion[] = [
id: "sentry",
group: "dev",
name: "Sentry",
description: "Crash alerts explained and triaged the moment they fire.",
descriptionKey: "pluginsPage.connectorDescriptions.sentry",
action: {
kind: "mcp",
mcp: {
@@ -459,7 +458,7 @@ export const CONNECTOR_SUGGESTIONS: readonly ConnectorSuggestion[] = [
id: "context7",
group: "dev",
name: "Context7",
description: "Version-specific library docs and code examples while coding. No signup needed.",
descriptionKey: "pluginsPage.connectorDescriptions.context7",
action: {
kind: "mcp",
mcp: {
@@ -474,7 +473,7 @@ export const CONNECTOR_SUGGESTIONS: readonly ConnectorSuggestion[] = [
id: "deepwiki",
group: "dev",
name: "DeepWiki",
description: "Ask questions about any public GitHub repo. Free, no account needed.",
descriptionKey: "pluginsPage.connectorDescriptions.deepwiki",
action: {
kind: "mcp",
mcp: {
@@ -489,7 +488,7 @@ export const CONNECTOR_SUGGESTIONS: readonly ConnectorSuggestion[] = [
id: "hugging-face",
group: "dev",
name: "Hugging Face",
description: "Search models, datasets, and papers; run Spaces as tools.",
descriptionKey: "pluginsPage.connectorDescriptions.huggingFace",
action: {
kind: "mcp",
mcp: {
@@ -504,14 +503,14 @@ export const CONNECTOR_SUGGESTIONS: readonly ConnectorSuggestion[] = [
id: "grafana",
group: "dev",
name: "Grafana",
description: "Grafana know-how and community connectors for dashboards and alerts.",
descriptionKey: "pluginsPage.connectorDescriptions.grafana",
action: { kind: "clawhub", query: "grafana" },
},
{
id: "kubernetes",
group: "dev",
name: "Kubernetes",
description: "Cluster operations and troubleshooting from chat.",
descriptionKey: "pluginsPage.connectorDescriptions.kubernetes",
action: { kind: "clawhub", query: "kubernetes" },
},
// --- Home & media ---
@@ -519,7 +518,7 @@ export const CONNECTOR_SUGGESTIONS: readonly ConnectorSuggestion[] = [
id: "home-assistant",
group: "home",
name: "Home Assistant",
description: "Control lights, climate, and automations across your whole home.",
descriptionKey: "pluginsPage.connectorDescriptions.homeAssistant",
action: {
kind: "mcp",
mcp: {
@@ -537,21 +536,21 @@ export const CONNECTOR_SUGGESTIONS: readonly ConnectorSuggestion[] = [
id: "spotify",
group: "home",
name: "Spotify",
description: "Search, queue, and soundtrack your day with mood-based playlists.",
descriptionKey: "pluginsPage.connectorDescriptions.spotify",
action: { kind: "clawhub", query: "spotify" },
},
{
id: "sonos",
group: "home",
name: "Sonos",
description: "Whole-home audio: play, group rooms, and queue by chat.",
descriptionKey: "pluginsPage.connectorDescriptions.sonos",
action: { kind: "clawhub", query: "sonos" },
},
{
id: "reddit",
group: "life",
name: "Reddit",
description: "Browse, search, and summarize subreddits and threads.",
descriptionKey: "pluginsPage.connectorDescriptions.reddit",
action: { kind: "clawhub", query: "reddit" },
},
// --- Everyday life ---
@@ -559,42 +558,42 @@ export const CONNECTOR_SUGGESTIONS: readonly ConnectorSuggestion[] = [
id: "portfolio-pulse",
group: "life",
name: "Markets",
description: "Live stocks and crypto with price alerts and daily digests.",
descriptionKey: "pluginsPage.connectorDescriptions.markets",
action: { kind: "clawhub", query: "stocks" },
},
{
id: "trip-scout",
group: "life",
name: "Travel",
description: "Flight and hotel search with fare watching and trip memory.",
descriptionKey: "pluginsPage.connectorDescriptions.travel",
action: { kind: "clawhub", query: "flights" },
},
{
id: "morning-brief",
group: "life",
name: "News",
description: "A personalized daily briefing: news, weather, and tasks in one message.",
descriptionKey: "pluginsPage.connectorDescriptions.news",
action: { kind: "clawhub", query: "news" },
},
{
id: "maps",
group: "life",
name: "Maps",
description: "Places, routing, and travel-time answers.",
descriptionKey: "pluginsPage.connectorDescriptions.maps",
action: { kind: "clawhub", query: "maps" },
},
{
id: "translation",
group: "life",
name: "Translation",
description: "Translate and localize text and documents.",
descriptionKey: "pluginsPage.connectorDescriptions.translation",
action: { kind: "clawhub", query: "translation" },
},
{
id: "notes",
group: "life",
name: "Notes",
description: "Capture notes to Markdown, Obsidian, Notion, or Bear.",
descriptionKey: "pluginsPage.connectorDescriptions.notes",
action: { kind: "clawhub", query: "notes" },
},
];
+7 -5
View File
@@ -158,7 +158,7 @@ function matchesConnector(connector: ConnectorSuggestion, query: string): boolea
if (!needle) {
return true;
}
return [connector.id, connector.name, connector.description].some((value) =>
return [connector.id, connector.name, t(connector.descriptionKey)].some((value) =>
value.toLocaleLowerCase().includes(needle),
);
}
@@ -751,8 +751,10 @@ function renderMcpRow(server: McpServerSummary, props: PluginsViewProps): Templa
<div class="plugins-row__copy">
<div class="plugins-row__title">
<h3>${server.name}</h3>
<span class="plugins-badge plugins-badge--mcp">MCP</span>
${server.auth === "oauth" ? html`<span class="plugins-badge">OAuth</span>` : nothing}
<span class="plugins-badge plugins-badge--mcp">${t("pluginsPage.mcp")}</span>
${server.auth === "oauth"
? html`<span class="plugins-badge">${t("pluginsPage.oauth")}</span>`
: nothing}
</div>
<p class="plugins-row__target">${server.target}</p>
<div class="plugins-row__meta"><span>${server.transport}</span></div>
@@ -921,10 +923,10 @@ function renderConnectorCard(
<div class="plugins-card__title-row">
<h3>${connector.name}</h3>
</div>
<p>${connector.description}</p>
<p>${t(connector.descriptionKey)}</p>
<div class="plugins-card__meta">
${isMcp
? html`<span class="plugins-badge plugins-badge--mcp">MCP</span>
? html`<span class="plugins-badge plugins-badge--mcp">${t("pluginsPage.mcp")}</span>
<span>${t("pluginsPage.connectorMcpNote")}</span>`
: html`<span>${t("pluginsPage.connectorClawHubNote")}</span>`}
</div>
@@ -167,7 +167,7 @@ function renderSkillWorkshopHeaderControls(state: SkillWorkshopState, requestUpd
<div
class="sw-mode-switch"
role="tablist"
aria-label="Workshop view"
aria-label=${t("skillWorkshop.header.view")}
data-mode=${state.skillWorkshopMode}
>
<button
@@ -182,7 +182,7 @@ function renderSkillWorkshopHeaderControls(state: SkillWorkshopState, requestUpd
<rect x="14" y="4" width="7" height="9" rx="1.5" />
<rect x="14" y="15" width="7" height="5" rx="1.5" />
</svg>
<span>Board</span>
<span>${t("skillWorkshop.header.board")}</span>
</button>
<button
type="button"
@@ -197,7 +197,7 @@ function renderSkillWorkshopHeaderControls(state: SkillWorkshopState, requestUpd
d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4 7 17M17 7l1.4-1.4"
/>
</svg>
<span>Today</span>
<span>${t("skillWorkshop.header.today")}</span>
</button>
<span class="sw-mode-switch__indicator" aria-hidden="true"></span>
</div>
+137 -95
View File
@@ -65,21 +65,21 @@ const STATUS_TABS: SkillWorkshopStatusFilter[] = [
];
const STATUS_LABEL: Record<SkillWorkshopStatusFilter, string> = {
all: "All",
pending: "Pending",
applied: "Applied",
rejected: "Rejected",
quarantined: "Quarantined",
stale: "Stale",
all: "skillWorkshop.status.all",
pending: "skillWorkshop.status.pending",
applied: "skillWorkshop.status.applied",
rejected: "skillWorkshop.status.rejected",
quarantined: "skillWorkshop.status.quarantined",
stale: "skillWorkshop.status.stale",
};
const TODAY_PREVIEW_MAX_ITEMS = 3;
const TODAY_PREVIEW_MAX_ITEM_CHARS = 120;
const GROUP_LABEL: Record<SkillWorkshopProposal["recencyGroup"], string> = {
today: "Today",
yesterday: "Yesterday",
earlier: "Earlier this week",
today: "skillWorkshop.recency.today",
yesterday: "skillWorkshop.recency.yesterday",
earlier: "skillWorkshop.recency.earlier",
};
export function renderSkillWorkshop(props: SkillWorkshopProps) {
@@ -123,7 +123,7 @@ export function renderSkillWorkshop(props: SkillWorkshopProps) {
.files=${selected.supportFiles}
.activePath=${preview.path}
.query=${props.filePreviewQuery}
.contextLabel=${`in ${selected.slug}`}
.contextLabel=${t("skillWorkshop.previewContext", { slug: selected.slug })}
@file-preview-query-change=${(event: CustomEvent<string>) =>
props.onFilePreviewQueryChange(event.detail)}
@file-preview-select=${(event: CustomEvent<string>) =>
@@ -139,7 +139,8 @@ export function renderSkillWorkshop(props: SkillWorkshopProps) {
function renderRevisionDialog(props: SkillWorkshopProps, proposal: SkillWorkshopProposal) {
const busy = props.actionBusy?.key === proposal.key && props.actionBusy.action === "revise";
const canSubmit = props.revisionDraft.trim().length > 0 && !props.actionBusy;
const verb = props.mode === "board" ? "Revise" : "Tweak";
const verb =
props.mode === "board" ? t("skillWorkshop.actions.revise") : t("skillWorkshop.actions.tweak");
return html`
<div class="sw-revision-backdrop" role="presentation" @click=${props.onRevisionCancel}>
@@ -152,13 +153,15 @@ function renderRevisionDialog(props: SkillWorkshopProps, proposal: SkillWorkshop
>
<div class="sw-revision-dialog__head">
<div>
<div class="sw-revision-dialog__eyebrow">${verb} proposal</div>
<div class="sw-revision-dialog__eyebrow">
${t("skillWorkshop.revision.title", { verb })}
</div>
<h2 id="sw-revision-title">${proposal.slug}</h2>
</div>
<openclaw-tooltip content="Close">
<openclaw-tooltip content=${t("skillWorkshop.actions.close")}>
<button
class="sw-revision-dialog__close"
aria-label="Close"
aria-label=${t("skillWorkshop.actions.close")}
?disabled=${Boolean(props.actionBusy)}
@click=${props.onRevisionCancel}
>
@@ -166,14 +169,11 @@ function renderRevisionDialog(props: SkillWorkshopProps, proposal: SkillWorkshop
</button>
</openclaw-tooltip>
</div>
<p class="sw-revision-dialog__copy">
Tell the agent what should change. The proposal stays pending and the workshop will create
a revised version.
</p>
<p class="sw-revision-dialog__copy">${t("skillWorkshop.revision.description")}</p>
<textarea
class="sw-revision-dialog__input"
autofocus
placeholder="Example: Make this use Gmail labels instead of unread search, and add a safer dry-run step."
placeholder=${t("skillWorkshop.revision.placeholder")}
.value=${props.revisionDraft}
?disabled=${Boolean(props.actionBusy)}
@input=${(event: Event) =>
@@ -183,7 +183,7 @@ function renderRevisionDialog(props: SkillWorkshopProps, proposal: SkillWorkshop
? html`
<div class="sw-revision-dialog__status" role="status">
<span class="sw-revision-dialog__status-dot" aria-hidden="true"></span>
<span>Preparing revision handoff</span>
<span>${t("skillWorkshop.revision.preparing")}</span>
</div>
`
: nothing}
@@ -193,14 +193,14 @@ function renderRevisionDialog(props: SkillWorkshopProps, proposal: SkillWorkshop
?disabled=${Boolean(props.actionBusy)}
@click=${props.onRevisionCancel}
>
Cancel
${t("skillWorkshop.actions.cancel")}
</button>
<button
class="sw-btn sw-btn--primary ${busy ? "is-busy" : ""}"
?disabled=${!canSubmit}
@click=${() => props.onRevisionSubmit(proposal.key)}
>
${busy ? "Sending" : "Send revision"}
${busy ? t("skillWorkshop.actions.sending") : t("skillWorkshop.revision.send")}
</button>
</div>
</section>
@@ -227,7 +227,7 @@ function renderQueueResizer(props: SkillWorkshopProps) {
<div
class="sw-queue-resizer"
role="separator"
aria-label="Resize proposal list"
aria-label=${t("skillWorkshop.queue.resize")}
aria-orientation="vertical"
tabindex="0"
@pointerdown=${(event: PointerEvent) => startQueueResize(event, props)}
@@ -289,7 +289,7 @@ function renderLifecycleTabs(props: SkillWorkshopProps) {
class="sw-lifecycle-tab ${isActive ? "is-active" : ""}"
@click=${() => props.onStatusFilterChange(status)}
>
${STATUS_LABEL[status]} <span class="sw-lifecycle-tab__count">${count}</span>
${t(STATUS_LABEL[status])} <span class="sw-lifecycle-tab__count">${count}</span>
</button>
`;
})}
@@ -308,7 +308,7 @@ function renderQueue(
<aside class="sw-queue">
<div class="sw-queue__search">
<input
placeholder="Search proposals…"
placeholder=${t("skillWorkshop.queue.search")}
.value=${props.query}
@input=${(event: Event) =>
props.onQueryChange((event.target as HTMLInputElement).value ?? "")}
@@ -320,7 +320,8 @@ function renderQueue(
: groups.map(
(group) => html`
<div class="sw-queue__group">
${group.label} <span class="sw-queue__group-pill">${group.items.length}</span>
${t(group.label)}
<span class="sw-queue__group-pill">${group.items.length}</span>
</div>
${group.items.map((proposal) => renderRow(props, proposal, selected))}
`,
@@ -356,8 +357,8 @@ function renderDetail(props: SkillWorkshopProps, proposal: SkillWorkshopProposal
const editedAt =
proposal.updatedAt && proposal.updatedAt > proposal.createdAt ? proposal.updatedAt : null;
const createdLabel = editedAt
? `Edited ${formatRelative(editedAt)}`
: `Created ${formatRelative(proposal.createdAt)}`;
? t("skillWorkshop.detail.edited", { time: formatRelative(editedAt) })
: t("skillWorkshop.detail.created", { time: formatRelative(proposal.createdAt) });
const detailLoading = props.inspectingKey === proposal.key && !proposal.body;
const firstSupportFile = proposal.supportFiles[0];
@@ -377,17 +378,21 @@ function renderDetail(props: SkillWorkshopProps, proposal: SkillWorkshopProposal
class="sw-detail__meta-link"
@click=${() => props.onPreviewFile(proposal.key, firstSupportFile.path)}
>
${proposal.supportFiles.length} support files
${t("skillWorkshop.detail.supportFiles", {
count: String(proposal.supportFiles.length),
})}
</button>`
: html`<span>0 support files</span>`}
: html`<span>${t("skillWorkshop.detail.noSupportFiles")}</span>`}
</div>
</div>
<div class="sw-detail__nav">
<openclaw-tooltip content="Previous">
<button aria-label="Previous" @click=${props.onPrev}>↑</button>
<openclaw-tooltip content=${t("skillWorkshop.actions.previous")}>
<button aria-label=${t("skillWorkshop.actions.previous")} @click=${props.onPrev}>
</button>
</openclaw-tooltip>
<openclaw-tooltip content="Next">
<button aria-label="Next" @click=${props.onNext}>↓</button>
<openclaw-tooltip content=${t("skillWorkshop.actions.next")}>
<button aria-label=${t("skillWorkshop.actions.next")} @click=${props.onNext}>↓</button>
</openclaw-tooltip>
</div>
</div>
@@ -396,14 +401,14 @@ function renderDetail(props: SkillWorkshopProps, proposal: SkillWorkshopProposal
<div class="sw-body-card">
<h1>${proposal.slug}</h1>
${detailLoading
? html`<p class="sw-muted">Loading proposal…</p>`
? html`<p class="sw-muted">${t("skillWorkshop.detail.loading")}</p>`
: renderProposalBody(proposal.body)}
</div>
${proposal.supportFiles.length > 0
? html`
<div class="sw-section" style="margin-top: 18px;">
<h3 class="sw-section__label">Support files</h3>
<h3 class="sw-section__label">${t("skillWorkshop.detail.supportFilesTitle")}</h3>
<div class="sw-files">
${proposal.supportFiles.map(
(file) => html`
@@ -414,7 +419,10 @@ function renderDetail(props: SkillWorkshopProps, proposal: SkillWorkshopProposal
<span>📄</span>
<span class="sw-file__name">${file.path}</span>
<span class="sw-file__size"
>${file.size} <span class="sw-file__hint">· click to preview</span></span
>${file.size}
<span class="sw-file__hint"
>${t("skillWorkshop.detail.clickToPreview")}</span
></span
>
</button>
`,
@@ -451,21 +459,25 @@ function renderPendingActions(props: SkillWorkshopProps, proposal: SkillWorkshop
?disabled=${disabled}
@click=${() => props.onApply(proposal.key)}
>
${busy === "apply" ? "Applying" : "Apply"}
${busy === "apply" ? t("skillWorkshop.actions.applying") : t("skillWorkshop.actions.apply")}
</button>
<button
class="sw-btn ${busy === "revise" ? "is-busy" : ""}"
?disabled=${disabled}
@click=${() => props.onRevise(proposal.key)}
>
${busy === "revise" ? "Opening…" : "Revise"}
${busy === "revise"
? t("skillWorkshop.actions.opening")
: t("skillWorkshop.actions.revise")}
</button>
<button
class="sw-btn sw-btn--ghost sw-btn--danger ${busy === "reject" ? "is-busy" : ""}"
?disabled=${disabled}
@click=${() => props.onReject(proposal.key)}
>
${busy === "reject" ? "Rejecting…" : "Reject"}
${busy === "reject"
? t("skillWorkshop.actions.rejecting")
: t("skillWorkshop.actions.reject")}
</button>
</div>
`;
@@ -494,8 +506,8 @@ function resolveBoardEmptyState(props: SkillWorkshopProps): {
if (props.query.trim()) {
return {
icon: "search",
title: "No matching proposals",
body: "Clear the search or try a different keyword.",
title: t("skillWorkshop.empty.searchTitle"),
body: t("skillWorkshop.empty.searchBody"),
};
}
@@ -503,44 +515,44 @@ function resolveBoardEmptyState(props: SkillWorkshopProps): {
case "pending":
return {
icon: "clock",
title: "No pending proposals",
body: "New drafts will appear here when they need review.",
title: t("skillWorkshop.empty.pendingTitle"),
body: t("skillWorkshop.empty.pendingBody"),
};
case "applied":
return {
icon: "check",
title: "Nothing applied yet",
body: "Use a pending proposal and it will appear here as a live skill.",
title: t("skillWorkshop.empty.appliedTitle"),
body: t("skillWorkshop.empty.appliedBody"),
};
case "rejected":
return {
icon: "x",
title: "No rejected proposals",
body: "Skipped proposals will stay here for a clean review history.",
title: t("skillWorkshop.empty.rejectedTitle"),
body: t("skillWorkshop.empty.rejectedBody"),
};
case "quarantined":
return {
icon: "shield",
title: "Nothing quarantined",
body: "Scanner-blocked or safety-held proposals will appear here.",
title: t("skillWorkshop.empty.quarantinedTitle"),
body: t("skillWorkshop.empty.quarantinedBody"),
};
case "stale":
return {
icon: "refresh",
title: "No stale proposals",
body: "Proposals that can no longer apply cleanly will appear here.",
title: t("skillWorkshop.empty.staleTitle"),
body: t("skillWorkshop.empty.staleBody"),
};
case "all":
return {
icon: "search",
title: "No proposals here",
body: "Skill Workshop proposals will appear here when your agent drafts them.",
title: t("skillWorkshop.empty.allTitle"),
body: t("skillWorkshop.empty.allBody"),
};
}
return {
icon: "search",
title: "No proposals here",
body: "Skill Workshop proposals will appear here when your agent drafts them.",
title: t("skillWorkshop.empty.allTitle"),
body: t("skillWorkshop.empty.allBody"),
};
}
@@ -594,19 +606,19 @@ function renderEmptyStateIcon(icon: SkillWorkshopEmptyIcon) {
}
function renderWorkshopEmptyState(props: SkillWorkshopProps) {
const assistantName = resolveSkillWorkshopAgentName(props, "Your agent");
const assistantName = resolveSkillWorkshopAgentName(props, t("skillWorkshop.empty.defaultAgent"));
return html`
<div class="sw-empty-state">
<section class="sw-empty-state__panel" aria-label="No Skill Workshop proposals">
<section class="sw-empty-state__panel" aria-label=${t("skillWorkshop.empty.noProposalsAria")}>
<div class="sw-empty-state__glyph" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
<p class="sw-empty-state__eyebrow">Skill Workshop</p>
<h2>No proposals yet</h2>
<p>${assistantName} hasn't drafted any skill proposals.</p>
<div class="sw-empty-state__footer">New proposals will appear here for review.</div>
<p class="sw-empty-state__eyebrow">${t("skillWorkshop.title")}</p>
<h2>${t("skillWorkshop.empty.noProposalsTitle")}</h2>
<p>${t("skillWorkshop.empty.noProposalsBody", { agent: assistantName })}</p>
<div class="sw-empty-state__footer">${t("skillWorkshop.empty.noProposalsFooter")}</div>
</section>
</div>
`;
@@ -624,10 +636,8 @@ function renderToday(
if (!hero) {
return html`
<div class="sw-today sw-today--empty">
<p class="sw-empty__title">Nothing waiting today</p>
<p class="sw-empty__sub">
Your agent hasn't drafted anything new. Switch to Board to browse history.
</p>
<p class="sw-empty__title">${t("skillWorkshop.today.emptyTitle")}</p>
<p class="sw-empty__sub">${t("skillWorkshop.today.emptyBody")}</p>
</div>
`;
}
@@ -639,27 +649,38 @@ function renderToday(
const total = Math.max(pending.length, 1);
const upNext = pending.filter((p) => p.key !== hero.key).slice(0, 3);
const applied = props.proposals.filter((p) => p.status === "applied").slice(0, 3);
const heroLabel = hero.isNew ? "NEW" : hero.status === "pending" ? "WAITING" : "REVIEWED";
const heroLabel = hero.isNew
? t("skillWorkshop.today.new")
: hero.status === "pending"
? t("skillWorkshop.today.waiting")
: t("skillWorkshop.today.reviewed");
const ageLabel = hero.ageLabel;
const dateLine = formatTodayDate(Date.now());
const isPending = hero.status === "pending";
const busy = props.actionBusy?.key === hero.key ? props.actionBusy.action : null;
const disabled = Boolean(props.actionBusy);
const assistantName = resolveSkillWorkshopAgentName(props, "agent");
const assistantName = resolveSkillWorkshopAgentName(props, t("skillWorkshop.today.agent"));
const firstSupportFile = hero.supportFiles[0];
return html`
<div class="sw-today">
<div class="sw-today__head">
<div class="sw-today__date">${dateLine}</div>
<h1 class="sw-today__h1">${pending.length} proposals waiting</h1>
<h1 class="sw-today__h1">
${t("skillWorkshop.today.proposalsWaiting", { count: String(pending.length) })}
</h1>
${pending.length === 0
? html`<div class="sw-today__sub">Browse what's already applied.</div>`
? html`<div class="sw-today__sub">${t("skillWorkshop.today.browseApplied")}</div>`
: nothing}
${pending.length > 0
? html`
<div class="sw-today__progress">
<span>${heroIndex + 1} of ${total}</span>
<span
>${t("skillWorkshop.today.progress", {
current: String(heroIndex + 1),
total: String(total),
})}</span
>
<div class="sw-today__dots">
${pending.map(
(_, i) => html`
@@ -691,17 +712,22 @@ function renderToday(
<div class="sw-today__author">
<span class="sw-today__avatar">v${hero.version}</span>
<span>
Drafted by <strong>${assistantName}</strong> · ${ageLabel}.
${t("skillWorkshop.today.draftedBy")}
<strong>${assistantName}</strong> · ${ageLabel}.
${firstSupportFile
? html`
<button
class="sw-today__files-link"
@click=${() => props.onPreviewFile(hero.key, firstSupportFile.path)}
>
${hero.supportFiles.length}
${hero.supportFiles.length === 1 ? "support file" : "support files"}
${t(
hero.supportFiles.length === 1
? "skillWorkshop.today.supportFile"
: "skillWorkshop.today.supportFiles",
{ count: String(hero.supportFiles.length) },
)}
</button>
come with it.
${t("skillWorkshop.today.comeWithIt")}
`
: nothing}
</span>
@@ -715,24 +741,30 @@ function renderToday(
?disabled=${disabled}
@click=${() => props.onApply(hero.key)}
>
${busy === "apply" ? "Applying…" : "Use it"}
<span class="sw-today__big-sub">Add to your skills</span>
${busy === "apply"
? t("skillWorkshop.actions.applying")
: t("skillWorkshop.today.useIt")}
<span class="sw-today__big-sub">${t("skillWorkshop.today.addToSkills")}</span>
</button>
<button
class="sw-today__big sw-today__big--tweak ${busy === "revise" ? "is-busy" : ""}"
?disabled=${disabled}
@click=${() => props.onRevise(hero.key)}
>
${busy === "revise" ? "Opening…" : "Tweak it"}
<span class="sw-today__big-sub">Ask the agent to change something</span>
${busy === "revise"
? t("skillWorkshop.actions.opening")
: t("skillWorkshop.today.tweakIt")}
<span class="sw-today__big-sub">${t("skillWorkshop.today.askAgent")}</span>
</button>
<button
class="sw-today__big sw-today__big--skip ${busy === "reject" ? "is-busy" : ""}"
?disabled=${disabled}
@click=${() => props.onReject(hero.key)}
>
${busy === "reject" ? "Skipping…" : "Skip"}
<span class="sw-today__big-sub">Not for me</span>
${busy === "reject"
? t("skillWorkshop.today.skipping")
: t("skillWorkshop.today.skip")}
<span class="sw-today__big-sub">${t("skillWorkshop.today.notForMe")}</span>
</button>
</div>
`
@@ -744,9 +776,13 @@ function renderToday(
? html`
<section class="sw-today__section">
<header class="sw-today__section-head">
<h3>Up next · ${pending.length - 1} more waiting</h3>
<h3>
${t("skillWorkshop.today.upNext", {
count: String(pending.length - 1),
})}
</h3>
<button class="sw-today__link" @click=${() => props.onModeChange("board")}>
See all proposals →
${t("skillWorkshop.today.seeAll")}
</button>
</header>
<div class="sw-today__upnext">
@@ -767,12 +803,16 @@ function renderToday(
? html`
<section class="sw-today__section">
<header class="sw-today__section-head">
<h3>Your collection · ${props.counts.applied} in use</h3>
<h3>
${t("skillWorkshop.today.collection", {
count: String(props.counts.applied),
})}
</h3>
<button
class="sw-today__link sw-today__link--muted"
@click=${() => props.onModeChange("board")}
>
Manage →
${t("skillWorkshop.today.manage")}
</button>
</header>
<div class="sw-today__applied">
@@ -838,7 +878,7 @@ function extractTodayProposalPreview(body: string): TodayProposalPreview | null
const workflowItems = workflow ? extractTopLevelListItems(workflow.lines) : [];
if (workflowItems.length > 0) {
return {
heading: "How the agent will use it",
heading: t("skillWorkshop.today.workflowHeading"),
items: workflowItems.slice(0, TODAY_PREVIEW_MAX_ITEMS),
};
}
@@ -853,7 +893,7 @@ function extractTodayProposalPreview(body: string): TodayProposalPreview | null
const applicabilityItems = applicability ? extractTopLevelListItems(applicability.lines) : [];
if (applicabilityItems.length > 0) {
return {
heading: "When the agent should use it",
heading: t("skillWorkshop.today.applicabilityHeading"),
items: applicabilityItems.slice(0, TODAY_PREVIEW_MAX_ITEMS),
};
}
@@ -1062,34 +1102,36 @@ function groupByRecency(
function queueEmptyText(props: SkillWorkshopProps): string {
if (props.error) {
return "Could not load proposals.";
return t("skillWorkshop.queue.loadError");
}
if (props.loading) {
return "Loading proposals…";
return t("skillWorkshop.queue.loading");
}
if (props.statusFilter !== "all") {
return `No ${STATUS_LABEL[props.statusFilter].toLowerCase()} proposals.`;
return t("skillWorkshop.queue.noStatus", {
status: t(STATUS_LABEL[props.statusFilter]).toLocaleLowerCase(),
});
}
return "No proposals match the current filter.";
return t("skillWorkshop.queue.noMatch");
}
function formatRelative(ms: number): string {
const diff = Math.max(0, Date.now() - ms);
const sec = Math.floor(diff / 1000);
if (sec < 60) {
return `${sec}s ago`;
return t("skillWorkshop.relative.secondsAgo", { count: String(sec) });
}
const min = Math.floor(sec / 60);
if (min < 60) {
return `${min} minutes ago`;
return t("skillWorkshop.relative.minutesAgo", { count: String(min) });
}
const hr = Math.floor(min / 60);
if (hr < 24) {
return `${hr}h ago`;
return t("skillWorkshop.relative.hoursAgo", { count: String(hr) });
}
const day = Math.floor(hr / 24);
if (day < 7) {
return `${day}d ago`;
return t("skillWorkshop.relative.daysAgo", { count: String(day) });
}
return new Date(ms).toLocaleDateString();
}
+64 -52
View File
@@ -102,13 +102,13 @@ type SkillsProps = {
onClawHubInstall: (slug: string, acknowledgeClawHubRisk?: boolean, version?: string) => void;
};
type StatusTabDef = { id: SkillsStatusFilter; label: string };
type StatusTabDef = { id: SkillsStatusFilter; labelKey: string };
const STATUS_TABS: StatusTabDef[] = [
{ id: "all", label: "All" },
{ id: "ready", label: "Ready" },
{ id: "needs-setup", label: "Needs Setup" },
{ id: "disabled", label: "Disabled" },
{ id: "all", labelKey: "skillsPage.tabs.all" },
{ id: "ready", labelKey: "skillsPage.tabs.ready" },
{ id: "needs-setup", labelKey: "skillsPage.tabs.needsSetup" },
{ id: "disabled", labelKey: "skillsPage.tabs.disabled" },
];
function skillMatchesStatus(skill: SkillStatusEntry, status: SkillsStatusFilter): boolean {
@@ -150,22 +150,22 @@ function verdictForSkill(skill: SkillStatusEntry, verdicts: SkillsProps["clawhub
function verdictLabel(verdict: ClawHubSkillSecurityVerdict | null | undefined): string {
if (!verdict) {
return "Unavailable";
return t("skillsPage.verdict.unavailable");
}
const status = verdict.securityStatus?.trim() || null;
if (verdict.ok && verdict.decision === "pass") {
return status === "clean" || !status ? "Clean" : status;
return status === "clean" || !status ? t("skillsPage.verdict.clean") : status;
}
if (status === "pending" || status === "not-run") {
return "Pending";
return t("skillsPage.verdict.pending");
}
if (status === "malicious") {
return "Blocked";
return t("skillsPage.verdict.blocked");
}
if (status === "suspicious") {
return "Review";
return t("skillsPage.verdict.review");
}
return "Unavailable";
return t("skillsPage.verdict.unavailable");
}
function verdictChipClass(verdict: ClawHubSkillSecurityVerdict | null | undefined): string {
@@ -183,7 +183,7 @@ type SkillsAgentOption = AgentsListResult["agents"][number];
function agentOptionLabel(agent: SkillsAgentOption, defaultId: string | undefined): string {
const baseName = agent.identity?.name?.trim() || agent.name?.trim() || agent.id;
return agent.id === defaultId ? `${baseName} (default)` : baseName;
return agent.id === defaultId ? t("skillsPage.defaultAgent", { name: baseName }) : baseName;
}
export function renderSkills(props: SkillsProps) {
@@ -231,8 +231,8 @@ export function renderSkills(props: SkillsProps) {
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Skills</div>
<div class="card-sub">Installed skills and their status.</div>
<div class="card-title">${t("skillsPage.title")}</div>
<div class="card-sub">${t("skillsPage.subtitle")}</div>
</div>
<button
class="btn"
@@ -250,7 +250,7 @@ export function renderSkills(props: SkillsProps) {
class="agent-tab ${props.statusFilter === tab.id ? "active" : ""}"
@click=${() => props.onStatusFilterChange(tab.id)}
>
${tab.label}<span class="agent-tab-count">${statusCounts[tab.id]}</span>
${t(tab.labelKey)}<span class="agent-tab-count">${statusCounts[tab.id]}</span>
</button>
`,
)}
@@ -287,20 +287,18 @@ export function renderSkills(props: SkillsProps) {
<input
.value=${props.filter}
@input=${(e: Event) => props.onFilterChange((e.target as HTMLInputElement).value)}
placeholder="Filter installed skills"
placeholder=${t("skillsPage.filterPlaceholder")}
autocomplete="off"
name="skills-filter"
/>
</label>
<div class="muted">${filtered.length} shown</div>
<div class="muted">${t("skillsPage.shown", { count: String(filtered.length) })}</div>
</div>
<div style="margin-top: 16px; border-top: 1px solid var(--border); padding-top: 16px;">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
<div style="font-weight: 600;">ClawHub</div>
<div class="muted" style="font-size: 13px;">
Search and install skills from the registry
</div>
<div style="font-weight: 600;">${t("skillsPage.clawHub")}</div>
<div class="muted" style="font-size: 13px;">${t("skillsPage.clawHubSubtitle")}</div>
</div>
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
<label class="field" style="flex: 1; min-width: 180px;">
@@ -308,12 +306,14 @@ export function renderSkills(props: SkillsProps) {
.value=${props.clawhubQuery}
@input=${(e: Event) =>
props.onClawHubQueryChange((e.target as HTMLInputElement).value)}
placeholder="Search ClawHub skills…"
placeholder=${t("skillsPage.searchClawHub")}
autocomplete="off"
name="clawhub-search"
/>
</label>
${props.clawhubSearchLoading ? html`<span class="muted">Searching…</span>` : nothing}
${props.clawhubSearchLoading
? html`<span class="muted">${t("skillsPage.searching")}</span>`
: nothing}
</div>
${props.clawhubSearchError
? html`<div class="callout danger" style="margin-top: 8px;">
@@ -345,7 +345,7 @@ export function renderSkills(props: SkillsProps) {
)}
>
${props.clawhubInstallMessage.acknowledgeLabel ??
"Acknowledge risk and install"}
t("skillsPage.acknowledgeRisk")}
</button>`
: nothing}
</div>`
@@ -360,8 +360,8 @@ export function renderSkills(props: SkillsProps) {
? html`
<div class="muted" style="margin-top: 16px">
${!props.connected && !props.report
? "Not connected to gateway."
: "No skills found."}
? t("skillsPage.disconnected")
: t("skillsPage.empty")}
</div>
`
: html`
@@ -398,7 +398,9 @@ function renderClawHubResults(props: SkillsProps) {
return nothing;
}
if (results.length === 0) {
return html`<div class="muted" style="margin-top: 8px;">No skills found on ClawHub.</div>`;
return html`<div class="muted" style="margin-top: 8px;">
${t("skillsPage.noClawHubResults")}
</div>`;
}
return html`
<div class="list" style="margin-top: 8px;">
@@ -424,7 +426,9 @@ function renderClawHubResults(props: SkillsProps) {
props.onClawHubInstall(r.slug);
}}
>
${props.clawhubInstallSlug === r.slug ? "Installing\u2026" : "Install"}
${props.clawhubInstallSlug === r.slug
? t("skillsPage.installing")
: t("skillsPage.install")}
</button>
</div>
</div>
@@ -460,7 +464,7 @@ function renderClawHubDetailDialog(props: SkillsProps) {
(e.currentTarget as HTMLElement).closest("dialog")?.close();
}}
>
Close
${t("skillsPage.close")}
</button>
</div>
<div class="md-preview-dialog__body" style="display: grid; gap: 16px;">
@@ -475,7 +479,7 @@ function renderClawHubDetailDialog(props: SkillsProps) {
</div>
${detail.owner?.displayName
? html`<div class="muted" style="font-size: 13px;">
By
${t("skillsPage.by")}
${detail.owner.displayName}${detail.owner.handle
? html` (@${detail.owner.handle})`
: nothing}
@@ -483,7 +487,7 @@ function renderClawHubDetailDialog(props: SkillsProps) {
: nothing}
${detail.latestVersion
? html`<div class="muted" style="font-size: 13px;">
Latest: v${detail.latestVersion.version}
${t("skillsPage.latest", { version: detail.latestVersion.version })}
</div>`
: nothing}
${detail.latestVersion?.changelog
@@ -495,7 +499,7 @@ function renderClawHubDetailDialog(props: SkillsProps) {
: nothing}
${detail.metadata?.os
? html`<div class="muted" style="font-size: 12px;">
Platforms: ${detail.metadata.os.join(", ")}
${t("skillsPage.platforms", { platforms: detail.metadata.os.join(", ") })}
</div>`
: nothing}
<button
@@ -508,11 +512,11 @@ function renderClawHubDetailDialog(props: SkillsProps) {
}}
>
${props.clawhubInstallSlug === props.clawhubDetailSlug
? "Installing\u2026"
: `Install ${detail.skill.displayName}`}
? t("skillsPage.installing")
: t("skillsPage.installNamed", { name: detail.skill.displayName })}
</button>
`
: html`<div class="muted">Skill not found.</div>`}
: html`<div class="muted">${t("skillsPage.notFound")}</div>`}
</div>
</div>
</dialog>
@@ -541,7 +545,7 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
${skill.clawhub?.status === "linked"
? html`<span class="chip ${verdictChipClass(verdict)}">${verdictLabel(verdict)}</span>`
: skill.clawhub?.status === "invalid"
? html`<span class="chip chip-warn">ClawHub link invalid</span>`
? html`<span class="chip chip-warn">${t("skillsPage.invalidLink")}</span>`
: nothing}
<label class="skill-toggle-wrap" @click=${(e: Event) => e.stopPropagation()}>
<input
@@ -601,7 +605,7 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) {
(e.currentTarget as HTMLElement).closest("dialog")?.close();
}}
>
Close
${t("skillsPage.close")}
</button>
</div>
<div class="md-preview-dialog__body" style="display: grid; gap: 16px;">
@@ -619,14 +623,14 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) {
class="agent-tab ${detailTab === "overview" ? "active" : ""}"
@click=${() => props.onDetailTabChange("overview")}
>
Overview
${t("skillsPage.overview")}
</button>
${skill.skillCard?.present
? html`<button
class="agent-tab ${detailTab === "card" ? "active" : ""}"
@click=${() => props.onDetailTabChange("card")}
>
Skill Card
${t("skillsPage.skillCard")}
</button>`
: nothing}
</div>
@@ -641,14 +645,18 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) {
class="callout"
style="border-color: var(--warn-subtle); background: var(--warn-subtle); color: var(--warn);"
>
<div style="font-weight: 600; margin-bottom: 4px;">Missing requirements</div>
<div style="font-weight: 600; margin-bottom: 4px;">
${t("skillsPage.missingRequirements")}
</div>
<div>${missing.join(", ")}</div>
</div>
`
: nothing}
${reasons.length > 0
? html`
<div class="muted" style="font-size: 13px;">Reason: ${reasons.join(", ")}</div>
<div class="muted" style="font-size: 13px;">
${t("skillsPage.reason", { reasons: reasons.join(", ") })}
</div>
`
: nothing}
@@ -663,7 +671,7 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) {
/>
</label>
<span style="font-size: 13px; font-weight: 500;">
${skill.disabled ? "Disabled" : "Enabled"}
${skill.disabled ? t("skillsPage.disabled") : t("skillsPage.enabled")}
</span>
${canInstall
? html`<button
@@ -672,7 +680,7 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) {
@click=${() =>
installOption && props.onInstall(skill.skillKey, skill.name, installOption.id)}
>
${busy ? "Installing\u2026" : installOption?.label}
${busy ? t("skillsPage.installing") : installOption?.label}
</button>`
: nothing}
</div>
@@ -687,7 +695,7 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) {
<div style="display: grid; gap: 8px;">
<div class="field">
<span
>API key
>${t("skillsPage.apiKey")}
<span class="muted" style="font-weight: normal; font-size: 0.88em;"
>(${skill.primaryEnv})</span
></span
@@ -703,7 +711,7 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) {
const href = safeExternalHref(skill.homepage);
return href
? html`<div class="muted" style="font-size: 13px;">
Get your key:
${t("skillsPage.getKey")}
<a href="${href}" target="_blank" rel="noopener noreferrer"
>${skill.homepage}</a
>
@@ -715,7 +723,7 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) {
?disabled=${busy}
@click=${() => props.onSaveKey(skill.skillKey)}
>
Save key
${t("skillsPage.saveKey")}
</button>
</div>
`
@@ -724,7 +732,9 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) {
<div
style="border-top: 1px solid var(--border); padding-top: 12px; display: grid; gap: 6px; font-size: 12px; color: var(--muted);"
>
<div><span style="font-weight: 600;">Source:</span> ${skill.source}</div>
<div>
<span style="font-weight: 600;">${t("skillsPage.source")}</span> ${skill.source}
</div>
<div style="font-family: var(--mono); word-break: break-all;">${skill.filePath}</div>
${(() => {
const safeHref = safeExternalHref(skill.homepage);
@@ -754,7 +764,7 @@ function renderInstalledClawHubOverview(
}
if (link.status === "invalid") {
return html`<div class="callout danger">
<div style="font-weight: 600; margin-bottom: 4px;">ClawHub link invalid</div>
<div style="font-weight: 600; margin-bottom: 4px;">${t("skillsPage.invalidLink")}</div>
<div>${link.reason}</div>
</div>`;
}
@@ -768,7 +778,9 @@ function renderInstalledClawHubOverview(
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
<span class="chip ${verdictChipClass(verdict)}">${verdictLabel(verdict)}</span>
<span class="muted" style="font-size: 12px;">${link.slug}@${link.installedVersion}</span>
${props.clawhubVerdictsLoading ? html`<span class="muted">Refreshing…</span>` : nothing}
${props.clawhubVerdictsLoading
? html`<span class="muted">${t("skillsPage.refreshing")}</span>`
: nothing}
</div>
${props.clawhubVerdictsError
? html`<div class="muted" style="font-size: 13px;">${props.clawhubVerdictsError}</div>`
@@ -778,7 +790,7 @@ function renderInstalledClawHubOverview(
${auditHref
? html`<div style="font-size: 13px;">
<a href="${auditHref}" target="_blank" rel="noopener noreferrer"
>Full security report</a
>${t("skillsPage.fullSecurityReport")}</a
>
</div>`
: nothing}
@@ -799,8 +811,8 @@ function renderInstalledSkillCard(skill: SkillStatusEntry, props: SkillsProps) {
}
return html`<div class="muted" style="font-size: 13px;">
${props.skillCardLoadingKey === skill.skillKey
? "Loading Skill Card..."
: "Skill Card not loaded."}
? t("skillsPage.loadingSkillCard")
: t("skillsPage.skillCardNotLoaded")}
</div>`;
}
return html`