From 54b34f8ccdeab8add82e855790f678ba3d97e35f Mon Sep 17 00:00:00 2001 From: abun <103836393+coderbaozi@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:04:44 +0800 Subject: [PATCH] feat(skills): search runtime local skills (#5104) * feat(skills): search runtime local skills * feat(skills): highlight matched substrings in runtime local skill search Reuse the shared HighlightText (the same component the global search command uses) to highlight matched substrings in a result's name, provider, description, and path, so styling stays consistent across the app. Narrow the search to the fields the row actually renders and drop `key`, so every match maps to something visible. While a query is active, lift the description's 2-line clamp so a match past the first two lines stays on screen instead of being clipped. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- packages/views/locales/en/skills.json | 3 + packages/views/locales/ja/skills.json | 3 + packages/views/locales/ko/skills.json | 3 + packages/views/locales/zh-Hans/skills.json | 3 + .../runtime-local-skill-import-panel.test.tsx | 116 ++++++++++++ .../runtime-local-skill-import-panel.tsx | 170 +++++++++++++----- 6 files changed, 253 insertions(+), 45 deletions(-) diff --git a/packages/views/locales/en/skills.json b/packages/views/locales/en/skills.json index 6b85661347..7d47a62ad8 100644 --- a/packages/views/locales/en/skills.json +++ b/packages/views/locales/en/skills.json @@ -253,6 +253,9 @@ "not_supported": "This runtime provider does not expose local skill inventory yet.", "no_skills_title": "No local skills found", "no_skills_hint": "This runtime does not have any discoverable local skills yet.", + "search_placeholder": "Search local skills", + "no_search_results_title": "No matching local skills", + "no_search_results_hint": "No local skills match \"{{query}}\".", "ignored_files_hint": "Symlinks, unreadable files, oversized files, and very large bundles are ignored during import.", "ready": "Ready to import", "into_workspace": "into this workspace.", diff --git a/packages/views/locales/ja/skills.json b/packages/views/locales/ja/skills.json index 20b43ee12b..d0d8a4b65c 100644 --- a/packages/views/locales/ja/skills.json +++ b/packages/views/locales/ja/skills.json @@ -246,6 +246,9 @@ "not_supported": "このランタイムプロバイダーは、まだローカルスキル一覧の取得に対応していません。", "no_skills_title": "ローカルスキルが見つかりません", "no_skills_hint": "このランタイムには、まだ検出可能なローカルスキルがありません。", + "search_placeholder": "ローカルスキルを検索", + "no_search_results_title": "一致するローカルスキルはありません", + "no_search_results_hint": "\"{{query}}\" に一致するローカルスキルはありません。", "ignored_files_hint": "シンボリックリンク、読み取れないファイル、サイズが大きすぎるファイル、非常に大きなバンドルはインポート時に無視されます。", "ready": "インポートの準備完了", "into_workspace": "をこのワークスペースに。", diff --git a/packages/views/locales/ko/skills.json b/packages/views/locales/ko/skills.json index d055c0fdba..670acc8740 100644 --- a/packages/views/locales/ko/skills.json +++ b/packages/views/locales/ko/skills.json @@ -246,6 +246,9 @@ "not_supported": "이 런타임 제공자는 아직 로컬 스킬 목록 조회를 지원하지 않습니다.", "no_skills_title": "로컬 스킬을 찾을 수 없습니다", "no_skills_hint": "이 런타임에는 아직 검색 가능한 로컬 스킬이 없습니다.", + "search_placeholder": "로컬 스킬 검색", + "no_search_results_title": "일치하는 로컬 스킬이 없습니다", + "no_search_results_hint": "\"{{query}}\"와 일치하는 로컬 스킬이 없습니다.", "ignored_files_hint": "심볼릭 링크, 읽을 수 없는 파일, 너무 큰 파일과 번들은 가져오기에서 제외됩니다.", "ready": "가져올 준비 완료", "into_workspace": "이 워크스페이스로 가져옵니다.", diff --git a/packages/views/locales/zh-Hans/skills.json b/packages/views/locales/zh-Hans/skills.json index f99cbdff84..fdd8696c27 100644 --- a/packages/views/locales/zh-Hans/skills.json +++ b/packages/views/locales/zh-Hans/skills.json @@ -258,6 +258,9 @@ "not_supported": "这个 provider 暂不支持本地 skill 列表。", "no_skills_title": "未找到本地 skill", "no_skills_hint": "这个运行时上还没有可发现的本地 skill。", + "search_placeholder": "搜索本地 skill", + "no_search_results_title": "没有匹配的本地 skill", + "no_search_results_hint": "没有本地 skill 匹配 \"{{query}}\"。", "ignored_files_hint": "导入时会忽略软链、不可读文件、超大文件以及超大目录。", "ready": "准备导入", "into_workspace": "到工作区。", diff --git a/packages/views/skills/components/runtime-local-skill-import-panel.test.tsx b/packages/views/skills/components/runtime-local-skill-import-panel.test.tsx index 5c13d0818c..1cb30d02a7 100644 --- a/packages/views/skills/components/runtime-local-skill-import-panel.test.tsx +++ b/packages/views/skills/components/runtime-local-skill-import-panel.test.tsx @@ -257,6 +257,122 @@ describe("RuntimeLocalSkillImportPanel", () => { expect(screen.getByText("Created")).toBeInTheDocument(); }); + it("filters local runtime skills and selects only visible matches", async () => { + mockRuntimeLocalSkillsOptions.mockReturnValue({ + queryKey: ["runtimes", "local-skills", "runtime-1"], + queryFn: () => + Promise.resolve({ + supported: true, + skills: [MOCK_SKILL_A, MOCK_SKILL_B], + }), + }); + + renderPanel(); + + expect( + await screen.findByText("Review Helper", {}, { timeout: 5000 }), + ).toBeInTheDocument(); + expect(screen.getByText("Code Gen")).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText("Search local skills"), { + target: { value: "code" }, + }); + + expect(screen.queryByText("Review Helper")).not.toBeInTheDocument(); + // "Code Gen" survives the filter; its matched substring is now wrapped in a + // highlight , so the row's name text is split across nodes. + expect(screen.getByText("Code", { selector: "mark" })).toBeInTheDocument(); + expect(screen.getByText("Select all (1)")).toBeInTheDocument(); + + const selectAllCheckbox = screen + .getByText(/Select all/i) + .closest("label")! + .querySelector("input[type='checkbox']")!; + fireEvent.click(selectAllCheckbox); + + const importButton = screen.getByRole("button", { + name: /Import to Workspace/i, + }); + await waitFor(() => expect(importButton).not.toBeDisabled(), { + timeout: 5000, + }); + fireEvent.click(importButton); + + await waitFor( + () => { + expect(mockResolveRuntimeLocalSkillImport).toHaveBeenCalledTimes(1); + expect(mockResolveRuntimeLocalSkillImport).toHaveBeenCalledWith( + "runtime-1", + { + skill_key: "code-gen", + name: "Code Gen", + description: "Generate code from specs", + supports_conflict: true, + }, + ); + }, + { timeout: 5000 }, + ); + }); + + it("highlights the matched substring in search results", async () => { + mockRuntimeLocalSkillsOptions.mockReturnValue({ + queryKey: ["runtimes", "local-skills", "runtime-1"], + queryFn: () => + Promise.resolve({ + supported: true, + skills: [MOCK_SKILL_A, MOCK_SKILL_B], + }), + }); + + renderPanel(); + + expect( + await screen.findByText("Review Helper", {}, { timeout: 5000 }), + ).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText("Search local skills"), { + target: { value: "code" }, + }); + + // The surviving row's name has its matched substring wrapped in a , + // so the user can see why the result matched. + const nameMark = screen.getByText("Code", { selector: "mark" }); + expect(nameMark.tagName).toBe("MARK"); + // The description and path also matched "code", each highlighted too. + expect(screen.getAllByText("code", { selector: "mark" }).length).toBe(2); + // The non-matching row is gone, so no stray highlights leak from it. + expect(screen.queryByText("Review Helper")).not.toBeInTheDocument(); + }); + + it("shows an empty search state when no local skills match", async () => { + mockRuntimeLocalSkillsOptions.mockReturnValue({ + queryKey: ["runtimes", "local-skills", "runtime-1"], + queryFn: () => + Promise.resolve({ + supported: true, + skills: [MOCK_SKILL_A, MOCK_SKILL_B], + }), + }); + + renderPanel(); + + expect( + await screen.findByText("Review Helper", {}, { timeout: 5000 }), + ).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText("Search local skills"), { + target: { value: "terraform" }, + }); + + expect(screen.getByText("No matching local skills")).toBeInTheDocument(); + expect( + screen.getByText('No local skills match "terraform".'), + ).toBeInTheDocument(); + expect(screen.queryByText("Review Helper")).not.toBeInTheDocument(); + expect(screen.queryByText("Code Gen")).not.toBeInTheDocument(); + }); + it("handles partial failures gracefully", async () => { mockRuntimeLocalSkillsOptions.mockReturnValue({ queryKey: ["runtimes", "local-skills", "runtime-1"], diff --git a/packages/views/skills/components/runtime-local-skill-import-panel.tsx b/packages/views/skills/components/runtime-local-skill-import-panel.tsx index 9e2deb5d9b..259e82ff92 100644 --- a/packages/views/skills/components/runtime-local-skill-import-panel.tsx +++ b/packages/views/skills/components/runtime-local-skill-import-panel.tsx @@ -10,6 +10,7 @@ import { HardDrive, Loader2, RefreshCw, + Search, SkipForward, XCircle, } from "lucide-react"; @@ -49,6 +50,7 @@ import { import { Skeleton } from "@multica/ui/components/ui/skeleton"; import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade"; import { useT } from "../../i18n"; +import { HighlightText } from "../../search/highlight-text"; import { isNameConflictError } from "../lib/utils"; // --------------------------------------------------------------------------- @@ -139,6 +141,7 @@ function SkillItem({ editDescription, onNameChange, onDescriptionChange, + query = "", }: { skill: RuntimeLocalSkillSummary; checked: boolean; @@ -149,6 +152,8 @@ function SkillItem({ editDescription?: string; onNameChange?: (v: string) => void; onDescriptionChange?: (v: string) => void; + /** Active search term; matched substrings are highlighted in this row. */ + query?: string; }) { const { t } = useT("skills"); return ( @@ -176,16 +181,27 @@ function SkillItem({ />
- {skill.name} - {skill.provider} + + + + + +
{skill.description && ( -

- {skill.description} + // Drop the 2-line clamp while searching so a highlighted match that + // falls past the first two lines stays visible instead of being + // clipped out of view. +

+

)}

- {skill.source_path} +

@@ -508,6 +524,7 @@ export function RuntimeLocalSkillImportPanel({ const [conflictResolutions, setConflictResolutions] = useState< Record >({}); + const [skillSearchQuery, setSkillSearchQuery] = useState(""); const cancelRef = useRef(false); // Single-select inline edit fields (shown when exactly 1 skill is checked) const [editName, setEditName] = useState(""); @@ -525,6 +542,7 @@ export function RuntimeLocalSkillImportPanel({ setSelectedKeys(new Set()); setBulkState(INITIAL_BULK_STATE); setConflictResolutions({}); + setSkillSearchQuery(""); setEditName(""); setEditDescription(""); }, [selectedRuntimeId]); @@ -540,6 +558,20 @@ export function RuntimeLocalSkillImportPanel({ () => skillsQuery.data?.skills ?? [], [skillsQuery.data], ); + const filteredRuntimeSkills = useMemo(() => { + const query = skillSearchQuery.trim().toLowerCase(); + if (!query) return runtimeSkills; + + // Search only over fields the row actually renders (name, provider, + // description, path) so every match can be highlighted in the result — + // `skill.key` is intentionally excluded because it is not displayed, and a + // key-only match would surface a result with no visible reason. + return runtimeSkills.filter((skill) => + [skill.name, skill.description, skill.provider, skill.source_path].some( + (value) => value?.toLowerCase().includes(query) ?? false, + ), + ); + }, [runtimeSkills, skillSearchQuery]); // The single selected skill (for inline editing). Only valid when exactly 1. const singleSelectedSkill = @@ -567,16 +599,34 @@ export function RuntimeLocalSkillImportPanel({ }; const toggleAll = () => { - if (selectedKeys.size === runtimeSkills.length) { - setSelectedKeys(new Set()); - } else { - setSelectedKeys(new Set(runtimeSkills.map((s) => s.key))); - } + const visibleKeys = new Set(filteredRuntimeSkills.map((s) => s.key)); + const visibleSelected = + visibleKeys.size > 0 && + filteredRuntimeSkills.every((s) => selectedKeys.has(s.key)); + + setSelectedKeys((prev) => { + const next = new Set(prev); + if (visibleSelected) { + for (const key of visibleKeys) next.delete(key); + } else { + for (const key of visibleKeys) next.add(key); + } + if (next.size === 1) { + const only = runtimeSkills.find((s) => next.has(s.key)); + if (only) { + setEditName(only.name); + setEditDescription(only.description ?? ""); + } + } + return next; + }); }; const allSelected = - runtimeSkills.length > 0 && selectedKeys.size === runtimeSkills.length; - const someSelected = selectedKeys.size > 0 && !allSelected; + filteredRuntimeSkills.length > 0 && + filteredRuntimeSkills.every((s) => selectedKeys.has(s.key)); + const someSelected = + filteredRuntimeSkills.some((s) => selectedKeys.has(s.key)) && !allSelected; const pendingConflicts = bulkState.results.filter( (r) => r.status === "conflict" && r.conflict, ); @@ -1010,41 +1060,71 @@ export function RuntimeLocalSkillImportPanel({ ); } return ( -
- {/* Select all header */} -