diff --git a/apps/desktop/src/renderer/styles/module-pages/skills.css b/apps/desktop/src/renderer/styles/module-pages/skills.css
index 604ccd58a9..c5ace23891 100644
--- a/apps/desktop/src/renderer/styles/module-pages/skills.css
+++ b/apps/desktop/src/renderer/styles/module-pages/skills.css
@@ -36,6 +36,11 @@
color: var(--muted-foreground);
}
+.maka-skill-install-complete-icon {
+ display: inline-flex;
+ color: var(--success);
+}
+
.maka-skill-inspector {
min-width: 0;
}
diff --git a/packages/ui/src/__tests__/skills-panel-install-state.test.tsx b/packages/ui/src/__tests__/skills-panel-install-state.test.tsx
new file mode 100644
index 0000000000..80e3a9ceba
--- /dev/null
+++ b/packages/ui/src/__tests__/skills-panel-install-state.test.tsx
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { LocaleProvider } from '../locale-context.js';
+import { SkillsModuleMain } from '../skills-panel.js';
+import { ToastProvider } from '../toast.js';
+
+function renderBundledSkill(installed: boolean): string {
+ return renderToStaticMarkup(
+
+
+ undefined}
+ />
+
+ ,
+ );
+}
+
+test('an available bundled skill renders the install action', () => {
+ const markup = renderBundledSkill(false);
+ assert.match(markup, /aria-label="Install Computer Use"/);
+ assert.match(markup, /lucide-download/);
+ assert.doesNotMatch(markup, /maka-skill-install-complete-icon/);
+});
+
+test('an installed bundled skill replaces the download action with a completion check', () => {
+ const markup = renderBundledSkill(true);
+ assert.match(markup, /aria-label="Computer Use is installed in this workspace"/);
+ assert.match(markup, /maka-skill-install-complete-icon/);
+ assert.match(markup, /lucide-check/);
+ assert.doesNotMatch(markup, /lucide-download/);
+});
diff --git a/packages/ui/src/skills-copy.ts b/packages/ui/src/skills-copy.ts
index f48a163ed8..5f2f569563 100644
--- a/packages/ui/src/skills-copy.ts
+++ b/packages/ui/src/skills-copy.ts
@@ -45,6 +45,7 @@ export interface SkillsCopy {
tabs: { ariaLabel: string; market: string; builtin: string; installed: string };
install: {
action: (name: string) => string;
+ installedAction: (name: string) => string;
installedTitle: string;
installed: string;
notInstalled: string;
@@ -162,7 +163,7 @@ const SKILLS_COPY = {
categories: { '内容创作': '内容创作', '数据与AI': '数据与 AI', '设计与UI': '设计与 UI', 'DevOps与部署': 'DevOps 与部署', '文档与写作': '文档与写作', '效率工具': '效率工具', '研究与分析': '研究与分析' },
market: { categoryAll: '全部分类', sortName: '排序:名称', sortRecent: '排序:最近', controls: '市场筛选与排序', categoryFilter: '按分类筛选市场技能', sortAriaLabel: '市场技能排序方式', ariaLabel: '技能市场', importLocal: '导入本地 Skill', emptySearchTitle: '没有匹配的市场技能', emptyTitle: '来源库还是空的', emptySearchBody: '换一个关键词,或清空搜索查看全部来源。', emptyBody: '导入一个含 SKILL.md 的本地文件,它会作为可安装的来源出现在这里。', emptyFilterBody: '换一个分类或关键词,或清空筛选查看全部来源。', clearSearch: '清空搜索', clearFilters: '清空筛选', sourceFallback: '本地来源库 Skill。' },
tabs: { ariaLabel: '技能视图', market: '市场', builtin: '内置', installed: '已安装' },
- install: { action: (name) => `安装 ${name}`, installedTitle: '已安装到当前工作区', installed: '已安装', notInstalled: '未安装' },
+ install: { action: (name) => `安装 ${name}`, installedAction: (name) => `${name} 已安装到当前工作区`, installedTitle: '已安装到当前工作区', installed: '已安装', notInstalled: '未安装' },
builtin: { ariaLabel: '内置技能', emptyTitle: '暂无内置技能', emptyBody: '应用自带的技能会出现在这里。', noMatchTitle: '没有匹配的内置技能', noMatchBody: '换一个关键词,或清空搜索查看全部内置技能。', fallback: '应用自带 Skill。', toolCount: (count: number) => `${count} 个工具` },
installed: { emptySearchTitle: '没有匹配的 Skill', emptyTitle: '等待添加 Skill', emptySearchBody: '换一个关键词,或清空搜索查看全部本地技能。', emptyBodyBeforeCode: '把一个含', emptyBodyAfterCode: '的文件夹放到工作区的 skills/ 目录下,刷新后会出现在这里。', refreshPending: '刷新中…', refresh: '刷新技能', listAriaLabel: '技能列表' },
context: { scope: { project: '项目', workspace: '工作区', user: '用户', custom: '自定义' }, decision: { advertised: '已进入上下文', disabled: '已停用', invalid: '元数据无效', host_incompatible: '主机不兼容', shadowed: '被高优先级覆盖', budget: '因预算省略' }, needsReview: '待确认', discoverySource: (scope, source) => `${scope}/${source} 发现源`, discoveryDiagnostic: { blocked_path: '路径被安全策略阻止', read_failed: '来源不可读取' } },
@@ -177,7 +178,7 @@ const SKILLS_COPY = {
categories: { '内容创作': 'Content creation', '数据与AI': 'Data & AI', '设计与UI': 'Design & UI', 'DevOps与部署': 'DevOps & deployment', '文档与写作': 'Documents & writing', '效率工具': 'Productivity', '研究与分析': 'Research & analysis' },
market: { categoryAll: 'All categories', sortName: 'Sort: Name', sortRecent: 'Sort: Recent', controls: 'Marketplace filters and sorting', categoryFilter: 'Filter marketplace skills by category', sortAriaLabel: 'Marketplace skill sort order', ariaLabel: 'Skill marketplace', importLocal: 'Import local Skill', emptySearchTitle: 'No matching marketplace skills', emptyTitle: 'The source library is empty', emptySearchBody: 'Try another keyword or clear search to see all sources.', emptyBody: 'Import a local file containing SKILL.md to make it available as an installable source.', emptyFilterBody: 'Try another category or keyword, or clear the filters.', clearSearch: 'Clear search', clearFilters: 'Clear filters', sourceFallback: 'Local source-library Skill.' },
tabs: { ariaLabel: 'Skill views', market: 'Marketplace', builtin: 'Built in', installed: 'Installed' },
- install: { action: (name) => `Install ${name}`, installedTitle: 'Installed in this workspace', installed: 'Installed', notInstalled: 'Not installed' },
+ install: { action: (name) => `Install ${name}`, installedAction: (name) => `${name} is installed in this workspace`, installedTitle: 'Installed in this workspace', installed: 'Installed', notInstalled: 'Not installed' },
builtin: { ariaLabel: 'Built-in skills', emptyTitle: 'No built-in skills', emptyBody: 'Skills included with the app appear here.', noMatchTitle: 'No matching built-in skills', noMatchBody: 'Try another keyword or clear search to see all built-in skills.', fallback: 'Skill included with the app.', toolCount: (count: number) => (count === 1 ? '1 tool' : `${count} tools`) },
installed: { emptySearchTitle: 'No matching Skills', emptyTitle: 'Waiting for a Skill', emptySearchBody: 'Try another keyword or clear search to see all local skills.', emptyBodyBeforeCode: 'Place a folder containing', emptyBodyAfterCode: 'in the workspace skills/ directory, then refresh to show it here.', refreshPending: 'Refreshing…', refresh: 'Refresh skills', listAriaLabel: 'Skill list' },
context: { scope: { project: 'Project', workspace: 'Workspace', user: 'User', custom: 'Custom' }, decision: { advertised: 'In context', disabled: 'Disabled', invalid: 'Invalid metadata', host_incompatible: 'Host incompatible', shadowed: 'Shadowed', budget: 'Budget omitted' }, needsReview: 'Needs review', discoverySource: (scope, source) => `${scope}/${source} discovery source`, discoveryDiagnostic: { blocked_path: 'Path blocked by the safety policy', read_failed: 'Source could not be read' } },
diff --git a/packages/ui/src/skills-panel.tsx b/packages/ui/src/skills-panel.tsx
index 11cdb5e82c..d59ec59dfb 100644
--- a/packages/ui/src/skills-panel.tsx
+++ b/packages/ui/src/skills-panel.tsx
@@ -40,6 +40,7 @@ import {
ICON_SIZE,
Blocks,
BookOpen,
+ Check,
Download,
FolderOpen,
Loader2,
@@ -334,6 +335,15 @@ export function SkillsModuleMain(props: {
// ── Catalog rows (市场 / 内置): one job each — install. ──────────────
function catalogInstallButton(key: string, name: string, installing: boolean, installed: boolean, onInstall?: () => void) {
+ const installIcon = installing
+ ?
+ : installed
+ ? (
+
+
+
+ )
+ : ;
return (
: }
+ icon={installIcon}
/>
);
}