From 44ee74eb253c6f1d220077674febec036570e89d Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Mon, 25 May 2026 08:59:10 +0800 Subject: [PATCH] feat(views): add squad popover hover card to ActorAvatar (#3176) Squad avatars now show a hover card on dwell, matching the existing agent and member cards. The card displays the squad name, member count badge, description (line-clamp 2), and a members list (top 3, leader first) with agent status dots. Clicking an avatar navigates to the squad detail page. Closes MUL-2586. Co-authored-by: Claude Opus 4.7 Co-authored-by: multica-agent --- packages/views/common/actor-avatar.tsx | 32 ++- packages/views/locales/en/squads.json | 10 + packages/views/locales/zh-Hans/squads.json | 10 + packages/views/squads/components/index.ts | 1 + .../squads/components/squad-profile-card.tsx | 233 ++++++++++++++++++ 5 files changed, 281 insertions(+), 5 deletions(-) create mode 100644 packages/views/squads/components/squad-profile-card.tsx diff --git a/packages/views/common/actor-avatar.tsx b/packages/views/common/actor-avatar.tsx index 497632cad..ccceb3e4d 100644 --- a/packages/views/common/actor-avatar.tsx +++ b/packages/views/common/actor-avatar.tsx @@ -13,6 +13,7 @@ import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths"; import { AgentProfileCard } from "../agents/components/agent-profile-card"; import { AgentLivePeekCard } from "../agents/components/agent-live-peek-card"; import { MemberProfileCard } from "../members/member-profile-card"; +import { SquadProfileCard } from "../squads/components/squad-profile-card"; import { availabilityConfig } from "../agents/presence"; import { useNavigation } from "../navigation"; @@ -106,13 +107,17 @@ export function ActorAvatar({ avatar ); const shouldLinkToProfile = - profileLink ?? (actorType === "member" || actorType === "agent"); - const profileHref = - shouldLinkToProfile && actorType === "member" + profileLink ?? + (actorType === "member" || actorType === "agent" || actorType === "squad"); + const profileHref = shouldLinkToProfile + ? actorType === "member" ? paths.memberDetail(actorId) - : shouldLinkToProfile && actorType === "agent" + : actorType === "agent" ? paths.agentDetail(actorId) - : null; + : actorType === "squad" + ? paths.squadDetail(actorId) + : null + : null; const content = profileHref ? ( {dotted} ) : ( @@ -132,6 +137,9 @@ export function ActorAvatar({ if (actorType === "member") { return {content}; } + if (actorType === "squad") { + return {content}; + } return content; } @@ -244,6 +252,20 @@ function MemberAvatarHoverCard({ ); } +function SquadAvatarHoverCard({ + squadId, + children, +}: { + squadId: string; + children: React.ReactNode; +}) { + return ( + }> + {children} + + ); +} + // Common chrome shared between agent and member hover cards. Keeps focus // behaviour and width consistent so the two surfaces feel structurally // parallel — content varies, frame doesn't. diff --git a/packages/views/locales/en/squads.json b/packages/views/locales/en/squads.json index e8c67a48b..7fd024ade 100644 --- a/packages/views/locales/en/squads.json +++ b/packages/views/locales/en/squads.json @@ -57,6 +57,16 @@ "active_issue_more": "and {{count}} more", "last_active_label": "last active {{time}}" }, + "profile_card": { + "unavailable": "Squad unavailable", + "detail_link": "Detail →", + "archived": "Archived", + "member_count_one": "{{count}} member", + "member_count_other": "{{count}} members", + "members_section": "Members", + "more_members_one": "+{{count}} more", + "more_members_other": "+{{count}} more" + }, "instructions_tab": { "description": "Squad instructions are injected into the leader agent's prompt whenever it works on an issue assigned to this squad. Use them to give the leader squad-wide guidance, working agreements, or context the leader should follow on every task.", "unsaved_changes": "Unsaved changes", diff --git a/packages/views/locales/zh-Hans/squads.json b/packages/views/locales/zh-Hans/squads.json index 33db9e629..c37fb442b 100644 --- a/packages/views/locales/zh-Hans/squads.json +++ b/packages/views/locales/zh-Hans/squads.json @@ -57,6 +57,16 @@ "active_issue_more": "还有 {{count}} 个", "last_active_label": "最近活动 {{time}}" }, + "profile_card": { + "unavailable": "小队不可用", + "detail_link": "详情 →", + "archived": "已归档", + "member_count_one": "{{count}} 名成员", + "member_count_other": "{{count}} 名成员", + "members_section": "成员", + "more_members_one": "还有 {{count}} 人", + "more_members_other": "还有 {{count}} 人" + }, "instructions_tab": { "description": "小队指引会在 Leader 智能体处理分配给该小队的 issue 时注入到它的 prompt 中。可用来给 Leader 提供贯穿全队的指导、协作规范,或每次任务都应遵循的上下文。", "unsaved_changes": "有未保存的修改", diff --git a/packages/views/squads/components/index.ts b/packages/views/squads/components/index.ts index b9d1d9a59..bf925c58f 100644 --- a/packages/views/squads/components/index.ts +++ b/packages/views/squads/components/index.ts @@ -1,2 +1,3 @@ export { SquadsPage } from "./squads-page"; export { SquadDetailPage } from "./squad-detail-page"; +export { SquadProfileCard } from "./squad-profile-card"; diff --git a/packages/views/squads/components/squad-profile-card.tsx b/packages/views/squads/components/squad-profile-card.tsx new file mode 100644 index 000000000..cc3209d80 --- /dev/null +++ b/packages/views/squads/components/squad-profile-card.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import type { SquadMemberStatus } from "@multica/core/types"; +import { useWorkspaceId } from "@multica/core/hooks"; +import { + squadListOptions, + squadMemberStatusOptions, + agentListOptions, + memberListOptions, +} from "@multica/core/workspace/queries"; +import { useWorkspacePaths } from "@multica/core/paths"; +import { ActorAvatar as ActorAvatarBase } from "@multica/ui/components/common/actor-avatar"; +import { Skeleton } from "@multica/ui/components/ui/skeleton"; +import { ActorAvatar } from "../../common/actor-avatar"; +import { AppLink } from "../../navigation"; +import { useT } from "../../i18n"; + +interface SquadProfileCardProps { + squadId: string; +} + +const STATUS_DOT_CLASS: Record = { + working: "bg-success", + idle: "bg-muted-foreground/40", + offline: "bg-muted-foreground/40", + unstable: "bg-warning", +}; + +export function SquadProfileCard({ squadId }: SquadProfileCardProps) { + const { t } = useT("squads"); + const wsId = useWorkspaceId(); + const p = useWorkspacePaths(); + const { data: squads = [], isLoading: squadsLoading } = useQuery( + squadListOptions(wsId), + ); + const { data: agents = [] } = useQuery(agentListOptions(wsId)); + const { data: wsMembers = [] } = useQuery(memberListOptions(wsId)); + const { data: memberStatusResp } = useQuery( + squadMemberStatusOptions(wsId, squadId), + ); + + const squad = squads.find((s) => s.id === squadId); + + if (squadsLoading && !squad) { + return ( +
+ +
+ + +
+
+ ); + } + + if (!squad) { + return ( +
+ {t(($) => $.profile_card.unavailable)} +
+ ); + } + + const isArchived = !!squad.archived_at; + const initials = squad.name + .split(" ") + .map((w) => w[0]) + .join("") + .toUpperCase() + .slice(0, 2); + + const memberStatuses = memberStatusResp?.members ?? []; + const leaderFirst = [...memberStatuses].sort((a, b) => { + const aLeader = a.member_type === "agent" && a.member_id === squad.leader_id; + const bLeader = b.member_type === "agent" && b.member_id === squad.leader_id; + if (aLeader && !bLeader) return -1; + if (!aLeader && bLeader) return 1; + return 0; + }); + + return ( +
+
+ +
+
+

{squad.name}

+ + {t(($) => $.profile_card.member_count, { + count: memberStatuses.length, + })} + + {isArchived && ( + + {t(($) => $.profile_card.archived)} + + )} +
+
+ {!isArchived && ( + + {t(($) => $.profile_card.detail_link)} + + )} +
+ + {squad.description && ( +

+ {squad.description} +

+ )} + + {leaderFirst.length > 0 && ( + + )} +
+ ); +} + +function MembersList({ + members, + leaderId, + agents, + wsMembers, +}: { + members: SquadMemberStatus[]; + leaderId: string; + agents: { id: string; name: string }[]; + wsMembers: { user_id: string; name: string; role: string }[]; +}) { + const { t } = useT("squads"); + const p = useWorkspacePaths(); + const visible = members.slice(0, 3); + const overflow = members.length - visible.length; + + return ( +
+ + {t(($) => $.profile_card.members_section)} + +
+ {visible.map((m) => { + const isLeader = + m.member_type === "agent" && m.member_id === leaderId; + const name = + m.member_type === "agent" + ? agents.find((a) => a.id === m.member_id)?.name ?? + m.member_id.slice(0, 8) + : wsMembers.find((u) => u.user_id === m.member_id)?.name ?? + m.member_id.slice(0, 8); + const statusDotClass = + m.status && m.status in STATUS_DOT_CLASS + ? STATUS_DOT_CLASS[m.status] + : null; + const statusLabel = + m.status === "working" + ? t(($) => $.members_tab.status_working) + : m.status === "idle" + ? t(($) => $.members_tab.status_idle) + : m.status === "offline" + ? t(($) => $.members_tab.status_offline) + : m.status === "unstable" + ? t(($) => $.members_tab.status_unstable) + : null; + const href = + m.member_type === "agent" + ? p.agentDetail(m.member_id) + : p.memberDetail(m.member_id); + const memberRole = + m.member_type === "member" + ? wsMembers.find((u) => u.user_id === m.member_id)?.role ?? null + : null; + + return ( + + + {name} + {isLeader && ( + + {t(($) => $.members_tab.leader_chip)} + + )} + {m.member_type === "agent" && statusLabel && ( + + + {statusLabel} + + )} + {m.member_type === "member" && memberRole && ( + + {memberRole} + + )} + + ); + })} + {overflow > 0 && ( + + {t(($) => $.profile_card.more_members, { count: overflow })} + + )} +
+
+ ); +}