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) <noreply@anthropic.com>

---------

Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
abun
2026-07-13 13:04:44 +08:00
committed by GitHub
parent 57ecdef38b
commit 54b34f8ccd
6 changed files with 253 additions and 45 deletions

View File

@@ -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.",

View File

@@ -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": "をこのワークスペースに。",

View File

@@ -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": "이 워크스페이스로 가져옵니다.",

View File

@@ -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": "到工作区。",

View File

@@ -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 <mark>, 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 <mark>,
// 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"],

View File

@@ -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({
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{skill.name}</span>
<Badge variant="secondary">{skill.provider}</Badge>
<span className="truncate text-sm font-medium">
<HighlightText text={skill.name} query={query} />
</span>
<Badge variant="secondary">
<HighlightText text={skill.provider} query={query} />
</Badge>
</div>
{skill.description && (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{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.
<p
className={`mt-1 text-xs text-muted-foreground ${
query.trim() ? "" : "line-clamp-2"
}`}
>
<HighlightText text={skill.description} query={query} />
</p>
)}
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">
{skill.source_path}
<HighlightText text={skill.source_path} query={query} />
</p>
</div>
<Badge variant="outline" className="shrink-0">
@@ -508,6 +524,7 @@ export function RuntimeLocalSkillImportPanel({
const [conflictResolutions, setConflictResolutions] = useState<
Record<string, ConflictResolutionState>
>({});
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 (
<div className="space-y-2">
{/* Select all header */}
<label className="flex cursor-pointer items-center gap-2 px-1 py-1">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={toggleAll}
className="cursor-pointer accent-primary"
<div className="flex flex-col gap-2">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={skillSearchQuery}
onChange={(e) => setSkillSearchQuery(e.target.value)}
placeholder={t(($) => $.runtime_import.search_placeholder)}
className="h-9 pl-8 text-sm"
/>
<span className="text-xs text-muted-foreground">
{t(($) => $.runtime_import.select_all, {
count: runtimeSkills.length,
})}
</span>
</label>
</div>
{runtimeSkills.map((s) => (
<SkillItem
key={s.key}
skill={s}
checked={selectedKeys.has(s.key)}
onToggle={() => toggleSkill(s.key)}
disabled={importing}
expanded={singleSelectedSkill?.key === s.key}
editName={singleSelectedSkill?.key === s.key ? editName : undefined}
editDescription={
singleSelectedSkill?.key === s.key ? editDescription : undefined
}
onNameChange={setEditName}
onDescriptionChange={setEditDescription}
/>
))}
{filteredRuntimeSkills.length === 0 ? (
<div className="rounded-lg border border-dashed px-4 py-8 text-center">
<p className="text-sm text-muted-foreground">
{t(($) => $.runtime_import.no_search_results_title)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t(($) => $.runtime_import.no_search_results_hint, {
query: skillSearchQuery.trim(),
})}
</p>
</div>
) : (
<>
{/* Select all header */}
<label className="flex cursor-pointer items-center gap-2 px-1 py-1">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={toggleAll}
className="cursor-pointer accent-primary"
/>
<span className="text-xs text-muted-foreground">
{t(($) => $.runtime_import.select_all, {
count: filteredRuntimeSkills.length,
})}
</span>
</label>
{filteredRuntimeSkills.map((s) => (
<SkillItem
key={s.key}
skill={s}
checked={selectedKeys.has(s.key)}
onToggle={() => toggleSkill(s.key)}
disabled={importing}
expanded={singleSelectedSkill?.key === s.key}
editName={
singleSelectedSkill?.key === s.key ? editName : undefined
}
editDescription={
singleSelectedSkill?.key === s.key
? editDescription
: undefined
}
onNameChange={setEditName}
onDescriptionChange={setEditDescription}
query={skillSearchQuery}
/>
))}
</>
)}
</div>
);
})();