mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 11:10:25 +02:00
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:
@@ -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.",
|
||||
|
||||
@@ -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": "をこのワークスペースに。",
|
||||
|
||||
@@ -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": "이 워크스페이스로 가져옵니다.",
|
||||
|
||||
@@ -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": "到工作区。",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user