Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
c1779aacd2 feat(agents): add search box to skill picker dialog
Filters available skills by name + description (case-insensitive) as the
user types. Auto-focuses on open and clears the query on close. Shows a
distinct "no match" empty state vs. the existing "all assigned" one.

Closes #2266

Co-authored-by: multica-agent <github@multica.ai>
2026-05-08 17:05:25 +08:00
3 changed files with 47 additions and 5 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { FileText } from "lucide-react";
import { FileText, Search } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import type { Agent } from "@multica/core/types";
@@ -20,6 +20,7 @@ import {
DialogHeader,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { Input } from "@multica/ui/components/ui/input";
import { useT } from "../../i18n";
/**
@@ -46,11 +47,27 @@ export function SkillAddDialog({
const qc = useQueryClient();
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
const [saving, setSaving] = useState(false);
const [query, setQuery] = useState("");
const agentSkillIds = new Set(agent.skills.map((s) => s.id));
const availableSkills = workspaceSkills.filter(
(s) => !agentSkillIds.has(s.id),
);
const trimmedQuery = query.trim().toLowerCase();
const filteredSkills = trimmedQuery
? availableSkills.filter((s) => {
const name = s.name.toLowerCase();
const description = s.description?.toLowerCase() ?? "";
return (
name.includes(trimmedQuery) || description.includes(trimmedQuery)
);
})
: availableSkills;
const handleOpenChange = (v: boolean) => {
if (!v) setQuery("");
onOpenChange(v);
};
const handleAdd = async (skillId: string) => {
setSaving(true);
@@ -58,7 +75,7 @@ export function SkillAddDialog({
const newIds = [...agent.skills.map((s) => s.id), skillId];
await api.setAgentSkills(agent.id, { skill_ids: newIds });
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
onOpenChange(false);
handleOpenChange(false);
} catch (e) {
toast.error(e instanceof Error ? e.message : t(($) => $.tab_body.skills.add_failed_toast));
} finally {
@@ -66,8 +83,11 @@ export function SkillAddDialog({
}
};
const showSearch = availableSkills.length > 0;
const noMatch = showSearch && filteredSkills.length === 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-sm">{t(($) => $.tab_body.skills.add_dialog_title)}</DialogTitle>
@@ -75,8 +95,21 @@ export function SkillAddDialog({
{t(($) => $.tab_body.skills.add_dialog_description)}
</DialogDescription>
</DialogHeader>
{showSearch && (
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t(($) => $.tab_body.skills.add_dialog_search_placeholder)}
aria-label={t(($) => $.tab_body.skills.add_dialog_search_placeholder)}
className="pl-7"
/>
</div>
)}
<div className="max-h-64 space-y-1 overflow-y-auto">
{availableSkills.map((skill) => (
{filteredSkills.map((skill) => (
<button
key={skill.id}
onClick={() => handleAdd(skill.id)}
@@ -99,9 +132,14 @@ export function SkillAddDialog({
{t(($) => $.tab_body.skills.add_dialog_empty)}
</p>
)}
{noMatch && (
<p className="py-6 text-center text-xs text-muted-foreground">
{t(($) => $.tab_body.skills.add_dialog_no_match)}
</p>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
<Button variant="ghost" onClick={() => handleOpenChange(false)}>
{t(($) => $.tab_body.skills.add_dialog_cancel)}
</Button>
</DialogFooter>

View File

@@ -259,7 +259,9 @@
"remove_failed_toast": "Failed to remove skill",
"add_dialog_title": "Add skill",
"add_dialog_description": "Select a workspace skill to assign to this agent.",
"add_dialog_search_placeholder": "Search skills",
"add_dialog_empty": "All workspace skills are already assigned.",
"add_dialog_no_match": "No skills match your search.",
"add_dialog_cancel": "Cancel",
"add_failed_toast": "Failed to add skill"
},

View File

@@ -255,7 +255,9 @@
"remove_failed_toast": "移除 skill 失败",
"add_dialog_title": "添加 skill",
"add_dialog_description": "选择一个工作区 skill 分配给该智能体。",
"add_dialog_search_placeholder": "搜索 skill",
"add_dialog_empty": "全部工作区 skill 都已分配。",
"add_dialog_no_match": "没有匹配的 skill。",
"add_dialog_cancel": "取消",
"add_failed_toast": "添加 skill 失败"
},