Stack team card avatars

This commit is contained in:
kenny lopez
2026-07-22 17:07:09 -07:00
parent d6fe9de678
commit ea89b93093
2 changed files with 80 additions and 6 deletions
@@ -115,6 +115,7 @@ function TeamAvatarRow({
}) {
const visiblePersonas = personas.slice(0, MAX_VISIBLE_MEMBER_AVATARS);
const overflowCount = Math.max(0, memberCount - visiblePersonas.length);
const stackItemCount = visiblePersonas.length + (overflowCount > 0 ? 1 : 0);
if (visiblePersonas.length === 0 && overflowCount === 0) {
return (
@@ -130,16 +131,26 @@ function TeamAvatarRow({
<div className="absolute inset-x-0 top-0 bottom-12 flex items-center justify-center">
<div
aria-label={`${teamName} member avatars`}
className="flex max-w-full items-center justify-center gap-2 px-4"
className="flex max-w-full items-center justify-center px-4"
role="img"
>
{visiblePersonas.map((persona, index) => (
<TeamAvatarItem index={index} key={persona.id} persona={persona} />
<TeamAvatarItem
index={index}
isFollowedByAnother={index < stackItemCount - 1}
key={persona.id}
persona={persona}
/>
))}
{overflowCount > 0 ? (
<span className="flex h-14 w-14 items-center justify-center rounded-full border-[3px] border-background bg-card text-sm font-semibold text-muted-foreground shadow-sm">
+{overflowCount}
</span>
<div
className={visiblePersonas.length > 0 ? "-ml-5" : ""}
style={{ zIndex: stackItemCount }}
>
<span className="flex h-14 w-14 items-center justify-center rounded-full border-[3px] border-background bg-card text-sm font-semibold text-muted-foreground shadow-sm">
+{overflowCount}
</span>
</div>
) : null}
</div>
</div>
@@ -148,15 +159,28 @@ function TeamAvatarRow({
function TeamAvatarItem({
index,
isFollowedByAnother,
persona,
}: {
index: number;
isFollowedByAnother: boolean;
persona: AgentPersona;
}) {
const avatarUrl = persona.avatarUrl?.trim() ?? null;
return (
<div className="h-14 w-14" data-team-member-avatar="avatar">
<div
className={`h-14 w-14 ${index > 0 ? "-ml-5" : ""}`}
data-team-member-avatar="avatar"
style={{
zIndex: index + 1,
...(isFollowedByAnother && {
mask: "radial-gradient(circle 32px at calc(100% + 8px) 50%, transparent 99%, #fff 100%)",
WebkitMask:
"radial-gradient(circle 32px at calc(100% + 8px) 50%, transparent 99%, #fff 100%)",
}),
}}
>
{avatarUrl ? (
<ProfileAvatar
avatarUrl={avatarUrl}
+50
View File
@@ -413,6 +413,56 @@ test("the new team card offers create and import", async ({ page }) => {
).toBeVisible();
});
test("team cards use the thread-style overlapping avatar stack", async ({
page,
}) => {
const personaIds = ["custom:design", "custom:build", "custom:ship"];
await installMockBridge(page, {
personas: [
{
id: personaIds[0],
displayName: "Design",
systemPrompt: "You design interfaces.",
},
{
id: personaIds[1],
displayName: "Build",
systemPrompt: "You build interfaces.",
},
{
id: personaIds[2],
displayName: "Ship",
systemPrompt: "You ship interfaces.",
},
],
teams: [
{
name: "Product crew",
personaIds,
},
],
});
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
const stack = page.getByLabel("Product crew member avatars");
const avatars = stack.locator('[data-team-member-avatar="avatar"]');
await expect(avatars).toHaveCount(3);
await expect(avatars.nth(1)).toHaveClass(/-ml-5/);
await expect(avatars.nth(2)).toHaveClass(/-ml-5/);
const boxes = await avatars.evaluateAll((elements) =>
elements.map((element) => {
const box = element.getBoundingClientRect();
return { left: box.left, right: box.right };
}),
);
expect(boxes[1]?.left).toBeLessThan(boxes[0]?.right ?? 0);
expect(boxes[2]?.left).toBeLessThan(boxes[1]?.right ?? 0);
await expect(avatars.first()).not.toHaveCSS("mask-image", "none");
await expect(avatars.last()).toHaveCSS("mask-image", "none");
});
test("agent defaults stays in the header without an actions menu", async ({
page,
}) => {