mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-25 08:29:18 +02:00
Compare commits
1 Commits
agent/lamb
...
agent/matt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20d6cf0fa7 |
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -74,7 +74,8 @@
|
||||
"my_agents": "マイエージェント",
|
||||
"others": "その他",
|
||||
"no_agents": "エージェントなし",
|
||||
"history_group": "チャット履歴"
|
||||
"history_group": "チャット履歴",
|
||||
"agent_filter_placeholder": "エージェントを検索..."
|
||||
},
|
||||
"empty_state": {
|
||||
"first_time_title": "エージェントとチャット",
|
||||
|
||||
@@ -77,7 +77,8 @@
|
||||
"my_agents": "내 에이전트",
|
||||
"others": "기타",
|
||||
"no_agents": "에이전트 없음",
|
||||
"history_group": "채팅 기록"
|
||||
"history_group": "채팅 기록",
|
||||
"agent_filter_placeholder": "에이전트 검색..."
|
||||
},
|
||||
"empty_state": {
|
||||
"first_time_title": "에이전트와 채팅하기",
|
||||
|
||||
@@ -74,7 +74,8 @@
|
||||
"my_agents": "我的智能体",
|
||||
"others": "其他",
|
||||
"no_agents": "暂无智能体",
|
||||
"history_group": "历史对话"
|
||||
"history_group": "历史对话",
|
||||
"agent_filter_placeholder": "搜索智能体..."
|
||||
},
|
||||
"empty_state": {
|
||||
"first_time_title": "和你的智能体对话",
|
||||
|
||||
Reference in New Issue
Block a user