diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index 04f2f05b5..f089de5bc 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -1896,7 +1896,7 @@ export const translations: Record> = { /* ── MCP Hub ── */ "mcpHub.title": "MCP Servers", - "mcpHub.subtitle": "Model Context Protocol", + "mcpHub.subtitle": "管理模型上下文协议(MCP)服务器", "mcpHub.localTitle": "本地 MCP Servers", "mcpHub.localSubtitle": "管理本地 stdio、Streamable HTTP 和 SSE MCP 配置", "mcpHub.enabled": "已启用", @@ -1910,6 +1910,8 @@ export const translations: Record> = { "mcpHub.importScanFailed": "扫描失败", "mcpHub.importNotDetected": "未检测到", "mcpHub.importEmpty": "该工具没有配置 MCP Server", + "mcpHub.importSearchPlaceholder": "搜索本地 MCP", + "mcpHub.importNoMatch": "未匹配到本地 MCP,换个关键词试试", "mcpHub.importAlreadyImported": "已导入", "mcpHub.importSelectAll": "全选", "mcpHub.importDeselectAll": "取消全选", @@ -1968,7 +1970,7 @@ export const translations: Record> = { "mcpHub.storeSubtitle": "官方 Registry、Smithery 与 Glama", "mcpHub.storeRefresh": "刷新", "mcpHub.storeSearch": "搜索", - "mcpHub.storeSearchPlaceholder": "搜索 MCP server", + "mcpHub.storeSearchPlaceholder": "搜索 MCP 服务器", "mcpHub.storeLoadFailed": "加载 MCP Store 失败", "mcpHub.storeInstallFailed": "添加 MCP Server 失败", "mcpHub.storeInstallUnavailable": "这个条目暂时无法自动生成 MCP 配置", @@ -2058,7 +2060,7 @@ export const translations: Record> = { "settings.skillsHubBulkSelect": "批量选择", "settings.skillsBulkSelect": "批量选择", "settings.skillsBulkSelectAll": "全部选中", - "settings.skillsBulkDeselectAll": "全部取消", + "settings.skillsBulkDeselectAll": "全部不选", "settings.skillsBulkHint": "点击卡片选择 · Shift+点击可选择一段区间", "settings.skillsBulkImportHint": "点击卡片勾选 · Shift+点击可选择一段区间", "settings.skillsBulkUpdated": "已更新 {count} 个技能", @@ -2093,8 +2095,8 @@ export const translations: Record> = { "settings.skillsInstalledPreviewDescription": "技能描述", "settings.skillsInstalledPreviewNoDescription": "暂无描述", "settings.skillsInstalledPreviewCopyDescription": "复制技能描述", - "settings.skillsInstalledPreviewCopyFile": "复制文件预览", - "settings.skillsInstalledPreviewCopied": "已复制", + "settings.skillsInstalledPreviewCopyFile": "复制技能文件内容", + "settings.skillsInstalledPreviewCopied": "已复制到剪贴板", "settings.skillsInstalledPreviewDetails": "信息", "settings.skillsInstalledPreviewBaseDir": "目录", "settings.skillsInstalledPreviewSkillFile": "技能文件", @@ -2115,6 +2117,12 @@ export const translations: Record> = { "settings.skillsAlwaysOn": "内置", "settings.skillsScan": "扫描", "settings.skillsScanning": "扫描中", + "settings.skillsScanHint": "重新读取 LiveAgent Skills 目录并同步当前会话可用能力", + "settings.skillsScanComplete": "扫描完成", + "settings.skillsScanFailed": "扫描失败", + "settings.skillsScanFound": "共发现 {count} 个 Skill", + "settings.skillsScanChanged": "新增 {added} · 更新 {updated} · 移除 {removed}", + "settings.skillsScanNoChanges": "未发现变化,当前列表已是最新", "settings.skillsDisabledHint": "技能功能已关闭,开启后才会将选中的技能注入 system prompt", "settings.skillsDisabledInChatMode": "Chat 模式下不启用技能。切换到 Agent 模式后,才会恢复扫描、选择和注入能力。", @@ -4239,6 +4247,8 @@ export const translations: Record> = { "mcpHub.importScanFailed": "Scan failed", "mcpHub.importNotDetected": "Not detected", "mcpHub.importEmpty": "No MCP servers configured for this tool", + "mcpHub.importSearchPlaceholder": "Search local MCPs", + "mcpHub.importNoMatch": "No local MCP matches — try a different keyword", "mcpHub.importAlreadyImported": "Imported", "mcpHub.importSelectAll": "Select all", "mcpHub.importDeselectAll": "Deselect all", @@ -4430,8 +4440,8 @@ export const translations: Record> = { "settings.skillsInstalledPreviewDescription": "Description", "settings.skillsInstalledPreviewNoDescription": "No description", "settings.skillsInstalledPreviewCopyDescription": "Copy Skill description", - "settings.skillsInstalledPreviewCopyFile": "Copy file preview", - "settings.skillsInstalledPreviewCopied": "Copied", + "settings.skillsInstalledPreviewCopyFile": "Copy Skill file contents", + "settings.skillsInstalledPreviewCopied": "Copied to clipboard", "settings.skillsInstalledPreviewDetails": "Details", "settings.skillsInstalledPreviewBaseDir": "Directory", "settings.skillsInstalledPreviewSkillFile": "Skill file", @@ -4452,6 +4462,13 @@ export const translations: Record> = { "settings.skillsAlwaysOn": "Built-in", "settings.skillsScan": "Scan", "settings.skillsScanning": "Scanning", + "settings.skillsScanHint": + "Rescan the LiveAgent Skills directory and sync capabilities available to this conversation", + "settings.skillsScanComplete": "Scan complete", + "settings.skillsScanFailed": "Scan failed", + "settings.skillsScanFound": "Found {count} Skills", + "settings.skillsScanChanged": "{added} added · {updated} updated · {removed} removed", + "settings.skillsScanNoChanges": "No changes found. The list is up to date", "settings.skillsDisabledHint": "Skills is off. Enable it to inject selected skills into the system prompt.", "settings.skillsDisabledInChatMode": diff --git a/crates/agent-gateway/web/src/index.css b/crates/agent-gateway/web/src/index.css index af1902c71..54d488f77 100644 --- a/crates/agent-gateway/web/src/index.css +++ b/crates/agent-gateway/web/src/index.css @@ -783,6 +783,80 @@ @apply my-5 border-border/45; } + /* Long-form files and documentation use a calmer, denser reading rhythm + * than conversational Markdown. This profile is shared by Skill and + * workspace file previews; chat typography remains unchanged. */ + .document-markdown { + @apply text-[calc(13px*var(--zone-font-scale,1))] leading-6 text-foreground/85; + } + .document-markdown p { + @apply my-2.5 text-[calc(13px*var(--zone-font-scale,1))] leading-6 text-foreground/85; + } + .document-markdown strong, + .document-markdown [data-streamdown="strong"] { + @apply font-semibold text-foreground; + } + .document-markdown [data-streamdown="heading-1"] { + @apply mb-3 mt-7 text-lg font-semibold leading-7 tracking-tight text-foreground; + } + .document-markdown [data-streamdown="heading-2"] { + @apply mb-2.5 mt-7 text-base font-semibold leading-6 tracking-tight text-foreground; + } + .document-markdown [data-streamdown="heading-3"] { + @apply mb-2 mt-6 text-sm font-semibold leading-6 tracking-tight text-foreground; + } + .document-markdown [data-streamdown="heading-4"], + .document-markdown [data-streamdown="heading-5"] { + @apply mb-1.5 mt-5 text-xs font-semibold leading-5 tracking-tight text-foreground; + } + .document-markdown [data-streamdown="heading-6"] { + @apply mb-1.5 mt-5 text-[11px] font-semibold leading-5 tracking-wide text-foreground/80; + } + .document-markdown [data-streamdown="unordered-list"], + .document-markdown [data-streamdown="ordered-list"] { + @apply my-3 ml-0 space-y-1 pl-5; + } + .document-markdown [data-streamdown="unordered-list"] [data-streamdown="unordered-list"], + .document-markdown [data-streamdown="ordered-list"] [data-streamdown="ordered-list"], + .document-markdown [data-streamdown="unordered-list"] [data-streamdown="ordered-list"], + .document-markdown [data-streamdown="ordered-list"] [data-streamdown="unordered-list"] { + @apply my-1; + } + .document-markdown [data-streamdown="list-item"] { + @apply pl-0.5 text-[calc(13px*var(--zone-font-scale,1))] leading-6 text-foreground/85; + } + .document-markdown [data-streamdown="list-item"] > p { + @apply my-0; + } + .document-markdown [data-streamdown="blockquote"] { + @apply my-4 border-l-2 border-border pl-3 not-italic text-foreground/75; + } + .document-markdown [data-streamdown="inline-code"] { + @apply rounded border border-border/50 bg-muted/65 px-1 py-0.5 text-[0.9em] text-foreground; + } + .document-markdown [data-streamdown="code-block"] { + @apply my-4; + } + .document-markdown [data-streamdown="code-block-header"] { + @apply h-7 px-1 text-[calc(10px*var(--zone-font-scale,1))] tracking-[0.04em] text-muted-foreground; + } + .document-markdown [data-streamdown="code-block-body"] { + @apply rounded-lg bg-muted/55 p-3; + } + .document-markdown pre > code { + @apply !py-3 text-[calc(12px*var(--zone-font-scale,1))] leading-5 text-foreground/90; + } + .document-markdown [data-streamdown="table"] { + @apply text-[calc(13px*var(--zone-font-scale,1))]; + } + .document-markdown [data-streamdown="table-header-cell"], + .document-markdown [data-streamdown="table-cell"] { + @apply py-2 leading-5; + } + .document-markdown [data-streamdown="horizontal-rule"] { + @apply my-7 border-border/60; + } + /* First/last child margin reset */ .chat-markdown > *:first-child, .chat-markdown > *:first-child > *:first-child { diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index b83cd3ed1..2fde81613 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -1965,7 +1965,7 @@ export const translations: Record> = { /* ── MCP Hub ── */ "mcpHub.title": "MCP Servers", - "mcpHub.subtitle": "Model Context Protocol", + "mcpHub.subtitle": "管理模型上下文协议(MCP)服务器", "mcpHub.localTitle": "本地 MCP Servers", "mcpHub.localSubtitle": "管理本地 stdio、Streamable HTTP 和 SSE MCP 配置", "mcpHub.enabled": "已启用", @@ -1979,6 +1979,8 @@ export const translations: Record> = { "mcpHub.importScanFailed": "扫描失败", "mcpHub.importNotDetected": "未检测到", "mcpHub.importEmpty": "该工具没有配置 MCP Server", + "mcpHub.importSearchPlaceholder": "搜索本地 MCP", + "mcpHub.importNoMatch": "未匹配到本地 MCP,换个关键词试试", "mcpHub.importAlreadyImported": "已导入", "mcpHub.importSelectAll": "全选", "mcpHub.importDeselectAll": "取消全选", @@ -2037,7 +2039,7 @@ export const translations: Record> = { "mcpHub.storeSubtitle": "官方 Registry、Smithery 与 Glama", "mcpHub.storeRefresh": "刷新", "mcpHub.storeSearch": "搜索", - "mcpHub.storeSearchPlaceholder": "搜索 MCP server", + "mcpHub.storeSearchPlaceholder": "搜索 MCP 服务器", "mcpHub.storeLoadFailed": "加载 MCP Store 失败", "mcpHub.storeInstallFailed": "添加 MCP Server 失败", "mcpHub.storeInstallUnavailable": "这个条目暂时无法自动生成 MCP 配置", @@ -2146,8 +2148,8 @@ export const translations: Record> = { "settings.skillsInstalledPreviewDescription": "技能描述", "settings.skillsInstalledPreviewNoDescription": "暂无描述", "settings.skillsInstalledPreviewCopyDescription": "复制技能描述", - "settings.skillsInstalledPreviewCopyFile": "复制文件预览", - "settings.skillsInstalledPreviewCopied": "已复制", + "settings.skillsInstalledPreviewCopyFile": "复制技能文件内容", + "settings.skillsInstalledPreviewCopied": "已复制到剪贴板", "settings.skillsInstalledPreviewDetails": "信息", "settings.skillsInstalledPreviewBaseDir": "目录", "settings.skillsInstalledPreviewSkillFile": "技能文件", @@ -2168,6 +2170,12 @@ export const translations: Record> = { "settings.skillsAlwaysOn": "内置", "settings.skillsScan": "扫描", "settings.skillsScanning": "扫描中", + "settings.skillsScanHint": "重新读取 LiveAgent Skills 目录并同步当前会话可用能力", + "settings.skillsScanComplete": "扫描完成", + "settings.skillsScanFailed": "扫描失败", + "settings.skillsScanFound": "共发现 {count} 个 Skill", + "settings.skillsScanChanged": "新增 {added} · 更新 {updated} · 移除 {removed}", + "settings.skillsScanNoChanges": "未发现变化,当前列表已是最新", "settings.skillsDisabledHint": "技能功能已关闭,开启后才会将选中的技能注入 system prompt", "settings.skillsDisabledInChatMode": "Chat 模式下不启用技能。切换到 Agent 模式后,才会恢复扫描、选择和注入能力。", @@ -2184,7 +2192,7 @@ export const translations: Record> = { "settings.skillsStoreSearch": "搜索 ClawHub 技能", "settings.skillsBulkSelect": "批量选择", "settings.skillsBulkSelectAll": "全部选中", - "settings.skillsBulkDeselectAll": "全部取消", + "settings.skillsBulkDeselectAll": "全部不选", "settings.skillsBulkHint": "点击卡片选择 · Shift+点击可选择一段区间", "settings.skillsBulkImportHint": "点击卡片勾选 · Shift+点击可选择一段区间", "settings.skillsBulkUpdated": "已更新 {count} 个技能", @@ -4388,6 +4396,8 @@ export const translations: Record> = { "mcpHub.importScanFailed": "Scan failed", "mcpHub.importNotDetected": "Not detected", "mcpHub.importEmpty": "No MCP servers configured for this tool", + "mcpHub.importSearchPlaceholder": "Search local MCPs", + "mcpHub.importNoMatch": "No local MCP matches — try a different keyword", "mcpHub.importAlreadyImported": "Imported", "mcpHub.importSelectAll": "Select all", "mcpHub.importDeselectAll": "Deselect all", @@ -4562,8 +4572,8 @@ export const translations: Record> = { "settings.skillsInstalledPreviewDescription": "Description", "settings.skillsInstalledPreviewNoDescription": "No description", "settings.skillsInstalledPreviewCopyDescription": "Copy Skill description", - "settings.skillsInstalledPreviewCopyFile": "Copy file preview", - "settings.skillsInstalledPreviewCopied": "Copied", + "settings.skillsInstalledPreviewCopyFile": "Copy Skill file contents", + "settings.skillsInstalledPreviewCopied": "Copied to clipboard", "settings.skillsInstalledPreviewDetails": "Details", "settings.skillsInstalledPreviewBaseDir": "Directory", "settings.skillsInstalledPreviewSkillFile": "Skill file", @@ -4584,6 +4594,13 @@ export const translations: Record> = { "settings.skillsAlwaysOn": "Built-in", "settings.skillsScan": "Scan", "settings.skillsScanning": "Scanning", + "settings.skillsScanHint": + "Rescan the LiveAgent Skills directory and sync capabilities available to this conversation", + "settings.skillsScanComplete": "Scan complete", + "settings.skillsScanFailed": "Scan failed", + "settings.skillsScanFound": "Found {count} Skills", + "settings.skillsScanChanged": "{added} added · {updated} updated · {removed} removed", + "settings.skillsScanNoChanges": "No changes found. The list is up to date", "settings.skillsDisabledHint": "Skills is off. Enable it to inject selected skills into the system prompt.", "settings.skillsDisabledInChatMode": diff --git a/crates/agent-gui/src/index.css b/crates/agent-gui/src/index.css index acdb0b72c..48d279197 100644 --- a/crates/agent-gui/src/index.css +++ b/crates/agent-gui/src/index.css @@ -929,6 +929,80 @@ @apply my-5 border-border/45; } + /* Long-form files and documentation use a calmer, denser reading rhythm + * than conversational Markdown. This profile is shared by Skill and + * workspace file previews; chat typography remains unchanged. */ + .document-markdown { + @apply text-[calc(13px*var(--zone-font-scale,1))] leading-6 text-foreground/85; + } + .document-markdown p { + @apply my-2.5 text-[calc(13px*var(--zone-font-scale,1))] leading-6 text-foreground/85; + } + .document-markdown strong, + .document-markdown [data-streamdown="strong"] { + @apply font-semibold text-foreground; + } + .document-markdown [data-streamdown="heading-1"] { + @apply mb-3 mt-7 text-lg font-semibold leading-7 tracking-tight text-foreground; + } + .document-markdown [data-streamdown="heading-2"] { + @apply mb-2.5 mt-7 text-base font-semibold leading-6 tracking-tight text-foreground; + } + .document-markdown [data-streamdown="heading-3"] { + @apply mb-2 mt-6 text-sm font-semibold leading-6 tracking-tight text-foreground; + } + .document-markdown [data-streamdown="heading-4"], + .document-markdown [data-streamdown="heading-5"] { + @apply mb-1.5 mt-5 text-xs font-semibold leading-5 tracking-tight text-foreground; + } + .document-markdown [data-streamdown="heading-6"] { + @apply mb-1.5 mt-5 text-[11px] font-semibold leading-5 tracking-wide text-foreground/80; + } + .document-markdown [data-streamdown="unordered-list"], + .document-markdown [data-streamdown="ordered-list"] { + @apply my-3 ml-0 space-y-1 pl-5; + } + .document-markdown [data-streamdown="unordered-list"] [data-streamdown="unordered-list"], + .document-markdown [data-streamdown="ordered-list"] [data-streamdown="ordered-list"], + .document-markdown [data-streamdown="unordered-list"] [data-streamdown="ordered-list"], + .document-markdown [data-streamdown="ordered-list"] [data-streamdown="unordered-list"] { + @apply my-1; + } + .document-markdown [data-streamdown="list-item"] { + @apply pl-0.5 text-[calc(13px*var(--zone-font-scale,1))] leading-6 text-foreground/85; + } + .document-markdown [data-streamdown="list-item"] > p { + @apply my-0; + } + .document-markdown [data-streamdown="blockquote"] { + @apply my-4 border-l-2 border-border pl-3 not-italic text-foreground/75; + } + .document-markdown [data-streamdown="inline-code"] { + @apply rounded border border-border/50 bg-muted/65 px-1 py-0.5 text-[0.9em] text-foreground; + } + .document-markdown [data-streamdown="code-block"] { + @apply my-4; + } + .document-markdown [data-streamdown="code-block-header"] { + @apply h-7 px-1 text-[calc(10px*var(--zone-font-scale,1))] tracking-[0.04em] text-muted-foreground; + } + .document-markdown [data-streamdown="code-block-body"] { + @apply rounded-lg bg-muted/55 p-3; + } + .document-markdown pre > code { + @apply !py-3 text-[calc(12px*var(--zone-font-scale,1))] leading-5 text-foreground/90; + } + .document-markdown [data-streamdown="table"] { + @apply text-[calc(13px*var(--zone-font-scale,1))]; + } + .document-markdown [data-streamdown="table-header-cell"], + .document-markdown [data-streamdown="table-cell"] { + @apply py-2 leading-5; + } + .document-markdown [data-streamdown="horizontal-rule"] { + @apply my-7 border-border/60; + } + /* First/last child margin reset */ .chat-markdown > *:first-child, .chat-markdown > *:first-child > *:first-child { diff --git a/crates/agent-gui/test/chat/markdown-code-collapse.test.mjs b/crates/agent-gui/test/chat/markdown-code-collapse.test.mjs index a91ca0b70..885abb83c 100644 --- a/crates/agent-gui/test/chat/markdown-code-collapse.test.mjs +++ b/crates/agent-gui/test/chat/markdown-code-collapse.test.mjs @@ -48,6 +48,7 @@ function createMarkdownModule(expanded) { X: "X", }, "./ui/button": { Button: "Button" }, + "./ui/copy-button": { CopyButton: "CopyButton" }, }, }); return loader.loadModule("@liveagent/ui/components/Markdown.tsx"); diff --git a/crates/agent-gui/test/settings/mcp-hub-tabs.test.mjs b/crates/agent-gui/test/settings/mcp-hub-tabs.test.mjs new file mode 100644 index 000000000..078ec0471 --- /dev/null +++ b/crates/agent-gui/test/settings/mcp-hub-tabs.test.mjs @@ -0,0 +1,174 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const pageSource = readFileSync( + new URL("../../../agent-ui/src/pages/mcp-hub/McpHubPage.tsx", import.meta.url), + "utf8", +); +const toolbarSource = readFileSync( + new URL("../../../agent-ui/src/pages/mcp-hub/McpRegistryToolbar.tsx", import.meta.url), + "utf8", +); +const importPickerSource = readFileSync( + new URL("../../../agent-ui/src/pages/mcp-hub/McpImportSourcePicker.tsx", import.meta.url), + "utf8", +); +const importViewSource = readFileSync( + new URL("../../../agent-ui/src/pages/mcp-hub/McpImportView.tsx", import.meta.url), + "utf8", +); +const serversFormSource = readFileSync( + new URL("../../../agent-ui/src/pages/mcp-hub/McpServersForm.tsx", import.meta.url), + "utf8", +); +const serverCardSource = readFileSync( + new URL("../../../agent-ui/src/pages/mcp-hub/McpServerCard.tsx", import.meta.url), + "utf8", +); +const registryBrowserSource = readFileSync( + new URL("../../../agent-ui/src/pages/mcp-hub/McpRegistryBrowser.tsx", import.meta.url), + "utf8", +); +const externalToolIconSource = readFileSync( + new URL( + "../../../agent-ui/src/components/resources/ExternalToolSourceIcon.tsx", + import.meta.url, + ), + "utf8", +); +const resourceTabsSource = readFileSync( + new URL("../../../agent-ui/src/components/resources/ResourceTabsList.tsx", import.meta.url), + "utf8", +); +const guiI18nSource = readFileSync(new URL("../../src/i18n/config.ts", import.meta.url), "utf8"); +const webI18nSource = readFileSync( + new URL("../../../agent-gateway/web/src/i18n/config.ts", import.meta.url), + "utf8", +); + +test("MCP navigation uses the same standard segmented and quiet Tabs as Skills Hub", () => { + for (const source of [toolbarSource, importPickerSource]) { + assert.match(source, /components\/ui\/tabs/); + assert.match(source, / { + assert.match(pageSource, / { + const searchPosition = pageSource.indexOf('type="search"'); + const tabsPosition = pageSource.indexOf("= 0); + assert.ok(tabsPosition > searchPosition); + assert.match(pageSource, /const \[searchQueries, setSearchQueries\]/); + assert.match(pageSource, /query=\{searchQueries\.installed\}/); + assert.match(pageSource, /query=\{searchQueries\.store\}/); + assert.match(pageSource, /query=\{searchQueries\.import\}/); + assert.doesNotMatch(serversFormSource, /type="search"/); + assert.doesNotMatch(toolbarSource, /type="search"/); + assert.match(registryBrowserSource, /window\.setTimeout\([\s\S]*runSearch\("replace", query\)/); + assert.match(importViewSource, /rankFuzzySearchResults/); +}); + +test("MCP import tabs keep source icons and compact count badges", () => { + assert.match(importPickerSource, /ExternalToolSourceIcon/); + assert.match(externalToolIconSource, /"claude-code": ClaudeIcon/); + assert.match(externalToolIconSource, /codex: OpenaiChatgptIcon/); + assert.match(externalToolIconSource, /codebuddy: Bot/); + assert.match(importPickerSource, / { + assert.match(serversFormSource, /divide-y[^"\n]*overflow-hidden[^"\n]*rounded-xl[^"\n]*border/); + assert.match(serverCardSource, /min-h-16[^"\n]*items-center/); + assert.match(serverCardSource, /ResourceActivationSwitch/); + assert.match(serverCardSource, /ToolPolicyToggle/); + assert.match(serverCardSource, /const argsCount = \(server\.args \?\? \[\]\)\.filter\(Boolean\)\.length/); + assert.match(serverCardSource, /const envCount = server\.env \? Object\.keys\(server\.env\)\.length : 0/); + assert.match( + serverCardSource, + /const headerCount = server\.headers \? Object\.keys\(server\.headers\)\.length : 0/, + ); + assert.match(serverCardSource, /label=\{t\("mcpHub\.previewArgs"\)\}/); + assert.match(serverCardSource, /label=\{t\("mcpHub\.previewEnv"\)\}/); + assert.match(serverCardSource, /label=\{t\("mcpHub\.previewHeaders"\)\}/); + assert.match(serverCardSource, /grid-cols-\[auto_2rem_2rem\]/); + assert.match(serverCardSource, /aria-hidden="true"/); + assert.ok( + serverCardSource.indexOf(" + serverCardSource.indexOf(" { + const pageLimit = Number( + registryBrowserSource.match(/MCP_STORE_PAGE_LIMIT = (\d+)/)?.[1], + ); + assert.ok(pageLimit > 0); + assert.equal(pageLimit % 4, 0); + assert.match(registryBrowserSource, /STORE_SKELETON_IDS = Array\.from\(\{ length: 8 \}/); + assert.match(registryBrowserSource, /new IntersectionObserver/); + assert.match(registryBrowserSource, /root: scrollRootRef\.current|root,/); + assert.match(registryBrowserSource, /rootMargin: "0px 0px 320px 0px"/); + assert.match(registryBrowserSource, /loadMoreRequestRef\.current/); + assert.match(registryBrowserSource, /ref=\{loadMoreSentinelRef\}/); + assert.doesNotMatch(registryBrowserSource, /mcpHub\.storeLoadMore/); +}); + +test("MCP Store cards center connection previews and use working external and add actions", () => { + assert.match(registryBrowserSource, /shims\/tauriOpener/); + assert.match(registryBrowserSource, /void openUrl\(link\)/); + assert.match(registryBrowserSource, /flex min-h-\[40px\] items-center/); + assert.match(registryBrowserSource, / { + assert.match(pageSource, /subtitle=\{t\("mcpHub\.subtitle"\)\}/); + for (const source of [guiI18nSource, webI18nSource]) { + assert.match(source, /"mcpHub\.subtitle": "管理模型上下文协议(MCP)服务器"/); + assert.match(source, /"mcpHub\.storeSearchPlaceholder": "搜索 MCP 服务器"/); + } +}); + +test("MCP local import rescan reports progress and completion without replacing existing content", () => { + assert.match(importViewSource, /const \[rescanComplete, setRescanComplete\]/); + assert.match(importViewSource, /aria-busy=\{loading\}/); + assert.match(importViewSource, /loading \? \(\s*]*animate-spin/); + assert.match(importViewSource, /rescanComplete[\s\S]*settings\.skillsScanComplete/); + assert.match(importViewSource, /aria-live="polite"/); + assert.doesNotMatch(importViewSource, /onClick=\{\(\) => void rescan\(\)\}/); +}); diff --git a/crates/agent-gui/test/settings/workspace-resource-settings.test.mjs b/crates/agent-gui/test/settings/workspace-resource-settings.test.mjs index 0e404e3df..b2c4d6b98 100644 --- a/crates/agent-gui/test/settings/workspace-resource-settings.test.mjs +++ b/crates/agent-gui/test/settings/workspace-resource-settings.test.mjs @@ -7,6 +7,18 @@ const sharedDrawer = readFileSync( new URL("../../../agent-ui/src/components/chat/WorkspaceResourceSettingsDrawer.tsx", import.meta.url), "utf8", ); +const sharedResourceTabs = readFileSync( + new URL("../../../agent-ui/src/components/resources/ResourceTabsList.tsx", import.meta.url), + "utf8", +); +const sharedResourceCard = readFileSync( + new URL("../../../agent-ui/src/components/resources/ResourceSelectionCard.tsx", import.meta.url), + "utf8", +); +const sharedSheet = readFileSync( + new URL("../../../agent-ui/src/components/ui/sheet.tsx", import.meta.url), + "utf8", +); const sharedSidebar = readFileSync( new URL("../../../agent-ui/src/components/chat/ChatHistorySidebar.tsx", import.meta.url), "utf8", @@ -28,8 +40,8 @@ const sharedSkillsHub = readFileSync( new URL("../../../agent-ui/src/pages/skills-hub/SkillsHubPage.tsx", import.meta.url), "utf8", ); -const sharedMcpHub = readFileSync( - new URL("../../../agent-ui/src/pages/mcp-hub/McpServersForm.tsx", import.meta.url), +const sharedMcpServerCard = readFileSync( + new URL("../../../agent-ui/src/pages/mcp-hub/McpServerCard.tsx", import.meta.url), "utf8", ); @@ -37,9 +49,26 @@ test("workspace resources use one entry and one combined settings drawer", () => assert.match(sharedSidebar, /chat\.workspaceResources/); assert.match(sharedSidebar, /onConfigureProjectResources\(project\)/); assert.match(sharedDrawer, /\["inherit", "custom", "off"\]/); - assert.match(sharedDrawer, /\["skills", "mcp"\]/); - assert.match(sharedDrawer, /CLAWHUB_CATEGORY_SLUGS/); - assert.match(sharedDrawer, /ResourceActivationSwitch/); + assert.match(sharedDrawer, /value: "skills"/); + assert.match(sharedDrawer, /value: "mcp"/); + assert.match(sharedDrawer, / { + assert.notEqual( + fuzzySearch.fuzzySearchScore("code review", ["liveagent-code-review", "Review pull requests"]), + null, + ); + assert.notEqual(fuzzySearch.fuzzySearchScore("githb", ["GitHub integration"]), null); + assert.notEqual(fuzzySearch.fuzzySearchScore("skillcreator", ["skill-creator"]), null); + assert.equal(fuzzySearch.normalizeSearchText("GitHub"), "github"); + assert.equal(fuzzySearch.fuzzySearchScore("calendar", ["GitHub integration"]), null); +}); + +test("local fuzzy results are filtered and ranked without disturbing equal-score order", () => { + const items = [ + { name: "review-notes", description: "Document review feedback" }, + { name: "liveagent-code-review", description: "Review a pull request" }, + { name: "calendar", description: "Manage events" }, + ]; + const ranked = fuzzySearch.rankFuzzySearchResults( + items, + "code revie", + (item) => [item.name, item.description], + ); + + assert.deepEqual( + ranked.map((item) => item.name), + ["liveagent-code-review"], + ); +}); + +test("remote fuzzy ranking keeps unmatched server results after relevant matches", () => { + const items = [{ name: "calendar" }, { name: "github" }, { name: "database" }]; + const ranked = fuzzySearch.rankFuzzySearchResults(items, "githb", (item) => [item.name], { + includeUnmatched: true, + }); + + assert.equal(ranked[0].name, "github"); + assert.deepEqual( + ranked.slice(1).map((item) => item.name), + ["calendar", "database"], + ); +}); + +test("search highlighting marks exact terms and the closest fuzzy word", () => { + assert.deepEqual(fuzzySearch.getSearchHighlightRanges("GitHub integration", "git hub"), [ + { start: 0, end: 6 }, + ]); + assert.deepEqual(fuzzySearch.getSearchHighlightRanges("Connect GitHub safely", "githb"), [ + { start: 8, end: 14 }, + ]); +}); + +test("Skills and MCP result cards share fuzzy ranking and keyword highlighting", () => { + const installedSkills = readUiSource("pages/skills-hub/InstalledSkillCard.tsx"); + const importedSkills = readUiSource("pages/skills-hub/SkillsImportView.tsx"); + const storeSkills = readUiSource("pages/skills-hub/SkillsStoreView.tsx"); + const installedMcp = readUiSource("pages/mcp-hub/McpServersForm.tsx"); + const storeMcp = readUiSource("pages/mcp-hub/McpRegistryBrowser.tsx"); + + assert.match(installedSkills, / new URL(`../../../agent-ui/src/pages/skills-hub/${file}`, import.meta.url), + ), }, ]; @@ -21,7 +30,7 @@ function skill(name, installedAt = null) { }; } -for (const { label, loader, page } of implementations) { +for (const { label, loader, sources } of implementations) { const sorting = loader.loadModule("@liveagent/ui/lib/skills/installedSort.ts"); test(`${label} keeps built-ins ahead of enabled and disabled skills`, () => { @@ -90,7 +99,7 @@ for (const { label, loader, page } of implementations) { }); test(`${label} wires visual order, selection order, persistence, and reduced-motion FLIP`, () => { - const source = readFileSync(page, "utf8"); + const source = sources.map((file) => readFileSync(file, "utf8")).join("\n"); assert.match(source, /skillsHub\.installedSort/); assert.match(source, /sortInstalledSkillItems\(filtered, installedSort, selected/); @@ -99,8 +108,8 @@ for (const { label, loader, page } of implementations) { assert.match(source, /ref=\{installedGridRef\}/); assert.equal(source.match(/data-flip-key=\{key\}/g)?.length, 2); assert.match(source, /prefers-reduced-motion: reduce/); - assert.match(source, /\[color-scheme:light\][^"]*dark:\[color-scheme:dark\]/); - assert.match(source, / { + const oneSelected = selection.toggleBulkSelection(new Set(), "alpha"); + assert.deepEqual([...oneSelected], ["alpha"]); + assert.equal(selection.toggleBulkSelection(oneSelected, "alpha").size, 0); + + const allSelected = selection.updateBulkSelection(oneSelected, ["beta", "gamma"], true); + assert.deepEqual([...allSelected], ["alpha", "beta", "gamma"]); + assert.equal(selection.includesEveryBulkSelection(allSelected, ["alpha", "gamma"]), true); + assert.equal(selection.includesEveryBulkSelection(allSelected, ["alpha", "missing"]), false); + assert.equal(selection.includesEveryBulkSelection(allSelected, []), false); +}); + +test("installed bulk mode exits on empty selection, all-deselect, actions, and Escape", () => { + const source = readSkillHubSource("SkillsHubPage.tsx"); + + assert.match(source, /if \(next\.size === 0\) \{\s*exitBulkMode\(\);\s*return;/); + assert.match(source, /if \(allVisibleBulkSelected\) exitBulkMode\(\)/); + assert.match( + source, + /allVisibleBulkSelected\s*\? t\("settings\.skillsBulkDeselectAll"\)/, + ); + assert.match(source, /setBulkUndo\([\s\S]*?exitBulkMode\(\);[\s\S]*?setSettings/); + assert.match(source, /if \(event\.key === "Escape"\) \{\s*exitBulkMode\(\);/); +}); + +test("Skill cards use one selected border and never move on hover", () => { + const installed = readSkillHubSource("InstalledSkillCard.tsx"); + const imported = readSkillHubSource("SkillsImportView.tsx"); + const store = readSkillHubSource("SkillsStoreView.tsx"); + + assert.match(installed, /bulkSelected\s*\? "border-foreground bg-muted\/30 shadow-sm"/); + assert.match(imported, /checked\s*\? "border-foreground bg-muted\/30 shadow-sm"/); + assert.match(imported, /focus-visible:ring-offset-2/); + assert.doesNotMatch(installed, /hover:-translate|hover:scale|ring-2 ring-ring\/40/); + assert.doesNotMatch(imported, /focus:ring-2|ring-2 ring-ring\/40/); + assert.doesNotMatch(imported, /hover:-translate|hover:scale/); + assert.doesNotMatch(store, /hover:-translate|hover:scale/); +}); + +test("bulk-mode guidance stays in the overlay instead of shifting page content", () => { + const page = readSkillHubSource("SkillsHubPage.tsx"); + const imported = readSkillHubSource("SkillsImportView.tsx"); + + assert.doesNotMatch(page, /hub-panel-enter flex items-center gap-2 text-\[11px\][^>]*skillsBulkHint/); + assert.doesNotMatch(imported, /hub-panel-enter flex items-center gap-2 text-\[11px\][^>]*skillsBulkImportHint/); + assert.match(page, /pointer-events-none absolute inset-x-0 bottom-4/); + assert.match(page, /settings\.skillsBulkClickToSelect/); +}); diff --git a/crates/agent-gui/test/skills/skill-card-interactions.test.mjs b/crates/agent-gui/test/skills/skill-card-interactions.test.mjs new file mode 100644 index 000000000..e24d17860 --- /dev/null +++ b/crates/agent-gui/test/skills/skill-card-interactions.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const uiRoot = new URL("../../../agent-ui/src/", import.meta.url); + +function readUiSource(path) { + return readFileSync(new URL(path, uiRoot), "utf8"); +} + +test("installed Skill card actions do not bubble into the card preview trigger", () => { + const source = readUiSource("pages/skills-hub/InstalledSkillCard.tsx"); + + assert.match(source, /data-card-action-zone=""/); + assert.match(source, /onPointerDown=\{\(event\) => event\.stopPropagation\(\)\}/); + assert.match(source, /onMouseDown=\{\(event\) => event\.stopPropagation\(\)\}/); + assert.match(source, /onClick=\{\(event\) => event\.stopPropagation\(\)\}/); + assert.match(source, /onKeyDown=\{\(event\) => event\.stopPropagation\(\)\}/); + assert.match(source, / { + const cardSource = readUiSource("pages/skills-hub/InstalledSkillCard.tsx"); + const pageSource = readUiSource("pages/skills-hub/SkillsHubPage.tsx"); + + assert.match(cardSource, /const effectivelyEnabled = skillsEnabled && checked/); + assert.match( + cardSource, + / { + const source = readUiSource("pages/skills-hub/InstalledSkillCard.tsx"); + const switchIndex = source.indexOf("', + ); + const deleteIndex = source.indexOf('data-card-delete-zone=""'); + + assert.ok(switchIndex > -1 && switchIndex < footerIndex); + assert.ok(footerIndex < deleteIndex); + assert.match( + source, + /data-card-delete-zone=""[\s\S]*group-hover:opacity-100[\s\S]*group-focus-within:opacity-100/, + ); + assert.match( + source, + /group-hover:opacity-0 group-focus-within:opacity-0 \[@media\(hover:none\)\]:opacity-0/, + ); + assert.match( + source, + /pointer-events-none col-start-1 row-start-1 inline-flex[\s\S]*data-card-delete-zone=""[\s\S]*relative z-10/, + ); + assert.match( + source, + /data-card-delete-zone=""[\s\S]*className="h-8 w-8 text-muted-foreground hover:bg-destructive\/10 hover:text-destructive"/, + ); +}); + +test("resource switches isolate pointer, mouse, click, and keyboard events when requested", () => { + const source = readUiSource("components/resources/ResourceActivationSwitch.tsx"); + + assert.match(source, /if \(props\.stopPropagation\) event\.stopPropagation\(\)/); + assert.match(source, /onPointerDown=\{stopEventPropagation\}/); + assert.match(source, /onMouseDown=\{stopEventPropagation\}/); + assert.match(source, /onKeyDown=\{stopEventPropagation\}/); + assert.match(source, /onClick=\{\(event\) => \{[\s\S]*stopEventPropagation\(event\)/); +}); + +test("confirmation popovers isolate cancel and confirm actions from parent cards", () => { + const source = readUiSource("components/ui/confirm-action-popover.tsx"); + + assert.match(source, / event\.stopPropagation\(\)\}/, + ); + assert.match(source, /event\.stopPropagation\(\);[\s\S]*onConfirm\(\);/); +}); diff --git a/crates/agent-gui/test/skills/skill-scan-feedback.test.mjs b/crates/agent-gui/test/skills/skill-scan-feedback.test.mjs new file mode 100644 index 000000000..fbc553ef0 --- /dev/null +++ b/crates/agent-gui/test/skills/skill-scan-feedback.test.mjs @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const scanSummary = loader.loadModule("@liveagent/ui/pages/skills-hub/skillScanSummary.ts"); + +function skill(name, overrides = {}) { + return { + name, + description: `${name} description`, + skillFile: `${name}/SKILL.md`, + baseDir: name, + ...overrides, + }; +} + +test("Skill scan summaries distinguish added, updated, removed, and unchanged entries", () => { + const previous = [skill("same"), skill("updated"), skill("removed")]; + const next = [skill("same"), skill("updated", { description: "new description" }), skill("added")]; + + assert.deepEqual(scanSummary.summarizeSkillScan(previous, next), { + total: 3, + added: 1, + updated: 1, + removed: 1, + }); + assert.deepEqual(scanSummary.summarizeSkillScan(next, next), { + total: 3, + added: 0, + updated: 0, + removed: 0, + }); +}); + +test("Skill discovery signatures are independent of discovery order", () => { + const first = skill("first"); + const second = skill("second"); + + assert.equal( + scanSummary.buildSkillDiscoverySignature("/skills", [first, second]), + scanSummary.buildSkillDiscoverySignature("/skills", [second, first]), + ); +}); + +test("manual Skill scans announce a persistent, dismissible result", () => { + const source = readFileSync( + new URL("../../../agent-ui/src/pages/skills-hub/SkillsHubPage.tsx", import.meta.url), + "utf8", + ); + + assert.match(source, /refresh\(\{ announce: true \}\)/); + assert.match(source, /SCAN_FEEDBACK_DURATION_MS/); + assert.match(source, /role=\{scanFeedback\.status === "error" \? "alert" : "status"\}/); + assert.match(source, /onClick=\{dismissScanFeedback\}/); + assert.match(source, /summarizeSkillScan\(skillsSnapshotRef\.current, discovery\.skills\)/); +}); + +test("manual Skill scan button holds a completed state before returning to idle", () => { + const source = readFileSync( + new URL("../../../agent-ui/src/pages/skills-hub/SkillsHubPage.tsx", import.meta.url), + "utf8", + ); + + assert.match(source, /SCAN_BUTTON_COMPLETE_DURATION_MS = 2400/); + assert.match(source, /setScanButtonComplete\(true\)/); + assert.match(source, /showScanButtonComplete\(\)/); + assert.match(source, /disabled=\{loading \|\| scanButtonComplete \|\| lockedByChatMode\}/); + assert.match(source, /scanButtonComplete\s*\? t\("settings\.skillsScanComplete"\)/); + assert.match(source, /text-\[hsl\(var\(--chat-success\)\)\]/); +}); diff --git a/crates/agent-gui/test/skills/skills-category-tabs.test.mjs b/crates/agent-gui/test/skills/skills-category-tabs.test.mjs new file mode 100644 index 000000000..9263b734a --- /dev/null +++ b/crates/agent-gui/test/skills/skills-category-tabs.test.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const controlsSource = readFileSync( + new URL("../../../agent-ui/src/pages/skills-hub/SkillCategoryControls.tsx", import.meta.url), + "utf8", +); +const hubSource = readFileSync( + new URL("../../../agent-ui/src/pages/skills-hub/SkillsHubPage.tsx", import.meta.url), + "utf8", +); +const importSource = readFileSync( + new URL("../../../agent-ui/src/pages/skills-hub/SkillsImportView.tsx", import.meta.url), + "utf8", +); +const importTabsSource = readFileSync( + new URL("../../../agent-ui/src/pages/skills-hub/SkillsImportSourceTabs.tsx", import.meta.url), + "utf8", +); +const externalToolIconSource = readFileSync( + new URL( + "../../../agent-ui/src/components/resources/ExternalToolSourceIcon.tsx", + import.meta.url, + ), + "utf8", +); +const resourceTabsSource = readFileSync( + new URL("../../../agent-ui/src/components/resources/ResourceTabsList.tsx", import.meta.url), + "utf8", +); + +test("skill category navigation uses the shared standard Tabs components", () => { + assert.match(controlsSource, /import \{ Tabs, TabsList, TabsTrigger \}/); + assert.match(controlsSource, / { + assert.match(hubSource, / { + assert.match(hubSource, / { + assert.match(hubSource, /overflow-y-auto px-0\.5 pr-1 \[overflow-anchor:none\]/); + assert.match(hubSource, /
/); + assert.match(importSource, /overflow-y-auto px-1\.5 pb-4 pt-1\.5/); + assert.match(importSource, /
/); + assert.doesNotMatch(hubSource, /overflow-y-auto px-0\.5 pr-1 pt-1\.5/); + assert.doesNotMatch(importSource, /sticky top-0[^\"]*(?:pb-1\.5|pt-1\.5)/); +}); diff --git a/crates/agent-gui/test/skills/skills-hub-deferred-content.test.mjs b/crates/agent-gui/test/skills/skills-hub-deferred-content.test.mjs index 3c290a13c..94d700cef 100644 --- a/crates/agent-gui/test/skills/skills-hub-deferred-content.test.mjs +++ b/crates/agent-gui/test/skills/skills-hub-deferred-content.test.mjs @@ -23,10 +23,9 @@ test("the shared Skills Hub defers the initial installed list behind a loading s test("the shared Skills Hub derives installed Skills from the deferred snapshot", () => { assert.match( source, - /(?:if \(!text\) return deferredSkills|const matchedSkills = !text\s*\? deferredSkills)/, + /rankFuzzySearchResults\(deferredSkills, deferredFilter/, ); - assert.match(source, /(?:return|:) deferredSkills\.filter/); - assert.doesNotMatch(source, /if \(!text\) return skills/); + assert.doesNotMatch(source, /rankFuzzySearchResults\(skills, deferredFilter/); }); test("the shared Skills Hub avoids discovery signatures during shell render", () => { diff --git a/crates/agent-gui/test/skills/skills-import-rescan.test.mjs b/crates/agent-gui/test/skills/skills-import-rescan.test.mjs new file mode 100644 index 000000000..8cf152766 --- /dev/null +++ b/crates/agent-gui/test/skills/skills-import-rescan.test.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const scanState = loader.loadModule( + "@liveagent/ui/pages/skills-hub/externalSkillScanState.ts", +); +const hubSource = readFileSync( + new URL("../../../agent-ui/src/pages/skills-hub/SkillsHubPage.tsx", import.meta.url), + "utf8", +); +const importViewSource = readFileSync( + new URL("../../../agent-ui/src/pages/skills-hub/SkillsImportView.tsx", import.meta.url), + "utf8", +); + +function scans(description = "A useful skill") { + return [ + { + tool: "codex", + rootDir: "/tmp/codex/skills", + exists: true, + errors: [], + skills: [ + { + name: "example", + description, + baseDir: "/tmp/codex/skills/example", + skillFile: "/tmp/codex/skills/example/SKILL.md", + }, + ], + }, + ]; +} + +test("unchanged external scan results preserve the current list reference", () => { + const previous = scans(); + assert.equal(scanState.reconcileExternalToolScans(previous, scans()), previous); + assert.notEqual( + scanState.reconcileExternalToolScans(previous, scans("Updated description")), + previous, + ); +}); + +test("manual rescans retain stale content and only mark the button busy", () => { + assert.match(hubSource, /reconcileExternalToolScans\(previous, scans\)/); + assert.match(hubSource, /setExternalScans\(\(previous\) => previous \?\? \[\]\)/); + assert.match(hubSource, /initializing=\{externalScans === null\}/); + assert.match(importViewSource, /\{initializing \? \(/); + assert.match(importViewSource, /aria-busy=\{loading\}/); + assert.match(importViewSource, /loading \? \(\s*]*animate-spin/); + assert.match(importViewSource, /rescanComplete[\s\S]*settings\.skillsScanComplete/); + assert.match(importViewSource, /aria-live="polite"/); + assert.doesNotMatch(importViewSource, /\{loading \? \(\s* { + assert.match(importViewSource, / { + assert.match(importViewSource, /pointer-events-none absolute inset-x-0 bottom-1/); + assert.match(importViewSource, /max-sm:bottom-\[calc\(0\.25rem\+env\(safe-area-inset-bottom\)\)\]/); + assert.match(importViewSource, /\? "py-2 pl-4 pr-2"\s*: "px-4 py-2\.5"/); + assert.match(importViewSource, /className="h-7 rounded-full px-3 text-xs"/); + assert.doesNotMatch(importViewSource, /pointer-events-none sticky bottom-3/); +}); diff --git a/crates/agent-gui/test/skills/skills-installed-preview-copy.test.mjs b/crates/agent-gui/test/skills/skills-installed-preview-copy.test.mjs index e39435114..00c0c9a98 100644 --- a/crates/agent-gui/test/skills/skills-installed-preview-copy.test.mjs +++ b/crates/agent-gui/test/skills/skills-installed-preview-copy.test.mjs @@ -6,26 +6,45 @@ const pageSources = [ { label: "共享 Skills Hub", source: readFileSync( - new URL("../../../agent-ui/src/pages/skills-hub/SkillsHubPage.tsx", import.meta.url), + new URL( + "../../../agent-ui/src/pages/skills-hub/InstalledSkillPreviewDrawer.tsx", + import.meta.url, + ), "utf8", ), }, ]; +const copyButtonSource = readFileSync( + new URL("../../../agent-ui/src/components/ui/copy-button.tsx", import.meta.url), + "utf8", +); + for (const { label, source } of pageSources) { test(`${label} can copy the installed Skill description`, () => { - assert.match(source, / { - assert.match(source, / { - assert.match(source, /navigator\.clipboard\?\.writeText/); - assert.match(source, /document\.execCommand\("copy"\)/); + test(`${label} uses the shared copy control with translated success feedback`, () => { + assert.match(source, /components\/ui\/copy-button/); assert.match(source, /settings\.skillsInstalledPreviewCopied/); }); } + +test("shared copy control explains its action and confirms success in a visible tooltip", () => { + assert.match(copyButtonSource, / { + assert.match(copyButtonSource, /navigator\.clipboard\?\.writeText/); + assert.match(copyButtonSource, /document\.execCommand\("copy"\)/); +}); diff --git a/crates/agent-gui/test/skills/skills-markdown-preview.test.mjs b/crates/agent-gui/test/skills/skills-markdown-preview.test.mjs new file mode 100644 index 000000000..d49cf2c95 --- /dev/null +++ b/crates/agent-gui/test/skills/skills-markdown-preview.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const skillDrawerSource = readFileSync( + new URL( + "../../../agent-ui/src/pages/skills-hub/InstalledSkillPreviewDrawer.tsx", + import.meta.url, + ), + "utf8", +); +const workspacePreviewSource = readFileSync( + new URL( + "../../../agent-ui/src/components/workspace-editor/WorkspaceMarkdownPreview.tsx", + import.meta.url, + ), + "utf8", +); +const documentMarkdownSource = readFileSync( + new URL("../../../agent-ui/src/components/markdown/DocumentMarkdown.tsx", import.meta.url), + "utf8", +); +const desktopStyles = readFileSync(new URL("../../src/index.css", import.meta.url), "utf8"); +const webStyles = readFileSync( + new URL("../../../agent-gateway/web/src/index.css", import.meta.url), + "utf8", +); + +test("Skill and workspace files share the document Markdown presentation", () => { + assert.match(skillDrawerSource, / { + for (const source of [desktopStyles, webStyles]) { + assert.match(source, /\.document-markdown p/); + assert.match(source, /\.document-markdown \[data-streamdown="heading-2"\]/); + assert.match(source, /\.document-markdown \[data-streamdown="list-item"\] > p/); + assert.match(source, /\.document-markdown \[data-streamdown="code-block-body"\]/); + assert.match( + source, + /\.document-markdown \[data-streamdown="heading-1"\] \{\s*@apply[^;]*text-lg/, + ); + } +}); + +test("Skill detail sections use whitespace instead of large divider lines", () => { + assert.doesNotMatch(skillDrawerSource, /]*border-b/); +}); diff --git a/crates/agent-gui/test/skills/skills-search-focus.test.mjs b/crates/agent-gui/test/skills/skills-search-focus.test.mjs new file mode 100644 index 000000000..a543ddf22 --- /dev/null +++ b/crates/agent-gui/test/skills/skills-search-focus.test.mjs @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const source = readFileSync( + new URL("../../../agent-ui/src/pages/skills-hub/SkillsHubPage.tsx", import.meta.url), + "utf8", +); + +test("Skills search uses the standard Input focus border without an extra focus ring", () => { + const searchInput = source.match( + / { + assert.match(hubSource, /const cached = readSkillStoreCatalog\(cacheKey\)/); + assert.match( + hubSource, + /setStoreItems\(cached\?\.items \?\? \[\]\)[\s\S]*cached && isSkillStoreCatalogFresh\(cached\)/, + ); + assert.match(cacheSource, /const catalogRequests = new Map/); + assert.match(cacheSource, /if \(existingRequest\) return existingRequest/); +}); + +test("the skill store appends the next cursor page instead of refetching the full list", () => { + assert.match( + cacheSource, + /listClawHubSkills\(\{[\s\S]*sort: params\.sort,[\s\S]*cursor: params\.cursor,[\s\S]*limit: params\.limit/, + ); + assert.match(hubSource, /loadMoreSkillStoreCatalog\(\{[\s\S]*cursor: storeCursor/); + assert.doesNotMatch(hubSource, /limit:\s*storeItems\.length\s*\+/); +}); + +test("an opened skill detail is shown from cache and refreshed only when stale", () => { + assert.match(storeSource, /const cached = readSkillStoreDetail\(previewSkill\)/); + assert.match(storeSource, /setPreviewDetail\(cached\?\.detail \?\? null\)/); + assert.match(storeSource, /setPreviewLoading\(!cached\)/); + assert.match(storeSource, /cached && isSkillStoreDetailFresh\(cached, previewSkill\)/); + assert.match(cacheSource, /const detailRequests = new Map/); + assert.match( + cacheSource, + /writeLruEntry\(detailCache, initialKey[\s\S]*buildClawHubSkillKey\(snapshot\.skill\)/, + ); +}); + +test("background catalog refresh does not move the tabs or disable the card grid", () => { + assert.match(storeSource, /absolute inset-x-0 -bottom-1 h-px/); + assert.match(storeSource, /hub-loading-progress h-full rounded-full bg-foreground\/45/); + assert.doesNotMatch(storeSource, /Loader2 aria-hidden=\{!refreshing\}/); + assert.doesNotMatch(storeSource, /blur-\[1px\]/); + assert.doesNotMatch(storeSource, /pointer-events-none saturate/); +}); + +test("store cards keep a static surface on pointer hover", () => { + assert.doesNotMatch(storeSource, /hover:shadow-md/); + assert.doesNotMatch(storeSource, /hover:-translate-y/); + assert.doesNotMatch(storeSource, /group-hover:bg-muted\/80/); +}); + +test("store cards keep the spacious original information hierarchy", () => { + assert.match(storeSource, /flex h-full cursor-pointer flex-col rounded-2xl/); + assert.match(storeSource, /line-clamp-3 text-\[11\.5px\]/); + assert.match(storeSource, /border-t border-border\/60 pt-2 text-\[10\.5px\]/); + assert.match(storeSource, /mt-auto h-9 w-full gap-1\.5 rounded-xl/); + assert.doesNotMatch(storeSource, /w-fit self-end/); +}); diff --git a/crates/agent-gui/test/skills/skills-store-install-feedback.test.mjs b/crates/agent-gui/test/skills/skills-store-install-feedback.test.mjs index 53e9a3c29..a04ba2d94 100644 --- a/crates/agent-gui/test/skills/skills-store-install-feedback.test.mjs +++ b/crates/agent-gui/test/skills/skills-store-install-feedback.test.mjs @@ -5,10 +5,14 @@ import test from "node:test"; const pageSources = [ { label: "共享 Skills Hub", - source: readFileSync( - new URL("../../../agent-ui/src/pages/skills-hub/SkillsHubPage.tsx", import.meta.url), - "utf8", - ), + source: ["SkillsHubPage.tsx", "SkillsStoreView.tsx"] + .map((file) => + readFileSync( + new URL(`../../../agent-ui/src/pages/skills-hub/${file}`, import.meta.url), + "utf8", + ), + ) + .join("\n"), }, ]; diff --git a/crates/agent-gui/test/tools/mcp-registry.test.mjs b/crates/agent-gui/test/tools/mcp-registry.test.mjs index f3fa46a11..3153f4524 100644 --- a/crates/agent-gui/test/tools/mcp-registry.test.mjs +++ b/crates/agent-gui/test/tools/mcp-registry.test.mjs @@ -139,7 +139,7 @@ test("official registry npm stdio packages become LiveAgent MCP drafts", async ( test("smithery search cards resolve detail endpoint before install", async () => { const fetchImpl = mockFetch((url) => { - if (url === "https://api.smithery.ai/servers?q=drive&pageSize=18&page=1") { + if (url === "https://api.smithery.ai/servers?q=drive&pageSize=24&page=1") { return { body: { servers: [ diff --git a/crates/agent-ui/src/components/Markdown.tsx b/crates/agent-ui/src/components/Markdown.tsx index 1315f53c8..376433184 100644 --- a/crates/agent-ui/src/components/Markdown.tsx +++ b/crates/agent-ui/src/components/Markdown.tsx @@ -1,17 +1,4 @@ -import { - Check, - ChevronDown, - ChevronUp, - Copy, - ExternalLink, - X, -} from "@liveagent/app/components/icons"; -import { - type ChatFileLink, - decodeChatFileLinkPayload, - encodeChatFileLink, - parseChatFileLink, -} from "../lib/chat/chatFileLinks"; +import { ChevronDown, ChevronUp, Copy, ExternalLink, X } from "@liveagent/app/components/icons"; import { openUrl } from "@liveagent/app/shims/tauriOpener"; import { useLocale } from "@liveagent/ui/i18n/index"; import { cjk } from "@streamdown/cjk"; @@ -39,6 +26,12 @@ import { Streamdown, type StreamdownTranslations, } from "streamdown"; +import { + type ChatFileLink, + decodeChatFileLinkPayload, + encodeChatFileLink, + parseChatFileLink, +} from "../lib/chat/chatFileLinks"; import { getCollapsedCodeBlockPreview, resolveCodeBlockRenderPolicy, @@ -46,6 +39,7 @@ import { import { normalizeLatexDelimiters } from "../lib/normalizeLatexDelimiters"; import { cn } from "../lib/shared/utils"; import { Button } from "./ui/button"; +import { CopyButton } from "./ui/copy-button"; const CHAT_FILE_NODE_DATA_KEY = "liveagentChatFileLink"; const LIVEAGENT_FILE_PROTOCOL = "liveagent-file:"; @@ -400,16 +394,6 @@ export function MarkdownLink(props: MarkdownFileLinkProps) { return ; } -async function copyCodeBlockText(text: string) { - try { - await navigator.clipboard.writeText(text); - return true; - } catch (error) { - console.error("Failed to copy code block", error); - return false; - } -} - function getCodeTextFromChild(child: ReactElement) { const raw = child.props.children; if (typeof raw === "string") return raw; @@ -432,26 +416,16 @@ function ensureCodeBlockLanguage(child: ReactElement) function CodeBlockActions({ code }: { code: string }) { const { t } = useLocale(); - const [copied, setCopied] = useState(false); - - const handleCopy = async () => { - if (!(await copyCodeBlockText(code))) return; - setCopied(true); - window.setTimeout(() => setCopied(false), 1600); - }; return (
-
- +
+
); diff --git a/crates/agent-ui/src/components/chat/WorkspaceResourceSettingsDrawer.tsx b/crates/agent-ui/src/components/chat/WorkspaceResourceSettingsDrawer.tsx index 6bc799489..2515d4fea 100644 --- a/crates/agent-ui/src/components/chat/WorkspaceResourceSettingsDrawer.tsx +++ b/crates/agent-ui/src/components/chat/WorkspaceResourceSettingsDrawer.tsx @@ -1,25 +1,49 @@ -import { Blend, Cable, Search, X } from "@liveagent/app/components/icons"; +import { Blend, Cable, Search } from "@liveagent/app/components/icons"; import { type AppSettings, type WorkspaceProject, type WorkspaceResourceSettingsMode, workspaceProjectPathKey, } from "@liveagent/app/lib/settings"; +import { getMcpTransportMeta } from "@liveagent/ui/components/resources/McpTransportMeta"; +import { ResourceSelectionCard } from "@liveagent/ui/components/resources/ResourceSelectionCard"; +import { ResourceTabsList } from "@liveagent/ui/components/resources/ResourceTabsList"; +import { Badge } from "@liveagent/ui/components/ui/badge"; +import { Input } from "@liveagent/ui/components/ui/input"; +import { + Sheet, + SheetClose, + SheetDescription, + SheetFooter, + SheetHeader, + SheetPanel, + SheetPopup, + SheetTitle, +} from "@liveagent/ui/components/ui/sheet"; +import { Tabs } from "@liveagent/ui/components/ui/tabs"; import { useLocale } from "@liveagent/ui/i18n/index"; -import { cn } from "@liveagent/ui/lib/shared/utils"; import { - CLAWHUB_CATEGORY_SLUGS, type ClawHubCategorySlug, classifyClawHubSkill, } from "@liveagent/ui/lib/skills/clawHubCategories"; import { isAlwaysEnabledSkillName, type SkillSummary } from "@liveagent/ui/lib/skills/index"; -import { useEffect, useMemo, useState } from "react"; -import { createPortal } from "react-dom"; -import { ResourceActivationSwitch } from "../resources/ResourceActivationSwitch"; +import { useMemo, useState } from "react"; +import { + STORE_CATEGORY_ICONS, + StoreCategoryChips, + type StoreCategoryValue, +} from "../../pages/skills-hub/SkillCategoryControls"; import { Button } from "../ui/button"; type ResourceTab = "skills" | "mcp"; -type SkillCategory = "all" | ClawHubCategorySlug; + +function isResourceTab(value: unknown): value is ResourceTab { + return value === "skills" || value === "mcp"; +} + +function isWorkspaceResourceMode(value: unknown): value is WorkspaceResourceSettingsMode { + return value === "inherit" || value === "custom" || value === "off"; +} function classifySkill(skill: Pick): ClawHubCategorySlug[] { if (isAlwaysEnabledSkillName(skill.name)) return ["other"]; @@ -64,7 +88,7 @@ export function WorkspaceResourceSettingsDrawer(props: { ); const [tab, setTab] = useState("skills"); const [query, setQuery] = useState(""); - const [category, setCategory] = useState("all"); + const [category, setCategory] = useState("all"); const listedSkills = useMemo(() => { const rows: Array<{ @@ -83,14 +107,6 @@ export function WorkspaceResourceSettingsDrawer(props: { return rows; }, [mode, skillNames, skills, t]); - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") onClose(); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [onClose]); - const selectMode = (next: WorkspaceResourceSettingsMode) => { if (next === "custom" && mode !== "custom") { setSkillNames(new Set(globalSkillNames)); @@ -107,6 +123,17 @@ export function WorkspaceResourceSettingsDrawer(props: { }); }, [category, listedSkills, query]); + const skillCategoryCounts = useMemo(() => { + const counts = new Map(); + counts.set("all", listedSkills.length); + for (const { skill } of listedSkills) { + for (const value of classifySkill(skill)) { + counts.set(value, (counts.get(value) ?? 0) + 1); + } + } + return counts; + }, [listedSkills]); + const filteredMcp = useMemo(() => { const text = query.trim().toLowerCase(); if (!text) return settings.mcp.servers; @@ -120,50 +147,69 @@ export function WorkspaceResourceSettingsDrawer(props: { const readonly = mode !== "custom"; const visibleSkillSelection = mode === "inherit" ? globalSkillNames : skillNames; const visibleMcpSelection = mode === "inherit" ? globalMcpIds : mcpServerIds; + const selectableSkills = listedSkills.filter( + ({ skill }) => !isAlwaysEnabledSkillName(skill.name), + ); + const visibleSelectedSkillCount = + settings.skills.enabled && mode !== "off" + ? selectableSkills.filter(({ skill }) => visibleSkillSelection.has(skill.name)).length + : 0; + const visibleSelectedMcpCount = + mode !== "off" + ? settings.mcp.servers.filter( + (server) => server.enabled && visibleMcpSelection.has(server.id), + ).length + : 0; - return createPortal( -
- - - -
-
- {(["inherit", "custom", "off"] as const).map((value) => ( - - ))} + {project.path} + +
+ + +
+ { + if (isWorkspaceResourceMode(value)) selectMode(value); + }} + > + ({ + value, + label: t(`chat.workspaceResourcesMode${value[0].toUpperCase()}${value.slice(1)}`), + }))} + ariaLabel={t("chat.workspaceResourcesTitle")} + className="grid w-full grid-cols-3" + triggerClassName="w-full px-2 text-xs" + /> +

{mode === "inherit" ? t("chat.workspaceResourcesInheritHint") @@ -173,93 +219,89 @@ export function WorkspaceResourceSettingsDrawer(props: {

-
-
-
- {(["skills", "mcp"] as const).map((value) => ( - - ))} -
-
- - setQuery(event.currentTarget.value)} - placeholder={t("chat.workspaceResourcesSearch")} - className="h-10 w-full rounded-lg border border-border/60 bg-background pl-9 pr-3 text-sm outline-none focus:ring-2 focus:ring-foreground/10" + + { + if (!isResourceTab(value)) return; + setTab(value); + setQuery(""); + }} + className="flex min-h-0 flex-1 flex-col" + > +
+ 0 + ? `${visibleSelectedSkillCount}/${selectableSkills.length}` + : null, + }, + { + value: "mcp", + label: "MCP", + icon: Cable, + countLabel: + settings.mcp.servers.length > 0 + ? `${visibleSelectedMcpCount}/${settings.mcp.servers.length}` + : null, + }, + ]} + ariaLabel={t("chat.workspaceResourcesTitle")} /> +
+ + setQuery(event.currentTarget.value)} + placeholder={t("chat.workspaceResourcesSearch")} + className="h-10 rounded-full border-border bg-background pl-10 pr-4 text-sm shadow-none placeholder:text-muted-foreground" + /> +
-
- {tab === "skills" ? ( -
- {(["all", ...CLAWHUB_CATEGORY_SLUGS] as SkillCategory[]).map((value) => ( - - ))} -
- ) : null} + {tab === "skills" ? ( + + ) : null} -
-
- {tab === "skills" - ? filteredSkills.map(({ skill, missing }) => { - const alwaysEnabled = isAlwaysEnabledSkillName(skill.name); - const checked = - settings.skills.enabled && - mode !== "off" && - (alwaysEnabled || visibleSkillSelection.has(skill.name)); - return ( -
-
-
{skill.name}
-
- {skill.description} -
-
- +
+ {tab === "skills" + ? filteredSkills.map(({ skill, missing }) => { + const alwaysEnabled = isAlwaysEnabledSkillName(skill.name); + const checked = + settings.skills.enabled && + mode !== "off" && + (alwaysEnabled || visibleSkillSelection.has(skill.name)); + const categories = classifySkill(skill); + const SkillIcon = STORE_CATEGORY_ICONS[categories[0] ?? "other"]; + return ( + + {t("settings.skillsAlwaysOn")} + + ) : null + } onCheckedChange={(next) => { const value = new Set(skillNames); if (next) value.add(skill.name); @@ -267,32 +309,35 @@ export function WorkspaceResourceSettingsDrawer(props: { setSkillNames(value); }} /> -
- ); - }) - : filteredMcp.map((server) => { - const checked = - mode !== "off" && visibleMcpSelection.has(server.id) && server.enabled; - return ( -
-
-
- {server.id} - - {server.transport} - -
-
- {server.command || server.url || t("mcpHub.statusEmptyDesc")} -
-
- { + const checked = + mode !== "off" && visibleMcpSelection.has(server.id) && server.enabled; + const { Icon: TransportIcon, label: transportLabel } = getMcpTransportMeta( + server.transport, + ); + return ( + + {transportLabel} + + } onCheckedChange={(next) => { const value = new Set(mcpServerIds); if (next) value.add(server.id); @@ -300,14 +345,14 @@ export function WorkspaceResourceSettingsDrawer(props: { setMcpServerIds(value); }} /> -
- ); - })} + ); + })} +
-
-
+ + -
+
{mode === "custom" ? t("chat.workspaceResourcesSelected") @@ -316,9 +361,7 @@ export function WorkspaceResourceSettingsDrawer(props: { : null}
- + }>{t("chat.cancel")}
-
- -
, - document.body, + + + ); } diff --git a/crates/agent-ui/src/components/hub/HubChrome.tsx b/crates/agent-ui/src/components/hub/HubChrome.tsx index aa56e1c2d..4f10cf209 100644 --- a/crates/agent-ui/src/components/hub/HubChrome.tsx +++ b/crates/agent-ui/src/components/hub/HubChrome.tsx @@ -33,21 +33,27 @@ export function HubBackdrop(props: { tone?: "amber" | "violet" | "neutral" }) { } export function HubHeader(props: { - icon: ReactNode; + icon?: ReactNode; title: string; subtitle?: string; tone?: "amber" | "violet" | "neutral"; actions?: ReactNode; + prominent?: boolean; sidebarOpen: boolean; onOpenSidebar: () => void; }) { - const { icon, title, subtitle, actions, sidebarOpen, onOpenSidebar } = props; + const { icon, title, subtitle, actions, prominent = false, sidebarOpen, onOpenSidebar } = props; const { t } = useLocale(); const showSidebarButton = !sidebarOpen && !usesOverlayTitleBar; return ( <> -
+
{showSidebarButton ? ( + } + /> + + + + + {copied ? : null} + {activeLabel} + + + + + + + {copied ? copiedLabel : ""} + + + ); +} diff --git a/crates/agent-ui/src/components/ui/search-highlight.tsx b/crates/agent-ui/src/components/ui/search-highlight.tsx new file mode 100644 index 000000000..dd8501e12 --- /dev/null +++ b/crates/agent-ui/src/components/ui/search-highlight.tsx @@ -0,0 +1,40 @@ +import { getSearchHighlightRanges } from "@liveagent/ui/lib/shared/fuzzySearch"; +import { cn } from "@liveagent/ui/lib/shared/utils"; +import { memo, useMemo } from "react"; + +export const SearchHighlight = memo(function SearchHighlight(props: { + text: string; + query: string; + className?: string; + markClassName?: string; +}) { + const { text, query, className, markClassName } = props; + const ranges = useMemo(() => getSearchHighlightRanges(text, query), [query, text]); + + if (ranges.length === 0) return {text}; + + let cursor = 0; + return ( + + {ranges.map((range) => { + const prefix = text.slice(cursor, range.start); + const match = text.slice(range.start, range.end); + cursor = range.end; + return ( + + {prefix} + + {match} + + + ); + })} + {text.slice(cursor)} + + ); +}); diff --git a/crates/agent-ui/src/components/ui/separator.tsx b/crates/agent-ui/src/components/ui/separator.tsx new file mode 100644 index 000000000..775c23a1f --- /dev/null +++ b/crates/agent-ui/src/components/ui/separator.tsx @@ -0,0 +1,22 @@ +import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"; +import * as React from "react"; + +import { cn } from "../../lib/shared/utils"; + +export const Separator = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef +>(({ className, orientation = "horizontal", ...props }, ref) => ( + +)); + +Separator.displayName = "Separator"; diff --git a/crates/agent-ui/src/components/ui/sheet.tsx b/crates/agent-ui/src/components/ui/sheet.tsx new file mode 100644 index 000000000..c7b288efc --- /dev/null +++ b/crates/agent-ui/src/components/ui/sheet.tsx @@ -0,0 +1,199 @@ +import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"; +import { X } from "@liveagent/app/components/icons"; +import * as React from "react"; + +import { cn } from "../../lib/shared/utils"; +import { Button } from "./button"; + +export const Sheet = SheetPrimitive.Root; +export const SheetPortal = SheetPrimitive.Portal; + +export function SheetTrigger(props: React.ComponentPropsWithoutRef) { + return ; +} + +export function SheetClose(props: React.ComponentPropsWithoutRef) { + return ; +} + +export const SheetBackdrop = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetBackdrop.displayName = "SheetBackdrop"; + +type SheetSide = "top" | "right" | "bottom" | "left"; +type SheetVariant = "default" | "inset"; + +type SheetViewportProps = React.ComponentPropsWithoutRef & { + side?: SheetSide; + variant?: SheetVariant; +}; + +export const SheetViewport = React.forwardRef( + ({ className, side = "right", variant = "default", ...props }, ref) => ( + + ), +); +SheetViewport.displayName = "SheetViewport"; + +type SheetPopupProps = React.ComponentPropsWithoutRef & { + closeLabel?: string; + closeProps?: React.ComponentPropsWithoutRef; + portalProps?: React.ComponentPropsWithoutRef; + showCloseButton?: boolean; + side?: SheetSide; + variant?: SheetVariant; +}; + +export const SheetPopup = React.forwardRef( + ( + { + side = "right", + variant = "default", + className, + children, + closeLabel = "Close", + closeProps, + portalProps, + showCloseButton = true, + ...props + }, + ref, + ) => ( + + + + + {children} + {showCloseButton ? ( + } + {...closeProps} + > + + + ) : null} + + + + ), +); +SheetPopup.displayName = "SheetPopup"; + +export const SheetHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +SheetHeader.displayName = "SheetHeader"; + +export const SheetPanel = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +SheetPanel.displayName = "SheetPanel"; + +type SheetFooterProps = React.HTMLAttributes & { + variant?: "default" | "bare"; +}; + +export const SheetFooter = React.forwardRef( + ({ className, variant = "default", ...props }, ref) => ( +
+ ), +); +SheetFooter.displayName = "SheetFooter"; + +export const SheetTitle = React.forwardRef< + HTMLHeadingElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetTitle.displayName = "SheetTitle"; + +export const SheetDescription = React.forwardRef< + HTMLParagraphElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetDescription.displayName = "SheetDescription"; + +export { SheetBackdrop as SheetOverlay, SheetPopup as SheetContent, SheetPrimitive }; diff --git a/crates/agent-ui/src/components/ui/switch.tsx b/crates/agent-ui/src/components/ui/switch.tsx index ff85f65a0..8237b37b2 100644 --- a/crates/agent-ui/src/components/ui/switch.tsx +++ b/crates/agent-ui/src/components/ui/switch.tsx @@ -3,23 +3,29 @@ import * as React from "react"; import { cn } from "../../lib/shared/utils"; -export const Switch = React.forwardRef< - HTMLElement, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - -)); +type SwitchProps = React.ComponentPropsWithoutRef & { + tone?: "default" | "success"; +}; + +export const Switch = React.forwardRef( + ({ className, tone = "default", ...props }, ref) => ( + + + + ), +); Switch.displayName = "Switch"; diff --git a/crates/agent-ui/src/components/ui/tabs.tsx b/crates/agent-ui/src/components/ui/tabs.tsx new file mode 100644 index 000000000..e948ebb7a --- /dev/null +++ b/crates/agent-ui/src/components/ui/tabs.tsx @@ -0,0 +1,60 @@ +import { Tabs as TabsPrimitive } from "@base-ui/react"; +import * as React from "react"; + +import { cn } from "../../lib/shared/utils"; + +export const Tabs = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Tabs.displayName = "Tabs"; + +export const TabsList = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = "TabsList"; + +export const TabsTrigger = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = "TabsTrigger"; + +export const TabsContent = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = "TabsContent"; diff --git a/crates/agent-ui/src/components/ui/toggle-group.tsx b/crates/agent-ui/src/components/ui/toggle-group.tsx new file mode 100644 index 000000000..1f671e88a --- /dev/null +++ b/crates/agent-ui/src/components/ui/toggle-group.tsx @@ -0,0 +1,33 @@ +import { Toggle, ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react"; +import * as React from "react"; + +import { cn } from "../../lib/shared/utils"; + +export const ToggleGroup = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToggleGroup.displayName = "ToggleGroup"; + +export const ToggleGroupItem = React.forwardRef< + HTMLButtonElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToggleGroupItem.displayName = "ToggleGroupItem"; diff --git a/crates/agent-ui/src/components/workspace-editor/WorkspaceMarkdownPreview.tsx b/crates/agent-ui/src/components/workspace-editor/WorkspaceMarkdownPreview.tsx index db85d8b4f..c88d1c176 100644 --- a/crates/agent-ui/src/components/workspace-editor/WorkspaceMarkdownPreview.tsx +++ b/crates/agent-ui/src/components/workspace-editor/WorkspaceMarkdownPreview.tsx @@ -12,7 +12,8 @@ import { } from "react"; import type { Components, ExtraProps } from "streamdown"; import { invokeFs } from "../../lib/tools/fsBackend"; -import { ExternalLinkModal, Markdown } from "../Markdown"; +import { ExternalLinkModal } from "../Markdown"; +import { DocumentMarkdown } from "../markdown/DocumentMarkdown"; import { classifyWorkspaceMarkdownTarget, workspaceMarkdownHeadingSlug, @@ -285,7 +286,7 @@ export const WorkspaceMarkdownPreview = memo(function WorkspaceMarkdownPreview( return (
- = { + item: T; + index: number; + score: number | null; +}; + +const QUERY_SEPARATOR = /[\s,,、;;|]+/u; +const COMPACT_SEPARATOR = /[\s\-_.:/\\]+/gu; +const WORD_PATTERN = /[\p{L}\p{N}+#@._-]+/gu; + +export function normalizeSearchText(value: string): string { + return value.normalize("NFKC").toLocaleLowerCase().trim(); +} + +export function tokenizeSearchQuery(query: string): string[] { + return Array.from(new Set(normalizeSearchText(query).split(QUERY_SEPARATOR).filter(Boolean))); +} + +function boundedEditDistance(left: string, right: string, limit: number): number | null { + if (Math.abs(left.length - right.length) > limit) return null; + if (left === right) return 0; + + let previous = Array.from({ length: right.length + 1 }, (_, index) => index); + for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { + const current = [leftIndex]; + let rowMinimum = leftIndex; + for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { + const substitution = + previous[rightIndex - 1] + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1); + const value = Math.min( + (previous[rightIndex] ?? limit + 1) + 1, + (current[rightIndex - 1] ?? limit + 1) + 1, + substitution, + ); + current.push(value); + rowMinimum = Math.min(rowMinimum, value); + } + if (rowMinimum > limit) return null; + previous = current; + } + + const distance = previous[right.length] ?? limit + 1; + return distance <= limit ? distance : null; +} + +function subsequenceGap(term: string, candidate: string): number | null { + let cursor = 0; + let first = -1; + let last = -1; + for (const character of term) { + const next = candidate.indexOf(character, cursor); + if (next === -1) return null; + if (first === -1) first = next; + last = next; + cursor = next + character.length; + } + return last - first + 1 - term.length; +} + +function scoreFuzzyTerm(term: string, rawCandidate: string): number | null { + const candidate = normalizeSearchText(rawCandidate); + if (!candidate) return null; + if (candidate === term) return 0; + + const exactIndex = candidate.indexOf(term); + if (exactIndex >= 0) { + const boundary = exactIndex === 0 || !/[\p{L}\p{N}]/u.test(candidate[exactIndex - 1] ?? ""); + return (boundary ? 3 : 8) + Math.min(exactIndex, 40) / 10; + } + + const compactCandidate = candidate.replace(COMPACT_SEPARATOR, ""); + const compactTerm = term.replace(COMPACT_SEPARATOR, ""); + const compactIndex = compactCandidate.indexOf(compactTerm); + if (compactTerm.length > 1 && compactIndex >= 0) { + return 12 + Math.min(compactIndex, 40) / 10; + } + + const words = candidate.match(WORD_PATTERN) ?? []; + let bestScore = Number.POSITIVE_INFINITY; + for (const word of words) { + if (word.startsWith(term)) { + bestScore = Math.min(bestScore, 5 + Math.min(word.length - term.length, 12) / 4); + } + + const editLimit = term.length >= 8 ? 2 : term.length >= 4 ? 1 : 0; + if (editLimit > 0) { + const distance = boundedEditDistance(term, word, editLimit); + if (distance !== null) { + bestScore = Math.min(bestScore, 24 + distance * 8 + Math.abs(word.length - term.length)); + } + } + + if (term.length >= 3) { + const gap = subsequenceGap(term, word); + const maximumGap = Math.max(2, Math.floor(term.length * 0.65)); + if (gap !== null && gap <= maximumGap) { + bestScore = Math.min(bestScore, 38 + gap * 3); + } + } + } + + return Number.isFinite(bestScore) ? bestScore : null; +} + +export function fuzzySearchScore( + query: string, + fields: readonly (string | null | undefined)[], +): number | null { + const terms = tokenizeSearchQuery(query); + if (terms.length === 0) return 0; + + let total = 0; + for (const term of terms) { + let bestScore = Number.POSITIVE_INFINITY; + fields.forEach((field, fieldIndex) => { + if (!field) return; + const score = scoreFuzzyTerm(term, field); + if (score !== null) bestScore = Math.min(bestScore, score + fieldIndex * 5); + }); + if (!Number.isFinite(bestScore)) return null; + total += bestScore; + } + + const phrase = normalizeSearchText(query); + const phraseFieldIndex = fields.findIndex((field) => + field ? normalizeSearchText(field).includes(phrase) : false, + ); + if (phraseFieldIndex >= 0) total -= Math.max(2, 8 - phraseFieldIndex * 2); + return Math.max(0, total); +} + +export function rankFuzzySearchResults( + items: readonly T[], + query: string, + getFields: (item: T) => readonly (string | null | undefined)[], + options: { includeUnmatched?: boolean } = {}, +): T[] { + if (tokenizeSearchQuery(query).length === 0) return [...items]; + + return items + .map>((item, index) => ({ + item, + index, + score: fuzzySearchScore(query, getFields(item)), + })) + .filter((entry) => options.includeUnmatched || entry.score !== null) + .sort((left, right) => { + if (left.score === null) return right.score === null ? left.index - right.index : 1; + if (right.score === null) return -1; + return left.score - right.score || left.index - right.index; + }) + .map((entry) => entry.item); +} + +function mergeHighlightRanges(ranges: SearchHighlightRange[]): SearchHighlightRange[] { + const sorted = [...ranges].sort( + (left, right) => left.start - right.start || left.end - right.end, + ); + const merged: SearchHighlightRange[] = []; + for (const range of sorted) { + const previous = merged[merged.length - 1]; + if (!previous || range.start > previous.end) { + merged.push({ ...range }); + } else { + previous.end = Math.max(previous.end, range.end); + } + } + return merged; +} + +export function getSearchHighlightRanges(text: string, query: string): SearchHighlightRange[] { + const terms = tokenizeSearchQuery(query); + if (!text || terms.length === 0) return []; + + const loweredText = text.toLocaleLowerCase(); + const ranges: SearchHighlightRange[] = []; + for (const term of terms) { + let exactMatchFound = false; + let cursor = 0; + while (cursor < loweredText.length) { + const index = loweredText.indexOf(term, cursor); + if (index === -1) break; + ranges.push({ start: index, end: index + term.length }); + exactMatchFound = true; + cursor = index + Math.max(term.length, 1); + } + if (exactMatchFound) continue; + + WORD_PATTERN.lastIndex = 0; + let bestWord: { start: number; end: number; score: number } | null = null; + for (const match of text.matchAll(WORD_PATTERN)) { + const word = match[0]; + const score = scoreFuzzyTerm(term, word); + if (score === null || (bestWord && bestWord.score <= score)) continue; + const start = match.index; + bestWord = { start, end: start + word.length, score }; + } + if (bestWord) ranges.push({ start: bestWord.start, end: bestWord.end }); + } + + return mergeHighlightRanges(ranges); +} diff --git a/crates/agent-ui/src/pages/mcp-hub/McpHubPage.tsx b/crates/agent-ui/src/pages/mcp-hub/McpHubPage.tsx index 6ec805c5f..2c3c04b9b 100644 --- a/crates/agent-ui/src/pages/mcp-hub/McpHubPage.tsx +++ b/crates/agent-ui/src/pages/mcp-hub/McpHubPage.tsx @@ -1,20 +1,15 @@ -import { - Cable, - Cloud, - Download, - Plug, - Plus, - Server, - Sparkles, -} from "@liveagent/app/components/icons"; +import { Cloud, Download, Plus, Search, Server } from "@liveagent/app/components/icons"; import { type AppSettings, type McpServerConfig, updateMcp } from "@liveagent/app/lib/settings"; +import { ResourceTabsList } from "@liveagent/ui/components/resources/ResourceTabsList"; +import { Badge } from "@liveagent/ui/components/ui/badge"; import { useLocale } from "@liveagent/ui/i18n/index"; import { McpRegistryBrowser } from "@liveagent/ui/pages/mcp-hub/McpRegistryBrowser"; import { McpServerEditModal, McpServersForm } from "@liveagent/ui/pages/mcp-hub/McpServersForm"; import { useState } from "react"; -import { HubBackdrop, HubHeader } from "../../components/hub/HubChrome"; +import { HubHeader } from "../../components/hub/HubChrome"; import { Button } from "../../components/ui/button"; -import { cn } from "../../lib/shared/utils"; +import { Input } from "../../components/ui/input"; +import { Tabs, TabsContent } from "../../components/ui/tabs"; import { McpImportView } from "./McpImportView"; type McpHubPageProps = { @@ -29,16 +24,30 @@ type McpHubView = "installed" | "store" | "import"; type EditingState = { mode: "add" } | { mode: "edit"; idx: number; server: McpServerConfig }; +function isMcpHubView(value: unknown): value is McpHubView { + return value === "installed" || value === "store" || value === "import"; +} + export function McpHubPage(props: McpHubPageProps) { const { settings, setSettings, sidebarOpen, onOpenSidebar } = props; const { t } = useLocale(); const [view, setView] = useState("installed"); const [editing, setEditing] = useState(null); + const [searchQueries, setSearchQueries] = useState>({ + installed: "", + store: "", + import: "", + }); const serverCount = settings.mcp.servers.length; const enabledCount = settings.mcp.servers.filter((server) => server.enabled).length; - const ready = serverCount > 0; - const statusHint = ready ? null : t("mcpHub.statusEmptyDesc"); + const activeSearchQuery = searchQueries[view]; + const searchPlaceholder = + view === "store" + ? t("mcpHub.storeSearchPlaceholder") + : view === "import" + ? t("mcpHub.importSearchPlaceholder") + : t("mcpHub.searchInstalled"); function openAdd() { setView("installed"); @@ -64,165 +73,113 @@ export function McpHubPage(props: McpHubPageProps) { } return ( -
- - +
} title="MCP Hub" subtitle={t("mcpHub.subtitle")} + prominent + actions={ +
+ 0 ? "success" : "muted"} + className="hidden h-7 gap-1 tabular-nums sm:inline-flex" + > + {serverCount > 0 + ? `${enabledCount}/${serverCount} ${t("mcpHub.enabled")}` + : t("mcpHub.statusEmpty")} + + +
+ } sidebarOpen={sidebarOpen} onOpenSidebar={onOpenSidebar} /> -
-
- {/* Status banner */} -
+
+ { + if (isMcpHubView(nextView)) setView(nextView); + }} + className="flex min-h-0 flex-1 flex-col" > -
-
-
- - {ready && enabledCount > 0 ? ( - - ) : null} -
-
-
-
- {ready ? t("mcpHub.statusReady") : t("mcpHub.statusEmpty")} -
- {ready ? ( - 0 - ? "bg-foreground/[0.06] text-foreground/85 ring-1 ring-border/50" - : "bg-background/60 text-muted-foreground ring-1 ring-border/40", - )} - > - {enabledCount} - / - {serverCount} - {t("mcpHub.enabled")} - - ) : null} -
- {statusHint ? ( -
- {statusHint} -
- ) : null} -
-
- - +
+ + { + const nextQuery = event.currentTarget.value; + setSearchQueries((current) => ({ ...current, [view]: nextQuery })); + }} + placeholder={searchPlaceholder} + aria-label={searchPlaceholder} + className="h-11 rounded-full border-border bg-background pl-11 pr-4 text-sm shadow-none placeholder:text-muted-foreground" + />
-
- {/* Tab bar */} -
-
- {[ - { - value: "installed" as const, - label: t("mcpHub.tabInstalled"), - icon: Server, - count: serverCount, - }, - { - value: "store" as const, - label: t("mcpHub.tabStore"), - icon: Cloud, - count: null, - }, - { - value: "import" as const, - label: t("mcpHub.tabImport"), - icon: Download, - count: null, - }, - ].map((item) => { - const Icon = item.icon; - const active = view === item.value; - return ( - - ); - })} +
+ 0 ? `${enabledCount}/${serverCount}` : null, + }, + { + value: "store" as const, + label: t("mcpHub.tabStore"), + icon: Cloud, + }, + { + value: "import" as const, + label: t("mcpHub.tabImport"), + icon: Download, + }, + ]} + ariaLabel="MCP Hub" + />
- {view === "store" ? ( -
- - {t("mcpHub.storeSubtitle")} -
- ) : null} -
- - {/* Content */} -
- {view === "installed" ? ( - - ) : view === "store" ? ( - - ) : ( - - )} -
+
+ + + + + + + + + +
+
diff --git a/crates/agent-ui/src/pages/mcp-hub/McpImportSourcePicker.tsx b/crates/agent-ui/src/pages/mcp-hub/McpImportSourcePicker.tsx new file mode 100644 index 000000000..5085d1db7 --- /dev/null +++ b/crates/agent-ui/src/pages/mcp-hub/McpImportSourcePicker.tsx @@ -0,0 +1,62 @@ +import { + EXTERNAL_TOOL_SOURCE_LABELS, + ExternalToolSourceIcon, +} from "@liveagent/ui/components/resources/ExternalToolSourceIcon"; +import { Badge } from "@liveagent/ui/components/ui/badge"; +import { Tabs, TabsList, TabsTrigger } from "@liveagent/ui/components/ui/tabs"; +import { useLocale } from "@liveagent/ui/i18n/index"; +import type { ExternalMcpToolScan } from "@liveagent/ui/lib/skills/index"; + +export const LOCAL_FILE_TOOL = "local-file"; + +function fileScanLabel(scan: ExternalMcpToolScan, fallback: string) { + const basename = scan.configPath.split(/[\\/]/).pop(); + return basename || fallback; +} + +export function McpImportSourcePicker(props: { + scans: ExternalMcpToolScan[]; + value: string; + onChange: (value: string) => void; +}) { + const { t } = useLocale(); + + return ( + { + if (props.scans.some((scan) => scan.tool === value)) props.onChange(value); + }} + className="max-w-full shrink-0" + > + + {props.scans.map((scan) => { + const isLocalFile = scan.tool === LOCAL_FILE_TOOL; + const toolLabel = isLocalFile + ? fileScanLabel(scan, t("mcpHub.importFileTab")) + : (EXTERNAL_TOOL_SOURCE_LABELS[scan.tool] ?? scan.tool); + return ( + + + {toolLabel} + + {scan.exists ? scan.servers.length : "—"} + + + ); + })} + + + ); +} diff --git a/crates/agent-ui/src/pages/mcp-hub/McpImportView.tsx b/crates/agent-ui/src/pages/mcp-hub/McpImportView.tsx index 6fcfce11a..e8e716ebd 100644 --- a/crates/agent-ui/src/pages/mcp-hub/McpImportView.tsx +++ b/crates/agent-ui/src/pages/mcp-hub/McpImportView.tsx @@ -3,7 +3,6 @@ import { Check, Download, FileText, - Folder, Globe2, Loader2, RefreshCw, @@ -11,7 +10,9 @@ import { } from "@liveagent/app/components/icons"; import { type AppSettings, type McpServerConfig, updateMcp } from "@liveagent/app/lib/settings"; import { invoke } from "@liveagent/app/shims/tauriCore"; +import { SearchHighlight } from "@liveagent/ui/components/ui/search-highlight"; import { useLocale } from "@liveagent/ui/i18n/index"; +import { rankFuzzySearchResults } from "@liveagent/ui/lib/shared/fuzzySearch"; import { type ExternalMcpServerEntry, type ExternalMcpToolScan, @@ -22,24 +23,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { GlassPanel } from "../../components/hub/HubChrome"; import { Button } from "../../components/ui/button"; import { cn } from "../../lib/shared/utils"; - -const EXTERNAL_MCP_TOOL_LABELS: Record = { - "claude-code": "Claude Code", - codex: "Codex", - "claude-desktop": "Claude Desktop", - codebuddy: "CodeBuddy", -}; - -/** 与后端 `LOCAL_FILE_MCP_TOOL` 对齐的「从文件导入」来源标识 */ -const LOCAL_FILE_TOOL = "local-file"; +import { LOCAL_FILE_TOOL, McpImportSourcePicker } from "./McpImportSourcePicker"; const DEFAULT_IMPORT_TIMEOUT_MS = 60_000; -function fileScanLabel(scan: ExternalMcpToolScan, fallback: string) { - const basename = scan.configPath.split(/[\\/]/).pop(); - return basename || fallback; -} - function externalServerKey(tool: string, server: ExternalMcpServerEntry) { return `${tool}:${server.id.toLowerCase()}`; } @@ -66,8 +53,9 @@ function toMcpServerConfig(entry: ExternalMcpServerEntry): McpServerConfig { export function McpImportView(props: { settings: AppSettings; setSettings: (updater: (prev: AppSettings) => AppSettings) => void; + query: string; }) { - const { settings, setSettings } = props; + const { settings, setSettings, query } = props; const { t } = useLocale(); const [scans, setScans] = useState(null); @@ -79,7 +67,9 @@ export function McpImportView(props: { const [selected, setSelected] = useState>(new Set()); const [importedCount, setImportedCount] = useState(null); const [activeTool, setActiveTool] = useState("claude-code"); + const [rescanComplete, setRescanComplete] = useState(false); const userChoseToolRef = useRef(false); + const rescanFeedbackTimerRef = useRef(null); const allScans = useMemo( () => (fileScan ? [...(scans ?? []), fileScan] : (scans ?? [])), @@ -109,13 +99,39 @@ export function McpImportView(props: { ); return next.size === prev.size ? prev : next; }); + return true; } catch (err) { setError(err instanceof Error ? err.message : String(err)); + return false; } finally { setLoading(false); } }, []); + const handleRescan = useCallback(async () => { + if (rescanFeedbackTimerRef.current !== null) { + window.clearTimeout(rescanFeedbackTimerRef.current); + rescanFeedbackTimerRef.current = null; + } + setRescanComplete(false); + const succeeded = await rescan(); + if (!succeeded) return; + setRescanComplete(true); + rescanFeedbackTimerRef.current = window.setTimeout(() => { + setRescanComplete(false); + rescanFeedbackTimerRef.current = null; + }, 2400); + }, [rescan]); + + useEffect( + () => () => { + if (rescanFeedbackTimerRef.current !== null) { + window.clearTimeout(rescanFeedbackTimerRef.current); + } + }, + [], + ); + useEffect(() => { if (scans === null && !loading) { void rescan(); @@ -161,12 +177,23 @@ export function McpImportView(props: { }, []); const activeScan = allScans.find((scan) => scan.tool === activeTool); - const importableInActive = useMemo( + const visibleServers = useMemo( () => - (activeScan?.servers ?? []).filter( - (server) => !installedIds.has(server.id.trim().toLowerCase()), - ), - [activeScan, installedIds], + rankFuzzySearchResults(activeScan?.servers ?? [], query, (server) => [ + server.id, + server.transport, + server.command, + server.url, + server.origin, + ...server.args, + ...Object.keys(server.env), + ...Object.keys(server.headers), + ]), + [activeScan, query], + ); + const importableInActive = useMemo( + () => visibleServers.filter((server) => !installedIds.has(server.id.trim().toLowerCase())), + [installedIds, visibleServers], ); const selectedInActive = importableInActive.filter((server) => selected.has(externalServerKey(activeTool, server)), @@ -270,55 +297,14 @@ export function McpImportView(props: { ) : ( <>
-
- {allScans.map((scan) => { - const isLocalFile = scan.tool === LOCAL_FILE_TOOL; - const toolLabel = isLocalFile - ? fileScanLabel(scan, t("mcpHub.importFileTab")) - : (EXTERNAL_MCP_TOOL_LABELS[scan.tool] ?? scan.tool); - const active = scan.tool === activeTool; - return ( - - ); - })} -
+ { + userChoseToolRef.current = true; + setActiveTool(nextTool); + }} + />
+
) : null}
@@ -410,9 +411,15 @@ export function McpImportView(props: { {t("mcpHub.importEmpty")}

+ ) : visibleServers.length === 0 ? ( + +

+ {t("mcpHub.importNoMatch")} +

+
) : ( -
- {activeScan.servers.map((server) => { +
+ {visibleServers.map((server) => { const key = externalServerKey(activeScan.tool, server); const alreadyImported = installedIds.has(server.id.trim().toLowerCase()); const checked = selected.has(key); @@ -436,12 +443,12 @@ export function McpImportView(props: { disabled={alreadyImported} onClick={() => toggleServer(activeScan.tool, server)} className={cn( - "group flex items-start gap-2.5 rounded-xl border p-3 text-left transition-all", + "group flex min-h-36 items-start gap-2.5 rounded-xl border p-3.5 text-left transition-[border-color,background-color,box-shadow]", alreadyImported - ? "cursor-not-allowed border-border/35 bg-muted/30 opacity-70" + ? "cursor-not-allowed border-border/70 bg-muted/50" : checked - ? "border-primary/60 bg-primary/5 shadow-sm shadow-primary/10" - : "border-border/40 bg-background/60 hover:border-border/70 hover:bg-background/85", + ? "border-primary/60 bg-primary/10 shadow-xs" + : "border-border bg-card shadow-xs hover:border-foreground/20 hover:bg-muted/30", )} > - {server.id} + {isStdio ? ( @@ -482,7 +489,7 @@ export function McpImportView(props: { ) : null} - {preview} + {extras.length > 0 ? ( diff --git a/crates/agent-ui/src/pages/mcp-hub/McpRegistryBrowser.tsx b/crates/agent-ui/src/pages/mcp-hub/McpRegistryBrowser.tsx index 0215a8462..53a8ad499 100644 --- a/crates/agent-ui/src/pages/mcp-hub/McpRegistryBrowser.tsx +++ b/crates/agent-ui/src/pages/mcp-hub/McpRegistryBrowser.tsx @@ -6,11 +6,8 @@ import { Key, Loader2, Plus, - RefreshCw, - Search, Server, Shield, - Sparkles, Terminal, X, } from "@liveagent/app/components/icons"; @@ -19,9 +16,9 @@ import { type McpServerConfig, updateMcp, } from "@liveagent/app/lib/settings/index"; +import { openUrl } from "@liveagent/app/shims/tauriOpener"; import { Button } from "@liveagent/ui/components/ui/button"; -import { Input } from "@liveagent/ui/components/ui/input"; -import { Label } from "@liveagent/ui/components/ui/label"; +import { SearchHighlight } from "@liveagent/ui/components/ui/search-highlight"; import { Select, SelectContent, @@ -29,15 +26,12 @@ import { SelectTrigger, SelectValue, } from "@liveagent/ui/components/ui/select"; -import { Textarea } from "@liveagent/ui/components/ui/textarea"; import { useLocale } from "@liveagent/ui/i18n/index"; import { - applyMcpRegistryInstallConfig, createUniqueMcpServerId, MCP_REGISTRY_SOURCE_OPTIONS, type McpRegistryCard, type McpRegistryConfigInput, - type McpRegistryInstallDraft, type McpRegistrySource, mcpRegistryConfigInputKey, resolveMcpRegistryInstallDraft, @@ -45,30 +39,21 @@ import { withUniqueMcpServerId, } from "@liveagent/ui/lib/mcpRegistry/index"; import { enrichMcpServerWithRegistryMetadata } from "@liveagent/ui/lib/mcpServerMetadata"; -import { useModalMotion } from "@liveagent/ui/lib/shared/modalMotion"; +import { rankFuzzySearchResults } from "@liveagent/ui/lib/shared/fuzzySearch"; import { cn } from "@liveagent/ui/lib/shared/utils"; -import { type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; +import { McpRegistryConfigureModal } from "./McpRegistryConfigureModal"; +import { McpRegistryToolbar } from "./McpRegistryToolbar"; -const STORE_PAGE_LIMIT = 18; +export const MCP_STORE_PAGE_LIMIT = 24; +const FROST_SPINNER_SEGMENTS = Array.from({ length: 12 }, (_, index) => `segment-${index + 1}`); +const STORE_SKELETON_IDS = Array.from({ length: 8 }, (_, index) => `skeleton-${index + 1}`); type McpRegistryBrowserProps = { settings: AppSettings; setSettings: (updater: (prev: AppSettings) => AppSettings) => void; -}; - -type McpConfigModalDraft = { - id: string; - transport: McpServerConfig["transport"]; - timeoutMs: string; - command: string; - cwd: string; - argsText: string; - envText: string; - url: string; - messageUrl: string; - headersText: string; - configValues: Record; + query: string; }; type McpPreviewLink = { @@ -85,20 +70,19 @@ type McpRegistryCardGroup = { function FrostSpinner() { return ( ); } function sourceTone(_source: McpRegistrySource) { - // Source label is rendered as a neutral frosted-glass chip; the text alone communicates the source. - return "border-border/45 bg-background/70 text-foreground/75"; + return "border-border/60 bg-muted text-foreground/75"; } function transportTone(_transport: string) { - return "bg-background/70 text-foreground/75 ring-border/45"; + return "bg-muted text-foreground/75 ring-border/60"; } function versionLabelForCard(card: McpRegistryCard) { @@ -127,6 +111,16 @@ function groupMcpRegistryCards(cards: McpRegistryCard[]) { return groups; } +function appendUniqueRegistryCards(current: McpRegistryCard[], incoming: McpRegistryCard[]) { + const seen = new Set(current.map((card) => card.id)); + const uniqueIncoming = incoming.filter((card) => { + if (seen.has(card.id)) return false; + seen.add(card.id); + return true; + }); + return [...current, ...uniqueIncoming]; +} + function installLabelKey(card: McpRegistryCard) { if (!card.installDraft && card.source === "smithery") return "mcpHub.storeInstall"; if (card.installDraft?.status === "needs_config") return "mcpHub.storeConfigure"; @@ -137,6 +131,14 @@ function configureDraftForCard(card: McpRegistryCard) { return card.installDraft ?? card.manualDraft; } +function configTargetLabel(input: McpRegistryConfigInput, t: (key: string) => string) { + if (input.target === "env") return t("mcpHub.previewEnv"); + if (input.target === "header") return t("mcpHub.previewHeaders"); + if (input.target === "argument") return t("mcpHub.previewArgs"); + if (input.target === "url") return "URL"; + return "Config"; +} + function primaryRegistryLink(card: McpRegistryCard) { return card.detailUrl ?? card.homepageUrl ?? card.repositoryUrl; } @@ -156,520 +158,11 @@ function registryExternalLinks(card: McpRegistryCard): McpPreviewLink[] { }); } -function formatKeyValueRecord(input: Record | undefined) { - return input - ? Object.entries(input) - .map(([key, value]) => `${key}=${value}`) - .join("\n") - : ""; -} - -function parseLineList(input: string) { - return input - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); -} - -function parseKeyValueDraft(input: string, errorPrefix: string) { - const out: Record = {}; - for (const line of input.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed) continue; - const eq = trimmed.indexOf("="); - if (eq <= 0) { - throw new Error(`${errorPrefix}: ${trimmed}`); - } - const key = trimmed.slice(0, eq).trim(); - const value = trimmed.slice(eq + 1).trim(); - if (!key || !value) { - throw new Error(`${errorPrefix}: ${trimmed}`); - } - out[key] = value; - } - return Object.keys(out).length ? out : undefined; -} - -function cleanConfigValue(value: string | undefined) { - if (!value || value === "...") return ""; - return value; -} - -function valueFromServerConfig(input: McpRegistryConfigInput, server: McpServerConfig) { - const targetName = input.targetName ?? input.name; - if (input.target === "env") { - return cleanConfigValue(server.env?.[targetName] ?? server.env?.[input.name]); - } - if (input.target === "header") { - return cleanConfigValue(server.headers?.[targetName] ?? server.headers?.[input.name]); - } - if (input.target === "url") { - try { - const parsed = new URL(server.url); - return cleanConfigValue(parsed.searchParams.get(targetName) ?? undefined); - } catch { - return ""; - } - } - if (input.target === "config") { - for (let index = 0; index < (server.args ?? []).length; index += 1) { - const arg = server.args[index]; - const rawConfig = - arg === "--config" - ? server.args[index + 1] - : arg.startsWith("--config=") - ? arg.slice("--config=".length) - : undefined; - if (!rawConfig) continue; - try { - const parsed = JSON.parse(rawConfig); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue; - const value = - (parsed as Record)[targetName] ?? - (parsed as Record)[input.name]; - return cleanConfigValue( - typeof value === "string" ? value : value === undefined ? undefined : String(value), - ); - } catch { - return ""; - } - } - } - return ""; -} - -function pickInitialTransport(card: McpRegistryCard): McpServerConfig["transport"] { - const transport = configureDraftForCard(card)?.server.transport ?? card.transportHints[0]; - if (transport === "http" || transport === "sse") return transport; - return "stdio"; -} - -function buildModalDraft( - card: McpRegistryCard, - existingServers: McpServerConfig[], -): McpConfigModalDraft { - const configureDraft = configureDraftForCard(card); - const server = configureDraft?.server; - const transport = pickInitialTransport(card); - const id = createUniqueMcpServerId( - server?.id || card.name || card.displayName, - existingServers.map((item) => item.id), - ); - const configValues: Record = {}; - for (const input of configureDraft?.requiredConfig ?? []) { - configValues[mcpRegistryConfigInputKey(input)] = server - ? valueFromServerConfig(input, server) - : ""; - } - - return { - id, - transport, - timeoutMs: String(server?.timeoutMs ?? 60_000), - command: server?.command ?? "", - cwd: server?.cwd ?? "", - argsText: (server?.args ?? []).join("\n"), - envText: formatKeyValueRecord(server?.env), - url: server?.url ?? "", - messageUrl: server?.messageUrl ?? "", - headersText: formatKeyValueRecord(server?.headers), - configValues, - }; -} - -function configTargetLabel(input: McpRegistryConfigInput, t: (key: string) => string) { - if (input.target === "env") return t("mcpHub.previewEnv"); - if (input.target === "header") return t("mcpHub.previewHeaders"); - if (input.target === "argument") return t("mcpHub.previewArgs"); - if (input.target === "url") return "URL"; - return "Config"; -} - function keyListLabel(record: Record | undefined) { const keys = Object.keys(record ?? {}).filter(Boolean); return keys.length > 0 ? keys.join(", ") : null; } -function buildServerFromModalDraft( - draft: McpConfigModalDraft, - requiredConfig: McpRegistryConfigInput[], - t: (key: string) => string, -): McpServerConfig { - const id = draft.id.trim(); - if (!id) { - throw new Error(t("mcpHub.storeConfigureNameRequired")); - } - - const timeoutMs = Number(draft.timeoutMs.trim()); - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { - throw new Error(t("mcpHub.storeConfigureTimeoutInvalid")); - } - - for (const input of requiredConfig) { - const value = draft.configValues[mcpRegistryConfigInputKey(input)]?.trim() ?? ""; - if (input.required && !value) { - throw new Error( - t("mcpHub.storeConfigureRequiredMissing").replace("{name}", input.label ?? input.name), - ); - } - } - - if (draft.transport === "stdio") { - const command = draft.command.trim(); - if (!command) { - throw new Error(t("mcpHub.storeConfigureCommandRequired")); - } - return { - id, - enabled: true, - transport: "stdio", - command, - args: parseLineList(draft.argsText), - env: parseKeyValueDraft(draft.envText, t("mcpHub.storeConfigureInvalidKeyValue")), - cwd: draft.cwd.trim() || undefined, - url: "", - timeoutMs: Math.floor(timeoutMs), - }; - } - - const url = draft.url.trim(); - if (!url) { - throw new Error(t("mcpHub.storeConfigureUrlRequired")); - } - - return { - id, - enabled: true, - transport: draft.transport, - command: "", - args: [], - url, - headers: parseKeyValueDraft(draft.headersText, t("mcpHub.storeConfigureInvalidKeyValue")), - timeoutMs: Math.floor(timeoutMs), - messageUrl: draft.transport === "sse" ? draft.messageUrl.trim() || undefined : undefined, - }; -} - -function McpConfigureModal(props: { - card: McpRegistryCard; - existingServers: McpServerConfig[]; - onClose: () => void; - onSave: (server: McpServerConfig) => void; -}) { - const { card, existingServers, onClose, onSave } = props; - const { t } = useLocale(); - const { modalState, requestClose } = useModalMotion(onClose); - const configureDraft = configureDraftForCard(card); - const requiredConfig = configureDraft?.requiredConfig ?? []; - const [draft, setDraft] = useState(() => buildModalDraft(card, existingServers)); - const [formError, setFormError] = useState(null); - - useEffect(() => { - setDraft(buildModalDraft(card, existingServers)); - setFormError(null); - }, [card, existingServers]); - - function updateDraft(patch: Partial) { - setFormError(null); - setDraft((prev) => ({ ...prev, ...patch })); - } - - function updateConfigValue(input: McpRegistryConfigInput, value: string) { - setFormError(null); - const key = mcpRegistryConfigInputKey(input); - setDraft((prev) => ({ - ...prev, - configValues: { - ...prev.configValues, - [key]: value, - }, - })); - } - - function handleSubmit(event: FormEvent) { - event.preventDefault(); - try { - const server = buildServerFromModalDraft(draft, requiredConfig, t); - const configuredDraft: McpRegistryInstallDraft = { - server, - status: requiredConfig.length > 0 ? "needs_config" : "ready", - requiredConfig, - warnings: configureDraft?.warnings ?? [], - commandPreview: "", - }; - const finalDraft = - requiredConfig.length > 0 - ? applyMcpRegistryInstallConfig(configuredDraft, draft.configValues) - : configuredDraft; - onSave(finalDraft.server); - requestClose(); - } catch (error) { - setFormError(error instanceof Error ? error.message : String(error)); - } - } - - const isStdio = draft.transport === "stdio"; - const isSse = draft.transport === "sse"; - - return createPortal( -
-
-
-
-
- -
-
-

{t("mcpHub.storeConfigureTitle")}

-

- {t("mcpHub.storeConfigureSubtitle").replace("{name}", card.displayName)} -

-
- -
- -
-
-
-
- - updateDraft({ id: event.currentTarget.value })} - /> -
-
- - -
-
- - updateDraft({ timeoutMs: event.currentTarget.value })} - /> -
-
- - {isStdio ? ( -
-
-
- - updateDraft({ command: event.currentTarget.value })} - /> -
-
- - updateDraft({ cwd: event.currentTarget.value })} - /> -
-
-
- -