Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
20d6cf0fa7 feat(chat): add searchable agent picker
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 17:21:02 +08:00
7 changed files with 222 additions and 61 deletions

View File

@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { I18nProvider } from "@multica/core/i18n/react";
import type { Agent } from "@multica/core/types";
import enChat from "../../locales/en/chat.json";
import enIssues from "../../locales/en/issues.json";
vi.mock("../../common/actor-avatar", () => ({
ActorAvatar: ({ actorId }: { actorId: string }) => (
<span data-testid={`avatar-${actorId}`} />
),
}));
import { AgentDropdown } from "./chat-window";
const TEST_RESOURCES = { en: { chat: enChat, issues: enIssues } };
function makeAgent(overrides: Partial<Agent> & Pick<Agent, "id" | "name" | "owner_id">): Agent {
return {
workspace_id: "ws-1",
runtime_id: "runtime-1",
description: "",
instructions: "",
avatar_url: null,
runtime_mode: "local",
runtime_config: {},
custom_args: [],
visibility: "workspace",
status: "idle",
max_concurrent_tasks: 1,
model: "sonnet",
skills: [],
created_at: new Date(0).toISOString(),
updated_at: new Date(0).toISOString(),
archived_at: null,
archived_by: null,
...overrides,
id: overrides.id,
name: overrides.name,
owner_id: overrides.owner_id,
};
}
const agents = [
makeAgent({ id: "mine-alpha", name: "Alpha", owner_id: "user-1" }),
makeAgent({ id: "mine-zhang", name: "张三", owner_id: "user-1" }),
makeAgent({ id: "other-beta", name: "Beta", owner_id: "user-2" }),
makeAgent({ id: "other-gamma", name: "Gamma", owner_id: "user-2" }),
];
function renderDropdown(onSelect = vi.fn()) {
render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<AgentDropdown
agents={agents}
activeAgent={agents[0]!}
userId="user-1"
onSelect={onSelect}
/>
</I18nProvider>,
);
fireEvent.click(screen.getByText("Alpha"));
return { onSelect };
}
describe("AgentDropdown", () => {
it("opens the shared picker upward from the chat input", async () => {
renderDropdown();
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveAttribute("data-side", "top");
});
it("filters both My agents and Others by agent name", async () => {
renderDropdown();
const input = await screen.findByRole("textbox", { name: "Filter options" });
fireEvent.change(input, { target: { value: "ta" } });
const dialog = screen.getByRole("dialog");
expect(within(dialog).queryByText("Alpha")).not.toBeInTheDocument();
expect(within(dialog).queryByText("张三")).not.toBeInTheDocument();
expect(within(dialog).getByText("Beta")).toBeInTheDocument();
expect(within(dialog).queryByText("Gamma")).not.toBeInTheDocument();
expect(within(dialog).getByText("Others")).toBeInTheDocument();
});
it("matches My agents by pinyin", async () => {
renderDropdown();
const input = await screen.findByRole("textbox", { name: "Filter options" });
fireEvent.change(input, { target: { value: "zhang" } });
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText("张三")).toBeInTheDocument();
expect(within(dialog).getByText("My agents")).toBeInTheDocument();
expect(within(dialog).queryByText("Alpha")).not.toBeInTheDocument();
expect(within(dialog).queryByText("Beta")).not.toBeInTheDocument();
});
it("shows the shared empty state when no agents match", async () => {
renderDropdown();
const input = await screen.findByRole("textbox", { name: "Filter options" });
fireEvent.change(input, { target: { value: "missing" } });
expect(screen.getByText("No results")).toBeInTheDocument();
expect(screen.queryByText("My agents")).not.toBeInTheDocument();
expect(screen.queryByText("Others")).not.toBeInTheDocument();
});
it("keeps the current agent marked and selects another agent", async () => {
const { onSelect } = renderDropdown();
const dialog = screen.getByRole("dialog");
const alphaRow = within(dialog).getByText("Alpha").closest("button");
expect(alphaRow).not.toBeNull();
expect(alphaRow!.querySelector("svg:not(.invisible)")).not.toBeNull();
fireEvent.click(within(dialog).getByText("Beta"));
expect(onSelect).toHaveBeenCalledWith(agents[2]);
await waitFor(() => {
expect(screen.queryByRole("textbox", { name: "Filter options" })).not.toBeInTheDocument();
});
});
});

View File

@@ -7,15 +7,6 @@ import { Minus, Maximize2, Minimize2, ChevronDown, Plus, Check, Trash2, Pencil,
import { Button } from "@multica/ui/components/ui/button";
import { cn } from "@multica/ui/lib/utils";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
@@ -29,6 +20,13 @@ import { api } from "@multica/core/api";
import { useAgentPresenceDetail, useWorkspaceAgentAvailability } from "@multica/core/agents";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { ActorAvatar } from "../../common/actor-avatar";
import {
PickerEmpty,
PickerItem,
PickerSection,
PropertyPicker,
} from "../../issues/components/pickers/property-picker";
import { matchesPinyin } from "../../editor/extensions/pinyin-match";
import { OfflineBanner } from "./offline-banner";
import { NoAgentBanner } from "./no-agent-banner";
import {
@@ -631,7 +629,7 @@ export function ChatWindow() {
* different agent = switch agent + start a fresh chat (session=null).
* The current agent is marked with a check and not clickable.
*/
function AgentDropdown({
export function AgentDropdown({
agents,
activeAgent,
userId,
@@ -643,6 +641,8 @@ function AgentDropdown({
onSelect: (agent: Agent) => void;
}) {
const { t } = useT("chat");
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState("");
// Split into the user's own agents and everyone else so the menu groups
// them — matches the old AgentSelector layout.
const { mine, others } = useMemo(() => {
@@ -655,57 +655,86 @@ function AgentDropdown({
return { mine, others };
}, [agents, userId]);
const query = filter.trim().toLowerCase();
const matches = (name: string) =>
!query || name.toLowerCase().includes(query) || matchesPinyin(name, query);
const filteredMine = mine.filter((agent) => matches(agent.name));
const filteredOthers = others.filter((agent) => matches(agent.name));
const handlePick = (agent: Agent) => {
onSelect(agent);
setOpen(false);
};
if (!activeAgent) {
return <span className="text-xs text-muted-foreground">{t(($) => $.window.no_agents)}</span>;
}
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 rounded-md px-1.5 py-1 -ml-1 cursor-pointer outline-none transition-colors hover:bg-accent aria-expanded:bg-accent">
<ActorAvatar
actorType="agent"
actorId={activeAgent.id}
size={24}
enableHoverCard
showStatusDot
<PropertyPicker
open={open}
onOpenChange={setOpen}
width="w-64"
align="start"
side="top"
searchable
searchPlaceholder={t(($) => $.window.agent_filter_placeholder)}
onSearchChange={setFilter}
triggerRender={
<button
type="button"
className="flex items-center gap-1.5 rounded-md px-1.5 py-1 -ml-1 cursor-pointer outline-none transition-colors hover:bg-accent aria-expanded:bg-accent"
/>
<span className="text-xs font-medium max-w-28 truncate">{activeAgent.name}</span>
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" className="max-h-80 w-auto max-w-64">
{mine.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>{t(($) => $.window.my_agents)}</DropdownMenuLabel>
{mine.map((agent) => (
<AgentMenuItem
key={agent.id}
agent={agent}
isCurrent={agent.id === activeAgent.id}
onSelect={onSelect}
/>
))}
</DropdownMenuGroup>
)}
{mine.length > 0 && others.length > 0 && <DropdownMenuSeparator />}
{others.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>{t(($) => $.window.others)}</DropdownMenuLabel>
{others.map((agent) => (
<AgentMenuItem
key={agent.id}
agent={agent}
isCurrent={agent.id === activeAgent.id}
onSelect={onSelect}
/>
))}
</DropdownMenuGroup>
)}
</DropdownMenuContent>
</DropdownMenu>
}
trigger={
<>
<ActorAvatar
actorType="agent"
actorId={activeAgent.id}
size={24}
enableHoverCard
showStatusDot
/>
<span className="text-xs font-medium max-w-28 truncate">{activeAgent.name}</span>
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
</>
}
>
{filteredMine.length === 0 && filteredOthers.length === 0 ? (
<PickerEmpty />
) : (
<>
{filteredMine.length > 0 && (
<PickerSection label={t(($) => $.window.my_agents)}>
{filteredMine.map((agent) => (
<AgentPickerItem
key={agent.id}
agent={agent}
isCurrent={agent.id === activeAgent.id}
onSelect={handlePick}
/>
))}
</PickerSection>
)}
{filteredOthers.length > 0 && (
<PickerSection label={t(($) => $.window.others)}>
{filteredOthers.map((agent) => (
<AgentPickerItem
key={agent.id}
agent={agent}
isCurrent={agent.id === activeAgent.id}
onSelect={handlePick}
/>
))}
</PickerSection>
)}
</>
)}
</PropertyPicker>
);
}
function AgentMenuItem({
function AgentPickerItem({
agent,
isCurrent,
onSelect,
@@ -715,9 +744,9 @@ function AgentMenuItem({
onSelect: (agent: Agent) => void;
}) {
return (
<DropdownMenuItem
<PickerItem
selected={isCurrent}
onClick={() => onSelect(agent)}
className="flex min-w-0 items-center gap-2"
>
<ActorAvatar
actorType="agent"
@@ -727,8 +756,7 @@ function AgentMenuItem({
showStatusDot
/>
<span className="truncate flex-1">{agent.name}</span>
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
</DropdownMenuItem>
</PickerItem>
);
}

View File

@@ -29,6 +29,7 @@ export function PropertyPicker({
triggerRender,
width = "w-48",
align = "end",
side = "bottom",
searchable = false,
searchPlaceholder,
onSearchChange,
@@ -43,6 +44,7 @@ export function PropertyPicker({
triggerRender?: React.ReactElement;
width?: string;
align?: "start" | "center" | "end";
side?: React.ComponentProps<typeof PopoverContent>["side"];
searchable?: boolean;
searchPlaceholder?: string | undefined;
onSearchChange?: (query: string) => void;
@@ -160,7 +162,7 @@ export function PropertyPicker({
) : (
popoverTrigger
)}
<PopoverContent align={align} className={`${width} gap-0 p-0`}>
<PopoverContent align={align} side={side} className={`${width} gap-0 p-0`}>
{searchable && (
<div className="px-2 py-1.5 border-b">
<input

View File

@@ -77,7 +77,8 @@
"my_agents": "My agents",
"others": "Others",
"no_agents": "No agents",
"history_group": "Chat history"
"history_group": "Chat history",
"agent_filter_placeholder": "Search agents..."
},
"empty_state": {
"first_time_title": "Chat with your agents",

View File

@@ -74,7 +74,8 @@
"my_agents": "マイエージェント",
"others": "その他",
"no_agents": "エージェントなし",
"history_group": "チャット履歴"
"history_group": "チャット履歴",
"agent_filter_placeholder": "エージェントを検索..."
},
"empty_state": {
"first_time_title": "エージェントとチャット",

View File

@@ -77,7 +77,8 @@
"my_agents": "내 에이전트",
"others": "기타",
"no_agents": "에이전트 없음",
"history_group": "채팅 기록"
"history_group": "채팅 기록",
"agent_filter_placeholder": "에이전트 검색..."
},
"empty_state": {
"first_time_title": "에이전트와 채팅하기",

View File

@@ -74,7 +74,8 @@
"my_agents": "我的智能体",
"others": "其他",
"no_agents": "暂无智能体",
"history_group": "历史对话"
"history_group": "历史对话",
"agent_filter_placeholder": "搜索智能体..."
},
"empty_state": {
"first_time_title": "和你的智能体对话",