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 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Naiyuan Qing
2026-05-25 08:59:10 +08:00
committed by GitHub
parent 993cf550ad
commit 44ee74eb25
5 changed files with 281 additions and 5 deletions

View File

@@ -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 ? (
<ActorAvatarProfileLink href={profileHref}>{dotted}</ActorAvatarProfileLink>
) : (
@@ -132,6 +137,9 @@ export function ActorAvatar({
if (actorType === "member") {
return <MemberAvatarHoverCard userId={actorId}>{content}</MemberAvatarHoverCard>;
}
if (actorType === "squad") {
return <SquadAvatarHoverCard squadId={actorId}>{content}</SquadAvatarHoverCard>;
}
return content;
}
@@ -244,6 +252,20 @@ function MemberAvatarHoverCard({
);
}
function SquadAvatarHoverCard({
squadId,
children,
}: {
squadId: string;
children: React.ReactNode;
}) {
return (
<ActorAvatarHoverCardShell content={<SquadProfileCard squadId={squadId} />}>
{children}
</ActorAvatarHoverCardShell>
);
}
// 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.

View File

@@ -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",

View File

@@ -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": "有未保存的修改",

View File

@@ -1,2 +1,3 @@
export { SquadsPage } from "./squads-page";
export { SquadDetailPage } from "./squad-detail-page";
export { SquadProfileCard } from "./squad-profile-card";

View File

@@ -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<string, string> = {
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 (
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-md" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-20" />
</div>
</div>
);
}
if (!squad) {
return (
<div className="text-xs text-muted-foreground">
{t(($) => $.profile_card.unavailable)}
</div>
);
}
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 (
<div className="group flex flex-col gap-3 text-left">
<div className="flex items-start gap-3">
<ActorAvatarBase
name={squad.name}
initials={initials}
avatarUrl={squad.avatar_url}
isSquad
size={40}
className="rounded-md"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className="truncate text-sm font-semibold">{squad.name}</p>
<span className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{t(($) => $.profile_card.member_count, {
count: memberStatuses.length,
})}
</span>
{isArchived && (
<span className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{t(($) => $.profile_card.archived)}
</span>
)}
</div>
</div>
{!isArchived && (
<AppLink
href={p.squadDetail(squad.id)}
className="mr-1 mt-0.5 shrink-0 text-xs font-normal text-brand opacity-0 transition-opacity group-hover:opacity-100"
>
{t(($) => $.profile_card.detail_link)}
</AppLink>
)}
</div>
{squad.description && (
<p className="line-clamp-2 text-xs text-muted-foreground">
{squad.description}
</p>
)}
{leaderFirst.length > 0 && (
<MembersList
members={leaderFirst}
leaderId={squad.leader_id}
agents={agents}
wsMembers={wsMembers}
/>
)}
</div>
);
}
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 (
<div className="flex flex-col gap-1.5 text-xs">
<span className="text-muted-foreground">
{t(($) => $.profile_card.members_section)}
</span>
<div className="flex flex-col gap-0.5">
{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 (
<AppLink
key={`${m.member_type}-${m.member_id}`}
href={href}
className="-mx-1 flex items-center gap-2 rounded-md px-1 py-1 transition-colors hover:bg-accent"
>
<ActorAvatar
actorType={m.member_type}
actorId={m.member_id}
size={20}
showStatusDot={m.member_type === "agent"}
className="shrink-0"
/>
<span className="min-w-0 truncate font-medium">{name}</span>
{isLeader && (
<span className="shrink-0 rounded-md bg-amber-100 px-1 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
{t(($) => $.members_tab.leader_chip)}
</span>
)}
{m.member_type === "agent" && statusLabel && (
<span className="ml-auto inline-flex shrink-0 items-center gap-1 text-muted-foreground">
<span
className={`h-1.5 w-1.5 rounded-full ${statusDotClass ?? "bg-muted-foreground/40"}`}
/>
{statusLabel}
</span>
)}
{m.member_type === "member" && memberRole && (
<span className="ml-auto shrink-0 text-muted-foreground">
{memberRole}
</span>
)}
</AppLink>
);
})}
{overflow > 0 && (
<span className="text-muted-foreground">
{t(($) => $.profile_card.more_members, { count: overflow })}
</span>
)}
</div>
</div>
);
}