mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(ui): localize plugins and skills
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user