"use client"; import { useMemo, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronDown, UserPlus, X } from "lucide-react"; import { api } from "@multica/core/api"; import { useAuthStore } from "@multica/core/auth"; import { useWorkspaceId } from "@multica/core/hooks"; import { useWorkspacePaths } from "@multica/core/paths"; import { agentListOptions, memberListOptions, workspaceKeys, } from "@multica/core/workspace/queries"; import { AGENT_DESCRIPTION_MAX_LENGTH } from "@multica/core/agents"; import { isImeComposing } from "@multica/core/utils"; import type { Agent, MemberWithUser } from "@multica/core/types"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@multica/ui/components/ui/dialog"; import { Popover, PopoverContent, PopoverTrigger, } from "@multica/ui/components/ui/popover"; import { Button } from "@multica/ui/components/ui/button"; import { Input } from "@multica/ui/components/ui/input"; import { Label } from "@multica/ui/components/ui/label"; import { toast } from "sonner"; import { useNavigation } from "../navigation"; import { ActorAvatar } from "../common/actor-avatar"; import { AvatarUploadControl } from "../common/avatar-upload-control"; import { CharCounter } from "../agents/components/char-counter"; import { PickerEmpty, PickerItem, PickerSection, } from "../issues/components/pickers/property-picker"; import { matchesPinyin } from "../editor/extensions/pinyin-match"; import { useT } from "../i18n"; type SelectedMember = { type: "agent" | "member"; id: string; name: string; }; // How many chips we show inline before collapsing the tail into "+N". const CHIP_DISPLAY_LIMIT = 3; export function CreateSquadModal({ onClose }: { onClose: () => void }) { const { t } = useT("modals"); const router = useNavigation(); const wsPaths = useWorkspacePaths(); const wsId = useWorkspaceId(); const queryClient = useQueryClient(); const currentUser = useAuthStore((s) => s.user); const currentUserId = currentUser?.id ?? null; const { data: agents = [] } = useQuery(agentListOptions(wsId)); const { data: wsMembers = [] } = useQuery(memberListOptions(wsId)); const activeAgents = useMemo( () => agents.filter((a: Agent) => !a.archived_at && a.runtime_id), [agents], ); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [avatarUrl, setAvatarUrl] = useState(null); const [leaderId, setLeaderId] = useState(""); const [selectedMembers, setSelectedMembers] = useState([]); const [creating, setCreating] = useState(false); // Promoting an agent to leader must actually drop it from selectedMembers, // not merely hide it. Otherwise switching leader away later resurrects the // hidden pick and silently submits it as a member. const handleLeaderChange = (id: string) => { setLeaderId(id); if (id) { setSelectedMembers((prev) => prev.filter((m) => !(m.type === "agent" && m.id === id)), ); } }; const canSubmit = !!name.trim() && !!leaderId && !creating; const handleSubmit = async () => { if (!canSubmit) return; setCreating(true); try { const squad = await api.createSquad({ name: name.trim(), description: description.trim() || undefined, leader_id: leaderId, avatar_url: avatarUrl ?? undefined, }); queryClient.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) }); if (selectedMembers.length > 0) { await Promise.allSettled( selectedMembers.map(async (m) => { try { await api.addSquadMember(squad.id, { member_type: m.type, member_id: m.id, }); } catch (err) { toast.warning( t(($) => $.create_squad.toast_member_add_failed, { name: m.name, error: err instanceof Error ? err.message : "unknown error", }), ); } }), ); queryClient.invalidateQueries({ queryKey: [...workspaceKeys.squads(wsId), squad.id, "members"], }); } onClose(); toast.success(t(($) => $.create_squad.toast_created)); router.push(wsPaths.squadDetail(squad.id)); } catch (err) { toast.error( err instanceof Error ? err.message : t(($) => $.create_squad.toast_failed), ); setCreating(false); } }; return ( { if (!v) onClose(); }}> {t(($) => $.create_squad.title)} {t(($) => $.create_squad.description)}
{/* Identity row mirrors CreateAgentDialog so the two creates read as siblings — avatar (left) + name/description stack (right). */}
setAvatarUrl(null)} />
setName(e.target.value)} placeholder={t(($) => $.create_squad.name_placeholder)} className="mt-1" onKeyDown={(e) => { if (isImeComposing(e)) return; if (e.key === "Enter") void handleSubmit(); }} />
setDescription(e.target.value)} placeholder={t(($) => $.create_squad.description_placeholder)} maxLength={AGENT_DESCRIPTION_MAX_LENGTH} className="mt-1" />
{/* Inline footer — see CreateAgentDialog: shadcn DialogFooter applies negative margins assuming a padded DialogContent. Our content is p-0, so a plain bordered row is the right call. */}
); } // --------------------------------------------------------------------------- // LeaderPicker — single-select agent picker, grouped "My Agents" first then // "Workspace Agents". Empty workspaces render a disabled trigger that points // the user at agent creation. // --------------------------------------------------------------------------- function LeaderPicker({ agents, currentUserId, value, onChange, }: { agents: Agent[]; currentUserId: string | null; value: string; onChange: (id: string) => void; }) { const { t } = useT("modals"); const [open, setOpen] = useState(false); const [filter, setFilter] = useState(""); const myAgents = useMemo( () => (currentUserId ? agents.filter((a) => a.owner_id === currentUserId) : []), [agents, currentUserId], ); const otherAgents = useMemo( () => currentUserId ? agents.filter((a) => a.owner_id !== currentUserId) : agents, [agents, currentUserId], ); const q = filter.trim().toLowerCase(); const matches = (a: Agent) => !q || a.name.toLowerCase().includes(q) || matchesPinyin(a.name, q); const filteredMine = myAgents.filter(matches); const filteredOthers = otherAgents.filter(matches); const selected = agents.find((a) => a.id === value) ?? null; const noAgents = agents.length === 0; return (

{t(($) => $.create_squad.leader_hint)}

{noAgents ? (
{t(($) => $.create_squad.no_agents)}
) : ( { setOpen(v); if (!v) setFilter(""); }} > {selected ? ( ) : ( )}
{selected?.name ?? t(($) => $.create_squad.leader_placeholder)}
{selected?.description && (
{selected.description}
)}
setFilter(e.target.value)} placeholder={t(($) => $.create_squad.picker_search_placeholder)} className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none" />
{filteredMine.length > 0 && ( $.create_squad.group_my_agents)}> {filteredMine.map((a) => ( { onChange(a.id); setOpen(false); setFilter(""); }} > {a.name} ))} )} {filteredOthers.length > 0 && ( $.create_squad.group_workspace_agents)}> {filteredOthers.map((a) => ( { onChange(a.id); setOpen(false); setFilter(""); }} > {a.name} ))} )} {filteredMine.length === 0 && filteredOthers.length === 0 && ( )}
)}
); } // --------------------------------------------------------------------------- // AdditionalMembersPicker — multi-select agents + workspace members. The // trigger shows up to 3 chips inline; the rest collapse into "+N". The popup // stays open while the user toggles selections so they can pick multiple // without re-opening it. // --------------------------------------------------------------------------- function AdditionalMembersPicker({ agents, members, currentUserId, leaderId, value, onChange, }: { agents: Agent[]; members: MemberWithUser[]; currentUserId: string | null; leaderId: string; value: SelectedMember[]; onChange: (next: SelectedMember[]) => void; }) { const { t } = useT("modals"); const [open, setOpen] = useState(false); const [filter, setFilter] = useState(""); const isSelected = (type: "agent" | "member", id: string) => value.some((m) => m.type === type && m.id === id); const toggle = (m: SelectedMember) => { if (isSelected(m.type, m.id)) { onChange(value.filter((x) => !(x.type === m.type && x.id === m.id))); } else { onChange([...value, m]); } }; const remove = (m: SelectedMember) => { onChange(value.filter((x) => !(x.type === m.type && x.id === m.id))); }; const myAgents = useMemo( () => currentUserId ? agents.filter((a) => a.owner_id === currentUserId && a.id !== leaderId) : [], [agents, currentUserId, leaderId], ); const otherAgents = useMemo( () => agents.filter( (a) => a.id !== leaderId && (currentUserId ? a.owner_id !== currentUserId : true), ), [agents, currentUserId, leaderId], ); const q = filter.trim().toLowerCase(); const agentMatches = (a: Agent) => !q || a.name.toLowerCase().includes(q) || matchesPinyin(a.name, q); const memberMatches = (m: MemberWithUser) => !q || m.name.toLowerCase().includes(q) || matchesPinyin(m.name, q); const filteredMine = myAgents.filter(agentMatches); const filteredOthers = otherAgents.filter(agentMatches); const filteredMembers = members.filter(memberMatches); const anyResults = filteredMine.length + filteredOthers.length + filteredMembers.length > 0; return (

{t(($) => $.create_squad.members_hint)}

{ setOpen(v); if (!v) setFilter(""); }} > {/* render={
} — chips contain their own remove
} />
setFilter(e.target.value)} placeholder={t(($) => $.create_squad.picker_search_placeholder)} className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none" />
{filteredMine.length > 0 && ( $.create_squad.group_my_agents)}> {filteredMine.map((a) => ( toggle({ type: "agent", id: a.id, name: a.name }) } > {a.name} ))} )} {filteredOthers.length > 0 && ( $.create_squad.group_workspace_agents)}> {filteredOthers.map((a) => ( toggle({ type: "agent", id: a.id, name: a.name }) } > {a.name} ))} )} {filteredMembers.length > 0 && ( $.create_squad.group_members)}> {filteredMembers.map((m) => ( toggle({ type: "member", id: m.user_id, name: m.name }) } > {m.name} ))} )} {!anyResults && }
); } function MemberChip({ m, onRemove, }: { m: SelectedMember; onRemove: () => void; }) { const { t } = useT("modals"); return ( {m.name} ); }