mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 18:17:44 +02:00
* refactor(ui): converge ActorAvatar size to semantic tiers (MUL-4277) Replace the free-form numeric `size` on ActorAvatar with a constrained `AvatarSize` union (xs/sm/md/lg/xl/2xl) so avatar dimensions are chosen by role instead of ad-hoc pixels. This eliminates the magic-number drift where the same role rendered at different sizes across pages. - Add `@multica/ui/lib/avatar-size` (AvatarSize union + AVATAR_SIZE_PX map + default tier). - Base `ActorAvatar` (packages/ui) and business `ActorAvatar`/`AgentStatusDot` (packages/views) now take `AvatarSize`; internal font/icon math and the presence-dot threshold read px from the map. - Migrate all web/desktop call sites (packages/ui + packages/views) from numeric sizes to tiers using the role table (12,14->xs 16,18,20->sm 22,24,28->md 30,32,34->lg 40,44->xl 56,64->2xl). - Token-ise the derived consumers `AgentAvatarStack` and `IssueAgentActivityIndicator` (px looked up internally for overlap/+N math). Out of scope (per plan): ui/avatar.tsx primitive, account-tab/AvatarPicker, mobile, and component-name disambiguation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(ui): unify all avatars and the upload cropper to round (MUL-4277, MUL-4184) main's avatar-shape decision rendered non-human actors (agent, squad, system) and the workspace logo as rounded squares, and the upload cropper mirrored that with a square crop window. Per the updated decision (avatars_and_cropper_round_required), every avatar and the crop UI are now circular; the square path is removed rather than left as dead config. - Base ActorAvatar: always rounded-full (drop the isHuman/rounded-md split). - avatar-crop-dialog: remove the AvatarCropShape/square path; crop window is always cropShape="round". - avatar-upload-control: drop VARIANT_SHAPE; the control is always round and no longer threads a shape to the dialog (variant still drives the fallback). - Strip rounded-md/rounded-none square overrides from agent/squad/member ActorAvatar call sites; round the read-only agent/squad static wrappers. - WorkspaceAvatar: round the org logo so it matches the (now round) workspace upload/crop and the shared avatar shape. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(ui): make round avatar shape a hard invariant (MUL-4277) Close the two remaining square squad-avatar paths flagged in review and prevent call sites from re-squaring the avatar: - base ActorAvatar: keep `rounded-full` as the last class in cn() so a call-site `className` can no longer override the circle. - SquadHeaderAvatar: drop `className="rounded"` (was overriding the base circle into a small rounded square). - SquadsPage no-avatar fallback: route through the shared ActorAvatarBase (`isSquad size="lg"`) instead of a hand-written rounded-md tile, so the fallback matches the image path — one shape source of truth. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(views): round the agent/squad avatar loading skeletons (MUL-4277) The avatar placeholder skeletons on the agent/squad list, detail, and profile-card loading states were still rounded squares (rounded-md/lg) from the pre-round era, so the avatar visibly popped from square to circle on load — inconsistent with the round avatars and with the member/inbox/issue skeletons that already use rounded-full. Round all five: agents-page, agent-detail-page, squads-page, squad-detail-page, squad-profile-card. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
618 lines
22 KiB
TypeScript
618 lines
22 KiB
TypeScript
"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<string | null>(null);
|
|
const [leaderId, setLeaderId] = useState("");
|
|
const [selectedMembers, setSelectedMembers] = useState<SelectedMember[]>([]);
|
|
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 (
|
|
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
|
<DialogContent className="p-0 gap-0 flex flex-col overflow-hidden !top-1/2 !left-1/2 !-translate-x-1/2 !-translate-y-1/2 !w-full !max-w-2xl !h-[85vh]">
|
|
<DialogHeader className="border-b px-5 py-3 space-y-0">
|
|
<DialogTitle className="text-base font-semibold">
|
|
{t(($) => $.create_squad.title)}
|
|
</DialogTitle>
|
|
<DialogDescription className="mt-1 text-xs">
|
|
{t(($) => $.create_squad.description)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex-1 overflow-y-auto p-5">
|
|
<div className="space-y-4 min-w-0">
|
|
{/* Identity row mirrors CreateAgentDialog so the two creates read
|
|
as siblings — avatar (left) + name/description stack (right). */}
|
|
<div className="flex items-start gap-4">
|
|
<AvatarUploadControl
|
|
variant="squad"
|
|
value={avatarUrl}
|
|
name={name}
|
|
size={64}
|
|
onUploaded={setAvatarUrl}
|
|
onClear={() => setAvatarUrl(null)}
|
|
/>
|
|
<div className="flex-1 min-w-0 space-y-3">
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">
|
|
{t(($) => $.create_squad.name_label)}
|
|
</Label>
|
|
<Input
|
|
autoFocus
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => 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();
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">
|
|
{t(($) => $.create_squad.description_label)}
|
|
</Label>
|
|
<Input
|
|
type="text"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder={t(($) => $.create_squad.description_placeholder)}
|
|
maxLength={AGENT_DESCRIPTION_MAX_LENGTH}
|
|
className="mt-1"
|
|
/>
|
|
<div className="mt-1">
|
|
<CharCounter
|
|
length={[...description].length}
|
|
max={AGENT_DESCRIPTION_MAX_LENGTH}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<LeaderPicker
|
|
agents={activeAgents}
|
|
currentUserId={currentUserId}
|
|
value={leaderId}
|
|
onChange={handleLeaderChange}
|
|
/>
|
|
|
|
<AdditionalMembersPicker
|
|
agents={activeAgents}
|
|
members={wsMembers}
|
|
currentUserId={currentUserId}
|
|
leaderId={leaderId}
|
|
value={selectedMembers}
|
|
onChange={setSelectedMembers}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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. */}
|
|
<div className="flex items-center justify-end gap-2 border-t bg-background px-5 py-3">
|
|
<Button variant="ghost" onClick={onClose}>
|
|
{t(($) => $.create_squad.cancel)}
|
|
</Button>
|
|
<Button onClick={() => void handleSubmit()} disabled={!canSubmit}>
|
|
{creating
|
|
? t(($) => $.create_squad.submitting)
|
|
: t(($) => $.create_squad.submit)}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 (
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">
|
|
{t(($) => $.create_squad.leader_label)}
|
|
</Label>
|
|
<p className="mt-0.5 mb-1.5 text-xs text-muted-foreground">
|
|
{t(($) => $.create_squad.leader_hint)}
|
|
</p>
|
|
|
|
{noAgents ? (
|
|
<div className="flex items-center gap-2 rounded-lg border border-dashed bg-muted/30 px-3 py-2.5 text-sm text-muted-foreground">
|
|
{t(($) => $.create_squad.no_agents)}
|
|
</div>
|
|
) : (
|
|
<Popover
|
|
open={open}
|
|
onOpenChange={(v) => {
|
|
setOpen(v);
|
|
if (!v) setFilter("");
|
|
}}
|
|
>
|
|
<PopoverTrigger className="flex w-full min-w-0 items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted">
|
|
{selected ? (
|
|
<ActorAvatar actorType="agent" actorId={selected.id} size="sm" showStatusDot />
|
|
) : (
|
|
<UserPlus className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate font-medium">
|
|
{selected?.name ?? t(($) => $.create_squad.leader_placeholder)}
|
|
</div>
|
|
{selected?.description && (
|
|
<div className="truncate text-xs text-muted-foreground">
|
|
{selected.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<ChevronDown
|
|
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${
|
|
open ? "rotate-180" : ""
|
|
}`}
|
|
/>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="start" className="w-[var(--anchor-width)] p-0">
|
|
<div className="border-b px-2 py-1.5">
|
|
<input
|
|
autoFocus
|
|
type="text"
|
|
value={filter}
|
|
onChange={(e) => setFilter(e.target.value)}
|
|
placeholder={t(($) => $.create_squad.picker_search_placeholder)}
|
|
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
|
|
/>
|
|
</div>
|
|
<div className="max-h-72 overflow-y-auto p-1">
|
|
{filteredMine.length > 0 && (
|
|
<PickerSection label={t(($) => $.create_squad.group_my_agents)}>
|
|
{filteredMine.map((a) => (
|
|
<PickerItem
|
|
key={a.id}
|
|
selected={value === a.id}
|
|
onClick={() => {
|
|
onChange(a.id);
|
|
setOpen(false);
|
|
setFilter("");
|
|
}}
|
|
>
|
|
<ActorAvatar actorType="agent" actorId={a.id} size="sm" showStatusDot />
|
|
<span className="truncate">{a.name}</span>
|
|
</PickerItem>
|
|
))}
|
|
</PickerSection>
|
|
)}
|
|
{filteredOthers.length > 0 && (
|
|
<PickerSection label={t(($) => $.create_squad.group_workspace_agents)}>
|
|
{filteredOthers.map((a) => (
|
|
<PickerItem
|
|
key={a.id}
|
|
selected={value === a.id}
|
|
onClick={() => {
|
|
onChange(a.id);
|
|
setOpen(false);
|
|
setFilter("");
|
|
}}
|
|
>
|
|
<ActorAvatar actorType="agent" actorId={a.id} size="sm" showStatusDot />
|
|
<span className="truncate">{a.name}</span>
|
|
</PickerItem>
|
|
))}
|
|
</PickerSection>
|
|
)}
|
|
{filteredMine.length === 0 && filteredOthers.length === 0 && (
|
|
<PickerEmpty />
|
|
)}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 (
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">
|
|
{t(($) => $.create_squad.members_label)}{" "}
|
|
<span className="text-muted-foreground/60">
|
|
{t(($) => $.create_squad.members_optional)}
|
|
</span>
|
|
</Label>
|
|
<p className="mt-0.5 mb-1.5 text-xs text-muted-foreground">
|
|
{t(($) => $.create_squad.members_hint)}
|
|
</p>
|
|
|
|
<Popover
|
|
open={open}
|
|
onOpenChange={(v) => {
|
|
setOpen(v);
|
|
if (!v) setFilter("");
|
|
}}
|
|
>
|
|
{/* render={<div role="combobox" />} — chips contain their own remove
|
|
<button>, so the trigger cannot itself be a <button> without
|
|
nesting interactive content. Base UI injects click/keyboard/ARIA
|
|
wiring into the rendered element. */}
|
|
<PopoverTrigger
|
|
render={
|
|
<div
|
|
role="combobox"
|
|
aria-haspopup="listbox"
|
|
aria-expanded={open}
|
|
aria-controls="squad-member-listbox"
|
|
tabIndex={0}
|
|
className="flex w-full min-w-0 cursor-pointer items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted focus:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
{value.length === 0 ? (
|
|
<>
|
|
<UserPlus className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
|
{t(($) => $.create_squad.members_placeholder)}
|
|
</span>
|
|
</>
|
|
) : (
|
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
|
{value.slice(0, CHIP_DISPLAY_LIMIT).map((m) => (
|
|
<MemberChip
|
|
key={`${m.type}:${m.id}`}
|
|
m={m}
|
|
onRemove={() => remove(m)}
|
|
/>
|
|
))}
|
|
{value.length > CHIP_DISPLAY_LIMIT && (
|
|
<span className="rounded-md bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground tabular-nums">
|
|
{t(($) => $.create_squad.members_more_count, {
|
|
count: value.length - CHIP_DISPLAY_LIMIT,
|
|
})}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
<ChevronDown
|
|
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${
|
|
open ? "rotate-180" : ""
|
|
}`}
|
|
/>
|
|
</div>
|
|
}
|
|
/>
|
|
<PopoverContent align="start" className="w-[var(--anchor-width)] p-0">
|
|
<div className="border-b px-2 py-1.5">
|
|
<input
|
|
autoFocus
|
|
type="text"
|
|
value={filter}
|
|
onChange={(e) => setFilter(e.target.value)}
|
|
placeholder={t(($) => $.create_squad.picker_search_placeholder)}
|
|
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
|
|
/>
|
|
</div>
|
|
<div id="squad-member-listbox" role="listbox" className="max-h-72 overflow-y-auto p-1">
|
|
{filteredMine.length > 0 && (
|
|
<PickerSection label={t(($) => $.create_squad.group_my_agents)}>
|
|
{filteredMine.map((a) => (
|
|
<PickerItem
|
|
key={a.id}
|
|
selected={isSelected("agent", a.id)}
|
|
onClick={() =>
|
|
toggle({ type: "agent", id: a.id, name: a.name })
|
|
}
|
|
>
|
|
<ActorAvatar actorType="agent" actorId={a.id} size="sm" showStatusDot />
|
|
<span className="truncate">{a.name}</span>
|
|
</PickerItem>
|
|
))}
|
|
</PickerSection>
|
|
)}
|
|
{filteredOthers.length > 0 && (
|
|
<PickerSection label={t(($) => $.create_squad.group_workspace_agents)}>
|
|
{filteredOthers.map((a) => (
|
|
<PickerItem
|
|
key={a.id}
|
|
selected={isSelected("agent", a.id)}
|
|
onClick={() =>
|
|
toggle({ type: "agent", id: a.id, name: a.name })
|
|
}
|
|
>
|
|
<ActorAvatar actorType="agent" actorId={a.id} size="sm" showStatusDot />
|
|
<span className="truncate">{a.name}</span>
|
|
</PickerItem>
|
|
))}
|
|
</PickerSection>
|
|
)}
|
|
{filteredMembers.length > 0 && (
|
|
<PickerSection label={t(($) => $.create_squad.group_members)}>
|
|
{filteredMembers.map((m) => (
|
|
<PickerItem
|
|
key={m.user_id}
|
|
selected={isSelected("member", m.user_id)}
|
|
onClick={() =>
|
|
toggle({ type: "member", id: m.user_id, name: m.name })
|
|
}
|
|
>
|
|
<ActorAvatar actorType="member" actorId={m.user_id} size="sm" />
|
|
<span className="truncate">{m.name}</span>
|
|
</PickerItem>
|
|
))}
|
|
</PickerSection>
|
|
)}
|
|
{!anyResults && <PickerEmpty />}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MemberChip({
|
|
m,
|
|
onRemove,
|
|
}: {
|
|
m: SelectedMember;
|
|
onRemove: () => void;
|
|
}) {
|
|
const { t } = useT("modals");
|
|
return (
|
|
<span className="inline-flex items-center gap-1 rounded-full border bg-background px-1.5 py-0.5 text-xs">
|
|
<ActorAvatar actorType={m.type} actorId={m.id} size="xs" />
|
|
<span className="max-w-[120px] truncate">{m.name}</span>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onRemove();
|
|
}}
|
|
aria-label={t(($) => $.create_squad.members_remove_aria, { name: m.name })}
|
|
className="rounded-full text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</span>
|
|
);
|
|
}
|